Compare commits

...

50 commits

Author SHA1 Message Date
le king fu
9a53022421 chore: bump version to 1.6.1 (versionCode 13)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:44:19 -04:00
fe43b65cfd Merge pull request 'fix: replace broken swipe-to-refresh with toolbar button (#61)' (#67) from issue-61-refresh-button-toolbar into master 2026-04-09 13:44:08 +00:00
le king fu
5b0d27175c fix: replace broken swipe-to-refresh with toolbar refresh button (#61)
The RefreshControl on DraggableFlatList never worked because the
library wraps its FlatList in a GestureDetector with Gesture.Pan(),
which intercepts vertical swipes before RefreshControl can detect
them — particularly with activationDistance=0 in position sort mode.

Replace with a toolbar refresh button (RefreshCw icon) on inbox and
list detail screens. The button uses an Animated spin during refresh,
matching the web UX. Removes all dead RefreshControl code and the
useless refreshControl prop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:37:26 -04:00
1a1eddfd68 Merge pull request 'fix: refresh Android widget after sync push and pull (#65)' (#66) from issue-65-widget-sync-refresh into master 2026-04-09 13:37:06 +00:00
le king fu
23f3144dc4 fix: refresh Android widget after sync push and pull (#65)
The sync client writes directly to the DB via drizzle, bypassing the
repository functions that normally trigger syncWidgetData(). As a
result, changes coming from the web (or any remote source) never
refreshed the home screen widget.

Call syncWidgetData() once at the end of pullChanges (after all remote
changes are applied) and after a successful pushChanges (to reflect
synced state). Single call per cycle avoids spamming widget updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:01:29 -04:00
le king fu
1df41cef1f fix: auto-apply migrations on startup + cleanup duplicate inboxes (#60)
- Add migration 0002 to soft-delete duplicate inboxes per user, keeping
  the oldest one and reassigning tasks to it.
- Run drizzle migrations on server startup via drizzle-orm/node-postgres
  migrator.
- Update Dockerfile to copy the migrations folder into the runtime image
  and externalize pg/drizzle-orm from the esbuild bundle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:53:09 -04:00
le king fu
2a0dd01307 chore: bump version to 1.6.0 (versionCode 12)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:37:50 -04:00
137dc83bf8 Merge pull request 'fix: issues #60 #61 #62 #63 — inbox, refresh, subtask depth, chevron/detail' (#64) from issue-60-fix-duplicate-inbox into master 2026-04-09 01:33:40 +00:00
le king fu
78471543c3 fix: separate subtask expand chevron from detail view icon (#63)
Split TaskItem into two independent states:
- `expanded` (chevron): toggles subtask visibility, only shown when
  subtasks exist
- `detailOpen` (search icon + title click): opens detail panel with
  notes, priority, edit/delete actions

The two actions are fully independent — expanding subtasks does not
open the detail view and vice versa.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:15:59 -04:00
le king fu
21020406b2 fix: prevent sub-subtask creation, limit nesting to 2 levels (#62)
Web: hide "Add subtask" button when depth >= 1 in TaskItem.
API: reject task creation if parentId points to a task that already
has a parentId (max depth validation).
Mobile: hide subtask section in task detail when viewing a subtask.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:13:43 -04:00
le king fu
894ac03072 feat: add refresh button on web + swipe-to-refresh on mobile (#61)
Web: add a RefreshCw button next to the list title in TaskList that
calls router.refresh() with a spin animation.

Mobile: add RefreshControl to DraggableFlatList on both inbox and
list detail screens, using the app's blue accent color.

Also deduplicate list insert values in sync/route.ts (review feedback).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:04:55 -04:00
le king fu
6c36ebcce5 fix: wrap inbox merge in transaction, revert seed to random UUID (#60)
Address review feedback:
1. Wrap inbox deduplication (select, reassign tasks, soft-delete) in a
   db.transaction() for atomicity.
2. Revert seed.ts to use random UUID — a fixed ID shared across users
   would cause PK conflicts. The sync endpoint handles deduplication.
3. Subtasks share the same listId as their parent, so the reassign
   query already covers them (clarified in comment).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:54:55 -04:00
le king fu
d9daf9eda4 fix: resolve duplicate inbox on web after mobile sync (#60)
When mobile syncs its inbox (fixed ID) to the web, check if an inbox
already exists for the user. If so, reassign tasks and soft-delete the
old inbox to prevent duplicates. Also harmonize seed.ts to use the same
fixed inbox ID as mobile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:50:12 -04:00
le king fu
71ee702739 chore: bump version to 1.5.2 (versionCode 11)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:42:22 -04:00
0893cea489 Merge pull request 'fix: resolve sync data inconsistency between mobile and web (#55)' (#58) from fix/simpl-liste-55-sync-data-inconsistency into master 2026-04-08 19:32:15 +00:00
le king fu
8f7204e4b1 fix: resolve sync data inconsistency between mobile and web (#55)
Three root causes fixed:

1. API GET /api/sync returned raw entities but mobile client expected
   {changes: [...], sync_token} format — pullChanges() iterated
   data.changes which was undefined, silently skipping all server data.
   Now transforms entities into the SyncPullChange format.

2. Mobile outbox writes used snake_case keys (due_date, list_id, etc.)
   but server processOperation spreads data directly into Drizzle which
   expects camelCase (dueDate, listId). Fixed all outbox writes to use
   camelCase. Also fixed task_tag → taskTag entity type.

3. Missing completedAt in task outbox payloads — completion state was
   lost during sync. Added completedAt to both create and update outbox
   entries, and added Date conversion in server update handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:28:36 -04:00
818f66205b Merge pull request 'fix: update deps for high vulnerabilities (#54)' (#57) from fix/simpl-liste-54-vulnerability-updates into master 2026-04-08 19:23:37 +00:00
le king fu
5b16882a83 fix: update drizzle-orm and @xmldom/xmldom to fix high vulnerabilities (#54)
- drizzle-orm 0.45.1 → 0.45.2 (SQL injection via improperly escaped identifiers)
- @xmldom/xmldom 0.8.11 → 0.8.12 (XML injection via unsafe CDATA serialization)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:22:42 -04:00
45d463c849 Merge pull request 'fix: rename .env.example to .env.template (#53)' (#56) from fix/simpl-liste-53-env-example-false-positive into master 2026-04-08 19:21:40 +00:00
le king fu
a5e9aa6f09 fix: rename .env.example to .env.template to avoid false positive (#53)
The defenseur flagged web/.env.example as a tracked secret file.
Renaming to .env.template avoids the .env* pattern match while
keeping the same purpose as a configuration template.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:20:55 -04:00
le king fu
89b3dada4a fix: replace Link with anchor tags for Logto auth routes
Next.js <Link> components prefetch routes on render/hover. When used
for /api/logto/sign-out, this triggered the sign-out handler during
normal navigation, clearing the session cookie and causing auth loops.

Also: wrap getAuthenticatedUser with React cache() for deduplication,
clean up diagnostic logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:31:04 -04:00
le king fu
9933c3678e fix: pass full URL to handleSignIn for callback URI matching
The Logto SDK needs the full callback URL (not just searchParams) to
verify it matches the redirect URI registered during sign-in.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:28:08 -04:00
le king fu
f786947941 fix: resolve Logto auth crash on web — remove illegal cookie set in layout
The (app)/layout.tsx was calling cookieStore.set() which is forbidden in
Server Components under Next.js 16 (only allowed in Server Actions and
Route Handlers). This caused a 500 error immediately after Logto login.

Also includes: mobile sync client improvements, i18n updates, web API
rate limiting, Bearer token support for mobile clients, and Dockerfile
optimizations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:12:59 -04:00
14c208be46 Merge pull request 'fix: force widget refresh after subtask toggle (#32)' (#33) from fix/simpl-liste-32-widget-subtask-toggle into master 2026-04-08 16:44:46 +00:00
le king fu
6328a8d0d3 fix: correct Logto session cookie prefix in middleware
Cookie is named `logto_<appId>` not `logto:<appId>`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:56:21 -04:00
8462aa9ef4 Merge pull request 'feat: add mobile sync client with outbox pattern (#40)' (#47) from issue-40-sync-mobile into master 2026-04-06 16:59:00 +00:00
b7a090df71 Merge pull request 'feat: implement web frontend with full task management UI (#39)' (#46) from issue-39-frontend-web into master 2026-04-06 16:58:35 +00:00
f4df9bbfd0 Merge pull request 'feat: setup Next.js web project with Drizzle + PostgreSQL schema (#35)' (#42) from issue-35-web-setup into master 2026-04-06 16:58:04 +00:00
d486be9227 Merge pull request 'feat: extract shared types, colors, priority and recurrence (#34)' (#41) from issue-34-shared-types into master 2026-04-06 16:57:29 +00:00
le king fu
c496d9586c feat: add mobile sync client with outbox pattern (#40)
- sync_outbox table in SQLite (migration 0004)
- Sync service: push/pull changes, fullSync, outbox cleanup
- Outbox writing in task/list/tag repositories after mutations
- Settings store: syncEnabled, lastSyncAt, userId
- Sync polling: on launch, every 2 min, on return from background
- Settings UI: Compte section with connect/sync/disconnect buttons
- i18n keys for sync strings (FR + EN)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:54:44 -04:00
le king fu
cb04adcc2e feat: implement web frontend with full task management UI (#39)
- Protected (app) layout with sidebar, header, theme toggle
- List detail page with tasks, filters, sorting
- Inline task editing (title, notes, priority, due date, recurrence)
- Subtask creation and nested display
- Dark mode (class-based, persisted to localStorage)
- WebSocket sync hook (connects via ticket auth, auto-refresh)
- Responsive sidebar (hamburger on mobile)
- French UI strings throughout
- Components: Sidebar, TaskList, TaskItem, TaskForm, FilterBar,
  ThemeToggle, Header, AppShell

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:40:11 -04:00
2f2a48f644 Merge pull request 'feat: add WebSocket server with ticket auth and heartbeat (#38)' (#45) from issue-38-websocket into issue-35-web-setup 2026-04-06 16:07:00 +00:00
le king fu
6d2e7449f3 feat: add WebSocket server with ticket auth and heartbeat (#38)
- Custom server (server.ts) wrapping Next.js + ws on same port
- Ticket-based auth: validates ephemeral nonce from /api/ws-ticket
- Origin validation against allowlist
- Session revalidation every 15 min (sends auth_expired, closes)
- Heartbeat every 30s (ping/pong, terminates dead connections)
- broadcastToUser() for API routes to notify connected clients
- Shared ticket store between API route and WS server via globalThis
- Health endpoint now reports active WS connections
- Dockerfile updated to use custom server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:55:28 -04:00
46ead345b4 Merge pull request 'feat: implement REST API backend with full CRUD and sync (#37)' (#44) from issue-37-api-rest into issue-35-web-setup 2026-04-06 15:52:30 +00:00
le king fu
be9ba65337 feat: implement REST API backend with full CRUD and sync (#37)
- Lists, Tasks, Tags CRUD endpoints with soft-delete
- Sync endpoints (GET since + POST batch with idempotency keys)
- WS ticket endpoint (ephemeral nonce, 30s TTL, single use)
- Auth middleware on all endpoints via getAuthenticatedUser()
- BOLA prevention: userId check on every entity operation
- Zod strict schemas for input validation
- Filters and sorting on task listing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:47:53 -04:00
0369597eb6 Merge pull request 'feat: integrate Logto auth with middleware and login page (#36)' (#43) from issue-36-auth-logto into issue-35-web-setup 2026-04-06 15:38:45 +00:00
le king fu
42c39907cd feat: integrate Logto auth with middleware and login page (#36)
- Logto config matching la-compagnie-maximus pattern
- API routes: sign-in, callback, sign-out
- Next.js middleware protecting all routes except /auth and /api
- Auth helper to extract userId (sub) from Logto context
- Login page with Compte Maximus branding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:37:20 -04:00
le king fu
022fe53b92 feat: setup Next.js web project with Drizzle + PostgreSQL schema (#35)
- Init Next.js App Router with TypeScript, Tailwind, standalone output
- Drizzle ORM pg-core schema (sl_lists, sl_tasks, sl_tags, sl_task_tags)
- Database client, seed script, drizzle.config
- Health endpoint (/api/health) with DB latency check
- Dockerfile for Coolify deployment
- .env.example with DATABASE_URL and Logto config placeholders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:03:34 -04:00
le king fu
91eef58186 fix: remove duplicate RecurrenceType definition in shared/types (#34)
Re-export from shared/recurrence instead of redefining.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:39:12 -04:00
le king fu
b277232462 feat: extract shared types, colors, priority and recurrence (#34)
Create src/shared/ with platform-agnostic types and helpers for
mobile/web code sharing. Add updatedAt to tags schema for sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:38:16 -04:00
escouade-bot
156e45496f fix: deduplicate WIDGET_NAMES and remove double-render in TOGGLE_SUBTASK (#32)
- Export WIDGET_NAMES from widgetSync.ts and import in widgetTaskHandler.ts
- Remove renderWithState call before forceWidgetRefresh to avoid double-render
- Use shared WIDGET_NAMES in widgetSync.ts refresh loop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 04:01:01 -04:00
escouade-bot
645f778db5 fix: remove unnecessary forceWidgetRefresh from TOGGLE_COMPLETE and TOGGLE_EXPAND (#32)
Only TOGGLE_SUBTASK needs forceWidgetRefresh() because ListView caches
items. TOGGLE_COMPLETE and TOGGLE_EXPAND already work with renderWithState()
alone since they perform structural changes (remove item / toggle children).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 02:00:49 -04:00
escouade-bot
82b501e753 fix: force widget refresh via requestWidgetUpdate after click actions (#32)
The Android ListView caches its items, so visual-only changes (like
toggling a subtask checkbox) were not reflected without calling
requestWidgetUpdate() to invalidate the cache. Added forceWidgetRefresh()
helper that calls requestWidgetUpdate() for all 3 widget sizes after
TOGGLE_SUBTASK, TOGGLE_COMPLETE, and TOGGLE_EXPAND handlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:01:14 -04:00
le king fu
7f8a0832d4 chore: bump version to 1.4.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:14:47 -04:00
244fbee405 Merge pull request 'fix: consolidate widget AsyncStorage and debounce expand (#29)' (#31) from issue-29-widget-expand-perf into master 2026-03-31 00:13:13 +00:00
9b1f7e79c9 Merge pull request 'feat: inline edit and delete for subtasks (#25)' (#30) from issue-25-edit-delete-subtasks into master 2026-03-31 00:13:04 +00:00
723f5d6501 Merge pull request 'fix: update vulnerable dependencies' (#28) from fix/simpl-liste-26-vulnerable-deps into master 2026-03-31 00:02:07 +00:00
le king fu
992c983026 chore: remove unused isWidgetTask function (#29)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:00:19 -04:00
le king fu
810bf2e939 fix: consolidate widget AsyncStorage keys and debounce expand (#29)
Merge widget:tasks, widget:isDark, and widget:expandedTaskIds into a
single widget:state key to reduce AsyncStorage I/O from 3 reads to 1.
Add 2s debounce on TOGGLE_EXPAND to prevent double-tap from collapsing
the subtask list. Legacy keys are migrated on first read.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:45:02 -04:00
le king fu
19706fa4a3 feat: add inline edit and delete for subtasks (#25)
Long-press a subtask to edit its title inline. Tap X to delete
with confirmation. Tap still toggles completion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:36:24 -04:00
115 changed files with 14929 additions and 284 deletions

View file

@ -0,0 +1,28 @@
---
name: eas-build
description: Build APK via EAS and create Forgejo release
user-invocable: true
---
# /eas-build — Build APK Simpl-Liste
## Context injection
1. Lire `app.json``expo.android.versionCode` et `expo.version`
2. Lire `eas.json` → profils disponibles
## Workflow
1. Lire le `versionCode` actuel dans `app.json`
2. Incrementer `versionCode` (+1) — doit etre strictement superieur
3. Si demande par l'utilisateur : bumper `version` dans `app.json` + `package.json`
4. Commit : `chore: bump versionCode to <N>`
5. Build : `npx --yes eas-cli build --platform android --profile preview --non-interactive`
6. Quand le build est termine : creer une release Forgejo avec le lien APK
## Regles
- `versionCode` doit etre **strictement superieur** a la valeur precedente
- `autoIncrement` dans eas.json ne s'applique qu'au profil `production`, pas `preview`
- Toujours utiliser `npx --yes eas-cli` (pas d'install globale)
- Ne JAMAIS `git push --tags` — push les tags un par un si necessaire

View file

@ -129,17 +129,17 @@ Couleurs sombres : fond `#1A1A1A`, surface `#2A2A2A`, bordure `#3A3A3A`, texte `
- **SimplListeLarge** (4×4) — Liste de 8 tâches
### Sync des données
- `widgetSync.ts` lit les tâches depuis SQLite et les cache dans AsyncStorage (`widget:tasks`)
- Le thème est lu depuis AsyncStorage (`simpl-liste-settings` → `state.theme`), résolu si `system` via `Appearance.getColorScheme()`, et stocké dans `widget:isDark`
- `widgetTaskHandler.ts` gère le rendu headless (quand l'app n'est pas ouverte) en lisant les deux clés AsyncStorage
- `widgetSync.ts` lit les tâches depuis SQLite et les cache dans AsyncStorage (`widget:state`)
- Le thème est lu depuis AsyncStorage (`simpl-liste-settings` → `state.theme`), résolu si `system` via `Appearance.getColorScheme()`
- `widgetTaskHandler.ts` gère le rendu headless (quand l'app n'est pas ouverte) en lisant la clé consolidée `widget:state`
- Les couleurs du widget suivent la même palette que l'app (voir `LIGHT_COLORS` / `DARK_COLORS` dans `TaskListWidget.tsx`)
- Un debounce de 2s sur `TOGGLE_EXPAND` empêche les double-taps d'annuler l'expansion
### Clés AsyncStorage utilisées par le widget
| Clé | Contenu |
|-----|---------|
| `widget:tasks` | `WidgetTask[]` sérialisé JSON |
| `widget:isDark` | `boolean` sérialisé JSON |
| `simpl-liste-settings` | Store Zustand persisté (contient `state.theme`) |
| `widget:state` | `WidgetState` sérialisé JSON (tasks, isDark, expandedTaskIds) |
| `simpl-liste-settings` | Store Zustand persisté (contient `state.theme`, `state.widgetPeriodWeeks`) |
## Build & déploiement
@ -160,16 +160,28 @@ npx eas-cli build --platform android --profile production --non-interactive # AA
### Processus de release
1. Bumper `version` dans `app.json` ET `package.json`
2. Le `versionCode` Android est auto-incrémenté par EAS (`autoIncrement: true`)
3. Build preview (APK) + production (AAB)
4. Créer la release sur Forgejo via API :
2. **Bumper `android.versionCode` dans `app.json`** — doit être **strictement supérieur** au versionCode du dernier build publié. Android refuse d'installer un APK avec un versionCode égal ou inférieur. Vérifier le dernier versionCode avec :
```bash
npx --yes eas-cli build:list --platform android --limit 1 --json 2>/dev/null | jq '.[0].appBuildVersion'
```
⚠️ `autoIncrement: true` dans eas.json ne s'applique qu'au profil `production`. Pour le profil `preview` (APK), le versionCode vient directement de `app.json` — il faut le mettre à jour manuellement.
3. Commit le bump de version, tag, push
4. Build preview (APK) :
```bash
npx --yes eas-cli build --platform android --profile preview --non-interactive
```
5. Télécharger l'APK et créer la release sur Forgejo :
```bash
# Récupérer l'URL de l'APK
npx --yes eas-cli build:list --platform android --limit 1 --json 2>/dev/null | jq -r '.[0].artifacts.buildUrl'
# Télécharger
curl -L -o simpl-liste-vX.Y.Z.apk "<url>"
# Créer la release
curl -X POST ".../api/v1/repos/maximus/simpl-liste/releases" -d '{"tag_name":"vX.Y.Z",...}'
# Attacher l'APK
curl -X POST ".../releases/{id}/assets?name=simpl-liste-vX.Y.Z.apk" -F "attachment=@fichier.apk"
```
5. Le bouton « Vérifier les mises à jour » dans l'app utilise l'endpoint `/releases/latest` et propose le téléchargement de l'asset `.apk`
6. Le bouton « Vérifier les mises à jour » dans l'app utilise l'endpoint `/releases/latest` et propose le téléchargement de l'asset `.apk`
### Repo Forgejo

View file

@ -2,7 +2,7 @@
"expo": {
"name": "Simpl-Liste",
"slug": "simpl-liste",
"version": "1.3.0",
"version": "1.6.1",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "simplliste",
@ -24,7 +24,7 @@
"backgroundColor": "#FFF8F0"
},
"edgeToEdgeEnabled": true,
"versionCode": 6
"versionCode": 13
},
"plugins": [
"expo-router",
@ -74,7 +74,9 @@
}
]
}
]
],
"expo-secure-store",
"expo-web-browser"
],
"experiments": {
"typedRoutes": true

View file

@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native';
import { View, Text, Pressable, TextInput, useColorScheme, Alert, Animated, Easing } from 'react-native';
import { useRouter } from 'expo-router';
import { Plus, ArrowUpDown, Filter, Download, Search, X } from 'lucide-react-native';
import { Plus, ArrowUpDown, Filter, Download, Search, X, RefreshCw } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import DraggableFlatList, { RenderItemParams } from 'react-native-draggable-flatlist';
@ -44,6 +44,8 @@ export default function InboxScreen() {
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const isDraggingRef = useRef(false);
const [refreshing, setRefreshing] = useState(false);
const spinAnim = useRef(new Animated.Value(0)).current;
const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore();
@ -70,6 +72,31 @@ export default function InboxScreen() {
return () => clearInterval(interval);
}, [loadTasks]);
const handleRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
spinAnim.setValue(0);
Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 800,
easing: Easing.linear,
useNativeDriver: true,
})
).start();
try {
await loadTasks();
} finally {
setRefreshing(false);
spinAnim.stopAnimation();
}
}, [loadTasks, refreshing, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
const handleToggle = async (id: string) => {
await toggleComplete(id);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
@ -164,6 +191,11 @@ export default function InboxScreen() {
</View>
) : (
<View className={`flex-row items-center justify-end border-b px-4 py-2 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
<Pressable onPress={handleRefresh} disabled={refreshing} className="mr-3 p-1">
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshCw size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Animated.View>
</Pressable>
<Pressable onPress={() => setShowSearch(true)} className="mr-3 p-1">
<Search size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>

View file

@ -2,14 +2,17 @@ import { useState, useEffect, useCallback } from 'react';
import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, Platform, Switch, Linking, ActivityIndicator } from 'react-native';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import { useTranslation } from 'react-i18next';
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, LayoutGrid, Mail, RefreshCw } from 'lucide-react-native';
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, LayoutGrid, Mail, RefreshCw, Cloud, LogIn, LogOut } from 'lucide-react-native';
import Constants from 'expo-constants';
import { useLogto } from '@logto/rn';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { redirectUri, postSignOutRedirectUri } from '@/src/lib/logtoConfig';
import { getAllTags, createTag, updateTag, deleteTag } from '@/src/db/repository/tags';
import { initCalendar } from '@/src/services/calendar';
import { syncWidgetData } from '@/src/services/widgetSync';
import { fullSync, initialMerge, initialReset } from '@/src/services/syncClient';
import i18n from '@/src/i18n';
type ThemeMode = 'light' | 'dark' | 'system';
@ -25,6 +28,9 @@ export default function SettingsScreen() {
reminderOffset, setReminderOffset,
calendarSyncEnabled, setCalendarSyncEnabled,
widgetPeriodWeeks, setWidgetPeriodWeeks,
syncEnabled, setSyncEnabled,
lastSyncAt, setLastSyncAt,
userId, setUserId,
} = useSettingsStore();
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
@ -34,6 +40,8 @@ export default function SettingsScreen() {
const [tagName, setTagName] = useState('');
const [tagColor, setTagColor] = useState(TAG_COLORS[0]);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const { signIn: logtoSignIn, signOut: logtoSignOut, getIdTokenClaims, isAuthenticated } = useLogto();
const loadTags = useCallback(async () => {
const result = await getAllTags();
@ -94,6 +102,125 @@ export default function SettingsScreen() {
]);
};
const handleSignIn = async () => {
try {
await logtoSignIn({ redirectUri });
const claims = await getIdTokenClaims();
setUserId(claims.sub);
setSyncEnabled(true);
// First sync: show merge/reset choice
Alert.alert(
t('sync.firstSyncTitle'),
t('sync.firstSyncMessage'),
[
{
text: t('sync.mergeLocal'),
onPress: async () => {
setIsSyncing(true);
try {
await initialMerge();
Alert.alert(t('sync.firstSyncTitle'), t('sync.mergeDone'));
} catch {
Alert.alert(t('sync.syncError'));
} finally {
setIsSyncing(false);
}
},
},
{
text: t('sync.resetFromServer'),
style: 'destructive',
onPress: async () => {
setIsSyncing(true);
try {
await initialReset();
Alert.alert(t('sync.firstSyncTitle'), t('sync.resetDone'));
} catch {
Alert.alert(t('sync.syncError'));
} finally {
setIsSyncing(false);
}
},
},
],
);
} catch (err) {
console.warn('[auth] sign-in error:', err);
}
};
const handleSignOut = () => {
Alert.alert(t('sync.signOutConfirm'), '', [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('sync.signOut'),
style: 'destructive',
onPress: async () => {
try {
await logtoSignOut(postSignOutRedirectUri);
} catch {
// Sign-out may fail if session expired, that's OK
}
setSyncEnabled(false);
setUserId(null);
setLastSyncAt(null);
},
},
]);
};
const handleSyncNow = async () => {
// If never synced before, show first-sync choice
if (!lastSyncAt) {
Alert.alert(
t('sync.firstSyncTitle'),
t('sync.firstSyncMessage'),
[
{
text: t('sync.mergeLocal'),
onPress: async () => {
setIsSyncing(true);
try {
await initialMerge();
Alert.alert(t('sync.firstSyncTitle'), t('sync.mergeDone'));
} catch {
Alert.alert(t('sync.syncError'));
} finally {
setIsSyncing(false);
}
},
},
{
text: t('sync.resetFromServer'),
style: 'destructive',
onPress: async () => {
setIsSyncing(true);
try {
await initialReset();
Alert.alert(t('sync.firstSyncTitle'), t('sync.resetDone'));
} catch {
Alert.alert(t('sync.syncError'));
} finally {
setIsSyncing(false);
}
},
},
],
);
return;
}
setIsSyncing(true);
try {
await fullSync();
} catch {
// Sync errors are logged internally
} finally {
setIsSyncing(false);
}
};
const handleCheckUpdate = async () => {
setCheckingUpdate(true);
try {
@ -301,6 +428,88 @@ export default function SettingsScreen() {
</View>
</View>
{/* Account / Sync Section */}
<View className="px-4 pt-6">
<Text
className={`mb-3 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('sync.title')}
</Text>
<View className={`overflow-hidden rounded-xl ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}>
{!userId ? (
<Pressable
onPress={handleSignIn}
className={`flex-row items-center px-4 py-3.5`}
>
<LogIn size={20} color={colors.bleu.DEFAULT} />
<Text
className="ml-3 text-base text-bleu"
style={{ fontFamily: 'Inter_500Medium' }}
>
{t('sync.signIn')}
</Text>
</Pressable>
) : (
<>
{/* Connected user */}
<View className={`px-4 py-3.5 border-b ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
<View className="flex-row items-center">
<Cloud size={20} color={colors.bleu.DEFAULT} />
<Text
className={`ml-3 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_500Medium' }}
>
{t('sync.connectedAs', { userId })}
</Text>
</View>
<Text
className={`mt-1 ml-8 text-xs ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{lastSyncAt
? t('sync.lastSync', { date: new Date(lastSyncAt).toLocaleString() })
: t('sync.never')}
</Text>
</View>
{/* Sync now button */}
<Pressable
onPress={handleSyncNow}
disabled={isSyncing}
className={`flex-row items-center border-b px-4 py-3.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
{isSyncing ? (
<ActivityIndicator size={20} color={colors.bleu.DEFAULT} />
) : (
<RefreshCw size={20} color={colors.bleu.DEFAULT} />
)}
<Text
className="ml-3 text-base text-bleu"
style={{ fontFamily: 'Inter_500Medium' }}
>
{isSyncing ? t('sync.syncing') : t('sync.syncNow')}
</Text>
</Pressable>
{/* Sign out */}
<Pressable
onPress={handleSignOut}
className="flex-row items-center px-4 py-3.5"
>
<LogOut size={20} color={colors.terracotta.DEFAULT} />
<Text
className="ml-3 text-base text-terracotta"
style={{ fontFamily: 'Inter_500Medium' }}
>
{t('sync.signOut')}
</Text>
</Pressable>
</>
)}
</View>
</View>
{/* Widget Section */}
<View className="px-4 pt-6">
<Text

View file

@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useColorScheme } from 'react-native';
import { useEffect, useRef } from 'react';
import { useColorScheme, AppState, type AppStateStatus } from 'react-native';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { useFonts, Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold } from '@expo-google-fonts/inter';
@ -8,12 +8,16 @@ import { useMigrations } from 'drizzle-orm/expo-sqlite/migrator';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import { LogtoProvider, useLogto } from '@logto/rn';
import { db } from '@/src/db/client';
import migrations from '@/src/db/migrations/migrations';
import { ensureInbox } from '@/src/db/repository/lists';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { initNotifications } from '@/src/services/notifications';
import { syncWidgetData } from '@/src/services/widgetSync';
import { fullSync, cleanOutbox } from '@/src/services/syncClient';
import { logtoConfig } from '@/src/lib/logtoConfig';
import { setTokenGetter, clearTokenGetter } from '@/src/lib/authToken';
import '@/src/i18n';
import '@/src/global.css';
@ -55,10 +59,6 @@ export default function RootLayout() {
const { success: migrationsReady, error: migrationError } = useMigrations(db, migrations);
const systemScheme = useColorScheme();
const theme = useSettingsStore((s) => s.theme);
const effectiveScheme = theme === 'system' ? systemScheme : theme;
useEffect(() => {
if (fontError) throw fontError;
if (migrationError) throw migrationError;
@ -78,6 +78,57 @@ export default function RootLayout() {
return null;
}
return (
<LogtoProvider config={logtoConfig}>
<AppContent />
</LogtoProvider>
);
}
function AppContent() {
const systemScheme = useColorScheme();
const theme = useSettingsStore((s) => s.theme);
const syncEnabled = useSettingsStore((s) => s.syncEnabled);
const effectiveScheme = theme === 'system' ? systemScheme : theme;
const appState = useRef(AppState.currentState);
const { getAccessToken, isAuthenticated } = useLogto();
// Register the token getter for syncClient when authenticated
useEffect(() => {
if (isAuthenticated && syncEnabled) {
setTokenGetter(getAccessToken);
} else {
clearTokenGetter();
}
return () => clearTokenGetter();
}, [isAuthenticated, syncEnabled, getAccessToken]);
// Sync polling: run on launch, every 2 min, and on return from background
useEffect(() => {
if (!syncEnabled) return;
// Initial sync
fullSync().then(() => cleanOutbox()).catch(() => {});
// 2-minute interval
const interval = setInterval(() => {
fullSync().then(() => cleanOutbox()).catch(() => {});
}, 2 * 60 * 1000);
// AppState listener: sync when returning from background
const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
if (appState.current.match(/inactive|background/) && nextState === 'active') {
fullSync().catch(() => {});
}
appState.current = nextState;
});
return () => {
clearInterval(interval);
subscription.remove();
};
}, [syncEnabled]);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>

View file

@ -1,8 +1,8 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native';
import { View, Text, Pressable, TextInput, useColorScheme, Alert, Animated, Easing } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import {
ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X,
ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X, RefreshCw,
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
Gift, Camera, Palette, Dog, Leaf, Zap,
@ -61,6 +61,8 @@ export default function ListDetailScreen() {
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const isDraggingRef = useRef(false);
const [refreshing, setRefreshing] = useState(false);
const spinAnim = useRef(new Animated.Value(0)).current;
const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore();
@ -95,6 +97,31 @@ export default function ListDetailScreen() {
return () => clearInterval(interval);
}, [loadData]);
const handleRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
spinAnim.setValue(0);
Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 800,
easing: Easing.linear,
useNativeDriver: true,
})
).start();
try {
await loadData();
} finally {
setRefreshing(false);
spinAnim.stopAnimation();
}
}, [loadData, refreshing, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
const handleToggle = async (taskId: string) => {
await toggleComplete(taskId);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
@ -192,6 +219,11 @@ export default function ListDetailScreen() {
</Text>
</View>
<View className="flex-row items-center">
<Pressable onPress={handleRefresh} disabled={refreshing} className="mr-3 p-1">
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshCw size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Animated.View>
</Pressable>
<Pressable onPress={() => setShowSearch(true)} className="mr-3 p-1">
<Search size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>

View file

@ -54,6 +54,7 @@ type TaskData = {
priority: number;
dueDate: Date | null;
listId: string;
parentId: string | null;
recurrence: string | null;
};
@ -76,6 +77,8 @@ export default function TaskDetailScreen() {
const [recurrence, setRecurrence] = useState<string | null>(null);
const [subtasks, setSubtasks] = useState<SubtaskData[]>([]);
const [newSubtask, setNewSubtask] = useState('');
const [editingSubtaskId, setEditingSubtaskId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [lists, setLists] = useState<{ id: string; name: string; color: string | null; icon: string | null; isInbox: boolean }[]>([]);
@ -162,6 +165,38 @@ export default function TaskDetailScreen() {
loadSubtasks();
};
const handleEditSubtask = (sub: SubtaskData) => {
setEditingSubtaskId(sub.id);
setEditingTitle(sub.title);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const handleSaveSubtaskEdit = async () => {
if (!editingSubtaskId) return;
const trimmed = editingTitle.trim();
if (trimmed) {
await updateTask(editingSubtaskId, { title: trimmed });
loadSubtasks();
}
setEditingSubtaskId(null);
setEditingTitle('');
};
const handleDeleteSubtask = (subtaskId: string) => {
Alert.alert(t('task.deleteSubtaskConfirm'), '', [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: async () => {
await deleteTask(subtaskId);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
loadSubtasks();
},
},
]);
};
const handleDateChange = (_: DateTimePickerEvent, date?: Date) => {
setShowDatePicker(Platform.OS === 'ios');
if (date) setDueDate(date);
@ -366,47 +401,71 @@ export default function TaskDetailScreen() {
</>
)}
{/* Subtasks */}
<Text className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('task.subtasks')}
</Text>
{subtasks.map((sub) => (
<Pressable
key={sub.id}
onPress={() => handleToggleSubtask(sub.id)}
className={`flex-row items-center border-b py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<View
className="mr-3 h-5 w-5 items-center justify-center rounded-full border-2"
style={{
borderColor: sub.completed ? colors.bleu.DEFAULT : colors.priority.none,
backgroundColor: sub.completed ? colors.bleu.DEFAULT : 'transparent',
}}
>
{sub.completed && <Text className="text-xs text-white" style={{ fontFamily: 'Inter_700Bold' }}></Text>}
</View>
<Text
className={`text-base ${sub.completed ? 'line-through ' + (isDark ? 'text-[#A0A0A0]' : 'text-[#9CA3AF]') : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{sub.title}
{/* Subtasks — only for root tasks (not subtasks themselves) */}
{!task?.parentId && (
<>
<Text className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('task.subtasks')}
</Text>
</Pressable>
))}
{subtasks.map((sub) => (
<Pressable
key={sub.id}
onPress={() => editingSubtaskId === sub.id ? undefined : handleToggleSubtask(sub.id)}
onLongPress={() => handleEditSubtask(sub)}
className={`flex-row items-center border-b py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<View
className="mr-3 h-5 w-5 items-center justify-center rounded-full border-2"
style={{
borderColor: sub.completed ? colors.bleu.DEFAULT : colors.priority.none,
backgroundColor: sub.completed ? colors.bleu.DEFAULT : 'transparent',
}}
>
{sub.completed && <Text className="text-xs text-white" style={{ fontFamily: 'Inter_700Bold' }}></Text>}
</View>
{editingSubtaskId === sub.id ? (
<TextInput
value={editingTitle}
onChangeText={setEditingTitle}
onSubmitEditing={handleSaveSubtaskEdit}
onBlur={handleSaveSubtaskEdit}
autoFocus
className={`flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
) : (
<Text
className={`flex-1 text-base ${sub.completed ? 'line-through ' + (isDark ? 'text-[#A0A0A0]' : 'text-[#9CA3AF]') : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{sub.title}
</Text>
)}
<Pressable
onPress={() => handleDeleteSubtask(sub.id)}
className="ml-2 p-1.5"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<X size={16} color={isDark ? '#A0A0A0' : '#9CA3AF'} />
</Pressable>
</Pressable>
))}
{/* Add subtask */}
<View className="mt-2 flex-row items-center">
<Plus size={18} color={colors.bleu.DEFAULT} />
<TextInput
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddSubtask}
placeholder={t('task.addSubtask')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
</View>
{/* Add subtask */}
<View className="mt-2 flex-row items-center">
<Plus size={18} color={colors.bleu.DEFAULT} />
<TextInput
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddSubtask}
placeholder={t('task.addSubtask')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
</View>
</>
)}
<View style={{ height: 32 }} />
</KeyboardAwareScrollView>

186
package-lock.json generated
View file

@ -1,22 +1,24 @@
{
"name": "simpl-liste",
"version": "1.3.0",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl-liste",
"version": "1.3.0",
"version": "1.5.1",
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo/ngrok": "^4.1.3",
"@expo/vector-icons": "^15.0.3",
"@logto/rn": "^1.1.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-navigation/native": "^7.1.8",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1",
"drizzle-orm": "^0.45.2",
"expo": "~54.0.33",
"expo-auth-session": "~7.0.10",
"expo-calendar": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.8",
@ -28,6 +30,7 @@
"expo-localization": "~17.0.8",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",
@ -2915,6 +2918,47 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@logto/client": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@logto/client/-/client-3.1.2.tgz",
"integrity": "sha512-HRu6qO4QYQn5ckO5wHi8On/C4Nsp/5qYDbf6zrFjymSVlJlXmDft+OW/AQ9jdPl1kAgZJIlQzjvpM9YFy/7c6Q==",
"license": "MIT",
"dependencies": {
"@logto/js": "^5.1.1",
"@silverhand/essentials": "^2.9.2",
"camelcase-keys": "^9.1.3",
"jose": "^5.2.2"
}
},
"node_modules/@logto/js": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@logto/js/-/js-5.1.1.tgz",
"integrity": "sha512-HMK9AFQ+mzJQ2WuKrJJ2apjoTjGbbu45vIhAl31t0JbSi++3IcPp3/oIhsS+VJ7AOs8x5P+fjWJO2AIwhQe3Vg==",
"license": "MIT",
"dependencies": {
"@silverhand/essentials": "^2.9.2",
"camelcase-keys": "^9.1.3"
}
},
"node_modules/@logto/rn": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@logto/rn/-/rn-1.1.0.tgz",
"integrity": "sha512-GcB6gGrjBASrTy4FsyJWCgYHaCjl2Tl/6CL+OZfU9Vro7meyfrW2+bHBOi7aKeXl+tLNqUybHoFcv+sVvUObxw==",
"license": "MIT",
"dependencies": {
"@logto/client": "3.1.2",
"@logto/js": "5.1.1",
"crypto-es": "^2.1.0",
"js-base64": "^3.7.7"
},
"peerDependencies": {
"@react-native-async-storage/async-storage": ">=1.23.1 <3",
"expo-crypto": ">=14.0.2 <16",
"expo-secure-store": ">=14.0.1 <16",
"expo-web-browser": ">=14.0.2 <16",
"react-native": ">=0.76.0 <1"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -3528,6 +3572,16 @@
"nanoid": "^3.3.11"
}
},
"node_modules/@silverhand/essentials": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/@silverhand/essentials/-/essentials-2.9.3.tgz",
"integrity": "sha512-OM9pyGc/yYJMVQw+fFOZZaTHXDWc45sprj+ky+QjC9inhf5w51L1WBmzAwFuYkHAwO1M19fxVf2sTH9KKP48yg==",
"license": "MIT",
"engines": {
"node": ">=18.12.0",
"pnpm": "^10.0.0"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
@ -3762,9 +3816,9 @@
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@ -4498,6 +4552,60 @@
"node": ">= 6"
}
},
"node_modules/camelcase-keys": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz",
"integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==",
"license": "MIT",
"dependencies": {
"camelcase": "^8.0.0",
"map-obj": "5.0.0",
"quick-lru": "^6.1.1",
"type-fest": "^4.3.2"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys/node_modules/camelcase": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz",
"integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys/node_modules/quick-lru": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001770",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
@ -4884,6 +4992,12 @@
"node": ">= 8"
}
},
"node_modules/crypto-es": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/crypto-es/-/crypto-es-2.1.0.tgz",
"integrity": "sha512-C5Dbuv4QTPGuloy5c5Vv/FZHtmK+lobLAypFfuRaBbwCsk3qbCWWESCH3MUcBsrgXloRNMrzwUAiPg4U6+IaKA==",
"license": "MIT"
},
"node_modules/crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@ -5261,9 +5375,9 @@
}
},
"node_modules/drizzle-orm": {
"version": "0.45.1",
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz",
"integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==",
"version": "0.45.2",
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz",
"integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==",
"license": "Apache-2.0",
"peerDependencies": {
"@aws-sdk/client-rds-data": ">=3",
@ -5690,6 +5804,24 @@
"react-native": "*"
}
},
"node_modules/expo-auth-session": {
"version": "7.0.10",
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.10.tgz",
"integrity": "sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==",
"license": "MIT",
"dependencies": {
"expo-application": "~7.0.8",
"expo-constants": "~18.0.11",
"expo-crypto": "~15.0.8",
"expo-linking": "~8.0.10",
"expo-web-browser": "~15.0.10",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-calendar": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-calendar/-/expo-calendar-15.0.8.tgz",
@ -6107,6 +6239,15 @@
"node": ">=10"
}
},
"node_modules/expo-secure-store": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz",
"integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-server": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
@ -7527,6 +7668,21 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -8067,6 +8223,18 @@
"tmpl": "1.0.5"
}
},
"node_modules/map-obj": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz",
"integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/marky": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",

View file

@ -1,7 +1,7 @@
{
"name": "simpl-liste",
"main": "index.js",
"version": "1.3.0",
"version": "1.6.1",
"scripts": {
"start": "expo start",
"android": "expo start --android",
@ -12,12 +12,14 @@
"@expo-google-fonts/inter": "^0.4.2",
"@expo/ngrok": "^4.1.3",
"@expo/vector-icons": "^15.0.3",
"@logto/rn": "^1.1.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-navigation/native": "^7.1.8",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1",
"drizzle-orm": "^0.45.2",
"expo": "~54.0.33",
"expo-auth-session": "~7.0.10",
"expo-calendar": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.8",
@ -29,6 +31,7 @@
"expo-localization": "~17.0.8",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",

View file

@ -0,0 +1 @@
ALTER TABLE `tags` ADD `updated_at` integer;

View file

@ -0,0 +1,9 @@
CREATE TABLE `sync_outbox` (
`id` text PRIMARY KEY NOT NULL,
`entity_type` text NOT NULL,
`entity_id` text NOT NULL,
`action` text NOT NULL,
`payload` text NOT NULL,
`created_at` text NOT NULL,
`synced_at` text
);

View file

@ -0,0 +1,316 @@
{
"version": "6",
"dialect": "sqlite",
"id": "d3023632-946c-4fe9-b543-61cdf8af873c",
"prevId": "3b2c3545-d1aa-4879-9654-4c6b58c73dc2",
"tables": {
"lists": {
"name": "lists",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_inbox": {
"name": "is_inbox",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'#4A90A4'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_tags": {
"name": "task_tags",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"task_tags_task_id_tasks_id_fk": {
"name": "task_tags_task_id_tasks_id_fk",
"tableFrom": "task_tags",
"tableTo": "tasks",
"columnsFrom": [
"task_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"task_tags_tag_id_tags_id_fk": {
"name": "task_tags_tag_id_tags_id_fk",
"tableFrom": "task_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"task_tags_task_id_tag_id_pk": {
"columns": [
"task_id",
"tag_id"
],
"name": "task_tags_task_id_tag_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed": {
"name": "completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"due_date": {
"name": "due_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"list_id": {
"name": "list_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"recurrence": {
"name": "recurrence",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"calendar_event_id": {
"name": "calendar_event_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"tasks_list_id_lists_id_fk": {
"name": "tasks_list_id_lists_id_fk",
"tableFrom": "tasks",
"tableTo": "lists",
"columnsFrom": [
"list_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,375 @@
{
"version": "6",
"dialect": "sqlite",
"id": "3bd69590-afd7-4470-a63b-68306ffbf911",
"prevId": "d3023632-946c-4fe9-b543-61cdf8af873c",
"tables": {
"lists": {
"name": "lists",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_inbox": {
"name": "is_inbox",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sync_outbox": {
"name": "sync_outbox",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"synced_at": {
"name": "synced_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'#4A90A4'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_tags": {
"name": "task_tags",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"task_tags_task_id_tasks_id_fk": {
"name": "task_tags_task_id_tasks_id_fk",
"tableFrom": "task_tags",
"tableTo": "tasks",
"columnsFrom": [
"task_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"task_tags_tag_id_tags_id_fk": {
"name": "task_tags_tag_id_tags_id_fk",
"tableFrom": "task_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"task_tags_task_id_tag_id_pk": {
"columns": [
"task_id",
"tag_id"
],
"name": "task_tags_task_id_tag_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed": {
"name": "completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"due_date": {
"name": "due_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"list_id": {
"name": "list_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"recurrence": {
"name": "recurrence",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"calendar_event_id": {
"name": "calendar_event_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"tasks_list_id_lists_id_fk": {
"name": "tasks_list_id_lists_id_fk",
"tableFrom": "tasks",
"tableTo": "lists",
"columnsFrom": [
"list_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -22,6 +22,20 @@
"when": 1771639773448,
"tag": "0002_majestic_wendell_rand",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1775486221676,
"tag": "0003_sharp_radioactive_man",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1775493830127,
"tag": "0004_nosy_human_torch",
"breakpoints": true
}
]
}

View file

@ -4,13 +4,17 @@ import journal from './meta/_journal.json';
import m0000 from './0000_bitter_phalanx.sql';
import m0001 from './0001_sticky_arachne.sql';
import m0002 from './0002_majestic_wendell_rand.sql';
import m0003 from './0003_sharp_radioactive_man.sql';
import m0004 from './0004_nosy_human_torch.sql';
export default {
journal,
migrations: {
m0000,
m0001,
m0002
m0002,
m0003,
m0004
}
}

View file

@ -3,6 +3,7 @@ import { db } from '../client';
import { lists } from '../schema';
import { randomUUID } from '@/src/lib/uuid';
import { truncate } from '@/src/lib/validation';
import { writeOutboxEntry } from './outbox';
const INBOX_ID = '00000000-0000-0000-0000-000000000001';
@ -46,6 +47,16 @@ export async function createList(name: string, color?: string, icon?: string) {
createdAt: now,
updatedAt: now,
});
writeOutboxEntry('list', id, 'create', {
id,
name,
color: color ?? null,
icon: icon ?? null,
position: maxPosition + 1,
isInbox: false,
}).catch(() => {});
return id;
}
@ -56,6 +67,8 @@ export async function updateList(id: string, data: { name?: string; color?: stri
.update(lists)
.set({ ...sanitized, updatedAt: new Date() })
.where(eq(lists.id, id));
writeOutboxEntry('list', id, 'update', { id, ...sanitized }).catch(() => {});
}
export async function reorderLists(updates: { id: string; position: number }[]) {
@ -68,4 +81,5 @@ export async function reorderLists(updates: { id: string; position: number }[])
export async function deleteList(id: string) {
await db.delete(lists).where(eq(lists.id, id));
writeOutboxEntry('list', id, 'delete', { id }).catch(() => {});
}

View file

@ -0,0 +1,31 @@
import { db } from '../client';
import { syncOutbox } from '../schema';
import { randomUUID } from '@/src/lib/uuid';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
type EntityType = 'task' | 'list' | 'tag' | 'taskTag';
type Action = 'create' | 'update' | 'delete';
/**
* Write an entry to the sync outbox if sync is enabled.
* The entry id serves as the idempotency key.
*/
export async function writeOutboxEntry(
entityType: EntityType,
entityId: string,
action: Action,
payload: Record<string, unknown>
): Promise<void> {
const { syncEnabled } = useSettingsStore.getState();
if (!syncEnabled) return;
await db.insert(syncOutbox).values({
id: randomUUID(),
entityType,
entityId,
action,
payload: JSON.stringify(payload),
createdAt: new Date().toISOString(),
syncedAt: null,
});
}

View file

@ -3,6 +3,7 @@ import { db } from '../client';
import { tags, taskTags } from '../schema';
import { randomUUID } from '@/src/lib/uuid';
import { truncate } from '@/src/lib/validation';
import { writeOutboxEntry } from './outbox';
export async function getAllTags() {
return db.select().from(tags).orderBy(tags.name);
@ -10,22 +11,29 @@ export async function getAllTags() {
export async function createTag(name: string, color: string) {
const id = randomUUID();
const now = new Date();
await db.insert(tags).values({
id,
name: truncate(name, 100),
color,
createdAt: new Date(),
createdAt: now,
updatedAt: now,
});
writeOutboxEntry('tag', id, 'create', { id, name, color }).catch(() => {});
return id;
}
export async function updateTag(id: string, name: string, color: string) {
await db.update(tags).set({ name: truncate(name, 100), color }).where(eq(tags.id, id));
await db.update(tags).set({ name: truncate(name, 100), color, updatedAt: new Date() }).where(eq(tags.id, id));
writeOutboxEntry('tag', id, 'update', { id, name, color }).catch(() => {});
}
export async function deleteTag(id: string) {
await db.delete(taskTags).where(eq(taskTags.tagId, id));
await db.delete(tags).where(eq(tags.id, id));
writeOutboxEntry('tag', id, 'delete', { id }).catch(() => {});
}
export async function getTagsForTask(taskId: string) {
@ -44,6 +52,10 @@ export async function setTagsForTask(taskId: string, tagIds: string[]) {
tagIds.map((tagId) => ({ taskId, tagId }))
);
}
// Send individual taskTag create operations (server expects entityId=taskId, data.tagId)
for (const tagId of tagIds) {
writeOutboxEntry('taskTag', taskId, 'create', { tagId }).catch(() => {});
}
}
export async function addTagToTask(taskId: string, tagId: string) {

View file

@ -4,22 +4,16 @@ import { tasks, taskTags } from '../schema';
import { randomUUID } from '@/src/lib/uuid';
import { getNextOccurrence, type RecurrenceType } from '@/src/lib/recurrence';
import { startOfDay, endOfDay, endOfWeek, startOfWeek } from 'date-fns';
import type { SortBy, SortOrder, FilterCompleted, FilterDueDate } from '@/src/stores/useTaskStore';
import type { TaskFilters, SortBy, SortOrder } from '@/src/shared/types';
import { scheduleTaskReminder, cancelTaskReminder } from '@/src/services/notifications';
import { addTaskToCalendar, updateCalendarEvent, removeCalendarEvent } from '@/src/services/calendar';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { syncWidgetData } from '@/src/services/widgetSync';
import { clamp, truncate } from '@/src/lib/validation';
import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence';
import { writeOutboxEntry } from './outbox';
export interface TaskFilters {
sortBy?: SortBy;
sortOrder?: SortOrder;
filterPriority?: number | null;
filterTag?: string | null;
filterCompleted?: FilterCompleted;
filterDueDate?: FilterDueDate;
}
export type { TaskFilters } from '@/src/shared/types';
export async function getTasksByList(listId: string, filters?: TaskFilters) {
const conditions = [eq(tasks.listId, listId), isNull(tasks.parentId)];
@ -177,6 +171,20 @@ export async function createTask(data: {
}
}
// Sync outbox
writeOutboxEntry('task', id, 'create', {
id,
title: data.title,
notes: data.notes ?? null,
completed: false,
completedAt: null,
priority: data.priority ?? 0,
dueDate: data.dueDate?.toISOString() ?? null,
listId: data.listId,
parentId: data.parentId ?? null,
recurrence: sanitizedRecurrence,
}).catch(() => {});
syncWidgetData().catch(() => {});
return id;
@ -242,6 +250,22 @@ export async function updateTask(
}
}
// Sync outbox
if (task) {
writeOutboxEntry('task', id, 'update', {
id,
title: task.title,
notes: task.notes,
completed: task.completed,
completedAt: task.completedAt ? new Date(task.completedAt).toISOString() : null,
priority: task.priority,
dueDate: task.dueDate ? new Date(task.dueDate).toISOString() : null,
listId: task.listId,
parentId: task.parentId,
recurrence: task.recurrence,
}).catch(() => {});
}
syncWidgetData().catch(() => {});
}
@ -310,11 +334,15 @@ export async function deleteTask(id: string) {
// Delete subtasks first
const subtasks = await getSubtasks(id);
for (const sub of subtasks) {
writeOutboxEntry('task', sub.id, 'delete', { id: sub.id }).catch(() => {});
await db.delete(taskTags).where(eq(taskTags.taskId, sub.id));
await db.delete(tasks).where(eq(tasks.id, sub.id));
}
await db.delete(taskTags).where(eq(taskTags.taskId, id));
await db.delete(tasks).where(eq(tasks.id, id));
// Sync outbox
writeOutboxEntry('task', id, 'delete', { id }).catch(() => {});
syncWidgetData().catch(() => {});
}

View file

@ -33,6 +33,17 @@ export const tags = sqliteTable('tags', {
name: text('name').notNull(),
color: text('color').notNull().default('#4A90A4'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }),
});
export const syncOutbox = sqliteTable('sync_outbox', {
id: text('id').primaryKey(),
entityType: text('entity_type').notNull(), // 'task' | 'list' | 'tag' | 'task_tag'
entityId: text('entity_id').notNull(),
action: text('action').notNull(), // 'create' | 'update' | 'delete'
payload: text('payload').notNull(), // JSON-serialized entity data
createdAt: text('created_at').notNull(), // ISO timestamp
syncedAt: text('synced_at'), // ISO timestamp, null = not synced
});
export const taskTags = sqliteTable(

View file

@ -21,6 +21,7 @@
"completed": "Completed",
"newTask": "New task",
"deleteConfirm": "Are you sure you want to delete this task?",
"deleteSubtaskConfirm": "Are you sure you want to delete this subtask?",
"swipeDelete": "Swipe to delete",
"swipeComplete": "Swipe to complete",
"dragHandle": "Hold to reorder"
@ -130,6 +131,29 @@
"inbox": "No tasks yet.\nTap + to get started.",
"list": "This list is empty."
},
"sync": {
"title": "Account",
"signIn": "Sign in",
"signOut": "Sign out",
"signOutConfirm": "Are you sure you want to sign out?",
"syncNow": "Sync now",
"syncing": "Syncing...",
"lastSync": "Last sync: {{date}}",
"never": "Never synced",
"connectedAs": "Connected: {{userId}}",
"syncEnabled": "Sync enabled",
"syncDescription": "Syncs your data across devices",
"firstSyncTitle": "First sync",
"firstSyncMessage": "You have tasks on this device. What would you like to do?",
"mergeLocal": "Merge my tasks",
"mergeDescription": "Sends your local tasks to the server",
"resetFromServer": "Start from server",
"resetDescription": "Replaces local data with server data",
"merging": "Merging...",
"mergeDone": "Tasks merged successfully!",
"resetDone": "Data synced from server.",
"syncError": "Sync error"
},
"widget": {
"title": "Simpl-Liste",
"taskCount_one": "{{count}} task",

View file

@ -21,6 +21,7 @@
"completed": "Terminée",
"newTask": "Nouvelle tâche",
"deleteConfirm": "Voulez-vous vraiment supprimer cette tâche ?",
"deleteSubtaskConfirm": "Voulez-vous vraiment supprimer cette sous-tâche ?",
"swipeDelete": "Glisser pour supprimer",
"swipeComplete": "Glisser pour compléter",
"dragHandle": "Maintenir pour réordonner"
@ -130,6 +131,29 @@
"inbox": "Aucune tâche.\nAppuyez sur + pour commencer.",
"list": "Cette liste est vide."
},
"sync": {
"title": "Compte",
"signIn": "Se connecter",
"signOut": "Se déconnecter",
"signOutConfirm": "Voulez-vous vraiment vous déconnecter ?",
"syncNow": "Synchroniser",
"syncing": "Synchronisation...",
"lastSync": "Dernière sync : {{date}}",
"never": "Jamais synchronisé",
"connectedAs": "Connecté : {{userId}}",
"syncEnabled": "Synchronisation activée",
"syncDescription": "Synchronise vos données entre appareils",
"firstSyncTitle": "Première synchronisation",
"firstSyncMessage": "Vous avez des tâches sur cet appareil. Que voulez-vous faire ?",
"mergeLocal": "Fusionner mes tâches",
"mergeDescription": "Envoie vos tâches locales vers le serveur",
"resetFromServer": "Repartir du serveur",
"resetDescription": "Remplace les données locales par celles du serveur",
"merging": "Fusion en cours...",
"mergeDone": "Tâches fusionnées avec succès !",
"resetDone": "Données synchronisées depuis le serveur.",
"syncError": "Erreur de synchronisation"
},
"widget": {
"title": "Simpl-Liste",
"taskCount_one": "{{count}} tâche",

23
src/lib/authToken.ts Normal file
View file

@ -0,0 +1,23 @@
// Holds a reference to the getAccessToken function from @logto/rn.
// Set from the React tree (via LogtoProvider/useLogto), used by syncClient.
type TokenGetter = () => Promise<string>;
let _getAccessToken: TokenGetter | null = null;
export function setTokenGetter(getter: TokenGetter): void {
_getAccessToken = getter;
}
export function clearTokenGetter(): void {
_getAccessToken = null;
}
export async function getAccessToken(): Promise<string | null> {
if (!_getAccessToken) return null;
try {
return await _getAccessToken();
} catch {
return null;
}
}

11
src/lib/logtoConfig.ts Normal file
View file

@ -0,0 +1,11 @@
import type { LogtoNativeConfig } from '@logto/rn';
export const logtoConfig: LogtoNativeConfig = {
endpoint: 'https://auth.lacompagniemaximus.com',
appId: 'sl-mobile-native',
scopes: ['openid', 'profile', 'email'],
};
// Redirect URI uses the app scheme defined in app.json
export const redirectUri = 'simplliste://callback';
export const postSignOutRedirectUri = 'simplliste://';

View file

@ -1,29 +1 @@
import { colors } from '@/src/theme/colors';
const lightColors = [
colors.priority.none,
colors.priority.low,
colors.priority.medium,
colors.priority.high,
];
const darkColors = [
colors.priority.noneLight,
colors.priority.lowLight,
colors.priority.mediumLight,
colors.priority.highLight,
];
export function getPriorityColor(priority: number, isDark: boolean): string {
const palette = isDark ? darkColors : lightColors;
return palette[priority] ?? palette[0];
}
export function getPriorityOptions(isDark: boolean) {
return [
{ value: 0, labelKey: 'priority.none', color: getPriorityColor(0, isDark) },
{ value: 1, labelKey: 'priority.low', color: getPriorityColor(1, isDark) },
{ value: 2, labelKey: 'priority.medium', color: getPriorityColor(2, isDark) },
{ value: 3, labelKey: 'priority.high', color: getPriorityColor(3, isDark) },
];
}
export { getPriorityColor, getPriorityOptions } from '@/src/shared/priority';

View file

@ -1,18 +1,2 @@
import { addDays, addWeeks, addMonths, addYears } from 'date-fns';
export type RecurrenceType = 'daily' | 'weekly' | 'monthly' | 'yearly';
export const RECURRENCE_OPTIONS: RecurrenceType[] = ['daily', 'weekly', 'monthly', 'yearly'];
export function getNextOccurrence(dueDate: Date, recurrence: RecurrenceType): Date {
switch (recurrence) {
case 'daily':
return addDays(dueDate, 1);
case 'weekly':
return addWeeks(dueDate, 1);
case 'monthly':
return addMonths(dueDate, 1);
case 'yearly':
return addYears(dueDate, 1);
}
}
export { getNextOccurrence, RECURRENCE_OPTIONS } from '@/src/shared/recurrence';
export type { RecurrenceType } from '@/src/shared/recurrence';

432
src/services/syncClient.ts Normal file
View file

@ -0,0 +1,432 @@
import { eq, isNull, not } from 'drizzle-orm';
import { db } from '@/src/db/client';
import { syncOutbox, lists, tasks, tags, taskTags } from '@/src/db/schema';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { getAccessToken } from '@/src/lib/authToken';
import { randomUUID } from '@/src/lib/uuid';
import { syncWidgetData } from '@/src/services/widgetSync';
const SYNC_API_BASE = 'https://liste.lacompagniemaximus.com';
const INBOX_ID = '00000000-0000-0000-0000-000000000001';
interface SyncOperation {
idempotencyKey: string;
entityType: 'list' | 'task' | 'tag' | 'taskTag';
entityId: string;
action: 'create' | 'update' | 'delete';
data?: Record<string, unknown>;
}
interface SyncPullChange {
entity_type: 'list' | 'task' | 'tag' | 'task_tag';
entity_id: string;
action: 'create' | 'update' | 'delete';
payload: Record<string, unknown>;
updated_at: string;
}
interface SyncPullResponse {
changes: SyncPullChange[];
sync_token: string;
}
async function getAuthHeaders(): Promise<Record<string, string>> {
const token = await getAccessToken();
if (!token) return {};
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
};
}
/**
* Send a batch of operations to the server sync endpoint.
*/
async function sendOperations(operations: SyncOperation[], headers: Record<string, string>): Promise<boolean> {
const batchSize = 50;
for (let i = 0; i < operations.length; i += batchSize) {
const batch = operations.slice(i, i + batchSize);
try {
const res = await fetch(`${SYNC_API_BASE}/api/sync`, {
method: 'POST',
headers,
body: JSON.stringify({ operations: batch }),
});
if (!res.ok) {
console.warn(`[sync] push failed with status ${res.status}`);
return false;
}
} catch (err) {
console.warn('[sync] push error:', err);
return false;
}
}
return true;
}
/**
* Push unsynced outbox entries to the server.
*/
export async function pushChanges(): Promise<void> {
const headers = await getAuthHeaders();
if (!headers['Authorization']) return;
const unsynced = await db
.select()
.from(syncOutbox)
.where(isNull(syncOutbox.syncedAt));
if (unsynced.length === 0) return;
const operations: SyncOperation[] = unsynced.map((entry) => {
const data = JSON.parse(entry.payload);
return {
idempotencyKey: entry.id,
entityType: entry.entityType as SyncOperation['entityType'],
entityId: entry.entityId,
action: entry.action as SyncOperation['action'],
data,
};
});
const ok = await sendOperations(operations, headers);
if (ok) {
const now = new Date().toISOString();
for (const entry of unsynced) {
await db
.update(syncOutbox)
.set({ syncedAt: now })
.where(eq(syncOutbox.id, entry.id));
}
// Refresh widget after a successful push to reflect the synced state
syncWidgetData().catch(() => {});
}
}
/**
* Pull changes from the server since the last sync timestamp.
*/
export async function pullChanges(since: string): Promise<void> {
const headers = await getAuthHeaders();
if (!headers['Authorization']) return;
try {
const url = `${SYNC_API_BASE}/api/sync?since=${encodeURIComponent(since)}`;
const res = await fetch(url, { method: 'GET', headers });
if (!res.ok) {
console.warn(`[sync] pull failed with status ${res.status}`);
return;
}
const data: SyncPullResponse = await res.json();
let appliedChanges = 0;
for (const change of data.changes) {
try {
await applyChange(change);
appliedChanges++;
} catch (err) {
console.warn(`[sync] failed to apply change for ${change.entity_type}/${change.entity_id}:`, err);
}
}
// Update last sync timestamp
if (data.sync_token) {
useSettingsStore.getState().setLastSyncAt(data.sync_token);
}
// Refresh widget once after applying all remote changes
if (appliedChanges > 0) {
syncWidgetData().catch(() => {});
}
} catch (err) {
console.warn('[sync] pull error:', err);
}
}
async function applyChange(change: SyncPullChange): Promise<void> {
const { entity_type, action, payload, entity_id } = change;
switch (entity_type) {
case 'list':
await applyListChange(entity_id, action, payload);
break;
case 'task':
await applyTaskChange(entity_id, action, payload);
break;
case 'tag':
await applyTagChange(entity_id, action, payload);
break;
case 'task_tag':
await applyTaskTagChange(entity_id, action, payload);
break;
}
}
async function applyListChange(id: string, action: string, payload: Record<string, unknown>) {
if (action === 'delete') {
await db.delete(lists).where(eq(lists.id, id));
return;
}
const existing = await db.select().from(lists).where(eq(lists.id, id));
const values = {
id,
name: payload.name as string,
color: (payload.color as string) ?? null,
icon: (payload.icon as string) ?? null,
position: (payload.position as number) ?? 0,
isInbox: (payload.is_inbox as boolean) ?? false,
createdAt: new Date(payload.created_at as string),
updatedAt: new Date(payload.updated_at as string),
};
if (existing.length > 0) {
await db.update(lists).set(values).where(eq(lists.id, id));
} else {
await db.insert(lists).values(values);
}
}
async function applyTaskChange(id: string, action: string, payload: Record<string, unknown>) {
if (action === 'delete') {
await db.delete(taskTags).where(eq(taskTags.taskId, id));
await db.delete(tasks).where(eq(tasks.id, id));
return;
}
const existing = await db.select().from(tasks).where(eq(tasks.id, id));
const values = {
id,
title: payload.title as string,
notes: (payload.notes as string) ?? null,
completed: (payload.completed as boolean) ?? false,
completedAt: payload.completed_at ? new Date(payload.completed_at as string) : null,
priority: (payload.priority as number) ?? 0,
dueDate: payload.due_date ? new Date(payload.due_date as string) : null,
listId: payload.list_id as string,
parentId: (payload.parent_id as string) ?? null,
position: (payload.position as number) ?? 0,
recurrence: (payload.recurrence as string) ?? null,
calendarEventId: (payload.calendar_event_id as string) ?? null,
createdAt: new Date(payload.created_at as string),
updatedAt: new Date(payload.updated_at as string),
};
if (existing.length > 0) {
await db.update(tasks).set(values).where(eq(tasks.id, id));
} else {
await db.insert(tasks).values(values);
}
}
async function applyTagChange(id: string, action: string, payload: Record<string, unknown>) {
if (action === 'delete') {
await db.delete(taskTags).where(eq(taskTags.tagId, id));
await db.delete(tags).where(eq(tags.id, id));
return;
}
const existing = await db.select().from(tags).where(eq(tags.id, id));
const values = {
id,
name: payload.name as string,
color: (payload.color as string) ?? '#4A90A4',
createdAt: new Date(payload.created_at as string),
updatedAt: payload.updated_at ? new Date(payload.updated_at as string) : null,
};
if (existing.length > 0) {
await db.update(tags).set(values).where(eq(tags.id, id));
} else {
await db.insert(tags).values(values);
}
}
async function applyTaskTagChange(id: string, action: string, payload: Record<string, unknown>) {
const taskId = payload.task_id as string;
const tagId = payload.tag_id as string;
if (action === 'delete') {
await db
.delete(taskTags)
.where(eq(taskTags.taskId, taskId));
return;
}
// Upsert: insert if not exists
try {
await db.insert(taskTags).values({ taskId, tagId }).onConflictDoNothing();
} catch {
// Ignore constraint errors
}
}
/**
* Full sync: push local changes then pull remote changes.
*/
export async function fullSync(): Promise<void> {
const { syncEnabled } = useSettingsStore.getState();
if (!syncEnabled) return;
try {
await pushChanges();
const since = useSettingsStore.getState().lastSyncAt ?? '1970-01-01T00:00:00.000Z';
await pullChanges(since);
} catch (err) {
console.warn('[sync] fullSync error:', err);
}
}
/**
* First-time sync: merge all local data to server.
* Creates an Inbox on the server, remaps the local hardcoded Inbox ID,
* then pushes all lists, tasks, tags, and task-tag relations.
*/
export async function initialMerge(): Promise<void> {
const headers = await getAuthHeaders();
if (!headers['Authorization']) return;
const operations: SyncOperation[] = [];
// 1. Read all local data
const allLists = await db.select().from(lists);
const allTasks = await db.select().from(tasks);
const allTags = await db.select().from(tags);
const allTaskTags = await db.select().from(taskTags);
// 2. First, create the Inbox on the server with a new UUID
const serverInboxId = randomUUID();
const localInbox = allLists.find((l) => l.id === INBOX_ID);
// Map old inbox ID → new inbox ID for task remapping
const idMap: Record<string, string> = {};
if (localInbox) {
idMap[INBOX_ID] = serverInboxId;
}
// 3. Push lists
for (const list of allLists) {
const newId = idMap[list.id] || list.id;
operations.push({
idempotencyKey: randomUUID(),
entityType: 'list',
entityId: newId,
action: 'create',
data: {
name: list.name,
color: list.color,
icon: list.icon,
position: list.position,
isInbox: list.isInbox,
},
});
}
// 4. Push tasks (remap listId if it pointed to the old inbox)
for (const task of allTasks) {
const remappedListId = idMap[task.listId] || task.listId;
const remappedParentId = task.parentId || undefined;
operations.push({
idempotencyKey: randomUUID(),
entityType: 'task',
entityId: task.id,
action: 'create',
data: {
title: task.title,
notes: task.notes,
completed: task.completed,
priority: task.priority,
dueDate: task.dueDate ? task.dueDate.toISOString() : undefined,
listId: remappedListId,
parentId: remappedParentId,
position: task.position,
recurrence: task.recurrence,
},
});
}
// 5. Push tags
for (const tag of allTags) {
operations.push({
idempotencyKey: randomUUID(),
entityType: 'tag',
entityId: tag.id,
action: 'create',
data: {
name: tag.name,
color: tag.color,
},
});
}
// 6. Push task-tag relations
for (const tt of allTaskTags) {
operations.push({
idempotencyKey: randomUUID(),
entityType: 'taskTag',
entityId: tt.taskId,
action: 'create',
data: { tagId: tt.tagId },
});
}
// 7. Send to server
const ok = await sendOperations(operations, headers);
if (!ok) {
throw new Error('Failed to push local data to server');
}
// 8. Remap local Inbox ID to match the server
if (localInbox) {
// Update all tasks pointing to the old inbox
await db.update(tasks).set({ listId: serverInboxId }).where(eq(tasks.listId, INBOX_ID));
// Delete old inbox and insert with new ID
await db.delete(lists).where(eq(lists.id, INBOX_ID));
await db.insert(lists).values({
...localInbox,
id: serverInboxId,
updatedAt: new Date(),
});
}
// 9. Mark sync timestamp
useSettingsStore.getState().setLastSyncAt(new Date().toISOString());
}
/**
* First-time sync: discard local data and pull everything from server.
*/
export async function initialReset(): Promise<void> {
const headers = await getAuthHeaders();
if (!headers['Authorization']) return;
// 1. Delete all local data
await db.delete(taskTags);
await db.delete(tasks);
await db.delete(tags);
await db.delete(lists);
await db.delete(syncOutbox);
// 2. Pull everything from server
await pullChanges('1970-01-01T00:00:00.000Z');
// 3. Ensure we have a local inbox (the server may have created one)
const serverLists = await db.select().from(lists);
const hasInbox = serverLists.some((l) => l.isInbox);
if (!hasInbox) {
// Import ensureInbox dynamically to avoid circular deps
const { ensureInbox } = await import('@/src/db/repository/lists');
await ensureInbox();
}
}
/**
* Clean up synced outbox entries to prevent unbounded growth.
* Deletes all entries that have been successfully synced.
*/
export async function cleanOutbox(): Promise<void> {
await db.delete(syncOutbox).where(not(isNull(syncOutbox.syncedAt)));
}

View file

@ -7,8 +7,13 @@ import { eq, and, isNull, gte, lte, lt, asc, sql } from 'drizzle-orm';
import { startOfDay, endOfDay, addWeeks } from 'date-fns';
import { TaskListWidget } from '../widgets/TaskListWidget';
export const WIDGET_DATA_KEY = 'widget:tasks';
export const WIDGET_DARK_KEY = 'widget:isDark';
export const WIDGET_STATE_KEY = 'widget:state';
export const WIDGET_NAMES = ['SimplListeSmall', 'SimplListeMedium', 'SimplListeLarge'] as const;
// Legacy keys — used for migration only
const LEGACY_DATA_KEY = 'widget:tasks';
const LEGACY_DARK_KEY = 'widget:isDark';
const LEGACY_EXPANDED_KEY = 'widget:expandedTaskIds';
export interface WidgetSubtask {
id: string;
@ -28,6 +33,51 @@ export interface WidgetTask {
subtasks: WidgetSubtask[];
}
export interface WidgetState {
tasks: WidgetTask[];
isDark: boolean;
expandedTaskIds: string[];
}
export async function getWidgetState(): Promise<WidgetState> {
try {
const raw = await AsyncStorage.getItem(WIDGET_STATE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return {
tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
isDark: parsed.isDark === true,
expandedTaskIds: Array.isArray(parsed.expandedTaskIds) ? parsed.expandedTaskIds : [],
};
}
// Migration from legacy keys
const [dataRaw, darkRaw, expandedRaw] = await Promise.all([
AsyncStorage.getItem(LEGACY_DATA_KEY),
AsyncStorage.getItem(LEGACY_DARK_KEY),
AsyncStorage.getItem(LEGACY_EXPANDED_KEY),
]);
const state: WidgetState = {
tasks: dataRaw ? JSON.parse(dataRaw) : [],
isDark: darkRaw ? JSON.parse(darkRaw) === true : false,
expandedTaskIds: expandedRaw ? JSON.parse(expandedRaw) : [],
};
// Write consolidated key and clean up legacy keys
await AsyncStorage.setItem(WIDGET_STATE_KEY, JSON.stringify(state));
await AsyncStorage.multiRemove([LEGACY_DATA_KEY, LEGACY_DARK_KEY, LEGACY_EXPANDED_KEY]);
return state;
} catch {
return { tasks: [], isDark: false, expandedTaskIds: [] };
}
}
export async function setWidgetState(state: WidgetState): Promise<void> {
await AsyncStorage.setItem(WIDGET_STATE_KEY, JSON.stringify(state));
}
export async function syncWidgetData(): Promise<void> {
if (Platform.OS !== 'android') return;
@ -35,8 +85,7 @@ export async function syncWidgetData(): Promise<void> {
const now = new Date();
const todayStart = startOfDay(now);
// Read widget period setting from AsyncStorage (0 = all, N = N weeks ahead)
// Coupled with useSettingsStore.ts — key 'simpl-liste-settings', path state.widgetPeriodWeeks
// Read widget period setting from AsyncStorage
let widgetPeriodWeeks = 0;
try {
const settingsRaw = await AsyncStorage.getItem('simpl-liste-settings');
@ -153,23 +202,20 @@ export async function syncWidgetData(): Promise<void> {
// Default to light
}
await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(allTasks));
await AsyncStorage.setItem(WIDGET_DARK_KEY, JSON.stringify(isDark));
// Read expanded state
// Read existing expanded state to preserve it
let expandedTaskIds: string[] = [];
try {
const expandedRaw = await AsyncStorage.getItem('widget:expandedTaskIds');
if (expandedRaw) {
const parsed = JSON.parse(expandedRaw);
if (Array.isArray(parsed)) expandedTaskIds = parsed;
}
const existing = await getWidgetState();
expandedTaskIds = existing.expandedTaskIds;
} catch {
// Default to none expanded
}
const state: WidgetState = { tasks: allTasks, isDark, expandedTaskIds };
await setWidgetState(state);
// Request widget update for all 3 sizes
const widgetNames = ['SimplListeSmall', 'SimplListeMedium', 'SimplListeLarge'];
const widgetNames = WIDGET_NAMES;
for (const widgetName of widgetNames) {
try {
await requestWidgetUpdate({

40
src/shared/colors.ts Normal file
View file

@ -0,0 +1,40 @@
export const colors = {
bleu: {
DEFAULT: '#4A90A4',
light: '#6BAEC2',
dark: '#3A7389',
},
creme: {
DEFAULT: '#FFF8F0',
dark: '#F5EDE3',
},
terracotta: {
DEFAULT: '#C17767',
light: '#D49585',
dark: '#A45F50',
},
priority: {
high: '#C17767',
medium: '#4A90A4',
low: '#8BA889',
none: '#9CA3AF',
highLight: '#E8A090',
mediumLight: '#7CC0D6',
lowLight: '#B0D4A8',
noneLight: '#C0C7CF',
},
light: {
background: '#FFF8F0',
surface: '#FFFFFF',
text: '#1A1A1A',
textSecondary: '#6B6B6B',
border: '#E5E7EB',
},
dark: {
background: '#1A1A1A',
surface: '#2A2A2A',
text: '#F5F5F5',
textSecondary: '#A0A0A0',
border: '#3A3A3A',
},
} as const;

29
src/shared/priority.ts Normal file
View file

@ -0,0 +1,29 @@
import { colors } from './colors';
const lightColors = [
colors.priority.none,
colors.priority.low,
colors.priority.medium,
colors.priority.high,
];
const darkColors = [
colors.priority.noneLight,
colors.priority.lowLight,
colors.priority.mediumLight,
colors.priority.highLight,
];
export function getPriorityColor(priority: number, isDark: boolean): string {
const palette = isDark ? darkColors : lightColors;
return palette[priority] ?? palette[0];
}
export function getPriorityOptions(isDark: boolean) {
return [
{ value: 0, labelKey: 'priority.none', color: getPriorityColor(0, isDark) },
{ value: 1, labelKey: 'priority.low', color: getPriorityColor(1, isDark) },
{ value: 2, labelKey: 'priority.medium', color: getPriorityColor(2, isDark) },
{ value: 3, labelKey: 'priority.high', color: getPriorityColor(3, isDark) },
];
}

18
src/shared/recurrence.ts Normal file
View file

@ -0,0 +1,18 @@
import { addDays, addWeeks, addMonths, addYears } from 'date-fns';
export type RecurrenceType = 'daily' | 'weekly' | 'monthly' | 'yearly';
export const RECURRENCE_OPTIONS: RecurrenceType[] = ['daily', 'weekly', 'monthly', 'yearly'];
export function getNextOccurrence(dueDate: Date, recurrence: RecurrenceType): Date {
switch (recurrence) {
case 'daily':
return addDays(dueDate, 1);
case 'weekly':
return addWeeks(dueDate, 1);
case 'monthly':
return addMonths(dueDate, 1);
case 'yearly':
return addYears(dueDate, 1);
}
}

17
src/shared/types.ts Normal file
View file

@ -0,0 +1,17 @@
export type { RecurrenceType } from './recurrence';
export type Priority = 0 | 1 | 2 | 3;
export type SortBy = 'position' | 'priority' | 'dueDate' | 'title' | 'createdAt';
export type SortOrder = 'asc' | 'desc';
export type FilterCompleted = 'all' | 'active' | 'completed';
export type FilterDueDate = 'all' | 'today' | 'week' | 'overdue' | 'noDate';
export interface TaskFilters {
sortBy?: SortBy;
sortOrder?: SortOrder;
filterPriority?: number | null;
filterTag?: string | null;
filterCompleted?: FilterCompleted;
filterDueDate?: FilterDueDate;
}

View file

@ -11,12 +11,18 @@ interface SettingsState {
reminderOffset: number; // hours before due date (0 = at time)
calendarSyncEnabled: boolean;
widgetPeriodWeeks: number; // 0 = all tasks, otherwise number of weeks ahead
syncEnabled: boolean;
lastSyncAt: string | null; // ISO timestamp
userId: string | null;
setTheme: (theme: ThemeMode) => void;
setLocale: (locale: 'fr' | 'en') => void;
setNotificationsEnabled: (enabled: boolean) => void;
setReminderOffset: (offset: number) => void;
setCalendarSyncEnabled: (enabled: boolean) => void;
setWidgetPeriodWeeks: (weeks: number) => void;
setSyncEnabled: (enabled: boolean) => void;
setLastSyncAt: (timestamp: string | null) => void;
setUserId: (userId: string | null) => void;
}
export const useSettingsStore = create<SettingsState>()(
@ -28,12 +34,18 @@ export const useSettingsStore = create<SettingsState>()(
reminderOffset: 0,
calendarSyncEnabled: false,
widgetPeriodWeeks: 0,
syncEnabled: false,
lastSyncAt: null,
userId: null,
setTheme: (theme) => set({ theme }),
setLocale: (locale) => set({ locale }),
setNotificationsEnabled: (notificationsEnabled) => set({ notificationsEnabled }),
setReminderOffset: (reminderOffset) => set({ reminderOffset }),
setCalendarSyncEnabled: (calendarSyncEnabled) => set({ calendarSyncEnabled }),
setWidgetPeriodWeeks: (widgetPeriodWeeks) => set({ widgetPeriodWeeks }),
setSyncEnabled: (syncEnabled) => set({ syncEnabled }),
setLastSyncAt: (lastSyncAt) => set({ lastSyncAt }),
setUserId: (userId) => set({ userId }),
}),
{
name: 'simpl-liste-settings',

View file

@ -1,11 +1,9 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { SortBy, SortOrder, FilterCompleted, FilterDueDate } from '@/src/shared/types';
export type SortBy = 'position' | 'priority' | 'dueDate' | 'title' | 'createdAt';
export type SortOrder = 'asc' | 'desc';
export type FilterCompleted = 'all' | 'active' | 'completed';
export type FilterDueDate = 'all' | 'today' | 'week' | 'overdue' | 'noDate';
export type { SortBy, SortOrder, FilterCompleted, FilterDueDate } from '@/src/shared/types';
interface TaskStoreState {
sortBy: SortBy;

View file

@ -1,40 +1 @@
export const colors = {
bleu: {
DEFAULT: '#4A90A4',
light: '#6BAEC2',
dark: '#3A7389',
},
creme: {
DEFAULT: '#FFF8F0',
dark: '#F5EDE3',
},
terracotta: {
DEFAULT: '#C17767',
light: '#D49585',
dark: '#A45F50',
},
priority: {
high: '#C17767',
medium: '#4A90A4',
low: '#8BA889',
none: '#9CA3AF',
highLight: '#E8A090',
mediumLight: '#7CC0D6',
lowLight: '#B0D4A8',
noneLight: '#C0C7CF',
},
light: {
background: '#FFF8F0',
surface: '#FFFFFF',
text: '#1A1A1A',
textSecondary: '#6B6B6B',
border: '#E5E7EB',
},
dark: {
background: '#1A1A1A',
surface: '#2A2A2A',
text: '#F5F5F5',
textSecondary: '#A0A0A0',
border: '#3A3A3A',
},
} as const;
export { colors } from '@/src/shared/colors';

View file

@ -1,73 +1,18 @@
import type { WidgetTaskHandlerProps } from 'react-native-android-widget';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { requestWidgetUpdate } from 'react-native-android-widget';
import { TaskListWidget } from './TaskListWidget';
import { WIDGET_DATA_KEY, WIDGET_DARK_KEY, type WidgetTask } from '../services/widgetSync';
import { getWidgetState, setWidgetState, WIDGET_NAMES, type WidgetTask } from '../services/widgetSync';
import { isValidUUID } from '../lib/validation';
const WIDGET_EXPANDED_KEY = 'widget:expandedTaskIds';
function isWidgetTask(item: unknown): item is WidgetTask {
if (typeof item !== 'object' || item === null) return false;
const obj = item as Record<string, unknown>;
return (
typeof obj.id === 'string' &&
typeof obj.title === 'string' &&
typeof obj.priority === 'number' &&
typeof obj.completed === 'boolean' &&
(obj.dueDate === null || typeof obj.dueDate === 'string') &&
(obj.listColor === null || obj.listColor === undefined || typeof obj.listColor === 'string') &&
(obj.subtaskCount === undefined || typeof obj.subtaskCount === 'number') &&
(obj.subtaskDoneCount === undefined || typeof obj.subtaskDoneCount === 'number')
);
}
async function getWidgetTasks(): Promise<WidgetTask[]> {
try {
const data = await AsyncStorage.getItem(WIDGET_DATA_KEY);
if (!data) return [];
const parsed: unknown = JSON.parse(data);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isWidgetTask).map((t) => ({
...t,
subtasks: Array.isArray(t.subtasks) ? t.subtasks : [],
}));
} catch {
return [];
}
}
async function getWidgetIsDark(): Promise<boolean> {
try {
const data = await AsyncStorage.getItem(WIDGET_DARK_KEY);
if (!data) return false;
return JSON.parse(data) === true;
} catch {
return false;
}
}
async function getExpandedTaskIds(): Promise<Set<string>> {
try {
const data = await AsyncStorage.getItem(WIDGET_EXPANDED_KEY);
if (!data) return new Set();
const parsed: unknown = JSON.parse(data);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((id): id is string => typeof id === 'string'));
} catch {
return new Set();
}
}
async function setExpandedTaskIds(ids: Set<string>): Promise<void> {
await AsyncStorage.setItem(WIDGET_EXPANDED_KEY, JSON.stringify([...ids]));
}
const EXPAND_DEBOUNCE_MS = 2000;
const lastExpandTimes = new Map<string, number>();
function renderWithState(
renderWidget: WidgetTaskHandlerProps['renderWidget'],
widgetInfo: WidgetTaskHandlerProps['widgetInfo'],
tasks: WidgetTask[],
isDark: boolean,
expandedTaskIds: Set<string>,
expandedTaskIds: string[],
) {
renderWidget(
TaskListWidget({
@ -75,11 +20,30 @@ function renderWithState(
widgetName: widgetInfo.widgetName,
tasks,
isDark,
expandedTaskIds: [...expandedTaskIds],
expandedTaskIds,
})
);
}
async function forceWidgetRefresh(
tasks: WidgetTask[],
isDark: boolean,
expandedTaskIds: string[],
): Promise<void> {
for (const widgetName of WIDGET_NAMES) {
try {
await requestWidgetUpdate({
widgetName,
renderWidget: (props) =>
TaskListWidget({ ...props, widgetName, tasks, isDark, expandedTaskIds }),
widgetNotFound: () => {},
});
} catch {
// Widget not placed on home screen
}
}
}
export async function widgetTaskHandler(
props: WidgetTaskHandlerProps
): Promise<void> {
@ -89,12 +53,8 @@ export async function widgetTaskHandler(
case 'WIDGET_ADDED':
case 'WIDGET_UPDATE':
case 'WIDGET_RESIZED': {
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds);
const state = await getWidgetState();
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
break;
}
@ -106,15 +66,11 @@ export async function widgetTaskHandler(
const taskId = props.clickActionData?.taskId;
if (!isValidUUID(taskId)) break;
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
const updatedTasks = tasks.filter((t) => t.id !== taskId);
await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(updatedTasks));
const state = await getWidgetState();
state.tasks = state.tasks.filter((t) => t.id !== taskId);
await setWidgetState(state);
renderWithState(renderWidget, widgetInfo, updatedTasks, isDark, expandedTaskIds);
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
try {
const { toggleComplete } = await import('../db/repository/tasks');
@ -128,20 +84,24 @@ export async function widgetTaskHandler(
const taskId = props.clickActionData?.taskId as string | undefined;
if (!taskId) break;
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
// Debounce: ignore rapid double-taps on the same task
const now = Date.now();
const lastTime = lastExpandTimes.get(taskId) ?? 0;
if (now - lastTime < EXPAND_DEBOUNCE_MS) break;
lastExpandTimes.set(taskId, now);
if (expandedTaskIds.has(taskId)) {
expandedTaskIds.delete(taskId);
const state = await getWidgetState();
const expandedSet = new Set(state.expandedTaskIds);
if (expandedSet.has(taskId)) {
expandedSet.delete(taskId);
} else {
expandedTaskIds.add(taskId);
expandedSet.add(taskId);
}
await setExpandedTaskIds(expandedTaskIds);
state.expandedTaskIds = [...expandedSet];
await setWidgetState(state);
renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds);
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
}
if (props.clickAction === 'TOGGLE_SUBTASK') {
@ -149,14 +109,10 @@ export async function widgetTaskHandler(
const parentId = props.clickActionData?.parentId as string | undefined;
if (!isValidUUID(subtaskId) || !parentId) break;
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
const state = await getWidgetState();
// Update subtask state in cached data
const parent = tasks.find((t) => t.id === parentId);
const parent = state.tasks.find((t) => t.id === parentId);
if (parent) {
const sub = parent.subtasks?.find((s) => s.id === subtaskId);
if (sub) {
@ -164,9 +120,9 @@ export async function widgetTaskHandler(
parent.subtaskDoneCount = (parent.subtasks ?? []).filter((s) => s.completed).length;
}
}
await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(tasks));
await setWidgetState(state);
renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds);
await forceWidgetRefresh(state.tasks, state.isDark, state.expandedTaskIds);
try {
const { toggleComplete } = await import('../db/repository/tasks');

View file

@ -14,5 +14,8 @@
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
],
"exclude": [
"web"
]
}

8
web/.env.template Normal file
View file

@ -0,0 +1,8 @@
DATABASE_URL=postgresql://user:password@localhost:5432/simpliste
# Logto
LOGTO_ENDPOINT=https://auth.lacompagniemaximus.com
LOGTO_APP_ID=
LOGTO_APP_SECRET=
LOGTO_COOKIE_SECRET=
LOGTO_BASE_URL=https://liste.lacompagniemaximus.com

41
web/.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
web/AGENTS.md Normal file
View file

@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
web/CLAUDE.md Normal file
View file

@ -0,0 +1 @@
@AGENTS.md

41
web/Dockerfile Normal file
View file

@ -0,0 +1,41 @@
FROM node:22-alpine AS base
# Install production dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Build
FROM base AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Bundle custom server + ws into a single JS file
RUN npx esbuild server.ts --bundle --platform=node --target=node22 --outfile=dist-server/server.js \
--external:next --external:.next --external:pg --external:pg-native --external:drizzle-orm
# Production
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy production node_modules (has full next package)
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/dist-server/server.js ./server.js
COPY --from=builder --chown=nextjs:nodejs /app/src/db/migrations ./src/db/migrations
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

36
web/README.md Normal file
View file

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

11
web/drizzle.config.ts Normal file
View file

@ -0,0 +1,11 @@
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

18
web/eslint.config.mjs Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
web/next.config.ts Normal file
View file

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

8669
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
web/package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@logto/next": "^4.2.9",
"@types/pg": "^8.20.0",
"dotenv": "^17.4.1",
"drizzle-orm": "^0.45.2",
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.7.0",
"next": "16.2.2",
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-i18next": "^17.0.2",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
web/postcss.config.mjs Normal file
View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
web/public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
web/public/globe.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

1
web/public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
web/public/vercel.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
web/public/window.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

47
web/server.ts Normal file
View file

@ -0,0 +1,47 @@
import { createServer } from 'http';
import next from 'next';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { setupWebSocket } from './src/lib/ws';
const dev = process.env.NODE_ENV !== 'production';
const hostname = process.env.HOSTNAME || '0.0.0.0';
const port = parseInt(process.env.PORT || '3000', 10);
async function runMigrations() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);
try {
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('> Migrations applied');
} finally {
await pool.end();
}
}
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
(async () => {
try {
await runMigrations();
} catch (err) {
console.error('> Migration error:', err);
process.exit(1);
}
await app.prepare();
const server = createServer((req, res) => {
// Don't log query params on /ws route (ticket security)
handle(req, res);
});
setupWebSocket(server);
server.listen(port, hostname, () => {
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server on ws://${hostname}:${port}/ws`);
});
})();

View file

@ -0,0 +1,46 @@
export const dynamic = "force-dynamic";
import { redirect } from "next/navigation";
import { getAuthenticatedUser } from "@/lib/auth";
import { db } from "@/db/client";
import { slLists, slTags } from "@/db/schema";
import { eq, isNull, and, asc } from "drizzle-orm";
import { Sidebar } from "@/components/Sidebar";
import { Header } from "@/components/Header";
import { AppShell } from "@/components/AppShell";
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getAuthenticatedUser();
if (!user) {
redirect("/auth");
}
const [lists, tags] = await Promise.all([
db
.select()
.from(slLists)
.where(and(eq(slLists.userId, user.userId), isNull(slLists.deletedAt)))
.orderBy(asc(slLists.position)),
db
.select()
.from(slTags)
.where(and(eq(slTags.userId, user.userId), isNull(slTags.deletedAt)))
.orderBy(asc(slTags.name)),
]);
return (
<AppShell>
<div className="flex h-screen overflow-hidden">
<Sidebar lists={lists} tags={tags} />
<div className="flex-1 flex flex-col min-w-0">
<Header userName={user.name || user.email || ""} />
<main className="flex-1 overflow-y-auto p-4 md:p-6">{children}</main>
</div>
</div>
</AppShell>
);
}

View file

@ -0,0 +1,108 @@
export const dynamic = "force-dynamic";
import { notFound, redirect } from "next/navigation";
import { getAuthenticatedUser } from "@/lib/auth";
import { db } from "@/db/client";
import { slLists, slTasks } from "@/db/schema";
import { eq, and, isNull, asc, desc } from "drizzle-orm";
import { TaskList } from "@/components/TaskList";
import type { Task } from "@/lib/types";
import type { SQL } from "drizzle-orm";
export default async function ListPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const user = await getAuthenticatedUser();
if (!user) redirect("/auth");
const userId = user.userId;
const { id: listId } = await params;
const search = await searchParams;
// Verify list belongs to user
const [list] = await db
.select()
.from(slLists)
.where(
and(
eq(slLists.id, listId),
eq(slLists.userId, userId),
isNull(slLists.deletedAt)
)
);
if (!list) notFound();
// Build conditions
const conditions: SQL[] = [
eq(slTasks.listId, listId),
eq(slTasks.userId, userId),
isNull(slTasks.deletedAt),
isNull(slTasks.parentId),
];
const completed = typeof search.completed === "string" ? search.completed : undefined;
if (completed === "true" || completed === "false") {
conditions.push(eq(slTasks.completed, completed === "true"));
}
const sortBy = (typeof search.sortBy === "string" ? search.sortBy : "position") as string;
const sortOrder = (typeof search.sortOrder === "string" ? search.sortOrder : "asc") as string;
const sortColumn =
sortBy === "priority"
? slTasks.priority
: sortBy === "dueDate"
? slTasks.dueDate
: sortBy === "createdAt"
? slTasks.createdAt
: sortBy === "title"
? slTasks.title
: slTasks.position;
const orderFn = sortOrder === "desc" ? desc : asc;
const tasks = await db
.select()
.from(slTasks)
.where(and(...conditions))
.orderBy(orderFn(sortColumn));
// Fetch subtasks for all parent tasks
const parentIds = tasks.map((t) => t.id);
let subtasksMap: Record<string, Task[]> = {};
if (parentIds.length > 0) {
const allSubtasks = await db
.select()
.from(slTasks)
.where(
and(
eq(slTasks.userId, userId),
isNull(slTasks.deletedAt)
)
)
.orderBy(asc(slTasks.position));
const parentIdSet = new Set(parentIds);
for (const sub of allSubtasks) {
if (sub.parentId && parentIdSet.has(sub.parentId)) {
if (!subtasksMap[sub.parentId]) subtasksMap[sub.parentId] = [];
subtasksMap[sub.parentId].push(sub as Task);
}
}
}
return (
<TaskList
tasks={tasks as Task[]}
subtasksMap={subtasksMap}
listId={listId}
listName={list.name}
/>
);
}

View file

@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { getAuthenticatedUser } from "@/lib/auth";
import { db } from "@/db/client";
import { slLists } from "@/db/schema";
import { eq, isNull, and, asc } from "drizzle-orm";
import { WelcomeMessage } from "@/components/WelcomeMessage";
export const dynamic = "force-dynamic";
export default async function AppHome() {
const user = await getAuthenticatedUser();
if (!user) redirect("/auth");
const userId = user.userId;
const lists = await db
.select()
.from(slLists)
.where(and(eq(slLists.userId, userId), isNull(slLists.deletedAt)))
.orderBy(asc(slLists.position));
const inbox = lists.find((l) => l.isInbox);
if (inbox) redirect(`/lists/${inbox.id}`);
if (lists.length > 0) redirect(`/lists/${lists[0].id}`);
return <WelcomeMessage />;
}

View file

@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { getLogtoContext } from '@logto/next/server-actions';
import { logtoConfig } from '@/lib/logto';
import { cookies } from 'next/headers';
import { db } from '@/db/client';
import { slLists } from '@/db/schema';
import { eq, isNull, and, asc } from 'drizzle-orm';
export const dynamic = 'force-dynamic';
export async function GET() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll().map(c => ({ name: c.name, length: c.value.length }));
try {
const context = await getLogtoContext(logtoConfig);
const userId = context.claims?.sub;
let lists = null;
if (userId) {
lists = await db
.select({ id: slLists.id, name: slLists.name, isInbox: slLists.isInbox, userId: slLists.userId })
.from(slLists)
.where(and(eq(slLists.userId, userId), isNull(slLists.deletedAt)))
.orderBy(asc(slLists.position));
}
return NextResponse.json({
cookies: allCookies,
isAuthenticated: context.isAuthenticated,
claims: context.claims ?? null,
lists,
});
} catch (error) {
return NextResponse.json({
cookies: allCookies,
error: error instanceof Error ? error.message : String(error),
});
}
}

View file

@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { sql } from 'drizzle-orm';
import { getActiveConnections } from '@/lib/ws';
export async function GET() {
const start = Date.now();
try {
await db.execute(sql`SELECT 1`);
const dbLatency = Date.now() - start;
return NextResponse.json({
status: 'ok',
timestamp: new Date().toISOString(),
db: {
status: 'connected',
latencyMs: dbLatency,
},
ws: {
activeConnections: getActiveConnections(),
},
});
} catch (error) {
return NextResponse.json({
status: 'degraded',
timestamp: new Date().toISOString(),
db: {
status: 'disconnected',
error: error instanceof Error ? error.message : 'Unknown error',
},
ws: {
activeConnections: getActiveConnections(),
},
}, { status: 503 });
}
}

View file

@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slLists } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { updateListSchema } from '@/lib/validators';
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id } = await params;
const body = await parseBody(request, (d) => updateListSchema.parse(d));
if (body.error) return body.error;
const [updated] = await db
.update(slLists)
.set({ ...body.data, updatedAt: new Date() })
.where(and(eq(slLists.id, id), eq(slLists.userId, auth.userId)))
.returning();
if (!updated) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(updated);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id } = await params;
const [deleted] = await db
.update(slLists)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(and(eq(slLists.id, id), eq(slLists.userId, auth.userId)))
.returning();
if (!deleted) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks, slLists, slTaskTags } from '@/db/schema';
import { eq, and, isNull, asc, desc, inArray, SQL } from 'drizzle-orm';
import { requireAuth } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'read');
if (rl) return rl;
const { id: listId } = await params;
// Verify list belongs to user
const [list] = await db
.select({ id: slLists.id })
.from(slLists)
.where(and(eq(slLists.id, listId), eq(slLists.userId, auth.userId)));
if (!list) {
return NextResponse.json({ error: 'List not found' }, { status: 404 });
}
const url = request.nextUrl;
const completed = url.searchParams.get('completed');
const priority = url.searchParams.get('priority');
const dueDate = url.searchParams.get('dueDate');
const tags = url.searchParams.get('tags');
const sortBy = url.searchParams.get('sortBy') || 'position';
const sortOrder = url.searchParams.get('sortOrder') || 'asc';
const conditions: SQL[] = [
eq(slTasks.listId, listId),
eq(slTasks.userId, auth.userId),
isNull(slTasks.deletedAt),
isNull(slTasks.parentId),
];
if (completed !== null) {
conditions.push(eq(slTasks.completed, completed === 'true'));
}
if (priority !== null) {
conditions.push(eq(slTasks.priority, parseInt(priority, 10)));
}
// Build query
let query = db
.select()
.from(slTasks)
.where(and(...conditions));
// Sort
const sortColumn = sortBy === 'priority' ? slTasks.priority
: sortBy === 'dueDate' ? slTasks.dueDate
: sortBy === 'createdAt' ? slTasks.createdAt
: sortBy === 'title' ? slTasks.title
: slTasks.position;
const orderFn = sortOrder === 'desc' ? desc : asc;
const tasks = await query.orderBy(orderFn(sortColumn));
// Filter by tags if specified (post-query since it's a join table)
if (tags) {
const tagIds = tags.split(',');
const taskTagRows = await db
.select({ taskId: slTaskTags.taskId })
.from(slTaskTags)
.where(inArray(slTaskTags.tagId, tagIds));
const taskIdsWithTags = new Set(taskTagRows.map((r) => r.taskId));
return NextResponse.json(tasks.filter((t) => taskIdsWithTags.has(t.id)));
}
// Filter by dueDate if specified (before/on that date)
if (dueDate) {
const cutoff = new Date(dueDate);
return NextResponse.json(
tasks.filter((t) => t.dueDate && t.dueDate <= cutoff)
);
}
return NextResponse.json(tasks);
}

View file

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slLists } from '@/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { reorderSchema } from '@/lib/validators';
export async function PUT(request: Request) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const body = await parseBody(request, (d) => reorderSchema.parse(d));
if (body.error) return body.error;
// Verify all lists belong to user
const existing = await db
.select({ id: slLists.id })
.from(slLists)
.where(and(eq(slLists.userId, auth.userId), inArray(slLists.id, body.data.ids)));
if (existing.length !== body.data.ids.length) {
return NextResponse.json({ error: 'Some lists not found' }, { status: 404 });
}
// Update positions in order
await Promise.all(
body.data.ids.map((id, index) =>
db
.update(slLists)
.set({ position: index, updatedAt: new Date() })
.where(and(eq(slLists.id, id), eq(slLists.userId, auth.userId)))
)
);
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slLists } from '@/db/schema';
import { eq, isNull, and, asc } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { createListSchema } from '@/lib/validators';
export async function GET() {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'read');
if (rl) return rl;
const lists = await db
.select()
.from(slLists)
.where(and(eq(slLists.userId, auth.userId), isNull(slLists.deletedAt)))
.orderBy(asc(slLists.position));
return NextResponse.json(lists);
}
export async function POST(request: Request) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const body = await parseBody(request, (d) => createListSchema.parse(d));
if (body.error) return body.error;
const [list] = await db
.insert(slLists)
.values({ ...body.data, userId: auth.userId })
.returning();
return NextResponse.json(list, { status: 201 });
}

View file

@ -0,0 +1,15 @@
import { handleSignIn } from '@logto/next/server-actions';
import { logtoConfig } from '@/lib/logto';
import { redirect } from 'next/navigation';
import { type NextRequest } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
const callbackUrl = new URL(
`/api/logto/callback?${request.nextUrl.searchParams.toString()}`,
logtoConfig.baseUrl
);
await handleSignIn(logtoConfig, callbackUrl);
redirect('/');
}

View file

@ -0,0 +1,9 @@
import { signIn } from '@logto/next/server-actions';
import { logtoConfig } from '@/lib/logto';
export const dynamic = 'force-dynamic';
export async function GET() {
// signIn calls redirect() internally — must not be in try/catch
await signIn(logtoConfig, `${logtoConfig.baseUrl}/api/logto/callback`);
}

View file

@ -0,0 +1,16 @@
import { signOut } from '@logto/next/server-actions';
import { logtoConfig } from '@/lib/logto';
import { cookies } from 'next/headers';
export const dynamic = 'force-dynamic';
export async function GET() {
// Clear the Logto session cookie explicitly
const cookieStore = await cookies();
const logtoCookie = cookieStore.getAll().find(c => c.name.startsWith('logto_'));
if (logtoCookie) {
cookieStore.delete(logtoCookie.name);
}
await signOut(logtoConfig, logtoConfig.baseUrl);
}

View file

@ -0,0 +1,341 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slLists, slTasks, slTags, slTaskTags } from '@/db/schema';
import { eq, and, gte, isNull } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { syncPushSchema, type SyncOperation } from '@/lib/validators';
// Idempotency key store (TTL 24h)
const idempotencyStore = new Map<string, { result: unknown; expiresAt: number }>();
// Cleanup expired keys periodically
function cleanupIdempotencyKeys() {
const now = Date.now();
for (const [key, entry] of idempotencyStore) {
if (entry.expiresAt < now) {
idempotencyStore.delete(key);
}
}
}
const TTL_24H = 24 * 60 * 60 * 1000;
export async function GET(request: NextRequest) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'sync');
if (rl) return rl;
const since = request.nextUrl.searchParams.get('since');
if (!since) {
return NextResponse.json({ error: 'Missing "since" parameter' }, { status: 400 });
}
const sinceDate = new Date(since);
if (isNaN(sinceDate.getTime())) {
return NextResponse.json({ error: 'Invalid "since" timestamp' }, { status: 400 });
}
// Fetch all entities updated since timestamp (including soft-deleted)
const [lists, tasks, tags] = await Promise.all([
db
.select()
.from(slLists)
.where(and(eq(slLists.userId, auth.userId), gte(slLists.updatedAt, sinceDate))),
db
.select()
.from(slTasks)
.where(and(eq(slTasks.userId, auth.userId), gte(slTasks.updatedAt, sinceDate))),
db
.select()
.from(slTags)
.where(and(eq(slTags.userId, auth.userId), gte(slTags.createdAt, sinceDate))),
]);
// Get task-tag relations for the affected tasks
const taskIds = tasks.map((t) => t.id);
let taskTags: { taskId: string; tagId: string }[] = [];
if (taskIds.length > 0) {
const { inArray } = await import('drizzle-orm');
taskTags = await db
.select()
.from(slTaskTags)
.where(inArray(slTaskTags.taskId, taskIds));
}
// Transform entities into the changes format expected by the mobile client
const changes: {
entity_type: string;
entity_id: string;
action: 'create' | 'update' | 'delete';
payload: Record<string, unknown>;
updated_at: string;
}[] = [];
for (const l of lists) {
changes.push({
entity_type: 'list',
entity_id: l.id,
action: l.deletedAt ? 'delete' : 'update',
payload: {
name: l.name,
color: l.color,
icon: l.icon,
position: l.position,
is_inbox: l.isInbox,
created_at: l.createdAt.toISOString(),
updated_at: l.updatedAt.toISOString(),
},
updated_at: l.updatedAt.toISOString(),
});
}
for (const t of tasks) {
changes.push({
entity_type: 'task',
entity_id: t.id,
action: t.deletedAt ? 'delete' : 'update',
payload: {
title: t.title,
notes: t.notes,
completed: t.completed,
completed_at: t.completedAt?.toISOString() ?? null,
priority: t.priority,
due_date: t.dueDate?.toISOString() ?? null,
list_id: t.listId,
parent_id: t.parentId,
position: t.position,
recurrence: t.recurrence,
created_at: t.createdAt.toISOString(),
updated_at: t.updatedAt.toISOString(),
},
updated_at: t.updatedAt.toISOString(),
});
}
for (const tag of tags) {
changes.push({
entity_type: 'tag',
entity_id: tag.id,
action: tag.deletedAt ? 'delete' : 'update',
payload: {
name: tag.name,
color: tag.color,
created_at: tag.createdAt.toISOString(),
updated_at: tag.createdAt.toISOString(),
},
updated_at: tag.createdAt.toISOString(),
});
}
for (const tt of taskTags) {
changes.push({
entity_type: 'task_tag',
entity_id: `${tt.taskId}:${tt.tagId}`,
action: 'update',
payload: {
task_id: tt.taskId,
tag_id: tt.tagId,
},
updated_at: new Date().toISOString(),
});
}
return NextResponse.json({
changes,
sync_token: new Date().toISOString(),
});
}
export async function POST(request: Request) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'sync');
if (rl) return rl;
const body = await parseBody(request, (d) => syncPushSchema.parse(d));
if (body.error) return body.error;
cleanupIdempotencyKeys();
const results: { idempotencyKey: string; status: 'applied' | 'skipped'; error?: string }[] = [];
for (const op of body.data.operations) {
const storeKey = `${auth.userId}:${op.idempotencyKey}`;
// Check idempotency
const existing = idempotencyStore.get(storeKey);
if (existing && existing.expiresAt > Date.now()) {
results.push({ idempotencyKey: op.idempotencyKey, status: 'skipped' });
continue;
}
try {
await processOperation(op, auth.userId);
idempotencyStore.set(storeKey, {
result: true,
expiresAt: Date.now() + TTL_24H,
});
results.push({ idempotencyKey: op.idempotencyKey, status: 'applied' });
} catch (e) {
results.push({
idempotencyKey: op.idempotencyKey,
status: 'skipped',
error: e instanceof Error ? e.message : 'Unknown error',
});
}
}
return NextResponse.json({ results, syncedAt: new Date().toISOString() });
}
async function processOperation(op: SyncOperation, userId: string) {
const { entityType, entityId, action, data } = op;
const now = new Date();
switch (entityType) {
case 'list': {
if (action === 'create') {
const d = (data as Record<string, unknown>) || {};
const incomingIsInbox = d.isInbox as boolean | undefined;
const listValues = {
id: entityId,
userId,
name: d.name as string || 'Untitled',
color: d.color as string | undefined,
icon: d.icon as string | undefined,
position: d.position as number | undefined,
isInbox: incomingIsInbox,
};
// If the incoming list is an inbox, check for an existing inbox and merge
if (incomingIsInbox) {
await db.transaction(async (tx) => {
const [existingInbox] = await tx
.select()
.from(slLists)
.where(and(eq(slLists.userId, userId), eq(slLists.isInbox, true), isNull(slLists.deletedAt)));
if (existingInbox && existingInbox.id !== entityId) {
// Reassign all tasks (including subtasks) from the old inbox to the new one
await tx.update(slTasks)
.set({ listId: entityId, updatedAt: now })
.where(and(eq(slTasks.listId, existingInbox.id), eq(slTasks.userId, userId)));
// Soft-delete the old inbox
await tx.update(slLists)
.set({ deletedAt: now, updatedAt: now })
.where(eq(slLists.id, existingInbox.id));
}
await tx.insert(slLists).values(listValues).onConflictDoNothing();
});
} else {
await db.insert(slLists).values(listValues).onConflictDoNothing();
}
} else if (action === 'update') {
await verifyOwnership(slLists, entityId, userId);
await db.update(slLists)
.set({ ...(data as Record<string, unknown>), updatedAt: now })
.where(and(eq(slLists.id, entityId), eq(slLists.userId, userId)));
} else if (action === 'delete') {
await verifyOwnership(slLists, entityId, userId);
await db.update(slLists)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(slLists.id, entityId), eq(slLists.userId, userId)));
}
break;
}
case 'task': {
if (action === 'create') {
const d = (data as Record<string, unknown>) || {};
await db.insert(slTasks).values({
id: entityId,
userId,
title: d.title as string || 'Untitled',
listId: d.listId as string,
notes: d.notes as string | undefined,
priority: d.priority as number | undefined,
dueDate: d.dueDate ? new Date(d.dueDate as string) : undefined,
parentId: d.parentId as string | undefined,
recurrence: d.recurrence as string | undefined,
position: d.position as number | undefined,
}).onConflictDoNothing();
} else if (action === 'update') {
await verifyOwnership(slTasks, entityId, userId);
const raw = { ...(data as Record<string, unknown>), updatedAt: now } as Record<string, unknown>;
// Remove id from payload to avoid overwriting primary key
delete raw.id;
if (raw.dueDate !== undefined) {
raw.dueDate = raw.dueDate ? new Date(raw.dueDate as string) : null;
}
if (raw.completedAt !== undefined) {
raw.completedAt = raw.completedAt ? new Date(raw.completedAt as string) : null;
}
await db.update(slTasks)
.set(raw)
.where(and(eq(slTasks.id, entityId), eq(slTasks.userId, userId)));
} else if (action === 'delete') {
await verifyOwnership(slTasks, entityId, userId);
await db.update(slTasks)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(slTasks.id, entityId), eq(slTasks.userId, userId)));
}
break;
}
case 'tag': {
if (action === 'create') {
const d = (data as Record<string, unknown>) || {};
await db.insert(slTags).values({
id: entityId,
userId,
name: d.name as string || 'Untitled',
color: d.color as string | undefined,
}).onConflictDoNothing();
} else if (action === 'update') {
await verifyTagOwnership(entityId, userId);
await db.update(slTags)
.set(data as Record<string, unknown>)
.where(and(eq(slTags.id, entityId), eq(slTags.userId, userId)));
} else if (action === 'delete') {
await verifyTagOwnership(entityId, userId);
await db.update(slTags)
.set({ deletedAt: now })
.where(and(eq(slTags.id, entityId), eq(slTags.userId, userId)));
}
break;
}
case 'taskTag': {
// entityId is used as taskId, tagId comes from data
const d = (data as Record<string, unknown>) || {};
const tagId = d.tagId as string;
if (action === 'create') {
await db.insert(slTaskTags)
.values({ taskId: entityId, tagId })
.onConflictDoNothing();
} else if (action === 'delete') {
await db.delete(slTaskTags)
.where(and(eq(slTaskTags.taskId, entityId), eq(slTaskTags.tagId, tagId)));
}
break;
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function verifyOwnership(table: any, entityId: string, userId: string) {
const [row] = await db
.select({ id: table.id })
.from(table)
.where(and(eq(table.id, entityId), eq(table.userId, userId)));
if (!row) throw new Error('Entity not found or access denied');
}
async function verifyTagOwnership(entityId: string, userId: string) {
const [row] = await db
.select({ id: slTags.id })
.from(slTags)
.where(and(eq(slTags.id, entityId), eq(slTags.userId, userId)));
if (!row) throw new Error('Tag not found or access denied');
}

View file

@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTags } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { updateTagSchema } from '@/lib/validators';
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id } = await params;
const body = await parseBody(request, (d) => updateTagSchema.parse(d));
if (body.error) return body.error;
const [updated] = await db
.update(slTags)
.set(body.data)
.where(and(eq(slTags.id, id), eq(slTags.userId, auth.userId)))
.returning();
if (!updated) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(updated);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id } = await params;
const [deleted] = await db
.update(slTags)
.set({ deletedAt: new Date() })
.where(and(eq(slTags.id, id), eq(slTags.userId, auth.userId)))
.returning();
if (!deleted) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTags } from '@/db/schema';
import { eq, isNull, and, asc } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { createTagSchema } from '@/lib/validators';
export async function GET() {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'read');
if (rl) return rl;
const tags = await db
.select()
.from(slTags)
.where(and(eq(slTags.userId, auth.userId), isNull(slTags.deletedAt)))
.orderBy(asc(slTags.name));
return NextResponse.json(tags);
}
export async function POST(request: Request) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const body = await parseBody(request, (d) => createTagSchema.parse(d));
if (body.error) return body.error;
const [tag] = await db
.insert(slTags)
.values({ ...body.data, userId: auth.userId })
.returning();
return NextResponse.json(tag, { status: 201 });
}

View file

@ -0,0 +1,72 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { updateTaskSchema } from '@/lib/validators';
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id } = await params;
const body = await parseBody(request, (d) => updateTaskSchema.parse(d));
if (body.error) return body.error;
const updateData: Record<string, unknown> = {
...body.data,
updatedAt: new Date(),
};
// Convert dueDate string to Date
if (body.data.dueDate !== undefined) {
updateData.dueDate = body.data.dueDate ? new Date(body.data.dueDate) : null;
}
// Set completedAt when toggling completed
if (body.data.completed !== undefined) {
updateData.completedAt = body.data.completed ? new Date() : null;
}
const [updated] = await db
.update(slTasks)
.set(updateData)
.where(and(eq(slTasks.id, id), eq(slTasks.userId, auth.userId)))
.returning();
if (!updated) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(updated);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id } = await params;
const [deleted] = await db
.update(slTasks)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(and(eq(slTasks.id, id), eq(slTasks.userId, auth.userId)))
.returning();
if (!deleted) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks } from '@/db/schema';
import { eq, and, isNull, asc } from 'drizzle-orm';
import { requireAuth } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'read');
if (rl) return rl;
const { id } = await params;
// Verify parent task belongs to user
const [parent] = await db
.select({ id: slTasks.id })
.from(slTasks)
.where(and(eq(slTasks.id, id), eq(slTasks.userId, auth.userId)));
if (!parent) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
const subtasks = await db
.select()
.from(slTasks)
.where(
and(
eq(slTasks.parentId, id),
eq(slTasks.userId, auth.userId),
isNull(slTasks.deletedAt)
)
)
.orderBy(asc(slTasks.position));
return NextResponse.json(subtasks);
}

View file

@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks, slTaskTags } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string; tagId: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id: taskId, tagId } = await params;
// Verify task belongs to user
const [task] = await db
.select({ id: slTasks.id })
.from(slTasks)
.where(and(eq(slTasks.id, taskId), eq(slTasks.userId, auth.userId)));
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
await db
.delete(slTaskTags)
.where(and(eq(slTaskTags.taskId, taskId), eq(slTaskTags.tagId, tagId)));
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks, slTags, slTaskTags } from '@/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { assignTagsSchema } from '@/lib/validators';
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id: taskId } = await params;
const body = await parseBody(request, (d) => assignTagsSchema.parse(d));
if (body.error) return body.error;
// Verify task belongs to user
const [task] = await db
.select({ id: slTasks.id })
.from(slTasks)
.where(and(eq(slTasks.id, taskId), eq(slTasks.userId, auth.userId)));
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
// Verify all tags belong to user
const existingTags = await db
.select({ id: slTags.id })
.from(slTags)
.where(and(eq(slTags.userId, auth.userId), inArray(slTags.id, body.data.tagIds)));
if (existingTags.length !== body.data.tagIds.length) {
return NextResponse.json({ error: 'Some tags not found' }, { status: 404 });
}
// Insert (ignore conflicts)
await db
.insert(slTaskTags)
.values(body.data.tagIds.map((tagId) => ({ taskId, tagId })))
.onConflictDoNothing();
return NextResponse.json({ ok: true }, { status: 201 });
}

View file

@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks } from '@/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { reorderSchema } from '@/lib/validators';
export async function PUT(request: Request) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const body = await parseBody(request, (d) => reorderSchema.parse(d));
if (body.error) return body.error;
// Verify all tasks belong to user
const existing = await db
.select({ id: slTasks.id })
.from(slTasks)
.where(and(eq(slTasks.userId, auth.userId), inArray(slTasks.id, body.data.ids)));
if (existing.length !== body.data.ids.length) {
return NextResponse.json({ error: 'Some tasks not found' }, { status: 404 });
}
await Promise.all(
body.data.ids.map((id, index) =>
db
.update(slTasks)
.set({ position: index, updatedAt: new Date() })
.where(and(eq(slTasks.id, id), eq(slTasks.userId, auth.userId)))
)
);
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks, slLists } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { createTaskSchema } from '@/lib/validators';
export async function POST(request: Request) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const body = await parseBody(request, (d) => createTaskSchema.parse(d));
if (body.error) return body.error;
// Verify list belongs to user
const [list] = await db
.select({ id: slLists.id })
.from(slLists)
.where(and(eq(slLists.id, body.data.listId), eq(slLists.userId, auth.userId)));
if (!list) {
return NextResponse.json({ error: 'List not found' }, { status: 404 });
}
// If parentId, verify parent task belongs to user and is not itself a subtask
if (body.data.parentId) {
const [parent] = await db
.select({ id: slTasks.id, parentId: slTasks.parentId })
.from(slTasks)
.where(and(eq(slTasks.id, body.data.parentId), eq(slTasks.userId, auth.userId)));
if (!parent) {
return NextResponse.json({ error: 'Parent task not found' }, { status: 404 });
}
if (parent.parentId) {
return NextResponse.json({ error: 'Cannot create sub-subtasks (max 2 levels)' }, { status: 400 });
}
}
const [task] = await db
.insert(slTasks)
.values({
...body.data,
dueDate: body.data.dueDate ? new Date(body.data.dueDate) : undefined,
userId: auth.userId,
})
.returning();
return NextResponse.json(task, { status: 201 });
}

View file

@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import { randomUUID } from 'crypto';
import { requireAuth } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { getTicketStore } from '@/lib/ws';
const TTL_30S = 30 * 1000;
function cleanupTickets() {
const store = getTicketStore();
const now = Date.now();
for (const [key, entry] of store) {
if (entry.expiresAt < now) {
store.delete(key);
}
}
}
export async function POST() {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'ws-ticket');
if (rl) return rl;
cleanupTickets();
const ticket = randomUUID();
getTicketStore().set(ticket, {
userId: auth.userId,
expiresAt: Date.now() + TTL_30S,
});
return NextResponse.json({ ticket }, { status: 201 });
}

22
web/src/app/auth/page.tsx Normal file
View file

@ -0,0 +1,22 @@
"use client";
import { useTranslation } from "react-i18next";
export default function AuthPage() {
const { t } = useTranslation();
return (
<div className="min-h-screen flex items-center justify-center bg-[#FFF8F0]">
<div className="text-center space-y-6 p-8">
<h1 className="text-3xl font-bold text-[#1A1A1A]">{t("app.name")}</h1>
<p className="text-[#6B6B6B]">{t("auth.subtitle")}</p>
<a
href="/api/logto/sign-in"
className="inline-block px-6 py-3 bg-[#4A90A4] text-white rounded-lg font-medium hover:bg-[#3A7389] transition-colors"
>
{t("auth.signIn")}
</a>
</div>
</div>
);
}

BIN
web/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

38
web/src/app/globals.css Normal file
View file

@ -0,0 +1,38 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-bleu: #4A90A4;
--color-creme: #FFF8F0;
--color-terracotta: #C17767;
--color-vert: #8BA889;
--color-sable: #D4A574;
--color-violet: #7B68EE;
--color-rouge: #E57373;
--color-teal: #4DB6AC;
--color-surface-light: #FFFFFF;
--color-surface-dark: #2A2A2A;
--color-border-light: #E5E7EB;
--color-border-dark: #3A3A3A;
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
:root {
--background: #FFF8F0;
--foreground: #1A1A1A;
}
.dark {
--background: #1A1A1A;
--foreground: #F5F5F5;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
}

41
web/src/app/layout.tsx Normal file
View file

@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeScript } from "@/components/ThemeScript";
import { I18nProvider } from "@/components/I18nProvider";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Simpl-Liste",
description: "Gestion de tâches minimaliste par La Compagnie Maximus",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="fr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
suppressHydrationWarning
>
<head>
<ThemeScript />
</head>
<body className="min-h-full flex flex-col">
<I18nProvider>{children}</I18nProvider>
</body>
</html>
);
}

View file

@ -0,0 +1,8 @@
"use client";
import { useSync } from "./useSync";
export function AppShell({ children }: { children: React.ReactNode }) {
useSync();
return <>{children}</>;
}

View file

@ -0,0 +1,27 @@
"use client";
import { createContext, useContext } from "react";
interface AuthUser {
userId: string;
email?: string | null;
name?: string | null;
}
const AuthContext = createContext<AuthUser | null>(null);
export function AuthProvider({
user,
children,
}: {
user: AuthUser;
children: React.ReactNode;
}) {
return <AuthContext.Provider value={user}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View file

@ -0,0 +1,85 @@
"use client";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { Filter, ArrowUpDown } from "lucide-react";
import { useTranslation } from "react-i18next";
export function FilterBar() {
const { t } = useTranslation();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const STATUS_OPTIONS = [
{ value: "", label: t("filter.all") },
{ value: "false", label: t("filter.active") },
{ value: "true", label: t("filter.completed") },
];
const SORT_OPTIONS = [
{ value: "position", label: t("sort.position") },
{ value: "priority", label: t("sort.priority") },
{ value: "dueDate", label: t("sort.dueDate") },
{ value: "title", label: t("sort.title") },
{ value: "createdAt", label: t("sort.createdAt") },
];
const completed = searchParams.get("completed") ?? "";
const sortBy = searchParams.get("sortBy") ?? "position";
const sortOrder = searchParams.get("sortOrder") ?? "asc";
const updateParam = (key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
router.push(`${pathname}?${params.toString()}`);
};
return (
<div className="flex flex-wrap items-center gap-3 text-sm">
{/* Status filter */}
<div className="flex items-center gap-1.5">
<Filter size={14} className="text-foreground/50" />
<select
value={completed}
onChange={(e) => updateParam("completed", e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Sort */}
<div className="flex items-center gap-1.5">
<ArrowUpDown size={14} className="text-foreground/50" />
<select
value={sortBy}
onChange={(e) => updateParam("sortBy", e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
onClick={() =>
updateParam("sortOrder", sortOrder === "asc" ? "desc" : "asc")
}
className="px-1.5 py-1 border border-border-light dark:border-border-dark rounded hover:bg-black/5 dark:hover:bg-white/5"
title={sortOrder === "asc" ? t("sort.asc") : t("sort.desc")}
>
{sortOrder === "asc" ? "↑" : "↓"}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,66 @@
"use client";
import { ThemeToggle } from "./ThemeToggle";
import { User, LogOut } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface HeaderProps {
userName: string;
}
export function Header({ userName }: HeaderProps) {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
return (
<header className="h-14 shrink-0 flex items-center justify-between px-4 md:px-6 border-b border-border-light dark:border-border-dark bg-surface-light dark:bg-surface-dark">
{/* Spacer for mobile hamburger */}
<div className="w-10 md:hidden" />
<div className="hidden md:block text-sm font-medium text-bleu">
{t("app.name")}
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
{/* User menu */}
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors text-sm"
>
<User size={18} />
<span className="hidden sm:inline max-w-[120px] truncate">
{userName}
</span>
</button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 top-full mt-1 z-50 w-48 bg-surface-light dark:bg-surface-dark border border-border-light dark:border-border-dark rounded-lg shadow-lg py-1">
<div className="px-3 py-2 text-xs text-foreground/50 truncate">
{userName}
</div>
<a
href="/api/logto/sign-out"
className="flex items-center gap-2 px-3 py-2 text-sm text-rouge hover:bg-rouge/10 transition-colors"
onClick={() => setMenuOpen(false)}
>
<LogOut size={14} />
{t("auth.signOut")}
</a>
</div>
</>
)}
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,7 @@
"use client";
import "@/i18n";
export function I18nProvider({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View file

@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
Inbox,
Plus,
Tag,
Menu,
X,
ChevronDown,
ChevronRight,
LogOut,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { List as ListType, Tag as TagType } from "@/lib/types";
interface SidebarProps {
lists: ListType[];
tags: TagType[];
}
export function Sidebar({ lists, tags }: SidebarProps) {
const { t } = useTranslation();
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [showNewList, setShowNewList] = useState(false);
const [newListName, setNewListName] = useState("");
const [tagsExpanded, setTagsExpanded] = useState(false);
const handleCreateList = async () => {
const name = newListName.trim();
if (!name) return;
await fetch("/api/lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
setNewListName("");
setShowNewList(false);
router.refresh();
};
const sidebarContent = (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-border-light dark:border-border-dark">
<h1 className="text-lg font-bold text-bleu">{t("app.name")}</h1>
</div>
{/* Lists */}
<nav className="flex-1 overflow-y-auto p-2 space-y-1">
<p className="px-3 py-1 text-xs font-semibold uppercase text-foreground/50">
{t("sidebar.lists")}
</p>
{lists.map((list) => {
const isActive = pathname === `/lists/${list.id}`;
return (
<Link
key={list.id}
href={`/lists/${list.id}`}
onClick={() => setMobileOpen(false)}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive
? "bg-bleu/10 text-bleu font-medium"
: "hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{list.isInbox ? (
<Inbox size={16} className="text-bleu shrink-0" />
) : (
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: list.color || "#4A90A4" }}
/>
)}
<span className="truncate">{list.name}</span>
</Link>
);
})}
{/* New list form */}
{showNewList ? (
<div className="px-3 py-1">
<input
autoFocus
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateList();
if (e.key === "Escape") {
setShowNewList(false);
setNewListName("");
}
}}
placeholder={t("sidebar.newListPlaceholder")}
className="w-full px-2 py-1 text-sm border border-border-light dark:border-border-dark rounded bg-transparent focus:outline-none focus:border-bleu"
/>
</div>
) : (
<button
onClick={() => setShowNewList(true)}
className="flex items-center gap-2 px-3 py-2 text-sm text-foreground/60 hover:text-foreground transition-colors w-full"
>
<Plus size={16} />
{t("sidebar.newList")}
</button>
)}
{/* Tags section */}
<div className="mt-4">
<button
onClick={() => setTagsExpanded(!tagsExpanded)}
className="flex items-center gap-2 px-3 py-1 text-xs font-semibold uppercase text-foreground/50 w-full hover:text-foreground/70"
>
{tagsExpanded ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
{t("sidebar.tags")}
</button>
{tagsExpanded &&
tags.map((tag) => (
<div
key={tag.id}
className="flex items-center gap-2 px-3 py-1.5 text-sm"
>
<Tag size={14} style={{ color: tag.color }} />
<span>{tag.name}</span>
</div>
))}
</div>
</nav>
{/* Sign out */}
<div className="p-4 border-t border-border-light dark:border-border-dark">
<a
href="/api/logto/sign-out"
className="flex items-center gap-2 text-sm text-foreground/60 hover:text-rouge transition-colors"
>
<LogOut size={16} />
{t("auth.signOut")}
</a>
</div>
</div>
);
return (
<>
{/* Mobile hamburger */}
<button
onClick={() => setMobileOpen(true)}
className="md:hidden fixed top-3 left-3 z-50 p-2 rounded-lg bg-surface-light dark:bg-surface-dark shadow-md"
>
<Menu size={20} />
</button>
{/* Mobile overlay */}
{mobileOpen && (
<div
className="md:hidden fixed inset-0 bg-black/50 z-40"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Mobile sidebar */}
<aside
className={`md:hidden fixed inset-y-0 left-0 z-50 w-72 bg-surface-light dark:bg-surface-dark transform transition-transform ${
mobileOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<button
onClick={() => setMobileOpen(false)}
className="absolute top-3 right-3 p-1"
>
<X size={20} />
</button>
{sidebarContent}
</aside>
{/* Desktop sidebar */}
<aside className="hidden md:flex md:w-64 md:shrink-0 bg-surface-light dark:bg-surface-dark border-r border-border-light dark:border-border-dark">
{sidebarContent}
</aside>
</>
);
}

View file

@ -0,0 +1,166 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, X } from "lucide-react";
import { useTranslation } from "react-i18next";
interface TaskFormProps {
listId: string;
parentId?: string;
onClose?: () => void;
}
export function TaskForm({ listId, parentId, onClose }: TaskFormProps) {
const { t } = useTranslation();
const router = useRouter();
const [title, setTitle] = useState("");
const [notes, setNotes] = useState("");
const [priority, setPriority] = useState(0);
const [dueDate, setDueDate] = useState("");
const [recurrence, setRecurrence] = useState("");
const [expanded, setExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const PRIORITY_LABELS = [
{ value: 0, label: t("priority.none"), color: "" },
{ value: 1, label: t("priority.low"), color: "text-vert" },
{ value: 2, label: t("priority.medium"), color: "text-sable" },
{ value: 3, label: t("priority.high"), color: "text-rouge" },
];
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || submitting) return;
setSubmitting(true);
try {
await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
notes: notes || undefined,
priority,
dueDate: dueDate || undefined,
recurrence: recurrence || undefined,
listId,
parentId: parentId || undefined,
}),
});
setTitle("");
setNotes("");
setPriority(0);
setDueDate("");
setRecurrence("");
setExpanded(false);
router.refresh();
onClose?.();
} finally {
setSubmitting(false);
}
};
if (!expanded && !parentId) {
return (
<button
onClick={() => setExpanded(true)}
className="flex items-center gap-2 w-full px-4 py-3 text-sm text-foreground/60 hover:text-foreground border border-dashed border-border-light dark:border-border-dark rounded-lg hover:border-bleu transition-colors"
>
<Plus size={16} />
{t("task.add")}
</button>
);
}
return (
<form
onSubmit={handleSubmit}
className="border border-border-light dark:border-border-dark rounded-lg p-4 space-y-3 bg-surface-light dark:bg-surface-dark"
>
<div className="flex items-center gap-2">
<input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={parentId ? t("task.subtaskPlaceholder") : t("task.titlePlaceholder")}
className="flex-1 bg-transparent text-sm focus:outline-none placeholder:text-foreground/40"
/>
{!parentId && (
<button
type="button"
onClick={() => {
setExpanded(false);
onClose?.();
}}
className="p-1 text-foreground/40 hover:text-foreground"
>
<X size={16} />
</button>
)}
</div>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("task.notesPlaceholder")}
rows={2}
className="w-full bg-transparent text-sm border border-border-light dark:border-border-dark rounded px-2 py-1 focus:outline-none focus:border-bleu resize-none placeholder:text-foreground/40"
/>
<div className="flex flex-wrap items-center gap-3">
<select
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
>
{PRIORITY_LABELS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
/>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
>
<option value="">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setExpanded(false);
setTitle("");
onClose?.();
}}
className="px-3 py-1.5 text-sm text-foreground/60 hover:text-foreground"
>
{t("task.cancel")}
</button>
<button
type="submit"
disabled={!title.trim() || submitting}
className="px-3 py-1.5 text-sm bg-bleu text-white rounded-lg hover:bg-bleu/90 disabled:opacity-50 transition-colors"
>
{submitting ? "..." : t("task.addBtn")}
</button>
</div>
</form>
);
}

View file

@ -0,0 +1,319 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import {
ChevronDown,
ChevronRight,
Trash2,
Calendar,
Repeat,
Check,
Search,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { Task } from "@/lib/types";
import { TaskForm } from "./TaskForm";
const PRIORITY_COLORS: Record<number, string> = {
0: "",
1: "border-l-vert",
2: "border-l-sable",
3: "border-l-rouge",
};
function formatDate(dateStr: string | Date | null): string {
if (!dateStr) return "";
const d = new Date(dateStr);
return d.toLocaleDateString("fr-CA", {
month: "short",
day: "numeric",
});
}
interface TaskItemProps {
task: Task;
subtasks?: Task[];
depth?: number;
}
export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
const { t } = useTranslation();
const router = useRouter();
const [expanded, setExpanded] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(task.title);
const [notes, setNotes] = useState(task.notes || "");
const [priority, setPriority] = useState(task.priority);
const [dueDate, setDueDate] = useState(
task.dueDate ? new Date(String(task.dueDate)).toISOString().split("T")[0] : ""
);
const [recurrence, setRecurrence] = useState(task.recurrence || "");
const [showSubtaskForm, setShowSubtaskForm] = useState(false);
const [saving, setSaving] = useState(false);
const PRIORITY_LABELS: Record<number, string> = {
0: t("priority.none"),
1: t("priority.low"),
2: t("priority.medium"),
3: t("priority.high"),
};
const RECURRENCE_LABELS: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
// Sync state when task prop changes
useEffect(() => {
setTitle(task.title);
setNotes(task.notes || "");
setPriority(task.priority);
setDueDate(
task.dueDate ? new Date(String(task.dueDate)).toISOString().split("T")[0] : ""
);
setRecurrence(task.recurrence || "");
}, [task]);
const toggleComplete = async () => {
await fetch(`/api/tasks/${task.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: !task.completed }),
});
router.refresh();
};
const saveEdit = async () => {
if (!title.trim() || saving) return;
setSaving(true);
try {
await fetch(`/api/tasks/${task.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
notes: notes || null,
priority,
dueDate: dueDate || null,
recurrence: recurrence || null,
}),
});
setEditing(false);
router.refresh();
} finally {
setSaving(false);
}
};
const deleteTask = async () => {
await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
router.refresh();
};
const borderClass = PRIORITY_COLORS[task.priority] || "";
return (
<div style={{ marginLeft: depth * 24 }}>
<div
className={`border border-border-light dark:border-border-dark rounded-lg mb-1.5 ${borderClass} ${
borderClass ? "border-l-[3px]" : ""
} ${task.completed ? "opacity-60" : ""}`}
>
{/* Main row */}
<div className="flex items-center gap-2 px-3 py-2">
{/* Expand subtasks toggle — only shown when subtasks exist */}
{subtasks.length > 0 ? (
<button
onClick={() => setExpanded(!expanded)}
className="p-0.5 text-foreground/40 hover:text-foreground shrink-0"
>
{expanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
) : (
<span className="w-[18px] shrink-0" />
)}
{/* Checkbox */}
<button
onClick={toggleComplete}
className={`w-5 h-5 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
task.completed
? "bg-bleu border-bleu text-white"
: "border-foreground/30 hover:border-bleu"
}`}
>
{task.completed && <Check size={12} />}
</button>
{/* Title — click opens detail */}
<span
className={`flex-1 text-sm cursor-pointer ${
task.completed ? "line-through text-foreground/50" : ""
}`}
onClick={() => setDetailOpen(!detailOpen)}
>
{task.title}
</span>
{/* Badges */}
{task.dueDate && (
<span className="flex items-center gap-1 text-xs text-foreground/50">
<Calendar size={12} />
{formatDate(task.dueDate)}
</span>
)}
{task.recurrence && (
<span className="text-xs text-foreground/50">
<Repeat size={12} />
</span>
)}
{subtasks.length > 0 && (
<span className="text-xs text-foreground/40">
{subtasks.filter((s) => s.completed).length}/{subtasks.length}
</span>
)}
{/* Detail view toggle */}
<button
onClick={() => setDetailOpen(!detailOpen)}
className={`p-0.5 shrink-0 transition-colors ${
detailOpen ? "text-bleu" : "text-foreground/30 hover:text-foreground/60"
}`}
>
<Search size={14} />
</button>
</div>
{/* Detail view */}
{detailOpen && (
<div className="px-3 pb-3 pt-1 border-t border-border-light dark:border-border-dark">
{editing ? (
<div className="space-y-2">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-transparent text-sm font-medium focus:outline-none border-b border-border-light dark:border-border-dark pb-1"
/>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("task.notesPlaceholder")}
rows={2}
className="w-full bg-transparent text-sm border border-border-light dark:border-border-dark rounded px-2 py-1 focus:outline-none focus:border-bleu resize-none placeholder:text-foreground/40"
/>
<div className="flex flex-wrap gap-2 items-center">
<select
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none"
>
<option value={0}>{t("priority.noneExplicit")}</option>
<option value={1}>{t("priority.low")}</option>
<option value={2}>{t("priority.medium")}</option>
<option value={3}>{t("priority.high")}</option>
</select>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none"
/>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none"
>
<option value="">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setEditing(false)}
className="px-3 py-1 text-sm text-foreground/60 hover:text-foreground"
>
{t("task.cancel")}
</button>
<button
onClick={saveEdit}
disabled={!title.trim() || saving}
className="px-3 py-1 text-sm bg-bleu text-white rounded hover:bg-bleu/90 disabled:opacity-50"
>
{saving ? "..." : t("task.save")}
</button>
</div>
</div>
) : (
<div className="space-y-2">
{task.notes && (
<p className="text-sm text-foreground/70">{task.notes}</p>
)}
<div className="flex flex-wrap gap-2 text-xs text-foreground/50">
{task.priority > 0 && (
<span>{t("task.priorityLabel", { value: PRIORITY_LABELS[task.priority] })}</span>
)}
{task.dueDate && (
<span>{t("task.dueDate", { value: formatDate(task.dueDate) })}</span>
)}
{task.recurrence && (
<span>{t("task.recurrenceLabel", { value: RECURRENCE_LABELS[task.recurrence] || task.recurrence })}</span>
)}
</div>
<div className="flex gap-2 pt-1">
<button
onClick={() => setEditing(true)}
className="text-xs text-bleu hover:underline"
>
{t("task.edit")}
</button>
{depth < 1 && (
<button
onClick={() => setShowSubtaskForm(!showSubtaskForm)}
className="text-xs text-bleu hover:underline"
>
{t("task.addSubtask")}
</button>
)}
<button
onClick={deleteTask}
className="text-xs text-rouge hover:underline flex items-center gap-1"
>
<Trash2 size={12} />
{t("task.delete")}
</button>
</div>
</div>
)}
</div>
)}
</div>
{/* Subtask form */}
{showSubtaskForm && detailOpen && (
<div style={{ marginLeft: 24 }} className="mb-1.5">
<TaskForm
listId={task.listId}
parentId={task.id}
onClose={() => setShowSubtaskForm(false)}
/>
</div>
)}
{/* Subtasks — toggled by chevron */}
{expanded && subtasks.map((sub) => (
<TaskItem key={sub.id} task={sub} depth={depth + 1} />
))}
</div>
);
}

View file

@ -0,0 +1,75 @@
"use client";
import type { Task } from "@/lib/types";
import { TaskItem } from "./TaskItem";
import { TaskForm } from "./TaskForm";
import { FilterBar } from "./FilterBar";
import { ClipboardList, RefreshCw } from "lucide-react";
import { Suspense, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
interface TaskListProps {
tasks: Task[];
subtasksMap: Record<string, Task[]>;
listId: string;
listName: string;
}
export function TaskList({ tasks, subtasksMap, listId, listName }: TaskListProps) {
const { t } = useTranslation();
const router = useRouter();
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
router.refresh();
// Brief visual feedback
setTimeout(() => setRefreshing(false), 500);
}, [router]);
return (
<div className="max-w-2xl mx-auto w-full">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-semibold">{listName}</h2>
<button
onClick={handleRefresh}
disabled={refreshing}
className="p-1.5 text-foreground/40 hover:text-foreground transition-colors disabled:opacity-50"
title={t("task.refresh")}
>
<RefreshCw size={18} className={refreshing ? "animate-spin" : ""} />
</button>
</div>
<Suspense fallback={null}>
<FilterBar />
</Suspense>
</div>
{/* Add task */}
<div className="mb-4">
<TaskForm listId={listId} />
</div>
{/* Tasks */}
{tasks.length === 0 ? (
<div className="text-center py-12 text-foreground/40">
<ClipboardList size={48} className="mx-auto mb-3 opacity-50" />
<p>{t("task.empty")}</p>
</div>
) : (
<div className="space-y-0">
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
subtasks={subtasksMap[task.id] || []}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,14 @@
// Inline script to set dark class before first paint (avoids flash)
export function ThemeScript() {
const script = `
(function() {
try {
var theme = localStorage.getItem('sl-theme') || 'system';
var isDark = theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) document.documentElement.classList.add('dark');
} catch(e) {}
})();
`;
return <script dangerouslySetInnerHTML={{ __html: script }} />;
}

View file

@ -0,0 +1,43 @@
"use client";
import { useState, useEffect } from "react";
import { Sun, Moon, Monitor } from "lucide-react";
import { useTranslation } from "react-i18next";
type Theme = "light" | "dark" | "system";
export function ThemeToggle() {
const { t } = useTranslation();
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
const stored = localStorage.getItem("sl-theme") as Theme | null;
if (stored) setTheme(stored);
}, []);
useEffect(() => {
localStorage.setItem("sl-theme", theme);
const isDark =
theme === "dark" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", isDark);
}, [theme]);
const cycle = () => {
setTheme((t) => (t === "light" ? "dark" : t === "dark" ? "system" : "light"));
};
const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
const themeLabel = t(`theme.${theme}`);
return (
<button
onClick={cycle}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
title={t("theme.label", { value: themeLabel })}
>
<Icon size={20} />
</button>
);
}

View file

@ -0,0 +1,16 @@
"use client";
import { useTranslation } from "react-i18next";
export function WelcomeMessage() {
const { t } = useTranslation();
return (
<div className="flex items-center justify-center h-full text-foreground/50">
<div className="text-center space-y-2">
<p className="text-lg">{t("welcome.title")}</p>
<p className="text-sm">{t("welcome.message")}</p>
</div>
</div>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export function useSync() {
const router = useRouter();
useEffect(() => {
let ws: WebSocket | null = null;
let retryTimeout: ReturnType<typeof setTimeout>;
async function connect() {
try {
// Get a WS ticket from the API
const res = await fetch("/api/ws-ticket", { method: "POST" });
if (!res.ok) return;
const { ticket } = await res.json();
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${window.location.host}/ws?ticket=${ticket}`);
ws.onmessage = () => {
// Any sync message triggers a data refresh
router.refresh();
};
ws.onclose = () => {
// Retry after 10 seconds
retryTimeout = setTimeout(connect, 10000);
};
} catch {
retryTimeout = setTimeout(connect, 10000);
}
}
connect();
return () => {
ws?.close();
clearTimeout(retryTimeout);
};
}, [router]);
}

9
web/src/db/client.ts Normal file
View file

@ -0,0 +1,9 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });

View file

@ -0,0 +1,54 @@
CREATE TABLE "sl_lists" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text NOT NULL,
"color" text,
"icon" text,
"position" integer DEFAULT 0 NOT NULL,
"is_inbox" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "sl_tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text NOT NULL,
"color" text DEFAULT '#4A90A4' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "sl_task_tags" (
"task_id" uuid NOT NULL,
"tag_id" uuid NOT NULL,
CONSTRAINT "sl_task_tags_task_id_tag_id_pk" PRIMARY KEY("task_id","tag_id")
);
--> statement-breakpoint
CREATE TABLE "sl_tasks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"title" text NOT NULL,
"notes" text,
"completed" boolean DEFAULT false NOT NULL,
"completed_at" timestamp with time zone,
"priority" integer DEFAULT 0 NOT NULL,
"due_date" timestamp with time zone,
"list_id" uuid NOT NULL,
"parent_id" uuid,
"position" integer DEFAULT 0 NOT NULL,
"recurrence" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "sl_task_tags" ADD CONSTRAINT "sl_task_tags_task_id_sl_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."sl_tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sl_task_tags" ADD CONSTRAINT "sl_task_tags_tag_id_sl_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."sl_tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sl_tasks" ADD CONSTRAINT "sl_tasks_list_id_sl_lists_id_fk" FOREIGN KEY ("list_id") REFERENCES "public"."sl_lists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_sl_lists_user" ON "sl_lists" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_sl_tags_user" ON "sl_tags" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_sl_tasks_user" ON "sl_tasks" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_sl_tasks_list" ON "sl_tasks" USING btree ("list_id");--> statement-breakpoint
CREATE INDEX "idx_sl_tasks_parent" ON "sl_tasks" USING btree ("parent_id");

View file

@ -0,0 +1,3 @@
ALTER TABLE "sl_lists" ALTER COLUMN "user_id" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "sl_tasks" ALTER COLUMN "user_id" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "sl_tags" ALTER COLUMN "user_id" SET DATA TYPE text;

View file

@ -0,0 +1,45 @@
-- Cleanup duplicate inboxes per user (#60)
-- For each user with more than one active inbox, keep the oldest one
-- (lowest created_at), reassign all tasks to it, and soft-delete the duplicates.
WITH ranked_inboxes AS (
SELECT
id,
user_id,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
FROM sl_lists
WHERE is_inbox = true
AND deleted_at IS NULL
),
canonical AS (
SELECT user_id, id AS canonical_id
FROM ranked_inboxes
WHERE rn = 1
),
duplicates AS (
SELECT r.id AS duplicate_id, c.canonical_id, r.user_id
FROM ranked_inboxes r
JOIN canonical c ON c.user_id = r.user_id
WHERE r.rn > 1
)
-- Reassign tasks from duplicate inboxes to the canonical one
UPDATE sl_tasks
SET list_id = d.canonical_id, updated_at = NOW()
FROM duplicates d
WHERE sl_tasks.list_id = d.duplicate_id
AND sl_tasks.user_id = d.user_id;
--> statement-breakpoint
-- Soft-delete the duplicate inboxes
WITH ranked_inboxes AS (
SELECT
id,
user_id,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
FROM sl_lists
WHERE is_inbox = true
AND deleted_at IS NULL
)
UPDATE sl_lists
SET deleted_at = NOW(), updated_at = NOW()
WHERE id IN (SELECT id FROM ranked_inboxes WHERE rn > 1);

View file

@ -0,0 +1,410 @@
{
"id": "a1bf2951-6318-42a2-adbb-758a703deb0b",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.sl_lists": {
"name": "sl_lists",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"is_inbox": {
"name": "is_inbox",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_sl_lists_user": {
"name": "idx_sl_lists_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sl_tags": {
"name": "sl_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'#4A90A4'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_sl_tags_user": {
"name": "idx_sl_tags_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sl_task_tags": {
"name": "sl_task_tags",
"schema": "",
"columns": {
"task_id": {
"name": "task_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sl_task_tags_task_id_sl_tasks_id_fk": {
"name": "sl_task_tags_task_id_sl_tasks_id_fk",
"tableFrom": "sl_task_tags",
"tableTo": "sl_tasks",
"columnsFrom": [
"task_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"sl_task_tags_tag_id_sl_tags_id_fk": {
"name": "sl_task_tags_tag_id_sl_tags_id_fk",
"tableFrom": "sl_task_tags",
"tableTo": "sl_tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"sl_task_tags_task_id_tag_id_pk": {
"name": "sl_task_tags_task_id_tag_id_pk",
"columns": [
"task_id",
"tag_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sl_tasks": {
"name": "sl_tasks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"completed": {
"name": "completed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"due_date": {
"name": "due_date",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"list_id": {
"name": "list_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"parent_id": {
"name": "parent_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"recurrence": {
"name": "recurrence",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_sl_tasks_user": {
"name": "idx_sl_tasks_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_sl_tasks_list": {
"name": "idx_sl_tasks_list",
"columns": [
{
"expression": "list_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_sl_tasks_parent": {
"name": "idx_sl_tasks_parent",
"columns": [
{
"expression": "parent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sl_tasks_list_id_sl_lists_id_fk": {
"name": "sl_tasks_list_id_sl_lists_id_fk",
"tableFrom": "sl_tasks",
"tableTo": "sl_lists",
"columnsFrom": [
"list_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

Some files were not shown because too many files have changed in this diff Show more