This commit is contained in:
2026-06-30 17:09:52 +02:00
commit 0806be135d
54 changed files with 10482 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
out/
dist/
*.local
.DS_Store
.env
.vite
+85
View File
@@ -0,0 +1,85 @@
# 🎮 GameDev Tracker
Application **desktop** de gestion de projet de jeu vidéo, pensée pour une **petite équipe (5-6 personnes)**.
Construite avec **Tauri + React + TypeScript + Tailwind + Framer Motion**, base de données **gratuite Supabase**.
## ✨ Fonctionnalités
- 🔐 **Connexion / inscription** par email + mot de passe
- 👥 **Équipe & rôles** (programmeur, artiste, game designer, sound, scénariste, QA…)
- 📋 **Tâches & Kanban** — glisser-déposer entre *À faire / En cours / En revue / Terminé*
- 🎯 **Jalons & planning** — échéances, progression par jalon (Prototype, Alpha, Beta, Release…)
- 🐛 **Suivi de bugs** — sévérité, priorité, statut, assignation
- 📊 **Tableau de bord** — avancement global, répartition des tâches, prochaines échéances
- 🔄 **Synchro temps réel** — quand un membre modifie une tâche, les autres la voient se mettre à jour
---
## 🚀 Mise en route
### 1. Créer la base de données (gratuit, une seule fois pour toute l'équipe)
1. Va sur **[supabase.com](https://supabase.com)** et crée un compte.
2. Clique **New project** (choisis un mot de passe de base de données, garde-le quelque part).
3. Attends ~2 min que le projet se crée.
4. Dans le menu de gauche : **SQL Editor → New query**.
5. Ouvre le fichier [`supabase/schema.sql`](supabase/schema.sql) de ce dépôt, **copie tout son contenu**, colle-le, puis clique **Run**.
> Cela crée les tables (profils, projets, tâches, jalons, bugs), la sécurité et la synchro temps réel.
6. Menu **Settings (⚙️) → API**. Note deux valeurs :
- **Project URL** → ressemble à `https://xxxxx.supabase.co`
- **anon public** (sous *Project API keys*) → longue chaîne commençant par `eyJ...`
7. **Recommandé pour une petite équipe** : désactive la confirmation d'email pour pouvoir se connecter tout de suite après l'inscription.
- Menu **Authentication → Sign In / Providers → Email** : désactive **« Confirm email »** et enregistre.
- (Sinon, chaque membre devra cliquer sur un lien reçu par email avant de pouvoir se connecter.)
> ️ La clé *anon public* est faite pour être distribuée à l'app cliente : pas de secret sensible exposé.
### 2. Lancer l'application
Prérequis (déjà installés sur cette machine) : **Node.js**, **Rust**.
```bash
npm install
npm run tauri:dev
```
Au premier lancement, l'app affiche un écran de **configuration** : colle l'**URL** et la **clé anon** obtenues à l'étape 1, puis **Connecter**.
Ensuite : crée ton compte (avec ton rôle), crée un **projet**, et c'est parti. 🎉
### 3. Distribuer à l'équipe
Pour générer un installeur (`.exe`/`.msi` sur Windows) :
```bash
npm run tauri:build
```
Le fichier d'installation se trouve dans `src-tauri/target/release/bundle/`.
Envoie-le à tes coéquipiers. À leur premier lancement, ils saisissent **la même URL + clé anon** que toi — ils rejoignent ainsi le même projet partagé.
---
## 🧰 Scripts utiles
| Commande | Effet |
| --- | --- |
| `npm run tauri:dev` | Lance l'app en mode développement (rechargement à chaud) |
| `npm run tauri:build` | Génère l'installeur de l'app |
| `npm run dev` | Lance uniquement le front-end dans le navigateur (sans la fenêtre desktop) |
| `npm run typecheck` | Vérifie les types TypeScript |
## 🏗️ Architecture
```text
├─ index.html # point d'entrée du front
├─ src/ # application React (front-end)
│ ├─ pages/ # Dashboard, Kanban, Milestones, Bugs, Members, Login, Setup
│ ├─ components/ # Layout, UI, animations
│ └─ lib/ # client Supabase, types, contexte global, config
├─ src-tauri/ # enveloppe desktop Tauri (Rust)
└─ supabase/schema.sql # schéma de la base à exécuter dans Supabase
```
## 🔒 Note de sécurité
L'app suppose une **équipe de confiance** : tout membre connecté peut lire et modifier les données du projet (politique RLS « accès complet pour les utilisateurs authentifiés »). C'est volontaire pour rester simple sur une petite équipe. Si un jour tu veux des permissions plus fines (ex. seul le lead supprime un projet), il faudra ajuster les *policies* dans `supabase/schema.sql`.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GameDev Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3146
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "gamedev-tracker",
"version": "0.1.0",
"description": "Application de gestion de projet de jeu vidéo pour petite équipe",
"type": "module",
"author": "Mathew",
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@supabase/supabase-js": "^2.45.4",
"@tauri-apps/api": "^2.0.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.5.4",
"lucide-react": "^0.441.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/node": "^20.16.5",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.45",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4",
"vite": "^5.4.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+4764
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.3", features = [] }
tauri-plugin-log = "2"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+16
View File
@@ -0,0 +1,16 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "GameDev Tracker",
"version": "0.1.0",
"identifier": "com.gamedevtracker.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "GameDev Tracker",
"width": 1280,
"height": 820,
"minWidth": 960,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"center": true,
"backgroundColor": "#0f1117"
}
],
"security": {
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://*.supabase.co wss://*.supabase.co; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; script-src 'self'"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+55
View File
@@ -0,0 +1,55 @@
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { AnimatePresence } from 'framer-motion'
import { AppProvider, useApp } from './lib/AppContext'
import { PageFade } from './components/motion'
import Setup from './pages/Setup'
import Login from './pages/Login'
import Layout, { Page } from './components/Layout'
import Dashboard from './pages/Dashboard'
import Kanban from './pages/Kanban'
import Milestones from './pages/Milestones'
import Bugs from './pages/Bugs'
import Members from './pages/Members'
const PAGES: Record<Page, () => JSX.Element> = {
dashboard: Dashboard,
kanban: Kanban,
milestones: Milestones,
bugs: Bugs,
members: Members
}
function Shell(): JSX.Element {
const { stage } = useApp()
const [page, setPage] = useState<Page>('dashboard')
if (stage === 'loading') {
return (
<div className="flex h-full items-center justify-center text-gray-400">
<Loader2 className="animate-spin" size={28} />
</div>
)
}
if (stage === 'setup') return <Setup />
if (stage === 'login') return <Login />
const ActivePage = PAGES[page]
return (
<Layout page={page} setPage={setPage}>
<AnimatePresence mode="wait">
<PageFade key={page}>
<ActivePage />
</PageFade>
</AnimatePresence>
</Layout>
)
}
export default function App(): JSX.Element {
return (
<AppProvider>
<Shell />
</AppProvider>
)
}
+198
View File
@@ -0,0 +1,198 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import {
LayoutDashboard,
KanbanSquare,
Target,
Bug,
Users,
Plus,
ChevronDown,
LogOut,
Settings,
Gamepad2
} from 'lucide-react'
import { useApp } from '../lib/AppContext'
import { supa } from '../lib/supabase'
import { ROLE_LABELS } from '../lib/types'
import { Avatar, Modal } from './ui'
export type Page = 'dashboard' | 'kanban' | 'milestones' | 'bugs' | 'members'
const NAV: { id: Page; label: string; icon: typeof LayoutDashboard }[] = [
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
{ id: 'kanban', label: 'Tâches (Kanban)', icon: KanbanSquare },
{ id: 'milestones', label: 'Jalons & planning', icon: Target },
{ id: 'bugs', label: 'Bugs', icon: Bug },
{ id: 'members', label: 'Équipe', icon: Users }
]
export default function Layout({
page,
setPage,
children
}: {
page: Page
setPage: (p: Page) => void
children: React.ReactNode
}): JSX.Element {
const { profile, projects, currentProject, setCurrentProject, refreshProjects, signOut } = useApp()
const [picker, setPicker] = useState(false)
const [showNew, setShowNew] = useState(false)
return (
<div className="flex h-full">
{/* Barre latérale */}
<aside className="flex w-64 shrink-0 flex-col border-r border-border bg-panel">
<div className="flex items-center gap-2 px-4 py-4 text-accent">
<Gamepad2 size={22} />
<span className="font-semibold text-white">GameDev Tracker</span>
</div>
{/* Sélecteur de projet */}
<div className="relative px-3">
<button
className="flex w-full items-center justify-between rounded-lg border border-border bg-panel2 px-3 py-2 text-left text-sm hover:border-accent"
onClick={() => setPicker((v) => !v)}
>
<span className="truncate">
<span className="block text-[10px] uppercase text-gray-500">Projet</span>
<span className="font-medium text-white">{currentProject?.name ?? 'Aucun projet'}</span>
</span>
<ChevronDown size={16} className="text-gray-400" />
</button>
{picker && (
<div className="absolute left-3 right-3 z-20 mt-1 rounded-lg border border-border bg-panel2 py-1 shadow-xl">
{projects.map((p) => (
<button
key={p.id}
className="block w-full px-3 py-2 text-left text-sm hover:bg-border"
onClick={() => {
setCurrentProject(p)
setPicker(false)
}}
>
{p.name}
</button>
))}
<button
className="flex w-full items-center gap-2 border-t border-border px-3 py-2 text-left text-sm text-accent2 hover:bg-border"
onClick={() => {
setShowNew(true)
setPicker(false)
}}
>
<Plus size={14} /> Nouveau projet
</button>
</div>
)}
</div>
<nav className="mt-4 flex-1 space-y-1 px-3">
{NAV.map((n) => (
<button
key={n.id}
onClick={() => setPage(n.id)}
className={`relative flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors ${
page === n.id ? 'text-white' : 'text-gray-400 hover:bg-panel2 hover:text-white'
}`}
>
{page === n.id && (
<motion.span
layoutId="nav-active"
className="absolute inset-0 rounded-lg bg-accent/20"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
<n.icon size={18} className="relative z-10" />
<span className="relative z-10">{n.label}</span>
</button>
))}
</nav>
{/* Pied : utilisateur */}
<div className="border-t border-border p-3">
<div className="flex items-center gap-2">
<Avatar profile={profile} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">{profile?.full_name}</div>
<div className="truncate text-xs text-gray-500">
{profile ? ROLE_LABELS[profile.role] : ''}
</div>
</div>
<button onClick={signOut} title="Se déconnecter" className="text-gray-400 hover:text-rose-400">
<LogOut size={18} />
</button>
</div>
</div>
</aside>
{/* Contenu */}
<main className="flex-1 overflow-hidden">
{currentProject ? (
children
) : (
<div className="flex h-full flex-col items-center justify-center gap-4 text-gray-400">
<Settings size={40} className="text-gray-600" />
<p>Aucun projet pour le moment.</p>
<button className="btn-primary" onClick={() => setShowNew(true)}>
<Plus size={16} /> Créer le premier projet
</button>
</div>
)}
</main>
{showNew && <NewProjectModal onClose={() => setShowNew(false)} onCreated={refreshProjects} />}
</div>
)
}
function NewProjectModal({
onClose,
onCreated
}: {
onClose: () => void
onCreated: () => Promise<void>
}): JSX.Element {
const { profile, setCurrentProject } = useApp()
const [name, setName] = useState('')
const [desc, setDesc] = useState('')
const [busy, setBusy] = useState(false)
const create = async (): Promise<void> => {
if (!name.trim()) return
setBusy(true)
const { data } = await supa()
.from('projects')
.insert({ name: name.trim(), description: desc.trim() || null, created_by: profile?.id })
.select()
.single()
await onCreated()
if (data) setCurrentProject(data)
setBusy(false)
onClose()
}
return (
<Modal title="Nouveau projet" onClose={onClose}>
<label className="label">Nom du jeu / projet</label>
<input
className="input mb-3"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Mon super jeu"
/>
<label className="label">Description (optionnel)</label>
<textarea
className="input mb-4 h-24 resize-none"
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="Genre, plateforme, pitch..."
/>
<button className="btn-primary w-full" onClick={create} disabled={busy || !name.trim()}>
Créer
</button>
</Modal>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { ReactNode } from 'react'
export default function PageHeader({
title,
subtitle,
children
}: {
title: string
subtitle?: string
children?: ReactNode
}): JSX.Element {
return (
<header className="flex items-center justify-between border-b border-border px-6 py-4">
<div>
<h1 className="text-lg font-semibold text-white">{title}</h1>
{subtitle && <p className="text-sm text-gray-400">{subtitle}</p>}
</div>
<div className="flex items-center gap-2">{children}</div>
</header>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { ReactNode } from 'react'
import { motion, Variants } from 'framer-motion'
// Apparition douce d'une page (fondu + léger glissement vers le haut).
export function PageFade({ children }: { children: ReactNode }): JSX.Element {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
className="h-full"
>
{children}
</motion.div>
)
}
// Conteneur d'une liste : ses enfants <StaggerItem> apparaissent en cascade.
export const staggerContainer: Variants = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.04 } }
}
export const staggerItem: Variants = {
hidden: { opacity: 0, y: 10 },
show: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 300, damping: 24 } }
}
export function Stagger({
children,
className
}: {
children: ReactNode
className?: string
}): JSX.Element {
return (
<motion.div variants={staggerContainer} initial="hidden" animate="show" className={className}>
{children}
</motion.div>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { ReactNode, SelectHTMLAttributes } from 'react'
import { motion } from 'framer-motion'
import { X } from 'lucide-react'
import { Profile, Priority, PRIORITY_LABELS } from '../lib/types'
export function Modal({
title,
children,
onClose,
wide
}: {
title: string
children: ReactNode
onClose: () => void
wide?: boolean
}): JSX.Element {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onMouseDown={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 320, damping: 26 }}
className={`card max-h-[90vh] w-full overflow-y-auto ${wide ? 'max-w-2xl' : 'max-w-md'}`}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white">
<X size={20} />
</button>
</div>
{children}
</motion.div>
</motion.div>
)
}
export function Avatar({ profile, size = 32 }: { profile?: Profile | null; size?: number }): JSX.Element {
const initials = (profile?.full_name ?? '?')
.split(' ')
.map((s) => s[0])
.slice(0, 2)
.join('')
.toUpperCase()
return (
<div
title={profile?.full_name ?? 'Non assigné'}
className="flex shrink-0 items-center justify-center rounded-full font-semibold text-white"
style={{
width: size,
height: size,
fontSize: size * 0.4,
background: profile?.avatar_color ?? '#3a3f4d'
}}
>
{profile ? initials : ''}
</div>
)
}
const PRIORITY_STYLES: Record<Priority, string> = {
low: 'bg-slate-500/20 text-slate-300',
medium: 'bg-sky-500/20 text-sky-300',
high: 'bg-amber-500/20 text-amber-300',
urgent: 'bg-rose-500/20 text-rose-300'
}
export function PriorityBadge({ priority }: { priority: Priority }): JSX.Element {
return <span className={`badge ${PRIORITY_STYLES[priority]}`}>{PRIORITY_LABELS[priority]}</span>
}
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>): JSX.Element {
return <select {...props} className={`input ${props.className ?? ''}`} />
}
export function EmptyState({ icon, text }: { icon: ReactNode; text: string }): JSX.Element {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-gray-500">
{icon}
<p className="text-sm">{text}</p>
</div>
)
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+52
View File
@@ -0,0 +1,52 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
body,
#root {
height: 100%;
}
body {
@apply bg-bg text-gray-200 antialiased;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
margin: 0;
overflow: hidden;
}
* {
scrollbar-width: thin;
scrollbar-color: #2a2f3d transparent;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-thumb {
@apply bg-border rounded-full;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-accent text-white hover:bg-accent/90;
}
.btn-ghost {
@apply btn bg-panel2 text-gray-200 hover:bg-border;
}
.input {
@apply w-full rounded-lg border border-border bg-panel2 px-3 py-2 text-sm text-gray-100 outline-none focus:border-accent placeholder:text-gray-500;
}
.label {
@apply mb-1 block text-xs font-medium uppercase tracking-wide text-gray-400;
}
.card {
@apply rounded-xl border border-border bg-panel p-4;
}
.badge {
@apply inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium;
}
}
+118
View File
@@ -0,0 +1,118 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'
import { Session } from '@supabase/supabase-js'
import { initSupabase, supa, hasClient } from './supabase'
import { getConfig, setConfig } from './config'
import { Profile, Project } from './types'
type Stage = 'loading' | 'setup' | 'login' | 'ready'
interface AppState {
stage: Stage
session: Session | null
profile: Profile | null
projects: Project[]
currentProject: Project | null
setCurrentProject: (p: Project) => void
saveConfig: (url: string, key: string) => Promise<void>
refreshProjects: () => Promise<void>
refreshProfile: () => Promise<void>
signOut: () => Promise<void>
}
const Ctx = createContext<AppState | null>(null)
export function useApp(): AppState {
const v = useContext(Ctx)
if (!v) throw new Error('useApp doit être utilisé dans <AppProvider>')
return v
}
export function AppProvider({ children }: { children: ReactNode }): JSX.Element {
const [stage, setStage] = useState<Stage>('loading')
const [session, setSession] = useState<Session | null>(null)
const [profile, setProfile] = useState<Profile | null>(null)
const [projects, setProjects] = useState<Project[]>([])
const [currentProject, setCurrentProject] = useState<Project | null>(null)
const refreshProfile = useCallback(async () => {
const uid = (await supa().auth.getUser()).data.user?.id
if (!uid) return
const { data } = await supa().from('profiles').select('*').eq('id', uid).single()
if (data) setProfile(data as Profile)
}, [])
const refreshProjects = useCallback(async () => {
const { data } = await supa().from('projects').select('*').order('created_at')
const list = (data ?? []) as Project[]
setProjects(list)
setCurrentProject((prev) => {
if (prev) return list.find((p) => p.id === prev.id) ?? list[0] ?? null
return list[0] ?? null
})
}, [])
// Démarrage : charge la config locale puis tente de restaurer la session.
useEffect(() => {
;(async () => {
const cfg = getConfig()
if (!cfg.supabaseUrl || !cfg.supabaseAnonKey) {
setStage('setup')
return
}
initSupabase(cfg.supabaseUrl, cfg.supabaseAnonKey)
const { data } = await supa().auth.getSession()
setSession(data.session)
supa().auth.onAuthStateChange((_e, s) => setSession(s))
setStage(data.session ? 'ready' : 'login')
})()
}, [])
// Quand la session change, on (re)charge profil + projets.
useEffect(() => {
if (!hasClient()) return
if (session) {
setStage('ready')
refreshProfile()
refreshProjects()
} else if (stage !== 'setup' && stage !== 'loading') {
setStage('login')
setProfile(null)
setProjects([])
setCurrentProject(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session])
const saveConfig = async (url: string, key: string): Promise<void> => {
setConfig({ supabaseUrl: url.trim(), supabaseAnonKey: key.trim() })
initSupabase(url.trim(), key.trim())
const { data } = await supa().auth.getSession()
setSession(data.session)
supa().auth.onAuthStateChange((_e, s) => setSession(s))
setStage(data.session ? 'ready' : 'login')
}
const signOut = async (): Promise<void> => {
await supa().auth.signOut()
setSession(null)
}
return (
<Ctx.Provider
value={{
stage,
session,
profile,
projects,
currentProject,
setCurrentProject,
saveConfig,
refreshProjects,
refreshProfile,
signOut
}}
>
{children}
</Ctx.Provider>
)
}
+21
View File
@@ -0,0 +1,21 @@
// Stockage local des clés Supabase (la clé "anon" est publique par nature,
// localStorage convient parfaitement et évite tout code natif).
export interface AppConfig {
supabaseUrl?: string
supabaseAnonKey?: string
}
const KEY = 'gamedev-tracker-config'
export function getConfig(): AppConfig {
try {
return JSON.parse(localStorage.getItem(KEY) ?? '{}')
} catch {
return {}
}
}
export function setConfig(config: AppConfig): void {
localStorage.setItem(KEY, JSON.stringify(config))
}
+25
View File
@@ -0,0 +1,25 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js'
let client: SupabaseClient | null = null
/** Crée (ou recrée) le client Supabase à partir des clés fournies. */
export function initSupabase(url: string, anonKey: string): SupabaseClient {
client = createClient(url, anonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
storageKey: 'gamedev-tracker-auth'
}
})
return client
}
/** Récupère le client courant (lève une erreur si non initialisé). */
export function supa(): SupabaseClient {
if (!client) throw new Error('Supabase non initialisé. Configure les clés dans Réglages.')
return client
}
export function hasClient(): boolean {
return client !== null
}
+124
View File
@@ -0,0 +1,124 @@
// Types métier alignés sur le schéma SQL Supabase (voir supabase/schema.sql).
export type MemberRole =
| 'lead'
| 'programmer'
| 'artist'
| 'game_designer'
| 'sound'
| 'writer'
| 'qa'
| 'other'
export const ROLE_LABELS: Record<MemberRole, string> = {
lead: 'Chef de projet',
programmer: 'Programmeur',
artist: 'Artiste',
game_designer: 'Game Designer',
sound: 'Sound Designer',
writer: 'Scénariste',
qa: 'Testeur (QA)',
other: 'Autre'
}
export type TaskStatus = 'todo' | 'in_progress' | 'review' | 'done'
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
todo: 'À faire',
in_progress: 'En cours',
review: 'En revue',
done: 'Terminé'
}
export const TASK_STATUS_ORDER: TaskStatus[] = ['todo', 'in_progress', 'review', 'done']
export type Priority = 'low' | 'medium' | 'high' | 'urgent'
export const PRIORITY_LABELS: Record<Priority, string> = {
low: 'Basse',
medium: 'Moyenne',
high: 'Haute',
urgent: 'Urgente'
}
export type MilestoneStatus = 'planned' | 'in_progress' | 'done'
export const MILESTONE_STATUS_LABELS: Record<MilestoneStatus, string> = {
planned: 'Planifié',
in_progress: 'En cours',
done: 'Atteint'
}
export type BugSeverity = 'low' | 'medium' | 'high' | 'critical'
export const SEVERITY_LABELS: Record<BugSeverity, string> = {
low: 'Mineur',
medium: 'Moyen',
high: 'Majeur',
critical: 'Critique'
}
export type BugStatus = 'open' | 'in_progress' | 'resolved' | 'closed'
export const BUG_STATUS_LABELS: Record<BugStatus, string> = {
open: 'Ouvert',
in_progress: 'En cours',
resolved: 'Résolu',
closed: 'Fermé'
}
export interface Profile {
id: string
full_name: string
role: MemberRole
avatar_color: string
created_at: string
}
export interface Project {
id: string
name: string
description: string | null
created_by: string | null
created_at: string
}
export interface Milestone {
id: string
project_id: string
title: string
description: string | null
due_date: string | null
status: MilestoneStatus
created_at: string
}
export interface Task {
id: string
project_id: string
title: string
description: string | null
status: TaskStatus
priority: Priority
assignee_id: string | null
milestone_id: string | null
due_date: string | null
position: number
created_by: string | null
created_at: string
updated_at: string
}
export interface Bug {
id: string
project_id: string
title: string
description: string | null
severity: BugSeverity
status: BugStatus
priority: Priority
assignee_id: string | null
reporter_id: string | null
created_at: string
updated_at: string
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react'
import { supa } from './supabase'
import { Profile } from './types'
/** Charge la liste des membres (profils) pour les listes d'assignation. */
export function useMembers(): { members: Profile[]; byId: (id: string | null) => Profile | undefined } {
const [members, setMembers] = useState<Profile[]>([])
useEffect(() => {
supa()
.from('profiles')
.select('*')
.order('full_name')
.then(({ data }) => setMembers((data ?? []) as Profile[]))
}, [])
const byId = (id: string | null): Profile | undefined =>
id ? members.find((m) => m.id === id) : undefined
return { members, byId }
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+261
View File
@@ -0,0 +1,261 @@
import { useEffect, useState, useCallback } from 'react'
import { Plus, Bug as BugIcon, Trash2 } from 'lucide-react'
import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext'
import { useMembers } from '../lib/useMembers'
import {
Bug,
BugSeverity,
BugStatus,
Priority,
SEVERITY_LABELS,
BUG_STATUS_LABELS,
PRIORITY_LABELS
} from '../lib/types'
import { Avatar, Modal, Select, EmptyState, PriorityBadge } from '../components/ui'
import PageHeader from '../components/PageHeader'
const SEVERITY_STYLES: Record<BugSeverity, string> = {
low: 'bg-slate-500/20 text-slate-300',
medium: 'bg-amber-500/20 text-amber-300',
high: 'bg-orange-500/20 text-orange-300',
critical: 'bg-rose-500/20 text-rose-300'
}
const STATUS_STYLES: Record<BugStatus, string> = {
open: 'bg-rose-500/20 text-rose-300',
in_progress: 'bg-sky-500/20 text-sky-300',
resolved: 'bg-emerald-500/20 text-emerald-300',
closed: 'bg-slate-500/20 text-slate-400'
}
export default function Bugs(): JSX.Element {
const { currentProject, profile } = useApp()
const { members, byId } = useMembers()
const [bugs, setBugs] = useState<Bug[]>([])
const [filter, setFilter] = useState<BugStatus | 'all'>('all')
const [editing, setEditing] = useState<Bug | null>(null)
const [creating, setCreating] = useState(false)
const projectId = currentProject?.id
const load = useCallback(async () => {
if (!projectId) return
const { data } = await supa()
.from('bugs')
.select('*')
.eq('project_id', projectId)
.order('created_at', { ascending: false })
setBugs((data ?? []) as Bug[])
}, [projectId])
useEffect(() => {
load()
}, [load])
const shown = bugs.filter((b) => filter === 'all' || b.status === filter)
const openCount = bugs.filter((b) => b.status === 'open' || b.status === 'in_progress').length
return (
<div className="flex h-full flex-col">
<PageHeader title="Bugs" subtitle={`${openCount} bug(s) actif(s)`}>
<Select
className="!w-auto"
value={filter}
onChange={(e) => setFilter(e.target.value as BugStatus | 'all')}
>
<option value="all">Tous les statuts</option>
{Object.entries(BUG_STATUS_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
<button className="btn-primary" onClick={() => setCreating(true)}>
<Plus size={16} /> Signaler un bug
</button>
</PageHeader>
<div className="flex-1 overflow-y-auto p-6">
{shown.length === 0 ? (
<EmptyState icon={<BugIcon size={40} />} text="Aucun bug. 🎉" />
) : (
<table className="w-full border-separate border-spacing-y-2 text-sm">
<thead className="text-left text-xs uppercase text-gray-500">
<tr>
<th className="px-3">Bug</th>
<th className="px-3">Sévérité</th>
<th className="px-3">Priorité</th>
<th className="px-3">Statut</th>
<th className="px-3">Assigné</th>
</tr>
</thead>
<tbody>
{shown.map((b) => (
<tr
key={b.id}
className="cursor-pointer bg-panel hover:bg-panel2"
onClick={() => setEditing(b)}
>
<td className="rounded-l-lg px-3 py-3 font-medium text-white">{b.title}</td>
<td className="px-3">
<span className={`badge ${SEVERITY_STYLES[b.severity]}`}>
{SEVERITY_LABELS[b.severity]}
</span>
</td>
<td className="px-3">
<PriorityBadge priority={b.priority} />
</td>
<td className="px-3">
<span className={`badge ${STATUS_STYLES[b.status]}`}>
{BUG_STATUS_LABELS[b.status]}
</span>
</td>
<td className="rounded-r-lg px-3">
<Avatar profile={byId(b.assignee_id)} size={26} />
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{(creating || editing) && (
<BugModal
bug={editing}
projectId={projectId!}
reporterId={profile?.id ?? null}
members={members}
onClose={() => {
setCreating(false)
setEditing(null)
}}
onSaved={load}
/>
)}
</div>
)
}
function BugModal({
bug,
projectId,
reporterId,
members,
onClose,
onSaved
}: {
bug: Bug | null
projectId: string
reporterId: string | null
members: ReturnType<typeof useMembers>['members']
onClose: () => void
onSaved: () => Promise<void>
}): JSX.Element {
const [title, setTitle] = useState(bug?.title ?? '')
const [description, setDescription] = useState(bug?.description ?? '')
const [severity, setSeverity] = useState<BugSeverity>(bug?.severity ?? 'medium')
const [status, setStatus] = useState<BugStatus>(bug?.status ?? 'open')
const [priority, setPriority] = useState<Priority>(bug?.priority ?? 'medium')
const [assignee, setAssignee] = useState(bug?.assignee_id ?? '')
const [busy, setBusy] = useState(false)
const save = async (): Promise<void> => {
if (!title.trim()) return
setBusy(true)
const payload = {
project_id: projectId,
title: title.trim(),
description: description.trim() || null,
severity,
status,
priority,
assignee_id: assignee || null
}
if (bug) await supa().from('bugs').update(payload).eq('id', bug.id)
else await supa().from('bugs').insert({ ...payload, reporter_id: reporterId })
await onSaved()
setBusy(false)
onClose()
}
const remove = async (): Promise<void> => {
if (!bug || !confirm('Supprimer ce bug ?')) return
await supa().from('bugs').delete().eq('id', bug.id)
await onSaved()
onClose()
}
return (
<Modal title={bug ? 'Modifier le bug' : 'Signaler un bug'} onClose={onClose} wide>
<label className="label">Titre</label>
<input
className="input mb-3"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Le joueur traverse le mur en sautant"
/>
<label className="label">Description / étapes pour reproduire</label>
<textarea
className="input mb-3 h-24 resize-none"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Sévérité</label>
<Select value={severity} onChange={(e) => setSeverity(e.target.value as BugSeverity)}>
{Object.entries(SEVERITY_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</div>
<div>
<label className="label">Priorité</label>
<Select value={priority} onChange={(e) => setPriority(e.target.value as Priority)}>
{Object.entries(PRIORITY_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</div>
<div>
<label className="label">Statut</label>
<Select value={status} onChange={(e) => setStatus(e.target.value as BugStatus)}>
{Object.entries(BUG_STATUS_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</div>
<div>
<label className="label">Assigné à</label>
<Select value={assignee} onChange={(e) => setAssignee(e.target.value)}>
<option value="">Personne</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.full_name}
</option>
))}
</Select>
</div>
</div>
<div className="mt-5 flex items-center justify-between">
{bug ? (
<button className="btn text-rose-400 hover:bg-rose-500/10" onClick={remove}>
<Trash2 size={16} /> Supprimer
</button>
) : (
<span />
)}
<button className="btn-primary" onClick={save} disabled={busy || !title.trim()}>
{bug ? 'Enregistrer' : 'Créer'}
</button>
</div>
</Modal>
)
}
+183
View File
@@ -0,0 +1,183 @@
import { useEffect, useState, useCallback } from 'react'
import { motion } from 'framer-motion'
import { KanbanSquare, Bug as BugIcon, Target, CheckCircle2, CalendarClock } from 'lucide-react'
import { format, isPast, differenceInCalendarDays } from 'date-fns'
import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext'
import {
Task,
Bug,
Milestone,
TASK_STATUS_ORDER,
TASK_STATUS_LABELS,
TaskStatus
} from '../lib/types'
import PageHeader from '../components/PageHeader'
import { Stagger, staggerItem } from '../components/motion'
const COL_COLORS: Record<TaskStatus, string> = {
todo: 'bg-slate-400',
in_progress: 'bg-sky-400',
review: 'bg-amber-400',
done: 'bg-emerald-400'
}
export default function Dashboard(): JSX.Element {
const { currentProject } = useApp()
const [tasks, setTasks] = useState<Task[]>([])
const [bugs, setBugs] = useState<Bug[]>([])
const [milestones, setMilestones] = useState<Milestone[]>([])
const projectId = currentProject?.id
const load = useCallback(async () => {
if (!projectId) return
const [{ data: t }, { data: b }, { data: m }] = await Promise.all([
supa().from('tasks').select('*').eq('project_id', projectId),
supa().from('bugs').select('*').eq('project_id', projectId),
supa().from('milestones').select('*').eq('project_id', projectId)
])
setTasks((t ?? []) as Task[])
setBugs((b ?? []) as Bug[])
setMilestones((m ?? []) as Milestone[])
}, [projectId])
useEffect(() => {
load()
}, [load])
const doneTasks = tasks.filter((t) => t.status === 'done').length
const pct = tasks.length ? Math.round((doneTasks / tasks.length) * 100) : 0
const openBugs = bugs.filter((b) => b.status === 'open' || b.status === 'in_progress').length
const upcoming = milestones
.filter((m) => m.status !== 'done' && m.due_date)
.sort((a, b) => (a.due_date ?? '').localeCompare(b.due_date ?? ''))
.slice(0, 4)
return (
<div className="flex h-full flex-col">
<PageHeader title="Tableau de bord" subtitle={currentProject?.name} />
<div className="flex-1 overflow-y-auto p-6">
{/* Cartes stats */}
<Stagger className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard icon={<KanbanSquare />} label="Tâches" value={tasks.length} color="text-accent" />
<StatCard
icon={<CheckCircle2 />}
label="Terminées"
value={`${doneTasks} (${pct}%)`}
color="text-emerald-400"
/>
<StatCard icon={<BugIcon />} label="Bugs actifs" value={openBugs} color="text-rose-400" />
<StatCard
icon={<Target />}
label="Jalons"
value={`${milestones.filter((m) => m.status === 'done').length}/${milestones.length}`}
color="text-accent2"
/>
</Stagger>
{/* Avancement global */}
<div className="card mt-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-medium text-white">Avancement global</h3>
<span className="text-sm text-gray-400">{pct}%</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-panel2">
<motion.div
className="h-full bg-accent2"
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
/>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Répartition des tâches */}
<div className="card">
<h3 className="mb-3 font-medium text-white">Répartition des tâches</h3>
<div className="space-y-2">
{TASK_STATUS_ORDER.map((s) => {
const n = tasks.filter((t) => t.status === s).length
const w = tasks.length ? (n / tasks.length) * 100 : 0
return (
<div key={s}>
<div className="mb-1 flex justify-between text-xs text-gray-400">
<span>{TASK_STATUS_LABELS[s]}</span>
<span>{n}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-panel2">
<div className={`h-full ${COL_COLORS[s]}`} style={{ width: `${w}%` }} />
</div>
</div>
)
})}
</div>
</div>
{/* Prochaines échéances */}
<div className="card">
<h3 className="mb-3 font-medium text-white">Prochaines échéances</h3>
{upcoming.length === 0 ? (
<p className="py-6 text-center text-sm text-gray-500">Aucune échéance à venir.</p>
) : (
<ul className="space-y-2">
{upcoming.map((m) => {
const overdue = isPast(new Date(m.due_date! + 'T23:59:59'))
const d = differenceInCalendarDays(new Date(m.due_date!), new Date())
return (
<li
key={m.id}
className="flex items-center justify-between rounded-lg bg-panel2 px-3 py-2"
>
<span className="flex items-center gap-2 text-sm text-white">
<Target size={14} className="text-accent2" />
{m.title}
</span>
<span
className={`flex items-center gap-1 text-xs ${
overdue ? 'text-rose-400' : 'text-gray-400'
}`}
>
<CalendarClock size={12} />
{format(new Date(m.due_date!), 'd MMM', { locale: fr })}
{' · '}
{overdue ? `retard ${-d}j` : d === 0 ? "auj." : `${d}j`}
</span>
</li>
)
})}
</ul>
)}
</div>
</div>
</div>
</div>
)
}
function StatCard({
icon,
label,
value,
color
}: {
icon: JSX.Element
label: string
value: string | number
color: string
}): JSX.Element {
return (
<motion.div
variants={staggerItem}
whileHover={{ y: -3 }}
className="card flex items-center gap-3"
>
<div className={`rounded-lg bg-panel2 p-2 ${color}`}>{icon}</div>
<div>
<div className="text-2xl font-semibold text-white">{value}</div>
<div className="text-xs text-gray-400">{label}</div>
</div>
</motion.div>
)
}
+307
View File
@@ -0,0 +1,307 @@
import { useEffect, useState, useCallback, DragEvent } from 'react'
import { motion } from 'framer-motion'
import { Plus, Trash2, CalendarDays } from 'lucide-react'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext'
import { useMembers } from '../lib/useMembers'
import {
Task,
TaskStatus,
Priority,
Milestone,
TASK_STATUS_ORDER,
TASK_STATUS_LABELS,
PRIORITY_LABELS
} from '../lib/types'
import { Avatar, Modal, Select, PriorityBadge } from '../components/ui'
import PageHeader from '../components/PageHeader'
export default function Kanban(): JSX.Element {
const { currentProject, profile } = useApp()
const { members, byId } = useMembers()
const [tasks, setTasks] = useState<Task[]>([])
const [editing, setEditing] = useState<Task | null>(null)
const [creatingIn, setCreatingIn] = useState<TaskStatus | null>(null)
const [dragId, setDragId] = useState<string | null>(null)
const projectId = currentProject?.id
const load = useCallback(async () => {
if (!projectId) return
const { data } = await supa()
.from('tasks')
.select('*')
.eq('project_id', projectId)
.order('position')
setTasks((data ?? []) as Task[])
}, [projectId])
useEffect(() => {
load()
}, [load])
// Synchro temps réel : recharge quand un membre modifie une tâche.
useEffect(() => {
if (!projectId) return
const ch = supa()
.channel(`tasks-${projectId}`)
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'tasks', filter: `project_id=eq.${projectId}` },
() => load()
)
.subscribe()
return () => {
supa().removeChannel(ch)
}
}, [projectId, load])
const moveTask = async (taskId: string, status: TaskStatus): Promise<void> => {
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status } : t)))
await supa().from('tasks').update({ status }).eq('id', taskId)
}
const onDrop = (e: DragEvent, status: TaskStatus): void => {
e.preventDefault()
if (dragId) {
const t = tasks.find((x) => x.id === dragId)
if (t && t.status !== status) moveTask(dragId, status)
}
setDragId(null)
}
return (
<div className="flex h-full flex-col">
<PageHeader title="Tâches" subtitle={currentProject?.name}>
<button className="btn-primary" onClick={() => setCreatingIn('todo')}>
<Plus size={16} /> Nouvelle tâche
</button>
</PageHeader>
<div className="flex flex-1 gap-4 overflow-x-auto p-6">
{TASK_STATUS_ORDER.map((status) => {
const colTasks = tasks.filter((t) => t.status === status)
return (
<div
key={status}
className="flex w-72 shrink-0 flex-col rounded-xl bg-panel/50"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(e, status)}
>
<div className="flex items-center justify-between px-3 py-3">
<span className="text-sm font-semibold text-white">
{TASK_STATUS_LABELS[status]}
</span>
<span className="badge bg-panel2 text-gray-400">{colTasks.length}</span>
</div>
<div className="flex-1 space-y-2 overflow-y-auto px-2 pb-2">
{colTasks.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -2 }}
draggable
onDragStart={() => setDragId(t.id)}
onClick={() => setEditing(t)}
className="card cursor-pointer p-3 hover:border-accent"
>
<div className="mb-2 flex items-start justify-between gap-2">
<p className="text-sm font-medium text-white">{t.title}</p>
<Avatar profile={byId(t.assignee_id)} size={24} />
</div>
<div className="flex items-center justify-between">
<PriorityBadge priority={t.priority} />
{t.due_date && (
<span className="flex items-center gap-1 text-xs text-gray-400">
<CalendarDays size={12} />
{format(new Date(t.due_date), 'd MMM', { locale: fr })}
</span>
)}
</div>
</motion.div>
))}
<button
className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-xs text-gray-500 hover:bg-panel2 hover:text-white"
onClick={() => setCreatingIn(status)}
>
<Plus size={14} /> Ajouter
</button>
</div>
</div>
)
})}
</div>
{(editing || creatingIn) && (
<TaskModal
task={editing}
defaultStatus={creatingIn ?? 'todo'}
projectId={projectId!}
createdBy={profile?.id ?? null}
members={members}
onClose={() => {
setEditing(null)
setCreatingIn(null)
}}
onSaved={load}
/>
)}
</div>
)
}
function TaskModal({
task,
defaultStatus,
projectId,
createdBy,
members,
onClose,
onSaved
}: {
task: Task | null
defaultStatus: TaskStatus
projectId: string
createdBy: string | null
members: ReturnType<typeof useMembers>['members']
onClose: () => void
onSaved: () => Promise<void>
}): JSX.Element {
const [title, setTitle] = useState(task?.title ?? '')
const [description, setDescription] = useState(task?.description ?? '')
const [status, setStatus] = useState<TaskStatus>(task?.status ?? defaultStatus)
const [priority, setPriority] = useState<Priority>(task?.priority ?? 'medium')
const [assignee, setAssignee] = useState(task?.assignee_id ?? '')
const [milestone, setMilestone] = useState(task?.milestone_id ?? '')
const [dueDate, setDueDate] = useState(task?.due_date ?? '')
const [milestones, setMilestones] = useState<Milestone[]>([])
const [busy, setBusy] = useState(false)
useEffect(() => {
supa()
.from('milestones')
.select('*')
.eq('project_id', projectId)
.then(({ data }) => setMilestones((data ?? []) as Milestone[]))
}, [projectId])
const save = async (): Promise<void> => {
if (!title.trim()) return
setBusy(true)
const payload = {
project_id: projectId,
title: title.trim(),
description: description.trim() || null,
status,
priority,
assignee_id: assignee || null,
milestone_id: milestone || null,
due_date: dueDate || null
}
if (task) {
await supa().from('tasks').update(payload).eq('id', task.id)
} else {
await supa().from('tasks').insert({ ...payload, created_by: createdBy })
}
await onSaved()
setBusy(false)
onClose()
}
const remove = async (): Promise<void> => {
if (!task) return
if (!confirm('Supprimer cette tâche ?')) return
await supa().from('tasks').delete().eq('id', task.id)
await onSaved()
onClose()
}
return (
<Modal title={task ? 'Modifier la tâche' : 'Nouvelle tâche'} onClose={onClose} wide>
<label className="label">Titre</label>
<input
className="input mb-3"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Implémenter le saut du personnage"
/>
<label className="label">Description</label>
<textarea
className="input mb-3 h-24 resize-none"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Statut</label>
<Select value={status} onChange={(e) => setStatus(e.target.value as TaskStatus)}>
{TASK_STATUS_ORDER.map((s) => (
<option key={s} value={s}>
{TASK_STATUS_LABELS[s]}
</option>
))}
</Select>
</div>
<div>
<label className="label">Priorité</label>
<Select value={priority} onChange={(e) => setPriority(e.target.value as Priority)}>
{Object.entries(PRIORITY_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</div>
<div>
<label className="label">Assigné à</label>
<Select value={assignee} onChange={(e) => setAssignee(e.target.value)}>
<option value="">Personne</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.full_name}
</option>
))}
</Select>
</div>
<div>
<label className="label">Jalon</label>
<Select value={milestone} onChange={(e) => setMilestone(e.target.value)}>
<option value="">Aucun</option>
{milestones.map((m) => (
<option key={m.id} value={m.id}>
{m.title}
</option>
))}
</Select>
</div>
<div className="col-span-2">
<label className="label">Échéance</label>
<input
type="date"
className="input"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
</div>
<div className="mt-5 flex items-center justify-between">
{task ? (
<button className="btn text-rose-400 hover:bg-rose-500/10" onClick={remove}>
<Trash2 size={16} /> Supprimer
</button>
) : (
<span />
)}
<button className="btn-primary" onClick={save} disabled={busy || !title.trim()}>
{task ? 'Enregistrer' : 'Créer'}
</button>
</div>
</Modal>
)
}
+131
View File
@@ -0,0 +1,131 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Gamepad2, Loader2 } from 'lucide-react'
import { supa } from '../lib/supabase'
import { Select } from '../components/ui'
import { MemberRole, ROLE_LABELS } from '../lib/types'
export default function Login(): JSX.Element {
const [mode, setMode] = useState<'signin' | 'signup'>('signin')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [fullName, setFullName] = useState('')
const [role, setRole] = useState<MemberRole>('programmer')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [info, setInfo] = useState('')
const submit = async (): Promise<void> => {
setError('')
setInfo('')
setBusy(true)
try {
if (mode === 'signup') {
const { error } = await supa().auth.signUp({
email,
password,
options: { data: { full_name: fullName || email.split('@')[0], role } }
})
if (error) throw error
setInfo('Compte créé ! Tu peux maintenant te connecter.')
setMode('signin')
} else {
const { error } = await supa().auth.signInWithPassword({ email, password })
if (error) throw error
}
} catch (e) {
setError(translate((e as Error).message))
} finally {
setBusy(false)
}
}
return (
<div className="flex h-full items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 260, damping: 24 }}
className="card w-full max-w-sm"
>
<div className="mb-6 flex flex-col items-center gap-2 text-center">
<div className="rounded-xl bg-accent/20 p-3 text-accent">
<Gamepad2 size={28} />
</div>
<h1 className="text-xl font-semibold text-white">GameDev Tracker</h1>
<p className="text-sm text-gray-400">
{mode === 'signin' ? 'Connecte-toi à ton équipe' : 'Crée ton compte membre'}
</p>
</div>
{mode === 'signup' && (
<>
<label className="label">Nom complet</label>
<input
className="input mb-3"
placeholder="Alex Martin"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
<label className="label">Rôle dans l'équipe</label>
<Select
className="mb-3"
value={role}
onChange={(e) => setRole(e.target.value as MemberRole)}
>
{Object.entries(ROLE_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</>
)}
<label className="label">Email</label>
<input
className="input mb-3"
type="email"
placeholder="toi@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label className="label">Mot de passe</label>
<input
className="input mb-4"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
/>
{error && <p className="mb-3 text-sm text-rose-400">{error}</p>}
{info && <p className="mb-3 text-sm text-accent2">{info}</p>}
<button className="btn-primary w-full" onClick={submit} disabled={busy}>
{busy && <Loader2 className="animate-spin" size={16} />}
{mode === 'signin' ? 'Se connecter' : "Créer le compte"}
</button>
<button
className="mt-4 w-full text-center text-sm text-gray-400 hover:text-white"
onClick={() => {
setMode(mode === 'signin' ? 'signup' : 'signin')
setError('')
setInfo('')
}}
>
{mode === 'signin' ? "Pas encore de compte ? S'inscrire" : 'Déjà un compte ? Se connecter'}
</button>
</motion.div>
</div>
)
}
function translate(msg: string): string {
if (msg.includes('Invalid login')) return 'Email ou mot de passe incorrect.'
if (msg.includes('already registered')) return 'Cet email est déjà utilisé.'
if (msg.includes('at least 6')) return 'Le mot de passe doit faire au moins 6 caractères.'
return msg
}
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useState } from 'react'
import { Users, Pencil } from 'lucide-react'
import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext'
import { Profile, MemberRole, ROLE_LABELS } from '../lib/types'
import { Avatar, Modal, Select, EmptyState } from '../components/ui'
import PageHeader from '../components/PageHeader'
const COLORS = ['#7c5cff', '#00d2b8', '#ff5c8a', '#ffa53c', '#3ca8ff', '#9bdb4d', '#e15cff', '#ff6b6b']
export default function Members(): JSX.Element {
const { profile, refreshProfile } = useApp()
const [members, setMembers] = useState<Profile[]>([])
const [editing, setEditing] = useState(false)
const load = async (): Promise<void> => {
const { data } = await supa().from('profiles').select('*').order('full_name')
setMembers((data ?? []) as Profile[])
}
useEffect(() => {
load()
}, [])
return (
<div className="flex h-full flex-col">
<PageHeader title="Équipe" subtitle={`${members.length} membre(s)`}>
<button className="btn-ghost" onClick={() => setEditing(true)}>
<Pencil size={16} /> Modifier mon profil
</button>
</PageHeader>
<div className="flex-1 overflow-y-auto p-6">
{members.length === 0 ? (
<EmptyState icon={<Users size={40} />} text="Aucun membre." />
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{members.map((m) => (
<div key={m.id} className="card flex items-center gap-3">
<Avatar profile={m} size={44} />
<div className="min-w-0">
<div className="truncate font-medium text-white">
{m.full_name}
{m.id === profile?.id && <span className="ml-1 text-xs text-accent2">(moi)</span>}
</div>
<div className="text-sm text-gray-400">{ROLE_LABELS[m.role]}</div>
</div>
</div>
))}
</div>
)}
</div>
{editing && (
<EditProfileModal
profile={profile!}
onClose={() => setEditing(false)}
onSaved={async () => {
await refreshProfile()
await load()
}}
/>
)}
</div>
)
}
function EditProfileModal({
profile,
onClose,
onSaved
}: {
profile: Profile
onClose: () => void
onSaved: () => Promise<void>
}): JSX.Element {
const [name, setName] = useState(profile.full_name)
const [role, setRole] = useState<MemberRole>(profile.role)
const [color, setColor] = useState(profile.avatar_color)
const [busy, setBusy] = useState(false)
const save = async (): Promise<void> => {
setBusy(true)
await supa()
.from('profiles')
.update({ full_name: name.trim() || 'Membre', role, avatar_color: color })
.eq('id', profile.id)
await onSaved()
setBusy(false)
onClose()
}
return (
<Modal title="Mon profil" onClose={onClose}>
<label className="label">Nom complet</label>
<input className="input mb-3" value={name} onChange={(e) => setName(e.target.value)} />
<label className="label">Rôle</label>
<Select className="mb-3" value={role} onChange={(e) => setRole(e.target.value as MemberRole)}>
{Object.entries(ROLE_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
<label className="label">Couleur d'avatar</label>
<div className="mb-4 flex flex-wrap gap-2">
{COLORS.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`h-8 w-8 rounded-full ${color === c ? 'ring-2 ring-white' : ''}`}
style={{ background: c }}
/>
))}
</div>
<button className="btn-primary w-full" onClick={save} disabled={busy}>
Enregistrer
</button>
</Modal>
)
}
+234
View File
@@ -0,0 +1,234 @@
import { useEffect, useState, useCallback } from 'react'
import { Plus, Target, Trash2, CalendarClock } from 'lucide-react'
import { format, isPast, differenceInCalendarDays } from 'date-fns'
import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext'
import {
Milestone,
MilestoneStatus,
Task,
MILESTONE_STATUS_LABELS
} from '../lib/types'
import { Modal, Select, EmptyState } from '../components/ui'
import PageHeader from '../components/PageHeader'
const STATUS_STYLES: Record<MilestoneStatus, string> = {
planned: 'bg-slate-500/20 text-slate-300',
in_progress: 'bg-sky-500/20 text-sky-300',
done: 'bg-emerald-500/20 text-emerald-300'
}
export default function Milestones(): JSX.Element {
const { currentProject } = useApp()
const [milestones, setMilestones] = useState<Milestone[]>([])
const [tasks, setTasks] = useState<Task[]>([])
const [editing, setEditing] = useState<Milestone | null>(null)
const [creating, setCreating] = useState(false)
const projectId = currentProject?.id
const load = useCallback(async () => {
if (!projectId) return
const [{ data: ms }, { data: ts }] = await Promise.all([
supa().from('milestones').select('*').eq('project_id', projectId),
supa().from('tasks').select('*').eq('project_id', projectId)
])
const sorted = ((ms ?? []) as Milestone[]).sort((a, b) =>
(a.due_date ?? '9999').localeCompare(b.due_date ?? '9999')
)
setMilestones(sorted)
setTasks((ts ?? []) as Task[])
}, [projectId])
useEffect(() => {
load()
}, [load])
const progressOf = (m: Milestone): { done: number; total: number; pct: number } => {
const linked = tasks.filter((t) => t.milestone_id === m.id)
const done = linked.filter((t) => t.status === 'done').length
const pct = linked.length ? Math.round((done / linked.length) * 100) : 0
return { done, total: linked.length, pct }
}
return (
<div className="flex h-full flex-col">
<PageHeader title="Jalons & planning" subtitle={currentProject?.name}>
<button className="btn-primary" onClick={() => setCreating(true)}>
<Plus size={16} /> Nouveau jalon
</button>
</PageHeader>
<div className="flex-1 overflow-y-auto p-6">
{milestones.length === 0 ? (
<EmptyState
icon={<Target size={40} />}
text="Aucun jalon. Crée des jalons pour structurer ton planning (ex : Prototype, Alpha, Beta, Release)."
/>
) : (
<div className="mx-auto max-w-3xl space-y-3">
{milestones.map((m) => {
const { done, total, pct } = progressOf(m)
const overdue =
m.due_date && m.status !== 'done' && isPast(new Date(m.due_date + 'T23:59:59'))
return (
<div
key={m.id}
className="card cursor-pointer hover:border-accent"
onClick={() => setEditing(m)}
>
<div className="mb-2 flex items-start justify-between gap-3">
<div>
<h3 className="font-medium text-white">{m.title}</h3>
{m.description && (
<p className="mt-0.5 text-sm text-gray-400">{m.description}</p>
)}
</div>
<span className={`badge ${STATUS_STYLES[m.status]}`}>
{MILESTONE_STATUS_LABELS[m.status]}
</span>
</div>
<div className="mb-2 h-2 overflow-hidden rounded-full bg-panel2">
<div
className="h-full rounded-full bg-accent2 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs text-gray-400">
<span>
{done}/{total} tâche(s) {pct}%
</span>
{m.due_date && (
<span
className={`flex items-center gap-1 ${overdue ? 'text-rose-400' : ''}`}
>
<CalendarClock size={12} />
{format(new Date(m.due_date), 'd MMM yyyy', { locale: fr })}
{m.status !== 'done' && ` (${daysLabel(m.due_date)})`}
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
{(creating || editing) && (
<MilestoneModal
milestone={editing}
projectId={projectId!}
onClose={() => {
setCreating(false)
setEditing(null)
}}
onSaved={load}
/>
)}
</div>
)
}
function daysLabel(due: string): string {
const d = differenceInCalendarDays(new Date(due), new Date())
if (d < 0) return `en retard de ${-d} j`
if (d === 0) return "aujourd'hui"
return `dans ${d} j`
}
function MilestoneModal({
milestone,
projectId,
onClose,
onSaved
}: {
milestone: Milestone | null
projectId: string
onClose: () => void
onSaved: () => Promise<void>
}): JSX.Element {
const [title, setTitle] = useState(milestone?.title ?? '')
const [description, setDescription] = useState(milestone?.description ?? '')
const [status, setStatus] = useState<MilestoneStatus>(milestone?.status ?? 'planned')
const [dueDate, setDueDate] = useState(milestone?.due_date ?? '')
const [busy, setBusy] = useState(false)
const save = async (): Promise<void> => {
if (!title.trim()) return
setBusy(true)
const payload = {
project_id: projectId,
title: title.trim(),
description: description.trim() || null,
status,
due_date: dueDate || null
}
if (milestone) await supa().from('milestones').update(payload).eq('id', milestone.id)
else await supa().from('milestones').insert(payload)
await onSaved()
setBusy(false)
onClose()
}
const remove = async (): Promise<void> => {
if (!milestone || !confirm('Supprimer ce jalon ?')) return
await supa().from('milestones').delete().eq('id', milestone.id)
await onSaved()
onClose()
}
return (
<Modal title={milestone ? 'Modifier le jalon' : 'Nouveau jalon'} onClose={onClose}>
<label className="label">Titre</label>
<input
className="input mb-3"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Alpha jouable"
/>
<label className="label">Description</label>
<textarea
className="input mb-3 h-20 resize-none"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Statut</label>
<Select value={status} onChange={(e) => setStatus(e.target.value as MilestoneStatus)}>
{Object.entries(MILESTONE_STATUS_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</div>
<div>
<label className="label">Date cible</label>
<input
type="date"
className="input"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
</div>
<div className="mt-5 flex items-center justify-between">
{milestone ? (
<button className="btn text-rose-400 hover:bg-rose-500/10" onClick={remove}>
<Trash2 size={16} /> Supprimer
</button>
) : (
<span />
)}
<button className="btn-primary" onClick={save} disabled={busy || !title.trim()}>
{milestone ? 'Enregistrer' : 'Créer'}
</button>
</div>
</Modal>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Database, ExternalLink, Loader2 } from 'lucide-react'
import { useApp } from '../lib/AppContext'
export default function Setup(): JSX.Element {
const { saveConfig } = useApp()
const [url, setUrl] = useState('')
const [key, setKey] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const submit = async (): Promise<void> => {
setError('')
if (!url.includes('supabase') || key.length < 20) {
setError('URL ou clé invalide. Vérifie tes valeurs dans Supabase > Settings > API.')
return
}
setBusy(true)
try {
await saveConfig(url, key)
} catch (e) {
setError((e as Error).message)
setBusy(false)
}
}
return (
<div className="flex h-full items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 260, damping: 24 }}
className="card w-full max-w-lg"
>
<div className="mb-4 flex items-center gap-3">
<div className="rounded-lg bg-accent/20 p-2 text-accent">
<Database size={24} />
</div>
<div>
<h1 className="text-xl font-semibold text-white">Connexion à la base de données</h1>
<p className="text-sm text-gray-400">Configuration unique pour toute l'équipe</p>
</div>
</div>
<div className="mb-4 rounded-lg border border-border bg-panel2 p-3 text-sm text-gray-300">
<p className="mb-2 font-medium text-white">Comment obtenir ces clés (gratuit) :</p>
<ol className="list-inside list-decimal space-y-1 text-gray-400">
<li>
Crée un compte sur{' '}
<a
href="https://supabase.com"
className="inline-flex items-center gap-1 text-accent2 hover:underline"
>
supabase.com <ExternalLink size={12} />
</a>
</li>
<li>Crée un nouveau projet (gratuit)</li>
<li>Onglet SQL Editor : colle le contenu de supabase/schema.sql et exécute-le</li>
<li>Onglet Settings → API : copie l'URL et la clé « anon public »</li>
</ol>
</div>
<label className="label">URL du projet</label>
<input
className="input mb-3"
placeholder="https://xxxxx.supabase.co"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<label className="label">Clé anon public</label>
<input
className="input mb-4"
placeholder="eyJhbGciOi..."
value={key}
onChange={(e) => setKey(e.target.value)}
/>
{error && <p className="mb-3 text-sm text-rose-400">{error}</p>}
<button className="btn-primary w-full" onClick={submit} disabled={busy}>
{busy && <Loader2 className="animate-spin" size={16} />}
Connecter
</button>
<p className="mt-3 text-center text-xs text-gray-500">
Les clés sont stockées localement sur ton ordinateur uniquement.
</p>
</motion.div>
</div>
)
}
+157
View File
@@ -0,0 +1,157 @@
-- =====================================================================
-- GameDev Tracker — Schéma de base de données (Supabase / PostgreSQL)
-- À exécuter UNE SEULE FOIS dans : Supabase > SQL Editor > New query
-- =====================================================================
-- ---------- Types énumérés -------------------------------------------
do $$ begin
create type member_role as enum
('lead','programmer','artist','game_designer','sound','writer','qa','other');
exception when duplicate_object then null; end $$;
do $$ begin
create type task_status as enum ('todo','in_progress','review','done');
exception when duplicate_object then null; end $$;
do $$ begin
create type priority_level as enum ('low','medium','high','urgent');
exception when duplicate_object then null; end $$;
do $$ begin
create type milestone_status as enum ('planned','in_progress','done');
exception when duplicate_object then null; end $$;
do $$ begin
create type bug_severity as enum ('low','medium','high','critical');
exception when duplicate_object then null; end $$;
do $$ begin
create type bug_status as enum ('open','in_progress','resolved','closed');
exception when duplicate_object then null; end $$;
-- ---------- Profils (étend auth.users) -------------------------------
create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
full_name text not null default 'Membre',
role member_role not null default 'other',
avatar_color text not null default '#7c5cff',
created_at timestamptz not null default now()
);
-- ---------- Projets --------------------------------------------------
create table if not exists public.projects (
id uuid primary key default gen_random_uuid(),
name text not null,
description text,
created_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now()
);
-- ---------- Jalons (milestones) --------------------------------------
create table if not exists public.milestones (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
title text not null,
description text,
due_date date,
status milestone_status not null default 'planned',
created_at timestamptz not null default now()
);
-- ---------- Tâches ---------------------------------------------------
create table if not exists public.tasks (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
title text not null,
description text,
status task_status not null default 'todo',
priority priority_level not null default 'medium',
assignee_id uuid references public.profiles(id) on delete set null,
milestone_id uuid references public.milestones(id) on delete set null,
due_date date,
position double precision not null default 0,
created_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- ---------- Bugs -----------------------------------------------------
create table if not exists public.bugs (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
title text not null,
description text,
severity bug_severity not null default 'medium',
status bug_status not null default 'open',
priority priority_level not null default 'medium',
assignee_id uuid references public.profiles(id) on delete set null,
reporter_id uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_tasks_project on public.tasks(project_id);
create index if not exists idx_bugs_project on public.bugs(project_id);
create index if not exists idx_milestones_project on public.milestones(project_id);
-- ---------- Maj automatique de updated_at ----------------------------
create or replace function public.touch_updated_at()
returns trigger language plpgsql as $$
begin new.updated_at = now(); return new; end $$;
drop trigger if exists trg_tasks_touch on public.tasks;
create trigger trg_tasks_touch before update on public.tasks
for each row execute function public.touch_updated_at();
drop trigger if exists trg_bugs_touch on public.bugs;
create trigger trg_bugs_touch before update on public.bugs
for each row execute function public.touch_updated_at();
-- ---------- Création auto du profil à l'inscription ------------------
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
begin
insert into public.profiles (id, full_name, role)
values (
new.id,
coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email,'@',1)),
coalesce((new.raw_user_meta_data->>'role')::member_role, 'other')
)
on conflict (id) do nothing;
return new;
end $$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- ---------- Sécurité (RLS) -------------------------------------------
-- Équipe privée et de confiance : tout membre connecté a accès complet.
alter table public.profiles enable row level security;
alter table public.projects enable row level security;
alter table public.milestones enable row level security;
alter table public.tasks enable row level security;
alter table public.bugs enable row level security;
do $$
declare t text;
begin
foreach t in array array['profiles','projects','milestones','tasks','bugs'] loop
execute format('drop policy if exists %I_all on public.%I', t, t);
execute format(
'create policy %I_all on public.%I for all to authenticated using (true) with check (true)',
t, t);
end loop;
end $$;
-- ---------- Realtime (synchro live entre membres) --------------------
do $$ begin
alter publication supabase_realtime add table public.tasks;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.bugs;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.milestones;
exception when duplicate_object then null; end $$;
+17
View File
@@ -0,0 +1,17 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
bg: '#0f1117',
panel: '#171a23',
panel2: '#1e222e',
border: '#2a2f3d',
accent: '#7c5cff',
accent2: '#00d2b8'
}
}
},
plugins: []
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"useDefineForClassFields": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client", "node"]
},
"include": ["src/**/*", "vite.config.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
// Configuration Vite pour le front-end React, servi par Tauri.
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': resolve(__dirname, 'src') }
},
clearScreen: false,
server: {
port: 5173,
strictPort: true,
// Ne pas surveiller le dossier de build Rust (évite l'erreur EBUSY sur les .dll).
watch: {
ignored: ['**/src-tauri/**']
}
}
})