Compare commits
24 Commits
feature/gi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 373030b81a | |||
| c96004f91d | |||
| 3496cf0f26 | |||
| e5869e0b19 | |||
| 3075495d1c | |||
| d945de3837 | |||
| 329c057632 | |||
| ae3dc4e9db | |||
| 70cf033fdc | |||
| 618afeb336 | |||
| 76b19cd33a | |||
| c98e47a050 | |||
| fac953fcea | |||
| 7a9122c235 | |||
| 7f38df22db | |||
| 86c113c412 | |||
| 1f34da9199 | |||
| 507c841a92 | |||
| 3ad216ab0c | |||
| 3ca1a9af08 | |||
| d981b9346f | |||
| 571268c9b4 | |||
| 55891eb7b5 | |||
| 4f8b83ba87 |
@ -12,47 +12,51 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Login to Gitea Container Registry
|
- name: Login to Gitea Container Registry
|
||||||
uses: https://github.com/docker/login-action@v3
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ vars.REGISTRY_HOST }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||||
with:
|
|
||||||
registry: ${{ vars.REGISTRY_HOST }}
|
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push backend
|
- name: Build and push backend
|
||||||
uses: https://github.com/docker/build-push-action@v5
|
run: |
|
||||||
with:
|
docker build --pull \
|
||||||
context: ./backend
|
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest \
|
||||||
push: true
|
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }} \
|
||||||
tags: |
|
./backend
|
||||||
${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest
|
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest
|
||||||
${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }}
|
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }}
|
||||||
|
|
||||||
- name: Build and push frontend
|
- name: Build and push frontend
|
||||||
uses: https://github.com/docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: ./frontend
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest
|
|
||||||
${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }}
|
|
||||||
|
|
||||||
- name: Pull new images
|
|
||||||
run: |
|
run: |
|
||||||
cd /opt/umbra
|
docker build --pull \
|
||||||
docker compose pull backend frontend
|
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest \
|
||||||
|
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }} \
|
||||||
|
./frontend
|
||||||
|
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest
|
||||||
|
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }}
|
||||||
|
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
run: |
|
run: |
|
||||||
cd /opt/umbra
|
# Spawn a short-lived container that mounts the host deploy path
|
||||||
docker compose up -d
|
# and runs compose commands against the host Docker daemon.
|
||||||
|
# DEPLOY_PATH is a Gitea variable — update it when moving hosts.
|
||||||
|
docker run --rm \
|
||||||
|
--network host \
|
||||||
|
--security-opt label:disable \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v ${{ vars.DEPLOY_PATH }}:/deploy \
|
||||||
|
-w /deploy \
|
||||||
|
docker:27-cli sh -c "
|
||||||
|
docker compose -p umbra --env-file stack.env pull backend frontend &&
|
||||||
|
docker compose -p umbra --env-file stack.env up -d db backend frontend
|
||||||
|
"
|
||||||
|
|
||||||
- name: Health check
|
- name: Health check
|
||||||
run: |
|
run: |
|
||||||
echo "Waiting for services to start..."
|
echo "Waiting for services to start..."
|
||||||
sleep 10
|
sleep 30
|
||||||
curl -f http://localhost/health || exit 1
|
curl -f http://localhost:${{ vars.DEPLOY_PORT }}/health || exit 1
|
||||||
|
|
||||||
- name: Prune old images
|
- name: Prune old images
|
||||||
if: success()
|
if: success()
|
||||||
|
|||||||
20
act_runner_config.yaml
Normal file
20
act_runner_config.yaml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
runner:
|
||||||
|
capacity: 1
|
||||||
|
timeout: 3h
|
||||||
|
insecure: false
|
||||||
|
|
||||||
|
cache:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
container:
|
||||||
|
network: host
|
||||||
|
privileged: false
|
||||||
|
options: "--security-opt label:disable"
|
||||||
|
valid_volumes:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
host:
|
||||||
|
workdir_parent: /tmp/act_runner
|
||||||
@ -2,7 +2,10 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
environment:
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
@ -21,7 +24,15 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
image: git.sentinelforest.xyz/rohskiddo/umbra-backend:main-latest
|
image: git.sentinelforest.xyz/rohskiddo/umbra-backend:main-latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
environment:
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- ENVIRONMENT=${ENVIRONMENT:-production}
|
||||||
|
- UMBRA_URL=${UMBRA_URL:-https://umbra.ghost6.xyz}
|
||||||
|
- OPENWEATHERMAP_API_KEY=${OPENWEATHERMAP_API_KEY:-}
|
||||||
|
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-umbra.ghost6.xyz}
|
||||||
|
- WEBAUTHN_RP_NAME=${WEBAUTHN_RP_NAME:-UMBRA}
|
||||||
|
- WEBAUTHN_ORIGIN=${WEBAUTHN_ORIGIN:-https://umbra.ghost6.xyz}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@ -30,7 +41,7 @@ services:
|
|||||||
- frontend_net
|
- frontend_net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
|
||||||
interval: 10s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
@ -51,7 +62,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- frontend_net
|
- frontend_net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/"]
|
test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:8080/"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@ -61,26 +72,8 @@ services:
|
|||||||
memory: 128M
|
memory: 128M
|
||||||
cpus: "0.5"
|
cpus: "0.5"
|
||||||
|
|
||||||
act_runner:
|
|
||||||
image: gitea/act_runner:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- act_runner_data:/data
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
environment:
|
|
||||||
- GITEA_INSTANCE_URL=https://git.sentinelforest.xyz
|
|
||||||
- GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}
|
|
||||||
- GITEA_RUNNER_NAME=umbra-runner
|
|
||||||
- GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:20-bookworm
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 256M
|
|
||||||
cpus: "1.0"
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
act_runner_data:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
backend_net:
|
backend_net:
|
||||||
|
|||||||
@ -12,6 +12,8 @@ limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m;
|
|||||||
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m;
|
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m;
|
||||||
# Event creation — recurrence amplification means 1 POST = up to 90-365 child rows
|
# Event creation — recurrence amplification means 1 POST = up to 90-365 child rows
|
||||||
limit_req_zone $binary_remote_addr zone=event_create_limit:10m rate=30r/m;
|
limit_req_zone $binary_remote_addr zone=event_create_limit:10m rate=30r/m;
|
||||||
|
# Health endpoint — lightweight but rate-limited for resilience
|
||||||
|
limit_req_zone $binary_remote_addr zone=health_limit:1m rate=30r/m;
|
||||||
|
|
||||||
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
|
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
|
||||||
map $http_x_forwarded_proto $forwarded_proto {
|
map $http_x_forwarded_proto $forwarded_proto {
|
||||||
@ -164,6 +166,13 @@ server {
|
|||||||
include /etc/nginx/proxy-params.conf;
|
include /etc/nginx/proxy-params.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Health endpoint — proxied to backend for external uptime monitoring
|
||||||
|
location = /health {
|
||||||
|
limit_req zone=health_limit burst=5 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
include /etc/nginx/proxy-params.conf;
|
||||||
|
}
|
||||||
|
|
||||||
# API proxy (catch-all for non-rate-limited endpoints)
|
# API proxy (catch-all for non-rate-limited endpoints)
|
||||||
location /api {
|
location /api {
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@ -184,7 +193,7 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,7 +201,7 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
# PT-I03: Restrict unnecessary browser APIs
|
# PT-I03: Restrict unnecessary browser APIs
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=(self), publickey-credentials-create=(self)" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=(self), publickey-credentials-create=(self)" always;
|
||||||
|
|||||||
@ -11,6 +11,6 @@ add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=(self), publickey-credentials-create=(self)" always;
|
||||||
|
|||||||
@ -591,32 +591,32 @@ export default function EventDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={() => setScopeStep(null)}
|
onClick={() => setScopeStep(null)}
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (isEditing || isCreating) ? (
|
) : (isEditing || isCreating) ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-green-400 hover:text-green-300"
|
className="h-8 w-8 text-green-400 hover:text-green-300"
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
title="Save"
|
title="Save"
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditCancel}
|
onClick={handleEditCancel}
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -628,12 +628,12 @@ export default function EventDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditStart}
|
onClick={handleEditStart}
|
||||||
disabled={isAcquiringLock || !!activeLockInfo}
|
disabled={isAcquiringLock || !!activeLockInfo}
|
||||||
title={activeLockInfo ? `Locked by ${activeLockInfo.locked_by_name || 'another user'}` : 'Edit event'}
|
title={activeLockInfo ? `Locked by ${activeLockInfo.locked_by_name || 'another user'}` : 'Edit event'}
|
||||||
>
|
>
|
||||||
{isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
|
{isAcquiringLock ? <Loader2 className="h-4 w-4 animate-spin" /> : <Pencil className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Leave button for invited events */}
|
{/* Leave button for invited events */}
|
||||||
@ -641,11 +641,11 @@ export default function EventDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => setShowLeaveDialog(true)}
|
onClick={() => setShowLeaveDialog(true)}
|
||||||
title="Leave event"
|
title="Leave event"
|
||||||
>
|
>
|
||||||
<LogOut className="h-3.5 w-3.5" />
|
<LogOut className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Delete button for own events */}
|
{/* Delete button for own events */}
|
||||||
@ -664,12 +664,12 @@ export default function EventDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={handleDeleteStart}
|
onClick={handleDeleteStart}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
title="Delete event"
|
title="Delete event"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@ -678,11 +678,11 @@ export default function EventDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close panel"
|
title="Close panel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -285,21 +285,21 @@ export default function TaskDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-green-400 hover:text-green-300"
|
className="h-8 w-8 text-green-400 hover:text-green-300"
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={updateTaskMutation.isPending}
|
disabled={updateTaskMutation.isPending}
|
||||||
title="Save changes"
|
title="Save changes"
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditCancel}
|
onClick={handleEditCancel}
|
||||||
title="Cancel editing"
|
title="Cancel editing"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -307,30 +307,30 @@ export default function TaskDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditStart}
|
onClick={handleEditStart}
|
||||||
title="Edit task"
|
title="Edit task"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => onDelete(task.id)}
|
onClick={() => onDelete(task.id)}
|
||||||
title="Delete task"
|
title="Delete task"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close panel"
|
title="Close panel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -562,7 +562,7 @@ export default function TaskDetailPanel({
|
|||||||
{/* Comments */}
|
{/* Comments */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||||
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
Comments
|
Comments
|
||||||
{comments.length > 0 && (
|
{comments.length > 0 && (
|
||||||
|
|||||||
@ -236,21 +236,21 @@ export default function ReminderDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-green-400 hover:text-green-300"
|
className="h-8 w-8 text-green-400 hover:text-green-300"
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
title="Save"
|
title="Save"
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditCancel}
|
onClick={handleEditCancel}
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -259,22 +259,22 @@ export default function ReminderDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 hover:bg-orange-500/10 hover:text-orange-400"
|
className="h-8 w-8 hover:bg-orange-500/10 hover:text-orange-400"
|
||||||
onClick={() => dismissMutation.mutate()}
|
onClick={() => dismissMutation.mutate()}
|
||||||
disabled={dismissMutation.isPending}
|
disabled={dismissMutation.isPending}
|
||||||
title="Dismiss reminder"
|
title="Dismiss reminder"
|
||||||
>
|
>
|
||||||
<BellOff className="h-3.5 w-3.5" />
|
<BellOff className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditStart}
|
onClick={handleEditStart}
|
||||||
title="Edit reminder"
|
title="Edit reminder"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{confirmingDelete ? (
|
{confirmingDelete ? (
|
||||||
<Button
|
<Button
|
||||||
@ -290,22 +290,22 @@ export default function ReminderDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={handleDeleteClick}
|
onClick={handleDeleteClick}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
title="Delete reminder"
|
title="Delete reminder"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close panel"
|
title="Close panel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export function EntityDetailPanel<T>({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={onToggleFavourite}
|
onClick={onToggleFavourite}
|
||||||
aria-label={isFavourite ? `Remove from ${favouriteLabel}s` : `Add to ${favouriteLabel}s`}
|
aria-label={isFavourite ? `Remove from ${favouriteLabel}s` : `Add to ${favouriteLabel}s`}
|
||||||
className={`h-7 w-7 ${isFavourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
|
className={`h-8 w-8 ${isFavourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
|
||||||
>
|
>
|
||||||
{isFavourite ? (
|
{isFavourite ? (
|
||||||
<Star className="h-4 w-4 fill-yellow-400" />
|
<Star className="h-4 w-4 fill-yellow-400" />
|
||||||
@ -75,7 +75,7 @@ export function EntityDetailPanel<T>({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close panel"
|
aria-label="Close panel"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -272,21 +272,21 @@ export default function TodoDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-green-400 hover:text-green-300"
|
className="h-8 w-8 text-green-400 hover:text-green-300"
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
title="Save"
|
title="Save"
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditCancel}
|
onClick={handleEditCancel}
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -294,11 +294,11 @@ export default function TodoDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditStart}
|
onClick={handleEditStart}
|
||||||
title="Edit todo"
|
title="Edit todo"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{confirmingDelete ? (
|
{confirmingDelete ? (
|
||||||
<Button
|
<Button
|
||||||
@ -314,22 +314,22 @@ export default function TodoDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={handleDeleteClick}
|
onClick={handleDeleteClick}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
title="Delete todo"
|
title="Delete todo"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close panel"
|
title="Close panel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user