Compare commits
No commits in common. "master" and "v1.4.0" have entirely different histories.
118 changed files with 436 additions and 15873 deletions
|
|
@ -1,76 +0,0 @@
|
|||
---
|
||||
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` (et `npm install` pour synchroniser le lockfile)
|
||||
4. Commit : `chore: bump versionCode to <N>` (ou `chore: bump version to X.Y.Z (versionCode N)` si version aussi bumpee)
|
||||
5. Push commit sur master, puis tag `vX.Y.Z` et `git push origin vX.Y.Z`
|
||||
6. Build : `npx --yes eas-cli build --platform android --profile preview --non-interactive`
|
||||
7. Quand le build est termine : telecharger l'APK, creer la release Forgejo, attacher l'APK
|
||||
|
||||
## Upload de l'APK — pattern resilient
|
||||
|
||||
```bash
|
||||
# 1. Telecharger
|
||||
APK_URL=$(npx --yes eas-cli build:view <build_id> --json | jq -r '.artifacts.buildUrl')
|
||||
curl -L -o "/tmp/simpl-liste-vX.Y.Z.apk" "$APK_URL"
|
||||
|
||||
# 2. Creer la release
|
||||
TOKEN=$(cat ~/.forgejo-token)
|
||||
RELEASE_ID=$(curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
"https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-liste/releases" \
|
||||
-d '{"tag_name":"vX.Y.Z","name":"vX.Y.Z","body":"...","draft":false,"prerelease":false}' \
|
||||
| jq -r '.id')
|
||||
|
||||
# 3. Attacher l'APK — RETRY sur 502/504 (variance reseau, pas un probleme infra)
|
||||
for i in 1 2 3 4 5; do
|
||||
http=$(curl -X POST -H "Authorization: token $TOKEN" \
|
||||
"https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-liste/releases/$RELEASE_ID/assets?name=simpl-liste-vX.Y.Z.apk" \
|
||||
-F "attachment=@/tmp/simpl-liste-vX.Y.Z.apk" \
|
||||
-o /tmp/asset-resp.json -w "%{http_code}" --max-time 300)
|
||||
case "$http" in
|
||||
201) echo "Upload OK"; break;;
|
||||
502|504) echo "Attempt $i: gateway timeout, retry"; sleep 5;;
|
||||
*) echo "Unexpected: HTTP $http"; cat /tmp/asset-resp.json; break;;
|
||||
esac
|
||||
done
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
## Gotcha — Upload APK : 502/504 transitoire
|
||||
|
||||
**Symptome** : `curl -X POST ... -F "attachment=@<apk>"` retourne HTTP 502 ou 504 apres ~60s. La progression curl montre upload coupe a 50-90% de progression. Affecte typiquement les APK ~90MB+.
|
||||
|
||||
**Cause** : Traefik (devant Forgejo via Coolify) a un timeout de gateway de 60s sur la fin de requete. Quand la vitesse upload tombe sous ~1.5 MB/s (variance reseau classique sur connexions residentielles), un APK de 92 MB depasse le seuil et la requete est coupee. **Ce n'est pas un probleme de config infra** — c'est purement la vitesse upload du client a un instant donne.
|
||||
|
||||
**Fix** : retry. La 2e ou 3e tentative passe quand la bande passante remonte (j'ai mesure 1.3 MB/s puis 17 MB/s a quelques minutes d'intervalle). Le pattern `for i in 1..5` ci-dessus est suffisant — pas besoin de toucher a Traefik ni Forgejo.
|
||||
|
||||
**Verification que c'est bien le bon probleme** :
|
||||
- L'APK precedent (~97 MB) a ete uploade avec succes les semaines passees → infra OK
|
||||
- Un fichier de test de 11 octets sur la meme release passe en 0.1s → API OK
|
||||
- HTTP 502 ou 504 apres 60s exactement → timeout gateway, pas erreur de logique
|
||||
- Vitesse upload curl reportee < 1.5 MB/s → confirme la cause
|
||||
|
||||
**Path API a connaitre** :
|
||||
- Creer release : `POST /repos/{owner}/{repo}/releases` (renvoie `.id`)
|
||||
- Attacher asset : `POST /repos/{owner}/{repo}/releases/{release_id}/assets?name=<filename>` (multipart `attachment`)
|
||||
- Supprimer asset : `DELETE /repos/{owner}/{repo}/releases/{release_id}/assets/{asset_id}` — **inclure le release_id dans le path**, pas seulement l'asset id, sinon HTTP 404
|
||||
50
SECURITY.md
50
SECURITY.md
|
|
@ -1,50 +0,0 @@
|
|||
# SECURITY — simpl-liste
|
||||
|
||||
Rapport d'etat securite. Scanne quotidiennement par `defenseur-simpl-liste`
|
||||
(04:15 UTC) dans `~/claude-code/defenseurs/`.
|
||||
|
||||
## CVE resolues
|
||||
|
||||
### 2026-04-24 — Overrides `@xmldom/xmldom` et `uuid`
|
||||
|
||||
Correction via `overrides` dans `package.json`. Aucune n'etait exploitable a
|
||||
runtime dans l'APK : chaine concernee est build-time (iOS build via xcode,
|
||||
Expo CLI, plist serialization) ou dev-tunnel (ngrok).
|
||||
|
||||
| CVE | Severite | Package | Fix version | Etat |
|
||||
|---|---|---|---|---|
|
||||
| GHSA-2v35-w6hq-6mfw | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
|
||||
| GHSA-f6ww-3ggp-fr8h | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
|
||||
| GHSA-x6wf-f3px-wcqx | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
|
||||
| GHSA-j759-j44w-7fr8 | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
|
||||
| GHSA-w5hq-g745-h8pq | MEDIUM | uuid | ^11.0.0 | Mitigation partielle (voir ci-dessous) |
|
||||
|
||||
## CVE residuelles
|
||||
|
||||
### GHSA-w5hq-g745-h8pq — `uuid` buffer bounds check
|
||||
|
||||
**Status** : mitigation partielle. L'advisory npm flag `uuid <14.0.0` meme apres
|
||||
override a `^11.0.0`. Bug concerne `uuid.v3() / v5() / v6()` quand `buf` param
|
||||
est fourni. Les consommateurs transitifs (`xcode`, `@expo/ngrok`) utilisent
|
||||
uniquement `uuid.v4()` — donc **pas de code path vulnerable atteint en pratique**.
|
||||
|
||||
Non bump vers `^14.0.0` car ESM-only (casserait les imports CJS de xcode et
|
||||
ngrok dans la chaine build iOS). Voir spec decision D3 pour details.
|
||||
|
||||
**Impact** : cascade via 18 advisories transitives (uuid → xcode/ngrok → expo/* → ...),
|
||||
toutes remontant a la meme racine. Non-bloquant pour la production.
|
||||
|
||||
**Re-evaluation** : lors du prochain upgrade Expo SDK (quand xcode + ngrok
|
||||
passeront a une version compatible avec `uuid@^14` ou retireront uuid).
|
||||
|
||||
## Procedure de scan manuel
|
||||
|
||||
```bash
|
||||
cd ~/claude-code/defenseurs && npx tsx src/defenseur.ts defenseur-simpl-liste
|
||||
```
|
||||
|
||||
## Politique de review
|
||||
|
||||
- **HIGH** : fix immediat via PR.
|
||||
- **MEDIUM** : triage sous 7 jours, fix si exploitable.
|
||||
- **LOW** : accepte tel quel, documente ici.
|
||||
22
STATE.md
22
STATE.md
|
|
@ -1,22 +0,0 @@
|
|||
# STATE — simpl-liste
|
||||
|
||||
> Derniere MAJ : 2026-05-10 (par fix-issue #87)
|
||||
|
||||
## Position actuelle
|
||||
|
||||
Version 1.6.1 (versionCode 13). Remediation vulnerabilites du defenseur en cours
|
||||
via overrides `@xmldom/xmldom@^0.8.13` et `uuid@^11.0.0` dans `package.json`
|
||||
(overnight 2026-04-24). 4 CVE HIGH xmldom nettoyees ; 1 advisory uuid residuelle
|
||||
non-exploitable en pratique (details dans `SECURITY.md`).
|
||||
|
||||
## Decisions recentes
|
||||
|
||||
- 2026-05-10 : Aligne 6 patches Expo SDK 54 via expo install --fix — expo-doctor 17/17 (ref #87)
|
||||
- 2026-05-08 : Defenseur rerun confirme 0 findings — overrides existants suffisent, pas de PR necessaire (ref #81)
|
||||
- 2026-04-24 : overrides xmldom + uuid (spec `spec-decisions-vuln-simpl-liste.md`) — PRs #77, #78, #79 (pending-human).
|
||||
- 2026-04-23 : PR #71 merged — fix widget render-optimiste + timing instrumentation.
|
||||
- 2026-04-18 : archive milestone `spec-simpl-liste-web` (12/12 done).
|
||||
|
||||
## Blockers actifs
|
||||
|
||||
Aucun.
|
||||
8
app.json
8
app.json
|
|
@ -2,7 +2,7 @@
|
|||
"expo": {
|
||||
"name": "Simpl-Liste",
|
||||
"slug": "simpl-liste",
|
||||
"version": "1.6.4",
|
||||
"version": "1.4.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "simplliste",
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"backgroundColor": "#FFF8F0"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"versionCode": 16
|
||||
"versionCode": 7
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
|
@ -74,9 +74,7 @@
|
|||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"expo-secure-store",
|
||||
"expo-web-browser"
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { View, Text, Pressable, TextInput, useColorScheme, Alert, Animated, Easing } from 'react-native';
|
||||
import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Plus, ArrowUpDown, Filter, Download, Search, X, RefreshCw } from 'lucide-react-native';
|
||||
import { Plus, ArrowUpDown, Filter, Download, Search, X } from 'lucide-react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import DraggableFlatList, { RenderItemParams } from 'react-native-draggable-flatlist';
|
||||
|
|
@ -44,8 +44,6 @@ 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();
|
||||
|
||||
|
|
@ -72,31 +70,6 @@ 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);
|
||||
|
|
@ -191,11 +164,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -2,17 +2,14 @@ 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, Cloud, LogIn, LogOut } from 'lucide-react-native';
|
||||
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, LayoutGrid, Mail, RefreshCw } 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';
|
||||
|
|
@ -28,9 +25,6 @@ export default function SettingsScreen() {
|
|||
reminderOffset, setReminderOffset,
|
||||
calendarSyncEnabled, setCalendarSyncEnabled,
|
||||
widgetPeriodWeeks, setWidgetPeriodWeeks,
|
||||
syncEnabled, setSyncEnabled,
|
||||
lastSyncAt, setLastSyncAt,
|
||||
userId, setUserId,
|
||||
} = useSettingsStore();
|
||||
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
|
||||
|
||||
|
|
@ -40,8 +34,6 @@ 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();
|
||||
|
|
@ -102,125 +94,6 @@ 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 {
|
||||
|
|
@ -428,88 +301,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useColorScheme, AppState, type AppStateStatus } from 'react-native';
|
||||
import { useEffect } from 'react';
|
||||
import { useColorScheme } 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,16 +8,12 @@ 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';
|
||||
|
||||
|
|
@ -59,6 +55,10 @@ 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,57 +78,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { View, Text, Pressable, TextInput, useColorScheme, Alert, Animated, Easing } from 'react-native';
|
||||
import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import {
|
||||
ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X, RefreshCw,
|
||||
ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X,
|
||||
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
|
||||
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
|
||||
Gift, Camera, Palette, Dog, Leaf, Zap,
|
||||
|
|
@ -61,8 +61,6 @@ 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();
|
||||
|
||||
|
|
@ -97,31 +95,6 @@ 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);
|
||||
|
|
@ -219,11 +192,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ type TaskData = {
|
|||
priority: number;
|
||||
dueDate: Date | null;
|
||||
listId: string;
|
||||
parentId: string | null;
|
||||
recurrence: string | null;
|
||||
};
|
||||
|
||||
|
|
@ -401,71 +400,67 @@ export default function TaskDetailScreen() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{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} />
|
||||
{/* 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={() => 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={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]'}`}
|
||||
value={editingTitle}
|
||||
onChangeText={setEditingTitle}
|
||||
onSubmitEditing={handleSaveSubtaskEdit}
|
||||
onBlur={handleSaveSubtaskEdit}
|
||||
autoFocus
|
||||
className={`flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
|
||||
style={{ fontFamily: 'Inter_400Regular' }}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<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>
|
||||
|
||||
<View style={{ height: 32 }} />
|
||||
</KeyboardAwareScrollView>
|
||||
|
|
|
|||
634
package-lock.json
generated
634
package-lock.json
generated
File diff suppressed because it is too large
Load diff
23
package.json
23
package.json
|
|
@ -1,10 +1,9 @@
|
|||
{
|
||||
"name": "simpl-liste",
|
||||
"main": "index.js",
|
||||
"version": "1.6.4",
|
||||
"version": "1.4.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"test": "node tests/smoke.test.cjs",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
|
|
@ -13,31 +12,28 @@
|
|||
"@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.2",
|
||||
"expo": "~54.0.34",
|
||||
"expo-auth-session": "~7.0.11",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"expo": "~54.0.33",
|
||||
"expo-calendar": "~15.0.8",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-crypto": "~15.0.9",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-notifications": "~0.32.17",
|
||||
"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",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.11",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"i18next": "^25.8.13",
|
||||
"lucide-react-native": "^0.575.0",
|
||||
"nativewind": "^4.2.2",
|
||||
|
|
@ -65,10 +61,7 @@
|
|||
"typescript": "~5.9.2"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.25.0",
|
||||
"@xmldom/xmldom": "^0.8.13",
|
||||
"uuid": "^11.1.1",
|
||||
"postcss": "^8.5.10"
|
||||
"esbuild": "^0.25.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,603 +0,0 @@
|
|||
# Spec — Simpl-Liste Web
|
||||
|
||||
> Date: 2026-04-06
|
||||
> Projet: simpl-liste
|
||||
> Statut: Draft
|
||||
> Dependance: Logto IdP (deploye et operationnel)
|
||||
|
||||
## Contexte
|
||||
|
||||
Simpl-Liste est actuellement une app mobile React Native/Expo avec stockage SQLite local (offline-first, sans compte). L'objectif est de rendre l'application disponible via le web, integree au systeme d'authentification Compte Maximus (Logto), avec synchronisation bidirectionnelle entre le mobile et le web.
|
||||
|
||||
L'app mobile continue de fonctionner sans compte. Le login debloque la sync ; il reste optionnel.
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre aux utilisateurs connectes avec un Compte Maximus de gerer leurs listes et taches depuis `liste.lacompagniemaximus.com`, avec sync hybride : temps reel sur le web (WebSocket), polling sur le mobile. Parite fonctionnelle complete avec le mobile (hors widgets, notifications push, et sync calendrier).
|
||||
|
||||
## Scope
|
||||
|
||||
### IN
|
||||
- Frontend web Next.js App Router dans `simpl-liste/web/`
|
||||
- API REST backend (route handlers Next.js) pour CRUD listes, taches, tags, sous-taches
|
||||
- WebSocket server pour notifications temps reel sur le web
|
||||
- Schema PostgreSQL (`sl_` prefix) avec Drizzle ORM (`drizzle-orm/pg-core`)
|
||||
- Auth via Logto (Compte Maximus) — SDK `@logto/next`
|
||||
- Sync hybride : WebSocket web + polling mobile
|
||||
- Login optionnel dans l'app mobile avec choix de migration des donnees locales
|
||||
- Types TypeScript partages entre mobile et web (`src/shared/`)
|
||||
- i18n FR/EN (francais par defaut)
|
||||
- Dark mode (light/dark/system)
|
||||
- Responsive design (utilisable sur mobile)
|
||||
- Deploiement sur Coolify (`liste.lacompagniemaximus.com`)
|
||||
|
||||
### OUT (explicitement exclu)
|
||||
- Widgets Android (specifique mobile)
|
||||
- Notifications push (specifique mobile)
|
||||
- Sync calendrier systeme (specifique mobile via expo-calendar)
|
||||
- Export ICS depuis le web (future phase)
|
||||
- Mode offline pour le web (connexion requise)
|
||||
- Partage de listes entre utilisateurs (future phase)
|
||||
- App iOS (future phase)
|
||||
|
||||
## Design
|
||||
|
||||
### Architecture globale
|
||||
|
||||
```
|
||||
simpl-liste/
|
||||
├── app/ # App mobile React Native (existant)
|
||||
├── src/ # Code mobile (existant)
|
||||
│ ├── shared/ # NOUVEAU — types, constantes, logique partagee
|
||||
│ │ ├── types.ts # Task, List, Tag, TaskFilters, etc.
|
||||
│ │ ├── colors.ts # Palette (extrait de theme/colors.ts)
|
||||
│ │ ├── priority.ts # Helpers priorite (extrait de lib/priority.ts)
|
||||
│ │ └── recurrence.ts # Calcul recurrence (extrait de lib/recurrence.ts)
|
||||
│ └── ...
|
||||
└── web/ # NOUVEAU — App Next.js
|
||||
├── package.json
|
||||
├── next.config.ts
|
||||
├── server.ts # Custom server (Next.js + WebSocket)
|
||||
├── Dockerfile
|
||||
├── src/db/
|
||||
│ ├── schema.ts # Schema PostgreSQL Drizzle (sl_ tables)
|
||||
│ ├── client.ts # Connexion pg + drizzle instance
|
||||
│ └── migrations/ # Migrations SQL (drizzle-kit generate)
|
||||
├── src/
|
||||
│ ├── app/ # App Router (pages, layouts, API routes)
|
||||
│ │ ├── layout.tsx
|
||||
│ │ ├── page.tsx # Redirect vers /lists ou /auth
|
||||
│ │ ├── auth/ # Login via Logto
|
||||
│ │ ├── api/ # Route handlers REST
|
||||
│ │ │ ├── lists/
|
||||
│ │ │ ├── tasks/
|
||||
│ │ │ ├── tags/
|
||||
│ │ │ └── sync/
|
||||
│ │ └── (app)/ # Pages authentifiees
|
||||
│ │ ├── layout.tsx # Sidebar listes + main area
|
||||
│ │ ├── inbox/
|
||||
│ │ └── lists/[id]/
|
||||
│ ├── lib/
|
||||
│ │ ├── auth.ts # Logto middleware + session
|
||||
│ │ ├── db.ts # Re-export drizzle client
|
||||
│ │ └── ws.ts # WebSocket server + broadcast
|
||||
│ ├── components/ # Composants React web
|
||||
│ └── i18n/ # next-intl ou i18next
|
||||
└── tailwind.config.ts # Palette partagee depuis src/shared/colors.ts
|
||||
```
|
||||
|
||||
### ORM unifie — Drizzle partout
|
||||
|
||||
Le projet utilise **Drizzle ORM** sur les deux plateformes :
|
||||
- **Mobile** : `drizzle-orm/sqlite-core` + `expo-sqlite` (existant)
|
||||
- **Web/serveur** : `drizzle-orm/pg-core` + `pg` (nouveau)
|
||||
|
||||
Un seul ORM a maitriser, un seul outil de migration (`drizzle-kit`), pas de codegen (contrairement a Prisma). Les schemas SQLite et PostgreSQL partagent la meme structure de colonnes — les types inferes par Drizzle (`$inferSelect`) sont directement compatibles.
|
||||
|
||||
Le dossier `src/shared/types.ts` exporte les types metier communs (filtres, tri, recurrence, priorites). Les types des entites (Task, List, Tag) sont inferes depuis les schemas Drizzle respectifs — pas besoin d'une couche de mapping manuelle.
|
||||
|
||||
```typescript
|
||||
// src/shared/types.ts — types partages (filtres, enums, helpers)
|
||||
export type RecurrenceType = 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
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; /* ... */ }
|
||||
|
||||
// Les types des entites sont inferes depuis les schemas Drizzle de chaque plateforme :
|
||||
// Mobile : typeof tasks.$inferSelect (depuis src/db/schema.ts, sqlite-core)
|
||||
// Web : typeof slTasks.$inferSelect (depuis web/src/db/schema.ts, pg-core)
|
||||
```
|
||||
|
||||
### Identite utilisateur — Compte Maximus
|
||||
|
||||
Le `userId` stocke dans les tables PostgreSQL est le **`sub` claim du JWT Logto**. Ce choix est delibere : Logto est le systeme d'identite centralise de La Compagnie Maximus. Toutes les apps actuelles et futures (la-suite-booking, famille-website, etc.) migreront vers Logto comme IdP unique. Utiliser le `sub` comme identifiant commun garantit la compatibilite cross-app et permettra un futur dashboard Compte Maximus unifie.
|
||||
|
||||
### Schema PostgreSQL (Drizzle pg-core)
|
||||
|
||||
Base sur le schema SQLite existant (`src/db/schema.ts`), avec ajout de `userId` (= `sub` Logto) pour l'isolation multi-utilisateur et `deletedAt` pour le soft-delete (sync). Structure quasi identique au schema mobile — meme ORM, memes conventions.
|
||||
|
||||
```typescript
|
||||
// web/src/db/schema.ts
|
||||
import { pgTable, uuid, text, integer, boolean, timestamp, primaryKey, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const slLists = pgTable('sl_lists', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
color: text('color'),
|
||||
icon: text('icon'),
|
||||
position: integer('position').notNull().default(0),
|
||||
isInbox: boolean('is_inbox').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
}, (table) => [
|
||||
index('idx_sl_lists_user').on(table.userId),
|
||||
]);
|
||||
|
||||
export const slTasks = pgTable('sl_tasks', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
title: text('title').notNull(),
|
||||
notes: text('notes'),
|
||||
completed: boolean('completed').notNull().default(false),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
priority: integer('priority').notNull().default(0), // 0=none, 1=low, 2=medium, 3=high
|
||||
dueDate: timestamp('due_date', { withTimezone: true }),
|
||||
listId: uuid('list_id').notNull().references(() => slLists.id, { onDelete: 'cascade' }),
|
||||
parentId: uuid('parent_id').references((): any => slTasks.id, { onDelete: 'cascade' }),
|
||||
position: integer('position').notNull().default(0),
|
||||
recurrence: text('recurrence'), // 'daily'|'weekly'|'monthly'|'yearly'
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
}, (table) => [
|
||||
index('idx_sl_tasks_user').on(table.userId),
|
||||
index('idx_sl_tasks_list').on(table.listId),
|
||||
index('idx_sl_tasks_parent').on(table.parentId),
|
||||
]);
|
||||
|
||||
export const slTags = pgTable('sl_tags', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
color: text('color').notNull().default('#4A90A4'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
}, (table) => [
|
||||
index('idx_sl_tags_user').on(table.userId),
|
||||
]);
|
||||
|
||||
export const slTaskTags = pgTable('sl_task_tags', {
|
||||
taskId: uuid('task_id').notNull().references(() => slTasks.id, { onDelete: 'cascade' }),
|
||||
tagId: uuid('tag_id').notNull().references(() => slTags.id, { onDelete: 'cascade' }),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.taskId, table.tagId] }),
|
||||
]);
|
||||
```
|
||||
|
||||
Notes :
|
||||
- `deletedAt` (soft-delete) sur lists, tasks, tags — necessaire pour la sync (les suppressions doivent etre propagees aux clients)
|
||||
- `calendar_event_id` omis (specifique mobile)
|
||||
- `updatedAt` sert de vecteur de version pour le last-write-wins
|
||||
- La table `sl_tags` n'a pas de `updatedAt` dans le schema SQLite mobile — a ajouter cote mobile pour la sync
|
||||
- Le schema PostgreSQL est structurellement identique au schema SQLite mobile, avec `userId` et `deletedAt` en plus — memes noms de colonnes, memes types logiques
|
||||
|
||||
### API REST
|
||||
|
||||
Base URL: `https://liste.lacompagniemaximus.com/api`
|
||||
Auth: Session Logto (cookie) pour le web, Bearer JWT pour le mobile
|
||||
|
||||
#### Listes
|
||||
| Methode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/api/lists` | Toutes les listes de l'utilisateur |
|
||||
| POST | `/api/lists` | Creer une liste |
|
||||
| PUT | `/api/lists/:id` | Modifier une liste |
|
||||
| DELETE | `/api/lists/:id` | Soft-delete (cascade taches) |
|
||||
| PUT | `/api/lists/reorder` | Reordonner (batch positions) |
|
||||
|
||||
#### Taches
|
||||
| Methode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/api/lists/:listId/tasks` | Taches d'une liste (filtres: completed, priority, dueDate, tags) |
|
||||
| GET | `/api/tasks/:id/subtasks` | Sous-taches |
|
||||
| POST | `/api/tasks` | Creer une tache |
|
||||
| PUT | `/api/tasks/:id` | Modifier (titre, notes, completed, priorite, dueDate, listId, position, recurrence) |
|
||||
| DELETE | `/api/tasks/:id` | Soft-delete (cascade sous-taches) |
|
||||
| PUT | `/api/tasks/reorder` | Reordonner dans une liste |
|
||||
|
||||
#### Tags
|
||||
| Methode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/api/tags` | Tous les tags de l'utilisateur |
|
||||
| POST | `/api/tags` | Creer un tag |
|
||||
| PUT | `/api/tags/:id` | Modifier un tag |
|
||||
| DELETE | `/api/tags/:id` | Soft-delete |
|
||||
| POST | `/api/tasks/:id/tags` | Assigner des tags a une tache |
|
||||
| DELETE | `/api/tasks/:taskId/tags/:tagId` | Retirer un tag |
|
||||
|
||||
#### Sync (mobile)
|
||||
| Methode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/api/sync?since=<ISO timestamp>` | Changements serveur depuis timestamp (inclut soft-deletes) |
|
||||
| POST | `/api/sync` | Push batch de changements locaux (avec idempotency keys) |
|
||||
|
||||
#### WebSocket (web)
|
||||
| Methode | Endpoint | Description |
|
||||
|---------|----------|-------------|
|
||||
| POST | `/api/ws-ticket` | Generer un ticket ephemere (nonce, TTL 30s) pour le handshake WS |
|
||||
| WS | `/ws?ticket=<nonce>` | Connexion WebSocket (ticket a usage unique) |
|
||||
|
||||
### Sync Strategy — Hybride
|
||||
|
||||
#### Web : WebSocket temps reel
|
||||
- Custom server Next.js (`server.ts`) qui attache un serveur `ws` sur le meme port HTTP (path `/ws`)
|
||||
- A chaque mutation via l'API, le serveur broadcast un message aux WebSocket connectes du meme `userId`
|
||||
- Le client web ecoute et rafraichit les donnees affectees (pas de re-fetch complet, notification granulaire par entity type + id)
|
||||
- Reconnexion automatique avec backoff exponentiel cote client
|
||||
- Heartbeat toutes les 30s pour detecter les connexions mortes
|
||||
|
||||
```typescript
|
||||
// Message WebSocket — notification seulement, pas de payload (securite)
|
||||
type WsMessage =
|
||||
| { type: 'sync'; entity: 'list' | 'task' | 'tag'; action: 'create' | 'update' | 'delete'; id: string }
|
||||
| { type: 'auth_expired' } // session expiree, le client doit se reconnecter
|
||||
```
|
||||
|
||||
#### Mobile : polling periodique
|
||||
- Sync au lancement de l'app (si connecte)
|
||||
- Sync toutes les 2 minutes en foreground
|
||||
- Sync au retour du background (app state change)
|
||||
- Indicateur visuel de statut sync (icone dans le header)
|
||||
|
||||
#### Resolution de conflits
|
||||
- **Last-write-wins** base sur `updatedAt` — le timestamp le plus recent gagne
|
||||
- En cas d'egalite exacte (improbable), le serveur gagne
|
||||
- Les suppressions (soft-delete) sont prioritaires : si un client supprime et l'autre modifie, la suppression gagne
|
||||
|
||||
#### Flux de sync mobile
|
||||
1. Le mobile envoie `POST /api/sync` avec tous les changements locaux depuis `lastSyncAt`
|
||||
2. Le serveur applique les changements (LWW), retourne les conflits resolus
|
||||
3. Le mobile fait `GET /api/sync?since=lastSyncAt` pour recuperer les changements serveur
|
||||
4. Le mobile applique les changements serveur dans SQLite
|
||||
5. `lastSyncAt` est mis a jour dans AsyncStorage
|
||||
|
||||
#### Premiere connexion — Migration des donnees locales
|
||||
Au premier login dans l'app mobile, une alerte propose :
|
||||
1. **"Fusionner mes taches"** — Upload toutes les donnees locales vers le serveur, puis sync bidirectionnel
|
||||
2. **"Repartir du serveur"** — Ecrase les donnees locales avec celles du serveur (si le compte a deja des donnees web)
|
||||
|
||||
**Reconciliation de l'Inbox** : L'Inbox mobile a un ID hardcode (`00000000-0000-0000-0000-000000000001`). Le serveur cree une Inbox avec un UUID genere. Lors du merge :
|
||||
1. Le serveur cree l'Inbox du user si elle n'existe pas
|
||||
2. Le mobile remap l'ID hardcode vers l'ID Inbox serveur dans toutes les taches locales
|
||||
3. Push les taches avec le nouvel ID
|
||||
4. Mettre a jour l'ID Inbox local pour matcher le serveur
|
||||
|
||||
#### Outbox pattern — file d'attente locale
|
||||
Les changements locaux sont d'abord ecrits dans une table SQLite `sync_outbox` avant d'etre envoyes au serveur :
|
||||
|
||||
```sql
|
||||
CREATE TABLE sync_outbox (
|
||||
id TEXT PRIMARY KEY, -- UUID (= idempotency key)
|
||||
entity_type TEXT NOT NULL, -- 'list' | 'task' | 'tag' | 'task_tag'
|
||||
entity_id TEXT NOT NULL, -- ID de l'entite modifiee
|
||||
action TEXT NOT NULL, -- 'create' | 'update' | 'delete'
|
||||
payload TEXT NOT NULL, -- JSON de l'operation
|
||||
created_at TEXT NOT NULL, -- ISO 8601
|
||||
synced_at TEXT -- NULL tant que pas sync, ISO 8601 apres
|
||||
);
|
||||
```
|
||||
|
||||
Flux :
|
||||
1. Chaque mutation locale ecrit dans la table metier ET dans `sync_outbox`
|
||||
2. Le sync service lit les entries `WHERE synced_at IS NULL`, les envoie en batch via `POST /api/sync`
|
||||
3. Les entries synced sont marquees (`synced_at = now()`)
|
||||
4. Les entries de plus de 7 jours sont purgees
|
||||
5. Si le serveur est inaccessible, l'outbox s'accumule — les changements ne sont jamais perdus
|
||||
|
||||
### Auth
|
||||
|
||||
#### Web (Next.js)
|
||||
- SDK `@logto/next` pour l'auth server-side
|
||||
- Middleware Next.js qui protege toutes les routes sous `/(app)/`
|
||||
- Session cookie securisee (HttpOnly, SameSite=Strict)
|
||||
- Extraction du `userId` depuis les claims JWT Logto
|
||||
- Verification optionnelle du claim `apps.simpl-liste` pour l'acces
|
||||
|
||||
#### Mobile (React Native)
|
||||
- SDK `@logto/rn` pour le flow OAuth2/OIDC
|
||||
- Bouton "Se connecter" dans Settings > Compte
|
||||
- Token JWT stocke securise (expo-secure-store)
|
||||
- Le token est envoye en header `Authorization: Bearer <jwt>` pour les appels API
|
||||
- Mode deconnecte : l'app fonctionne normalement sans token, la sync est desactivee
|
||||
|
||||
### Frontend Web — UX
|
||||
|
||||
#### Layout principal
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Simpl-Liste [FR] [🌙] [👤] │
|
||||
├──────────┬──────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 📥 Inbox │ ☐ Acheter du lait ⚡ Haute │
|
||||
│ 📋 Courses│ ☐ Appeler le dentiste 📅 Lun │
|
||||
│ 🏠 Maison │ ☑ Faire le menage ✓ Fait │
|
||||
│ + Liste │ ☐ Reparer le robinet │
|
||||
│ │ │
|
||||
│ Tags │ [+ Nouvelle tache] │
|
||||
│ 🔵 Urgent│ │
|
||||
│ 🟢 Perso │ Filtres: [Actives ▾] [Priorite ▾] │
|
||||
│ │ Tri: [Position ▾] │
|
||||
├──────────┴──────────────────────────────────────┤
|
||||
│ ● Connecte — Sync OK │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar gauche** : listes (avec couleur/icone), section tags, bouton + pour creer
|
||||
- **Zone principale** : taches de la liste selectionnee, avec filtres et tri en haut
|
||||
- **Barre d'etat** : statut de connexion et derniere sync
|
||||
- Edition inline des taches (titre, completion, priorite) — modal pour les details complets (notes, date, tags, sous-taches, recurrence)
|
||||
- Responsive : sidebar se replie en hamburger menu sur mobile web
|
||||
|
||||
### Securite
|
||||
|
||||
#### Transport et authentification
|
||||
- TLS obligatoire (HTTPS/WSS via Caddy) — jamais de connexion non chiffree
|
||||
- Toutes les requetes API authentifiees (session cookie web / JWT Bearer mobile)
|
||||
- Session cookie web : `HttpOnly`, `Secure`, `SameSite=Strict`
|
||||
- JWT mobile : access token court (15 min) + refresh token avec rotation (chaque refresh invalide l'ancien)
|
||||
- Validation JWT systematique sur chaque requete : signature, `exp`, `aud`, `iss`
|
||||
|
||||
#### Stockage des tokens mobile
|
||||
- Adaptateur de stockage custom pour `@logto/rn` basé sur `expo-secure-store` (Keychain iOS / Keystore Android)
|
||||
- Ne JAMAIS stocker de tokens dans AsyncStorage (non chiffre, lisible sur appareil roote)
|
||||
- Ne pas inclure de donnees sensibles dans le payload JWT (le payload est encode en base64, pas chiffre)
|
||||
|
||||
#### WebSocket — Anti-CSWSH (Cross-Site WebSocket Hijacking)
|
||||
- Authentification par **ticket ephemere** (pas par cookie) :
|
||||
1. Le client appelle `POST /api/ws-ticket` (authentifie par session)
|
||||
2. Le serveur genere un nonce a usage unique (TTL 30s), stocke en memoire
|
||||
3. Le client se connecte a `wss://host/ws?ticket=<nonce>`
|
||||
4. Le serveur valide et invalide le ticket au handshake (usage unique)
|
||||
- Validation du header `Origin` contre une allowlist (`liste.lacompagniemaximus.com`)
|
||||
- Re-validation de la session toutes les 15 minutes — fermer la connexion si expiree, envoyer `{ type: 'auth_expired' }` au client
|
||||
- Ne pas logger les query params sur la route `/ws` (eviter fuite du ticket dans les access logs)
|
||||
|
||||
#### Isolation des donnees et autorisation
|
||||
- Isolation par `userId` sur chaque requete (`WHERE userId = ?`)
|
||||
- **BOLA prevention sur le sync batch** : pour chaque operation dans `POST /api/sync`, verifier que l'entite appartient au `userId` (`WHERE id = ? AND userId = ?`). Rejeter le batch entier si une seule verification echoue.
|
||||
- Schemas Zod **strict** sur chaque endpoint — whitelist explicite des champs modifiables
|
||||
- Ne jamais permettre au client de modifier `userId`, `createdAt`, `deletedAt` via l'API
|
||||
|
||||
#### Anti-replay sur les endpoints sync
|
||||
- **Idempotency keys** : chaque operation dans le batch porte un UUID genere par le client. Le serveur stocke les IDs traites (TTL 24h) et retourne le meme resultat si resoumis.
|
||||
- **Fenetre temporelle** : rejeter les requetes dont le timestamp depasse +/- 5 minutes
|
||||
|
||||
#### Rate limiting granulaire
|
||||
|
||||
| Endpoint | Limite par user | Raison |
|
||||
|----------|----------------|--------|
|
||||
| `POST /api/sync` | 10/min | Operation lourde (batch) |
|
||||
| `POST /api/ws-ticket` | 10/min | Prevenir brute-force de tickets |
|
||||
| `POST /api/lists`, `POST /api/tasks`, `POST /api/tags` | 30/min | Creation |
|
||||
| `GET /api/*` | 200/min | Lecture |
|
||||
| Connexions WebSocket | 5/min | Prevenir flood de reconnexions |
|
||||
|
||||
#### Messages WebSocket
|
||||
- Envoyer seulement le type d'entite + id dans les messages WS (pas le contenu des taches)
|
||||
- Le client re-fetch les donnees modifiees via l'API REST (securisee)
|
||||
- Evite la fuite de donnees si la connexion WS est compromise
|
||||
|
||||
#### Soft-delete et retention
|
||||
- Purge automatique des enregistrements soft-deleted apres 30 jours (cron job)
|
||||
- Les clients qui n'ont pas sync depuis 30+ jours effectuent un full sync au lieu d'un delta
|
||||
- Politique de retention documentee dans les conditions d'utilisation
|
||||
|
||||
#### Input validation
|
||||
- Validation/sanitization (zod) sur tous les champs texte
|
||||
- Protection XSS : echapper le contenu utilisateur a l'affichage (React le fait par defaut)
|
||||
- CSRF protection sur les mutations (cookie `SameSite=Strict`)
|
||||
|
||||
## Plan de travail
|
||||
|
||||
### Issue 1 — Types partages et refactoring mobile [type:task]
|
||||
Dependances : aucune
|
||||
- [ ] Creer `src/shared/types.ts` — exporter Task, List, Tag, TaskFilters, SortBy, etc.
|
||||
- [ ] Creer `src/shared/colors.ts` — extraire la palette de `theme/colors.ts`
|
||||
- [ ] Creer `src/shared/priority.ts` — extraire de `lib/priority.ts`
|
||||
- [ ] Creer `src/shared/recurrence.ts` — extraire de `lib/recurrence.ts`
|
||||
- [ ] Refactorer le code mobile pour importer depuis `shared/`
|
||||
- [ ] Ajouter `updatedAt` au schema `tags` dans le mobile (migration Drizzle)
|
||||
|
||||
### Issue 2 — Setup projet web + schema PostgreSQL [type:task]
|
||||
Dependances : aucune (parallele avec Issue 1)
|
||||
- [ ] Init Next.js App Router dans `web/` avec TypeScript, Tailwind, Drizzle ORM (`drizzle-orm/pg-core` + `pg`)
|
||||
- [ ] Schema Drizzle PostgreSQL (sl_lists, sl_tasks, sl_tags, sl_task_tags) — userId = `sub` Logto
|
||||
- [ ] Migration initiale + seed (inbox par defaut par user)
|
||||
- [ ] Endpoint `/api/health` (status API, latence DB, connexions WS actives)
|
||||
- [ ] Dockerfile + config Coolify pour `liste.lacompagniemaximus.com` (verifier support WS dans Caddy)
|
||||
- [ ] Configurer Logto app pour le web (redirect URIs, etc.)
|
||||
|
||||
### Issue 3 — Auth web + middleware [type:feature]
|
||||
Dependances : Issue 2
|
||||
- [ ] Integrer `@logto/next` (sign-in, sign-out, callback)
|
||||
- [ ] Middleware Next.js : proteger `/(app)/`, extraire userId
|
||||
- [ ] Page de login (`/auth`) avec redirect vers Logto
|
||||
- [ ] Gestion de session (cookie securise)
|
||||
|
||||
### Issue 4 — API REST backend [type:feature]
|
||||
Dependances : Issue 2, Issue 3
|
||||
- [ ] Middleware auth sur `/api/*` (session cookie + Bearer JWT avec validation signature/exp/aud/iss)
|
||||
- [ ] Endpoints CRUD listes (avec soft-delete)
|
||||
- [ ] Endpoints CRUD taches (avec filtres, tri, sous-taches, soft-delete)
|
||||
- [ ] Endpoints CRUD tags + assignation (avec soft-delete)
|
||||
- [ ] Endpoints sync (GET since + POST batch avec idempotency keys)
|
||||
- [ ] Verification BOLA par entite sur le batch sync (`WHERE id = ? AND userId = ?`)
|
||||
- [ ] Schemas Zod strict sur chaque endpoint (whitelist de champs, rejeter champs inconnus)
|
||||
- [ ] Rate limiting granulaire (sync 10/min, creation 30/min, lecture 200/min)
|
||||
- [ ] Endpoint `POST /api/ws-ticket` (nonce ephemere TTL 30s, usage unique)
|
||||
- [ ] Tests API (optionnel mais recommande)
|
||||
|
||||
### Issue 5 — WebSocket server [type:feature]
|
||||
Dependances : Issue 4
|
||||
- [ ] Custom server (`server.ts`) : Next.js + `ws` sur le meme port
|
||||
- [ ] Auth WebSocket par ticket ephemere (valider et invalider le nonce au handshake)
|
||||
- [ ] Validation du header `Origin` contre l'allowlist
|
||||
- [ ] Broadcast par userId sur chaque mutation API (type + id seulement, pas de payload)
|
||||
- [ ] Re-validation de session toutes les 15 min (fermer si expiree + message `auth_expired`)
|
||||
- [ ] Heartbeat 30s + reconnexion auto cote client avec backoff exponentiel
|
||||
- [ ] Ne pas logger les query params sur la route `/ws`
|
||||
- [ ] Mettre a jour le Dockerfile pour utiliser le custom server
|
||||
|
||||
### Issue 6 — Frontend web [type:feature]
|
||||
Dependances : Issue 4, Issue 5
|
||||
- [ ] Layout principal (sidebar listes + zone taches)
|
||||
- [ ] CRUD listes (creer, renommer, supprimer, couleur/icone, reordonner)
|
||||
- [ ] CRUD taches (creer, editer inline, completer, supprimer, priorite, date, notes)
|
||||
- [ ] Sous-taches (affichage imbrique, creer/editer)
|
||||
- [ ] Tags (creer, assigner, filtrer)
|
||||
- [ ] Filtres et tri
|
||||
- [ ] Recurrence (affichage, creation, auto-recurrence au complete)
|
||||
- [ ] Integration WebSocket (mise a jour temps reel)
|
||||
- [ ] Dark mode (light/dark/system)
|
||||
- [ ] i18n FR/EN
|
||||
- [ ] Responsive design
|
||||
- [ ] Barre de statut sync
|
||||
|
||||
### Issue 7 — Sync mobile [type:feature]
|
||||
Dependances : Issue 1, Issue 4
|
||||
- [ ] Integrer `@logto/rn` avec adaptateur `expo-secure-store` (ne pas utiliser AsyncStorage pour les tokens)
|
||||
- [ ] Bouton login dans Settings > Compte
|
||||
- [ ] Access token court (15 min) + refresh token avec rotation automatique
|
||||
- [ ] Intercepteur HTTP pour refresh transparent (capturer 401, rafraichir, relancer)
|
||||
- [ ] Table `sync_outbox` dans SQLite (migration Drizzle) — outbox pattern
|
||||
- [ ] Client sync : push outbox entries + pull changes avec idempotency keys
|
||||
- [ ] Reconciliation Inbox au premier merge (remap ID hardcode → ID serveur)
|
||||
- [ ] Polling periodique (2 min) + sync au lancement + sync au retour foreground
|
||||
- [ ] Gestion des conflits LWW
|
||||
- [ ] Ecran de premiere connexion : choix merge local ou reset serveur
|
||||
- [ ] Indicateur visuel de statut sync (header)
|
||||
- [ ] Mode degrade si serveur inaccessible (outbox s'accumule, sync au retour)
|
||||
|
||||
### Ordre d'execution
|
||||
|
||||
```
|
||||
Issue 1 (Types partages) ──┐
|
||||
├── Issue 4 (API REST) ── Issue 5 (WebSocket) ─┐
|
||||
Issue 2 (Setup web + DB) ──┤ ├── Issue 6 (Frontend)
|
||||
└── Issue 3 (Auth web) ─┘ │
|
||||
Issue 1 ── Issue 4 ── Issue 7 (Sync mobile) │
|
||||
```
|
||||
|
||||
## Fichiers concernes
|
||||
|
||||
| Fichier | Action | Raison |
|
||||
|---------|--------|--------|
|
||||
| `web/` | Creer | Nouveau projet Next.js complet |
|
||||
| `src/shared/types.ts` | Creer | Types TypeScript partages mobile/web |
|
||||
| `src/shared/colors.ts` | Creer | Palette centralisee |
|
||||
| `src/shared/priority.ts` | Creer | Helpers priorite partages |
|
||||
| `src/shared/recurrence.ts` | Creer | Logique recurrence partagee |
|
||||
| `src/theme/colors.ts` | Modifier | Importer depuis shared/ |
|
||||
| `src/lib/priority.ts` | Modifier | Importer depuis shared/ |
|
||||
| `src/lib/recurrence.ts` | Modifier | Importer depuis shared/ |
|
||||
| `src/db/schema.ts` | Modifier | Ajouter updatedAt aux tags |
|
||||
| `src/db/schema.ts` | Modifier | Ajouter table `sync_outbox` |
|
||||
| `src/db/repository/tasks.ts` | Modifier | Ecrire dans l'outbox a chaque mutation (si connecte) |
|
||||
| `src/services/syncClient.ts` | Creer | Client sync (push outbox, pull changes, reconciliation Inbox) |
|
||||
| `src/stores/useSettingsStore.ts` | Modifier | Ajouter syncEnabled, lastSyncAt, userId |
|
||||
| `app/(tabs)/settings.tsx` | Modifier | Ajouter section Compte (login/logout/sync) |
|
||||
| `app/_layout.tsx` | Modifier | Init sync polling si connecte |
|
||||
| `package.json` | Modifier | Ajouter deps auth mobile (@logto/rn, expo-secure-store) |
|
||||
|
||||
## Criteres d'acceptation
|
||||
|
||||
- [ ] Un utilisateur connecte peut acceder a ses listes depuis liste.lacompagniemaximus.com
|
||||
- [ ] Les operations CRUD fonctionnent pour les listes, taches, tags et sous-taches sur le web
|
||||
- [ ] Les modifications sur le web apparaissent en temps reel si deux onglets sont ouverts (WebSocket)
|
||||
- [ ] Les modifications sur le web apparaissent sur le mobile apres sync polling (et vice versa)
|
||||
- [ ] L'app mobile continue de fonctionner sans compte (offline-first preserve)
|
||||
- [ ] Au premier login mobile, l'utilisateur choisit de fusionner ou remplacer ses donnees locales
|
||||
- [ ] Le site fonctionne en FR et EN, en light et dark mode
|
||||
- [ ] Le site est responsive (utilisable sur mobile web)
|
||||
- [ ] Les suppressions se propagent correctement (soft-delete + sync)
|
||||
- [ ] Le WebSocket se reconnecte automatiquement apres une deconnexion
|
||||
- [ ] Les tokens mobile sont stockes dans expo-secure-store (pas AsyncStorage)
|
||||
- [ ] Le handshake WebSocket utilise un ticket ephemere (pas de cookie seul)
|
||||
- [ ] Les endpoints API rejettent les champs non autorises (userId, createdAt, deletedAt)
|
||||
- [ ] Le batch sync verifie l'ownership de chaque entite individuellement
|
||||
- [ ] Les soft-deletes sont purges automatiquement apres 30 jours
|
||||
|
||||
## Edge cases et risques
|
||||
|
||||
| Cas | Mitigation |
|
||||
|-----|------------|
|
||||
| Conflit de modification simultanee (web + mobile) | Last-write-wins sur `updatedAt`. Acceptable pour usage personnel. |
|
||||
| Suppression sur un appareil, modification sur l'autre | La suppression gagne (soft-delete prioritaire) |
|
||||
| Serveur inaccessible depuis le mobile | Mode degrade : l'app fonctionne en local, les changements sont queued et sync au retour |
|
||||
| WebSocket deconnecte | Reconnexion auto avec backoff exponentiel. Fallback : refresh manuel de la page |
|
||||
| Premiere sync avec beaucoup de donnees locales | Batch l'upload en chunks de 50 entites pour eviter les timeouts |
|
||||
| Token JWT expire pendant une session mobile | Refresh token avec rotation via Logto SDK. Si echec, re-login requis |
|
||||
| Deux listes "Inbox" apres merge (locale + serveur) | Remap ID hardcode mobile vers ID Inbox serveur dans toutes les taches locales avant push |
|
||||
| Utilisateur supprime son compte | Cascade delete de toutes les donnees sl_* du user dans PostgreSQL |
|
||||
| Custom server Next.js + Coolify | S'assurer que le Dockerfile utilise `node server.ts` et non `next start`. Verifier le support WebSocket dans Caddy. |
|
||||
| Changements offline perdus (crash app) | Outbox pattern : les mutations sont persistees dans `sync_outbox` SQLite avant envoi |
|
||||
| Broadcast WS multi-instance | Architecture single-instance pour la v1. Si scaling necessaire, ajouter Redis pub/sub |
|
||||
| VPS memoire insuffisante | Surveiller via `/api/health`. Prevoir upgrade 8 Go si migration famille-website en parallele |
|
||||
| CSWSH (Cross-Site WebSocket Hijacking) | Ticket ephemere a usage unique + validation Origin + cookies SameSite=Strict |
|
||||
| BOLA sur le batch sync | Verification d'ownership par entite dans chaque operation du batch |
|
||||
| Replay attack sur POST /api/sync | Idempotency keys par operation + fenetre temporelle +/- 5 min |
|
||||
| Token mobile sur appareil roote | Stockage dans expo-secure-store (Keychain/Keystore), jamais AsyncStorage |
|
||||
| Session WS survit a l'expiration auth | Re-validation serveur toutes les 15 min, fermeture si expiree |
|
||||
| Fuite de donnees via messages WS | Messages WS ne contiennent que type + id, pas le contenu des taches |
|
||||
| Soft-deletes accumules indefiniment | Purge automatique apres 30 jours, full sync pour clients inactifs |
|
||||
| Mass assignment via l'API | Schemas Zod strict, whitelist de champs modifiables, `userId`/`createdAt`/`deletedAt` non modifiables |
|
||||
|
||||
## Decisions prises
|
||||
|
||||
| Question | Decision | Raison |
|
||||
|----------|----------|--------|
|
||||
| Stack web | Next.js App Router + Drizzle + PostgreSQL | Fullstack dans un seul projet. Drizzle unifie mobile et serveur. |
|
||||
| Emplacement du code | `simpl-liste/web/` dans le repo existant | Partage des types via `src/shared/`. Un seul repo a maintenir. |
|
||||
| ORM serveur | Drizzle (`pg-core`) | Meme ORM que le mobile (`sqlite-core`). Un seul outil a maitriser, schemas compatibles, pas de codegen. |
|
||||
| Auth | Logto (deploye) | Systeme d'auth centralise La Compagnie Maximus. |
|
||||
| Sync web | WebSocket (via `ws` + custom server) | Temps reel pour le web. Le VPS a largement les ressources (4 cores, 4 Go libres). |
|
||||
| Sync mobile | Polling periodique (2 min) | React Native ne maintient pas de WebSocket en background. Polling simple et fiable. |
|
||||
| Conflits | Last-write-wins sur updatedAt | Simple, suffisant pour un usage personnel avec peu d'appareils. |
|
||||
| Suppressions | Soft-delete (deletedAt) | Necessaire pour propager les suppressions aux autres clients via sync. |
|
||||
| Compte requis | Non, optionnel | Preserver l'experience offline-first sans friction. Le login debloque la sync. |
|
||||
| Migration donnees locales | Choix utilisateur (merge ou reset) | Eviter la perte de donnees et laisser le controle a l'utilisateur. |
|
||||
| Identite utilisateur | `sub` Logto comme `userId` | Compte Maximus unifie cross-app. Toutes les apps actuelles et futures utilisent le meme identifiant. |
|
||||
| ORM unifie | Drizzle partout (sqlite-core mobile + pg-core serveur) | Un seul ORM, schemas compatibles, types inferes directement, pas de couche de mapping. |
|
||||
| Reverse proxy | Caddy (pas Traefik) | Caddy est le reverse proxy en place sur le VPS. Support natif WebSocket. |
|
||||
| Outbox pattern | Table `sync_outbox` dans SQLite mobile | Les changements offline survivent aux crashes. Idempotency keys integrees. |
|
||||
| Scaling WS | Single-instance, pas de Redis | Suffisant pour l'usage actuel. Redis pub/sub a ajouter si multi-instance futur. |
|
||||
| Monitoring | Endpoint `/api/health` | Metriques applicatives (DB, WS, API) en complement de vps-health-api (hardware). |
|
||||
|
||||
## References
|
||||
|
||||
| Source | Pertinence |
|
||||
|--------|------------|
|
||||
| [Socket.IO + Next.js guide](https://socket.io/how-to/use-with-nextjs) | Pattern custom server pour WebSocket avec Next.js. On utilise `ws` directement (plus leger). |
|
||||
| [Next.js WebSocket discussion #58698](https://github.com/vercel/next.js/discussions/58698) | Confirme que les route handlers App Router ne supportent pas les WS — custom server necessaire. |
|
||||
| [PowerSync React Native SDK](https://docs.powersync.com/client-sdks/reference/react-native-and-expo) | Alternative evaluee pour la sync. Trop lourd pour notre cas (single user). DIY LWW + tombstones suffit. |
|
||||
| [Expo local-first guide](https://docs.expo.dev/guides/local-first/) | Patterns de sync offline-first avec expo-sqlite. Valide notre approche outbox + polling. |
|
||||
| [OWASP WebSocket Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html) | CSWSH, auth WS, validation Origin — base de notre strategie de ticket ephemere. |
|
||||
| [OWASP API Security Top 10](https://owasp.org/www-project-api-security/) | BOLA (#1), Mass Assignment (#3) — guides les controles d'acces sur le batch sync. |
|
||||
| [CSWSH Exploitation in 2025 — Include Security](https://blog.includesecurity.com/2025/04/cross-site-websocket-hijacking-exploitation-in-2025/) | Confirme que SameSite=Lax ne suffit pas si un sous-domaine est compromis. |
|
||||
| [Logto Security docs](https://docs.logto.io/security) | Rotation des cles, logout centralise, stockage securise des tokens. |
|
||||
| [Expo SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore/) | API pour le stockage chiffre (Keychain iOS / Keystore Android) — adaptateur pour @logto/rn. |
|
||||
|
||||
## Estimation
|
||||
|
||||
7-10 sessions de travail, decoupees ainsi :
|
||||
- Issues 1-2 (setup) : 1-2 sessions
|
||||
- Issue 3 (auth) : 1 session
|
||||
- Issue 4 (API) : 2 sessions
|
||||
- Issue 5 (WebSocket) : 1 session
|
||||
- Issue 6 (frontend) : 2-3 sessions
|
||||
- Issue 7 (sync mobile) : 1-2 sessions
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE `tags` ADD `updated_at` integer;
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
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
|
||||
);
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,375 +0,0 @@
|
|||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,20 +22,6 @@
|
|||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -4,17 +4,13 @@ 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,
|
||||
m0003,
|
||||
m0004
|
||||
m0002
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3,7 +3,6 @@ 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';
|
||||
|
||||
|
|
@ -47,16 +46,6 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -67,8 +56,6 @@ 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 }[]) {
|
||||
|
|
@ -81,5 +68,4 @@ 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(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
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,
|
||||
});
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ 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);
|
||||
|
|
@ -11,29 +10,22 @@ 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: now,
|
||||
updatedAt: now,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
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, updatedAt: new Date() }).where(eq(tags.id, id));
|
||||
writeOutboxEntry('tag', id, 'update', { id, name, color }).catch(() => {});
|
||||
await db.update(tags).set({ name: truncate(name, 100), color }).where(eq(tags.id, id));
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -52,10 +44,6 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -4,16 +4,22 @@ 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 { TaskFilters, SortBy, SortOrder } from '@/src/shared/types';
|
||||
import type { SortBy, SortOrder, FilterCompleted, FilterDueDate } from '@/src/stores/useTaskStore';
|
||||
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 type { TaskFilters } from '@/src/shared/types';
|
||||
export interface TaskFilters {
|
||||
sortBy?: SortBy;
|
||||
sortOrder?: SortOrder;
|
||||
filterPriority?: number | null;
|
||||
filterTag?: string | null;
|
||||
filterCompleted?: FilterCompleted;
|
||||
filterDueDate?: FilterDueDate;
|
||||
}
|
||||
|
||||
export async function getTasksByList(listId: string, filters?: TaskFilters) {
|
||||
const conditions = [eq(tasks.listId, listId), isNull(tasks.parentId)];
|
||||
|
|
@ -171,20 +177,6 @@ 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;
|
||||
|
|
@ -250,22 +242,6 @@ 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(() => {});
|
||||
}
|
||||
|
||||
|
|
@ -334,15 +310,11 @@ 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(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,17 +33,6 @@ 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(
|
||||
|
|
|
|||
|
|
@ -131,29 +131,6 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -131,29 +131,6 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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://';
|
||||
|
|
@ -1 +1,29 @@
|
|||
export { getPriorityColor, getPriorityOptions } from '@/src/shared/priority';
|
||||
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) },
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,18 @@
|
|||
export { getNextOccurrence, RECURRENCE_OPTIONS } from '@/src/shared/recurrence';
|
||||
export type { RecurrenceType } from '@/src/shared/recurrence';
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,432 +0,0 @@
|
|||
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)));
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ import { startOfDay, endOfDay, addWeeks } from 'date-fns';
|
|||
import { TaskListWidget } from '../widgets/TaskListWidget';
|
||||
|
||||
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';
|
||||
|
|
@ -215,7 +214,7 @@ export async function syncWidgetData(): Promise<void> {
|
|||
await setWidgetState(state);
|
||||
|
||||
// Request widget update for all 3 sizes
|
||||
const widgetNames = WIDGET_NAMES;
|
||||
const widgetNames = ['SimplListeSmall', 'SimplListeMedium', 'SimplListeLarge'];
|
||||
for (const widgetName of widgetNames) {
|
||||
try {
|
||||
await requestWidgetUpdate({
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
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) },
|
||||
];
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -11,18 +11,12 @@ 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>()(
|
||||
|
|
@ -34,18 +28,12 @@ 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',
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
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, 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';
|
||||
|
||||
interface TaskStoreState {
|
||||
sortBy: SortBy;
|
||||
|
|
|
|||
|
|
@ -1 +1,40 @@
|
|||
export { colors } from '@/src/shared/colors';
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,24 +1,11 @@
|
|||
import type { WidgetTaskHandlerProps } from 'react-native-android-widget';
|
||||
import { requestWidgetUpdate } from 'react-native-android-widget';
|
||||
import { TaskListWidget } from './TaskListWidget';
|
||||
import { getWidgetState, setWidgetState, WIDGET_NAMES, type WidgetTask } from '../services/widgetSync';
|
||||
import { getWidgetState, setWidgetState, type WidgetTask } from '../services/widgetSync';
|
||||
import { isValidUUID } from '../lib/validation';
|
||||
|
||||
const EXPAND_DEBOUNCE_MS = 600;
|
||||
const EXPAND_DEBOUNCE_MS = 2000;
|
||||
const lastExpandTimes = new Map<string, number>();
|
||||
|
||||
// Dev-only timing helper. Output goes to logcat:
|
||||
// adb logcat -s ReactNativeJS | grep '\[widget\]'
|
||||
async function timed<T>(label: string, fn: () => Promise<T> | T): Promise<T> {
|
||||
if (!__DEV__) return await fn();
|
||||
const start = Date.now();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
console.log(`[widget] ${label}: ${Date.now() - start}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderWithState(
|
||||
renderWidget: WidgetTaskHandlerProps['renderWidget'],
|
||||
widgetInfo: WidgetTaskHandlerProps['widgetInfo'],
|
||||
|
|
@ -37,49 +24,17 @@ function renderWithState(
|
|||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort persist: failure leaves AsyncStorage stale, but the next
|
||||
// handler call's getWidgetState() returns the prior state and re-renders
|
||||
// from it, so the UI self-heals on the next interaction.
|
||||
async function persistState(state: Awaited<ReturnType<typeof getWidgetState>>): Promise<void> {
|
||||
try {
|
||||
await setWidgetState(state);
|
||||
} catch {
|
||||
if (__DEV__) console.log('[widget] setWidgetState failed (state will resync on next sync push)');
|
||||
}
|
||||
}
|
||||
|
||||
export async function widgetTaskHandler(
|
||||
props: WidgetTaskHandlerProps
|
||||
): Promise<void> {
|
||||
const { widgetAction, widgetInfo, renderWidget } = props;
|
||||
const handlerStart = __DEV__ ? Date.now() : 0;
|
||||
|
||||
switch (widgetAction) {
|
||||
case 'WIDGET_ADDED':
|
||||
case 'WIDGET_UPDATE':
|
||||
case 'WIDGET_RESIZED': {
|
||||
const state = await timed(`${widgetAction} getState`, getWidgetState);
|
||||
const state = await getWidgetState();
|
||||
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
|
||||
if (__DEV__) console.log(`[widget] ${widgetAction} total: ${Date.now() - handlerStart}ms`);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -91,36 +46,31 @@ export async function widgetTaskHandler(
|
|||
const taskId = props.clickActionData?.taskId;
|
||||
if (!isValidUUID(taskId)) break;
|
||||
|
||||
const state = await timed('TOGGLE_COMPLETE getState', getWidgetState);
|
||||
const state = await getWidgetState();
|
||||
state.tasks = state.tasks.filter((t) => t.id !== taskId);
|
||||
await setWidgetState(state);
|
||||
|
||||
// Render first so the user sees the row disappear immediately,
|
||||
// then persist + run the DB write.
|
||||
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
|
||||
await timed('TOGGLE_COMPLETE setState', () => persistState(state));
|
||||
|
||||
try {
|
||||
const { toggleComplete } = await import('../db/repository/tasks');
|
||||
await timed('TOGGLE_COMPLETE db', () => toggleComplete(taskId));
|
||||
await toggleComplete(taskId);
|
||||
} catch {
|
||||
// DB might not be available in headless mode
|
||||
}
|
||||
|
||||
if (__DEV__) console.log(`[widget] TOGGLE_COMPLETE total: ${Date.now() - handlerStart}ms`);
|
||||
}
|
||||
|
||||
if (props.clickAction === 'TOGGLE_EXPAND') {
|
||||
const taskId = props.clickActionData?.taskId as string | undefined;
|
||||
if (!taskId) break;
|
||||
|
||||
// Anti-double-tap. Short enough to not feel laggy when the user
|
||||
// genuinely wants to expand-then-collapse.
|
||||
// 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);
|
||||
|
||||
const state = await timed('TOGGLE_EXPAND getState', getWidgetState);
|
||||
const state = await getWidgetState();
|
||||
const expandedSet = new Set(state.expandedTaskIds);
|
||||
|
||||
if (expandedSet.has(taskId)) {
|
||||
|
|
@ -129,11 +79,9 @@ export async function widgetTaskHandler(
|
|||
expandedSet.add(taskId);
|
||||
}
|
||||
state.expandedTaskIds = [...expandedSet];
|
||||
await setWidgetState(state);
|
||||
|
||||
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
|
||||
await timed('TOGGLE_EXPAND setState', () => persistState(state));
|
||||
|
||||
if (__DEV__) console.log(`[widget] TOGGLE_EXPAND total: ${Date.now() - handlerStart}ms`);
|
||||
}
|
||||
|
||||
if (props.clickAction === 'TOGGLE_SUBTASK') {
|
||||
|
|
@ -141,8 +89,9 @@ export async function widgetTaskHandler(
|
|||
const parentId = props.clickActionData?.parentId as string | undefined;
|
||||
if (!isValidUUID(subtaskId) || !parentId) break;
|
||||
|
||||
const state = await timed('TOGGLE_SUBTASK getState', getWidgetState);
|
||||
const state = await getWidgetState();
|
||||
|
||||
// Update subtask state in cached data
|
||||
const parent = state.tasks.find((t) => t.id === parentId);
|
||||
if (parent) {
|
||||
const sub = parent.subtasks?.find((s) => s.id === subtaskId);
|
||||
|
|
@ -151,24 +100,16 @@ export async function widgetTaskHandler(
|
|||
parent.subtaskDoneCount = (parent.subtasks ?? []).filter((s) => s.completed).length;
|
||||
}
|
||||
}
|
||||
await setWidgetState(state);
|
||||
|
||||
// forceWidgetRefresh fans out to all 3 widget sizes (state changed
|
||||
// affects every widget on the home screen). Run before persist for
|
||||
// immediate visual feedback; the data passed in is the in-memory
|
||||
// mutated state, not re-read from AsyncStorage.
|
||||
await timed('TOGGLE_SUBTASK render', () =>
|
||||
forceWidgetRefresh(state.tasks, state.isDark, state.expandedTaskIds)
|
||||
);
|
||||
await timed('TOGGLE_SUBTASK setState', () => persistState(state));
|
||||
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
|
||||
|
||||
try {
|
||||
const { toggleComplete } = await import('../db/repository/tasks');
|
||||
await timed('TOGGLE_SUBTASK db', () => toggleComplete(subtaskId));
|
||||
await toggleComplete(subtaskId);
|
||||
} catch {
|
||||
// DB might not be available in headless mode
|
||||
}
|
||||
|
||||
if (__DEV__) console.log(`[widget] TOGGLE_SUBTASK total: ${Date.now() - handlerStart}ms`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// Non-regression smoke tests for npm overrides + deps integrity.
|
||||
// Runs on plain node (no jest, no Expo runtime). Catches the obvious
|
||||
// breakage paths after `npm install` rewrites lock for an `overrides` bump.
|
||||
//
|
||||
// node tests/smoke.test.cjs
|
||||
//
|
||||
// Exit 0 if all checks pass, 1 if any fails.
|
||||
|
||||
const assert = require('node:assert').strict;
|
||||
|
||||
let failed = 0;
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`OK ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.error(`FAIL ${name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
||||
|
||||
check('package.json is valid JSON with name + deps', () => {
|
||||
const pkg = require('../package.json');
|
||||
assert.ok(pkg.name, 'package.json missing name');
|
||||
assert.ok(pkg.dependencies, 'package.json missing dependencies');
|
||||
});
|
||||
|
||||
check('uuid v4 generates well-formed UUID', () => {
|
||||
const { v4 } = require('uuid');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
assert.match(v4(), UUID_RE);
|
||||
}
|
||||
});
|
||||
|
||||
// The uuid <14.0.0 advisory (GHSA-w5hq-g745-h8pq) is specifically about
|
||||
// missing buffer bounds checks in v3/v5/v6. After bumping to v14, these
|
||||
// must still produce valid UUIDs from a name + namespace.
|
||||
check('uuid v3 with namespace produces valid UUID', () => {
|
||||
const { v3 } = require('uuid');
|
||||
const id = v3('test-name', NAMESPACE);
|
||||
assert.match(id, UUID_RE);
|
||||
});
|
||||
|
||||
check('uuid v5 with namespace produces valid UUID', () => {
|
||||
const { v5 } = require('uuid');
|
||||
const id = v5('test-name', NAMESPACE);
|
||||
assert.match(id, UUID_RE);
|
||||
});
|
||||
|
||||
// Buffer-arg path is the actual vuln site — must not throw and must
|
||||
// fill the buffer with the UUID bytes.
|
||||
check('uuid v3 with buffer arg fills buffer (vuln site)', () => {
|
||||
const { v3 } = require('uuid');
|
||||
const buf = Buffer.alloc(16);
|
||||
v3('test-name', NAMESPACE, buf);
|
||||
// After the call, buf must have at least one non-zero byte.
|
||||
assert.ok(
|
||||
buf.some((b) => b !== 0),
|
||||
'buffer was not filled by uuid v3'
|
||||
);
|
||||
});
|
||||
|
||||
check('uuid v5 with buffer arg fills buffer (vuln site)', () => {
|
||||
const { v5 } = require('uuid');
|
||||
const buf = Buffer.alloc(16);
|
||||
v5('test-name', NAMESPACE, buf);
|
||||
assert.ok(
|
||||
buf.some((b) => b !== 0),
|
||||
'buffer was not filled by uuid v5'
|
||||
);
|
||||
});
|
||||
|
||||
if (failed === 0) {
|
||||
console.log('\nsmoke OK');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`\nsmoke FAIL (${failed} failure${failed > 1 ? 's' : ''})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -14,8 +14,5 @@
|
|||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts",
|
||||
"nativewind-env.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"web"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
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
41
web/.gitignore
vendored
|
|
@ -1,41 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<!-- 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 +0,0 @@
|
|||
@AGENTS.md
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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"]
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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!,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
8669
web/package-lock.json
generated
8669
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,39 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 391 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 1 KiB |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 128 B |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 385 B |
|
|
@ -1,47 +0,0 @@
|
|||
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`);
|
||||
});
|
||||
})();
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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 />;
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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('/');
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
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`);
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,341 +0,0 @@
|
|||
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');
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
|
|
@ -1,38 +0,0 @@
|
|||
@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;
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useSync } from "./useSync";
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
useSync();
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
"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;
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import "@/i18n";
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,319 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
// 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 }} />;
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
"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]);
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
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 });
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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");
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue