Compare commits
6 Commits
v1.4.1
...
ea7b2fd278
| Author | SHA1 | Date | |
|---|---|---|---|
| ea7b2fd278 | |||
| 4e1c5b2c33 | |||
| c6ea9f7930 | |||
| ca6700d6ce | |||
| dcb6cd1ec7 | |||
| 3b251c8174 |
@@ -27,6 +27,7 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run build
|
||||
- run: npm run build:site
|
||||
- run: npx vitest run
|
||||
|
||||
pack-smoke:
|
||||
55
.gitea/workflows/prod.yml
Normal file
55
.gitea/workflows/prod.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Deploy site (production)
|
||||
|
||||
# Déploie le site vitrine (packages/site) sur Plesk via FTPS (lftp), à l'identique
|
||||
# de lidge_web / tracksniff-web. Ne se déclenche que sur un changement du site.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'packages/site/**'
|
||||
- '.gitea/workflows/prod.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-site
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:22-bookworm
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Install scopé au workspace du site : pas de build des deps natives du
|
||||
# serveur (node-pty / node:sqlite) qui n'ont rien à faire ici.
|
||||
- name: Install deps (@arboretum/site)
|
||||
run: npm install -w @arboretum/site --no-audit --no-fund
|
||||
|
||||
- name: Build site
|
||||
run: npm run build:site
|
||||
|
||||
- name: Check build output
|
||||
run: ls -la packages/site/dist
|
||||
|
||||
- name: Deploy via FTPS (lftp)
|
||||
run: |
|
||||
if [ -z "$REMOTE_PATH" ]; then
|
||||
echo "::error::Secret SITE_REMOTE_PATH manquant (chemin docroot du vhost git-arboretum.com)."
|
||||
exit 1
|
||||
fi
|
||||
apt-get update && apt-get install -y lftp
|
||||
lftp -c "
|
||||
open -u \"$FTP_USER\",\"$FTP_PASSWORD\" \"$FTP_HOST\"
|
||||
set ssl:verify-certificate no
|
||||
mirror -R --delete --verbose packages/site/dist \"$REMOTE_PATH\"
|
||||
"
|
||||
env:
|
||||
FTP_HOST: ${{ secrets.FTP_HOST }}
|
||||
FTP_USER: ${{ secrets.FTP_USER }}
|
||||
FTP_PASSWORD: ${{ secrets.FTP_PASSWORD }}
|
||||
# Chemin docroot du vhost git-arboretum.com sur Plesk (ex. /httpdocs/arboretum/public).
|
||||
REMOTE_PATH: ${{ secrets.SITE_REMOTE_PATH }}
|
||||
111
package-lock.json
generated
111
package-lock.json
generated
@@ -23,6 +23,10 @@
|
||||
"resolved": "packages/shared",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@arboretum/site": {
|
||||
"resolved": "packages/site",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@arboretum/web": {
|
||||
"resolved": "packages/web",
|
||||
"link": true
|
||||
@@ -764,6 +768,16 @@
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
|
||||
"dev": true,
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@homebridge/node-pty-prebuilt-multiarch": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz",
|
||||
@@ -4840,7 +4854,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@johanleroy/git-arboretum",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.0",
|
||||
@@ -4871,6 +4885,101 @@
|
||||
"name": "@arboretum/shared",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"packages/site": {
|
||||
"name": "@arboretum/site",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.38",
|
||||
"vue-i18n": "^11.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource/jetbrains-mono": "^5.1.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^8.0.16",
|
||||
"vue-tsc": "^3.3.4"
|
||||
}
|
||||
},
|
||||
"packages/site/node_modules/vite": {
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.3",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.18",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitejs/devtools": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@arboretum/web",
|
||||
"version": "0.1.0",
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
"pack": "npm run build && npm pack -w @johanleroy/git-arboretum",
|
||||
"test": "vitest run",
|
||||
"dev:server": "npm run dev -w @johanleroy/git-arboretum",
|
||||
"dev:web": "npm run dev -w @arboretum/web"
|
||||
"dev:web": "npm run dev -w @arboretum/web",
|
||||
"build:site": "npm run build -w @arboretum/site",
|
||||
"dev:site": "npm run dev -w @arboretum/site",
|
||||
"preview:site": "npm run preview -w @arboretum/site"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@johanleroy/git-arboretum",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.0",
|
||||
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -24,15 +24,20 @@ const check = (name, ok, detail = '') => {
|
||||
};
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p5-'));
|
||||
const repo = join(tmp, 'demo-repo');
|
||||
execFileSync('mkdir', ['-p', repo]);
|
||||
const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' });
|
||||
function initRepo(path) {
|
||||
execFileSync('mkdir', ['-p', path]);
|
||||
const git = (...args) => execFileSync('git', args, { cwd: path, stdio: 'pipe' });
|
||||
git('init', '-b', 'main');
|
||||
git('config', 'user.email', 'test@arboretum.dev');
|
||||
git('config', 'user.name', 'Test');
|
||||
execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: repo });
|
||||
execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: path });
|
||||
git('add', '-A');
|
||||
git('commit', '-m', 'init');
|
||||
}
|
||||
const repo = join(tmp, 'demo-repo');
|
||||
const repo2 = join(tmp, 'demo-repo-2');
|
||||
initRepo(repo);
|
||||
initRepo(repo2);
|
||||
|
||||
const srv = spawn(
|
||||
'node',
|
||||
@@ -109,6 +114,41 @@ try {
|
||||
const pushedAdd = await c.waitMsg((m) => m.type === 'group_update' && m.group?.repoIds?.includes(repoSummary.id));
|
||||
check('broadcast WS group_update (ajout repo)', !!pushedAdd);
|
||||
|
||||
// ---- P6 : session de groupe multi-repo (UNE session couvrant tous les repos via --add-dir) ----
|
||||
// Enregistre un 2e repo, l'ajoute au groupe, puis lance UNE session de groupe (bash, sans quota).
|
||||
const addRepo2 = await j('/api/v1/repos', 'POST', cookie, { path: repo2 });
|
||||
const repo2Summary = (await addRepo2.json()).repo;
|
||||
check('POST /repos (2e repo) → 201', addRepo2.status === 201 && repo2Summary?.valid === true);
|
||||
await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: repo2Summary.id });
|
||||
|
||||
// Mode « checkouts principaux » (pas de branch) : couvre le worktree principal de chaque repo.
|
||||
const gsRes = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash' });
|
||||
const gsBody = await gsRes.json();
|
||||
const gsession = gsBody.session;
|
||||
check('POST /groups/:id/session → 201', gsRes.status === 201 && !!gsession);
|
||||
check('session de groupe : 2 répertoires couverts', Array.isArray(gsBody.dirs) && gsBody.dirs.length === 2);
|
||||
check('session de groupe : addedDirs (1 dir supplémentaire)', (gsession?.addedDirs?.length ?? 0) === 1);
|
||||
check('session de groupe : groupId posé', gsession?.groupId === group.id);
|
||||
check('session de groupe : cwd primaire + addedDir = 2 repos', new Set([gsession?.cwd, ...(gsession?.addedDirs ?? [])]).size === 2);
|
||||
|
||||
// La session apparaît dans la liste globale avec son groupId.
|
||||
const sessList = await (await j('/api/v1/sessions', 'GET', cookie)).json();
|
||||
const listed = sessList.sessions?.find((s) => s.id === gsession.id);
|
||||
check('GET /sessions : session de groupe présente avec groupId', listed?.groupId === group.id);
|
||||
|
||||
// broadcast WS session_update reçu pour la session de groupe.
|
||||
const pushedSession = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === gsession.id);
|
||||
check('broadcast WS session_update (session de groupe)', !!pushedSession);
|
||||
|
||||
// groupe sans worktree résolu (branche inexistante) → 400.
|
||||
const gsBad = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash', branch: 'no/such/branch' });
|
||||
check('POST /groups/:id/session branche absente → 400', gsBad.status === 400);
|
||||
|
||||
// Arrêt de la session de groupe + nettoyage du 2e repo (CASCADE retire repo2 de la membership).
|
||||
const killSession = await j(`/api/v1/sessions/${gsession.id}`, 'DELETE', cookie);
|
||||
check('DELETE session de groupe → 200', killSession.status === 200);
|
||||
await j(`/api/v1/repos/${repo2Summary.id}`, 'DELETE', cookie);
|
||||
|
||||
// Renommage via PATCH.
|
||||
const patched = await j(`/api/v1/groups/${group.id}`, 'PATCH', cookie, { label: 'Renamed' });
|
||||
check('PATCH /groups/:id → 200 (renommé)', patched.status === 200 && (await patched.json()).group?.label === 'Renamed');
|
||||
|
||||
@@ -170,7 +170,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
||||
registerSessionRoutes(app, manager, discovery);
|
||||
registerRepoRoutes(app, worktrees, db);
|
||||
registerGroupRoutes(app, groups, db);
|
||||
registerGroupRoutes(app, groups, db, worktrees, manager);
|
||||
registerWorktreeRoutes(app, worktrees);
|
||||
registerPushRoutes(app, push, db);
|
||||
registerSettingsRoutes(app, db, config, serverVersion, push);
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface SpawnOptions {
|
||||
command: 'claude' | 'bash';
|
||||
/** reprise d'une session existante (P2) : `--resume <id>`, `--fork-session` si fork. */
|
||||
resume?: { claudeSessionId: string; fork?: boolean };
|
||||
/** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir <path>` répété. */
|
||||
addDirs?: string[];
|
||||
}
|
||||
|
||||
let cachedClaudeBin: string | null = null;
|
||||
@@ -42,5 +44,7 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
||||
args.push('--resume', opts.resume.claudeSessionId);
|
||||
if (opts.resume.fork) args.push('--fork-session');
|
||||
}
|
||||
// Session de groupe : relie plusieurs repos/worktrees dans une seule session (P6).
|
||||
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
|
||||
return { file: resolveClaudeBin(), args, env };
|
||||
}
|
||||
|
||||
@@ -167,6 +167,8 @@ export class GroupManager extends EventEmitter<GroupManagerEvents> {
|
||||
// CASCADE (PRAGMA foreign_keys = ON) purge group_repos.
|
||||
const res = this.db.prepare('DELETE FROM groups WHERE id = ?').run(id);
|
||||
if (res.changes === 0) return false;
|
||||
// Les sessions de groupe (P6) ne sont pas en FK : on désorpheline leur group_id manuellement.
|
||||
this.db.prepare('UPDATE sessions SET group_id = NULL WHERE group_id = ?').run(id);
|
||||
this.emit('group_removed', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,17 @@ const NOTIFY_DEBOUNCE_MS = 1500;
|
||||
const CLAUDE_ID_POLL_MS = 400;
|
||||
const CLAUDE_ID_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Parse la colonne `added_dirs` (JSON array de chemins) de façon défensive ; [] si NULL/invalide. */
|
||||
function parseAddedDirs(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
|
||||
export interface ClientBinding {
|
||||
channel: number;
|
||||
@@ -48,6 +59,10 @@ interface ManagedSession {
|
||||
killTimer: NodeJS.Timeout | null;
|
||||
/** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
|
||||
claudeSessionId: string | null;
|
||||
/** répertoires supplémentaires reliés dans la session (--add-dir) ; [] pour une session mono-repo (P6). */
|
||||
addedDirs: string[];
|
||||
/** groupe propriétaire d'une session de groupe multi-repo ; null sinon (P6). */
|
||||
groupId: string | null;
|
||||
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
|
||||
tracker: SessionActivityTracker | null;
|
||||
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
|
||||
@@ -72,14 +87,33 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
super();
|
||||
}
|
||||
|
||||
spawn(opts: { cwd: string; command?: 'claude' | 'bash'; resume?: { claudeSessionId: string; fork?: boolean } }): SessionSummary {
|
||||
spawn(opts: {
|
||||
cwd: string;
|
||||
command?: 'claude' | 'bash';
|
||||
resume?: { claudeSessionId: string; fork?: boolean };
|
||||
/** répertoires supplémentaires à relier (session de groupe multi-repo, P6). */
|
||||
addDirs?: string[];
|
||||
/** groupe propriétaire (session de groupe, P6). */
|
||||
groupId?: string;
|
||||
}): SessionSummary {
|
||||
const cwd = opts.cwd;
|
||||
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
||||
throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
|
||||
}
|
||||
// Dédoublonne et écarte le cwd primaire ; valide chaque répertoire supplémentaire (comme le cwd).
|
||||
const addedDirs = [...new Set(opts.addDirs ?? [])].filter((d) => d !== cwd);
|
||||
for (const dir of addedDirs) {
|
||||
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
||||
throw Object.assign(new Error(`Not a directory: ${dir}`), { statusCode: 400 });
|
||||
}
|
||||
}
|
||||
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1).
|
||||
const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
|
||||
const spec = buildSpawnSpec({ command, ...(opts.resume ? { resume: opts.resume } : {}) });
|
||||
const spec = buildSpawnSpec({
|
||||
command,
|
||||
...(opts.resume ? { resume: opts.resume } : {}),
|
||||
...(addedDirs.length ? { addDirs: addedDirs } : {}),
|
||||
});
|
||||
const id = randomUUID();
|
||||
const proc = pty.spawn(spec.file, spec.args, {
|
||||
name: 'xterm-256color',
|
||||
@@ -101,6 +135,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
exited: null,
|
||||
killTimer: null,
|
||||
claudeSessionId: null,
|
||||
addedDirs,
|
||||
groupId: opts.groupId ?? null,
|
||||
tracker: null,
|
||||
prevActivity: null,
|
||||
notifyTimer: null,
|
||||
@@ -116,8 +152,16 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
}
|
||||
this.live.set(id, session);
|
||||
this.db
|
||||
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(id, cwd, command, session.createdAt, opts.resume?.claudeSessionId ?? null);
|
||||
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from, added_dirs, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(
|
||||
id,
|
||||
cwd,
|
||||
command,
|
||||
session.createdAt,
|
||||
opts.resume?.claudeSessionId ?? null,
|
||||
addedDirs.length ? JSON.stringify(addedDirs) : null,
|
||||
session.groupId,
|
||||
);
|
||||
|
||||
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
|
||||
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
|
||||
@@ -145,6 +189,18 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Contexte de session de groupe (P6) à réinjecter au resume : derniers `added_dirs`/`group_id`
|
||||
* persistés pour ce claudeSessionId. Permet à `--resume` de re-relier les mêmes répertoires.
|
||||
*/
|
||||
groupSessionContext(claudeSessionId: string): { addedDirs: string[]; groupId: string | null } | null {
|
||||
const row = this.db
|
||||
.prepare('SELECT added_dirs, group_id FROM sessions WHERE claude_session_id = ? AND added_dirs IS NOT NULL ORDER BY created_at DESC LIMIT 1')
|
||||
.get(claudeSessionId) as { added_dirs: string | null; group_id: string | null } | undefined;
|
||||
if (!row) return null;
|
||||
return { addedDirs: parseAddedDirs(row.added_dirs), groupId: row.group_id };
|
||||
}
|
||||
|
||||
/** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */
|
||||
findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null {
|
||||
for (const s of this.live.values()) {
|
||||
@@ -170,29 +226,34 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
||||
const liveIds = new Set(this.live.keys());
|
||||
const rows = this.db
|
||||
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id FROM sessions ORDER BY created_at DESC LIMIT 100')
|
||||
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null }>;
|
||||
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id FROM sessions ORDER BY created_at DESC LIMIT 100')
|
||||
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null; added_dirs: string | null; group_id: string | null }>;
|
||||
const historical: SessionSummary[] = rows
|
||||
.filter((r) => !liveIds.has(r.id))
|
||||
.map((r) => ({
|
||||
.map((r) => {
|
||||
const addedDirs = parseAddedDirs(r.added_dirs);
|
||||
return {
|
||||
id: r.id,
|
||||
cwd: r.cwd,
|
||||
command: r.command,
|
||||
title: r.title,
|
||||
status: 'exited',
|
||||
status: 'exited' as const,
|
||||
live: false,
|
||||
createdAt: r.created_at,
|
||||
endedAt: r.ended_at,
|
||||
exitCode: r.exit_code,
|
||||
clients: 0,
|
||||
source: 'managed',
|
||||
source: 'managed' as const,
|
||||
claudeSessionId: r.claude_session_id,
|
||||
pid: null,
|
||||
// une session claude morte avec un claudeSessionId connu est reprenable (--resume direct).
|
||||
resumable: r.command === 'claude' && r.claude_session_id != null,
|
||||
attachable: false,
|
||||
registryStatus: null,
|
||||
}));
|
||||
...(addedDirs.length ? { addedDirs } : {}),
|
||||
groupId: r.group_id,
|
||||
};
|
||||
});
|
||||
return [...liveSummaries, ...historical];
|
||||
}
|
||||
|
||||
@@ -427,6 +488,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
activity: act?.activity ?? null,
|
||||
waitingFor: act?.waitingFor ?? null,
|
||||
dialog: act?.dialog ?? null,
|
||||
...(s.addedDirs.length ? { addedDirs: s.addedDirs } : {}),
|
||||
groupId: s.groupId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,17 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
||||
CREATE INDEX idx_audit_ts ON audit_logs(ts);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// P6 — session de groupe multi-repo. Une session peut couvrir plusieurs répertoires (--add-dir)
|
||||
// et appartenir à un groupe. `added_dirs` : JSON array de chemins absolus (NULL si mono-repo).
|
||||
// `group_id` : pas de FK (ALTER ADD COLUMN sqlite n'en pose pas) ; nettoyé à la suppression du groupe.
|
||||
id: 8,
|
||||
sql: `
|
||||
ALTER TABLE sessions ADD COLUMN added_dirs TEXT;
|
||||
ALTER TABLE sessions ADD COLUMN group_id TEXT;
|
||||
CREATE INDEX idx_sessions_group_id ON sessions(group_id);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export type Db = DatabaseSync;
|
||||
|
||||
@@ -2,16 +2,26 @@ import type { FastifyInstance } from 'fastify';
|
||||
import type {
|
||||
AddRepoRequest,
|
||||
CreateGroupRequest,
|
||||
CreateGroupSessionRequest,
|
||||
GroupResponse,
|
||||
GroupSessionResponse,
|
||||
GroupsListResponse,
|
||||
UpdateGroupRequest,
|
||||
} from '@arboretum/shared';
|
||||
import type { GroupManager } from '../core/group-manager.js';
|
||||
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||
import type { PtyManager } from '../core/pty-manager.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
import { recordAudit } from '../core/audit-log.js';
|
||||
import { sendManagerError } from './repos.js';
|
||||
|
||||
export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db: Db): void {
|
||||
export function registerGroupRoutes(
|
||||
app: FastifyInstance,
|
||||
gm: GroupManager,
|
||||
db: Db,
|
||||
wt: WorktreeManager,
|
||||
manager: PtyManager,
|
||||
): void {
|
||||
app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ groups: gm.listGroups() }));
|
||||
|
||||
app.get('/api/v1/groups/:id', async (req, reply) => {
|
||||
@@ -88,4 +98,62 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db:
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Session de groupe (P6) : UNE session Claude couvrant tous les repos du groupe (--add-dir).
|
||||
// Les répertoires sont résolus côté serveur depuis les propres worktrees du groupe — le client
|
||||
// ne passe jamais de chemin brut. `branch` présent → worktree de cette branche par repo ;
|
||||
// absent → le worktree principal de chaque repo.
|
||||
app.post('/api/v1/groups/:id/session', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = (req.body as Partial<CreateGroupSessionRequest> | null) ?? {};
|
||||
const command = body.command ?? 'claude';
|
||||
if (command !== 'claude' && command !== 'bash') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } });
|
||||
}
|
||||
const branch = typeof body.branch === 'string' && body.branch.trim() !== '' ? body.branch.trim() : null;
|
||||
|
||||
let group;
|
||||
try {
|
||||
group = gm.getGroup(id);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
|
||||
const dirs: string[] = [];
|
||||
const skipped: Array<{ repoId: string; reason: string }> = [];
|
||||
for (const repoId of group.repoIds) {
|
||||
let worktrees;
|
||||
try {
|
||||
worktrees = await wt.listRepoWorktrees(repoId);
|
||||
} catch (err) {
|
||||
skipped.push({ repoId, reason: err instanceof Error ? err.message : String(err) });
|
||||
continue;
|
||||
}
|
||||
const match = branch ? worktrees.find((w) => w.branch === branch) : worktrees.find((w) => w.isMain);
|
||||
if (!match) {
|
||||
skipped.push({ repoId, reason: branch ? `no worktree on branch ${branch}` : 'no main worktree' });
|
||||
continue;
|
||||
}
|
||||
if (!dirs.includes(match.path)) dirs.push(match.path);
|
||||
}
|
||||
|
||||
const [primary, ...rest] = dirs;
|
||||
if (primary === undefined) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'no directory to span (group has no resolvable worktree)' } });
|
||||
}
|
||||
|
||||
try {
|
||||
const session = manager.spawn({ cwd: primary, addDirs: rest, command, groupId: id });
|
||||
recordAudit(db, {
|
||||
actor: req.authContext?.tokenId ?? 'unknown',
|
||||
action: 'group.session.create',
|
||||
resourceId: id,
|
||||
details: { command, dirs: dirs.length, ...(branch ? { branch } : {}) },
|
||||
});
|
||||
return reply.status(201).send({ session, dirs, skipped } satisfies GroupSessionResponse);
|
||||
} catch (err) {
|
||||
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
|
||||
return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,7 +39,14 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager,
|
||||
return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live — fork it instead' } });
|
||||
}
|
||||
try {
|
||||
const session = manager.spawn({ cwd: discovered.cwd, resume: { claudeSessionId: id } });
|
||||
// Session de groupe (P6) : re-relie les mêmes répertoires (--add-dir) et son groupe au resume.
|
||||
const ctx = manager.groupSessionContext(id);
|
||||
const session = manager.spawn({
|
||||
cwd: discovered.cwd,
|
||||
resume: { claudeSessionId: id },
|
||||
...(ctx?.addedDirs.length ? { addDirs: ctx.addedDirs } : {}),
|
||||
...(ctx?.groupId ? { groupId: ctx.groupId } : {}),
|
||||
});
|
||||
const res: SessionResponse = { session };
|
||||
return reply.status(201).send(res);
|
||||
} catch (err) {
|
||||
|
||||
29
packages/server/test/claude-launcher.test.ts
Normal file
29
packages/server/test/claude-launcher.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { buildSpawnSpec } from '../src/core/claude-launcher.js';
|
||||
|
||||
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||||
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||
|
||||
describe('buildSpawnSpec — session de groupe multi-repo (P6)', () => {
|
||||
it('émet un --add-dir par répertoire supplémentaire (claude)', () => {
|
||||
const spec = buildSpawnSpec({ command: 'claude', addDirs: ['/a', '/b', '/c'] });
|
||||
expect(spec.file).toBe('/usr/bin/claude');
|
||||
expect(spec.args).toEqual(['--add-dir', '/a', '--add-dir', '/b', '--add-dir', '/c']);
|
||||
});
|
||||
|
||||
it('combine --resume et --add-dir (reprise d’une session de groupe)', () => {
|
||||
const spec = buildSpawnSpec({ command: 'claude', resume: { claudeSessionId: 'sid' }, addDirs: ['/x'] });
|
||||
expect(spec.args).toEqual(['--resume', 'sid', '--add-dir', '/x']);
|
||||
});
|
||||
|
||||
it('aucun --add-dir quand addDirs est vide/absent', () => {
|
||||
expect(buildSpawnSpec({ command: 'claude' }).args).toEqual([]);
|
||||
expect(buildSpawnSpec({ command: 'claude', addDirs: [] }).args).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignore addDirs pour bash (pas de --add-dir)', () => {
|
||||
const spec = buildSpawnSpec({ command: 'bash', addDirs: ['/a', '/b'] });
|
||||
expect(spec.file).toBe('bash');
|
||||
expect(spec.args).toEqual(['--norc']);
|
||||
});
|
||||
});
|
||||
@@ -97,4 +97,15 @@ describe('GroupManager', () => {
|
||||
db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a');
|
||||
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']);
|
||||
});
|
||||
|
||||
it('deleteGroup : désorpheline le group_id des sessions de groupe (P6)', () => {
|
||||
const g = gm.createGroup({ label: 'A' });
|
||||
// session de groupe liée à g (insérée directement, comme le ferait PtyManager.spawn).
|
||||
db.prepare(
|
||||
'INSERT INTO sessions (id, cwd, command, created_at, group_id) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('sess-1', '/tmp/a', 'claude', new Date().toISOString(), g.id);
|
||||
expect(gm.deleteGroup(g.id)).toBe(true);
|
||||
const row = db.prepare('SELECT group_id FROM sessions WHERE id = ?').get('sess-1') as { group_id: string | null };
|
||||
expect(row.group_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -574,4 +574,53 @@ describe('PtyManager (pty mocké)', () => {
|
||||
expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('session de groupe multi-repo (P6)', () => {
|
||||
it('spawn avec addDirs : --add-dir au pty, addedDirs + groupId résumés et persistés', () => {
|
||||
const d1 = mkdtempSync(join(tmpdir(), 'arb-g1-'));
|
||||
const d2 = mkdtempSync(join(tmpdir(), 'arb-g2-'));
|
||||
try {
|
||||
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1, d2], groupId: 'grp1' });
|
||||
expect(lastPty().args).toEqual(['--add-dir', d1, '--add-dir', d2]);
|
||||
expect(summary.addedDirs).toEqual([d1, d2]);
|
||||
expect(summary.groupId).toBe('grp1');
|
||||
const row = db.prepare('SELECT added_dirs, group_id FROM sessions WHERE id = ?').get(summary.id) as {
|
||||
added_dirs: string | null;
|
||||
group_id: string | null;
|
||||
};
|
||||
expect(JSON.parse(row.added_dirs as string)).toEqual([d1, d2]);
|
||||
expect(row.group_id).toBe('grp1');
|
||||
} finally {
|
||||
rmSync(d1, { recursive: true, force: true });
|
||||
rmSync(d2, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('dédoublonne et écarte le cwd primaire des addDirs', () => {
|
||||
const d1 = mkdtempSync(join(tmpdir(), 'arb-g4-'));
|
||||
try {
|
||||
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1, d1, cwd] });
|
||||
expect(summary.addedDirs).toEqual([d1]);
|
||||
} finally {
|
||||
rmSync(d1, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejette un addDir inexistant (400)', () => {
|
||||
expect(() => manager.spawn({ cwd, command: 'bash', addDirs: ['/no/such/dir/xyz-arb'] })).toThrow();
|
||||
});
|
||||
|
||||
it('groupSessionContext renvoie les addedDirs/groupId persistés (resume)', () => {
|
||||
const d1 = mkdtempSync(join(tmpdir(), 'arb-g5-'));
|
||||
try {
|
||||
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1], groupId: 'grpX' });
|
||||
// simule la capture du claudeSessionId (normalement résolue via le registre)
|
||||
db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('cs-1', summary.id);
|
||||
expect(manager.groupSessionContext('cs-1')).toEqual({ addedDirs: [d1], groupId: 'grpX' });
|
||||
expect(manager.groupSessionContext('unknown')).toBeNull();
|
||||
} finally {
|
||||
rmSync(d1, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +152,22 @@ export interface AddRepoRequest {
|
||||
repoId: string;
|
||||
}
|
||||
|
||||
// ---- Session de groupe multi-repo (P6) ----
|
||||
/** Lance UNE session Claude couvrant tous les repos du groupe (via `--add-dir`). */
|
||||
export interface CreateGroupSessionRequest {
|
||||
/** défaut : claude. */
|
||||
command?: 'claude' | 'bash';
|
||||
/** présent → couvre le worktree de cette branche dans chaque repo ; absent → les checkouts principaux. */
|
||||
branch?: string;
|
||||
}
|
||||
export interface GroupSessionResponse {
|
||||
session: SessionSummary;
|
||||
/** répertoires effectivement couverts par la session (cwd primaire en tête). */
|
||||
dirs: string[];
|
||||
/** repos du groupe pour lesquels aucun worktree n'a pu être résolu. */
|
||||
skipped: Array<{ repoId: string; reason: string }>;
|
||||
}
|
||||
|
||||
// ---- Navigateur de répertoires (sélecteur de dossier côté web) ----
|
||||
export interface FsEntry {
|
||||
name: string;
|
||||
|
||||
@@ -113,6 +113,11 @@ export interface SessionSummary {
|
||||
waitingFor?: string | null;
|
||||
/** dialogue typé en cours (présent quand activity === 'waiting'). */
|
||||
dialog?: SessionDialog | null;
|
||||
// ---- P6 : session de groupe multi-repo (additif) ----
|
||||
/** répertoires supplémentaires couverts via `--add-dir` ; absent/[] pour une session mono-repo. */
|
||||
addedDirs?: string[];
|
||||
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
|
||||
groupId?: string | null;
|
||||
}
|
||||
|
||||
// ---- Worktrees & repos (P3) ----
|
||||
|
||||
53
packages/site/index.html
Normal file
53
packages/site/index.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="background-color: #09090b">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
|
||||
<title>Arboretum — Mission control for your AI coding agents</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Arboretum is a local-first daemon you launch with npx that serves a web dashboard to run and supervise many Claude Code sessions across git worktrees — from any device, even your phone."
|
||||
/>
|
||||
<link rel="canonical" href="https://git-arboretum.com/" />
|
||||
|
||||
<!-- Open Graph / Twitter -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://git-arboretum.com/" />
|
||||
<meta property="og:title" content="Arboretum — Mission control for your AI coding agents" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Run and supervise many Claude Code sessions across every git worktree — from any device, even your phone."
|
||||
/>
|
||||
<meta property="og:site_name" content="Arboretum" />
|
||||
<meta property="og:image" content="https://git-arboretum.com/assets/og-cover.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="Arboretum — mission control for your AI coding agents" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Arboretum — Mission control for your AI coding agents" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Run and supervise many Claude Code sessions across every git worktree — from any device, even your phone."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://git-arboretum.com/assets/og-cover.png" />
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<!-- Polices JetBrains Mono self-host via @fontsource (importées dans src/main.ts). -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<noscript>
|
||||
<div style="padding: 24px; font-family: system-ui, sans-serif; color: #f4f4f5; text-align: center">
|
||||
Arboretum — mission control for your AI coding agents.
|
||||
<a href="https://git.lidge.fr/johanleroy/arboretum" style="color: #34d399">View on Gitea</a>.
|
||||
</div>
|
||||
</noscript>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
packages/site/package.json
Normal file
24
packages/site/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@arboretum/site",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.38",
|
||||
"vue-i18n": "^11.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource/jetbrains-mono": "^5.1.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^8.0.16",
|
||||
"vue-tsc": "^3.3.4"
|
||||
}
|
||||
}
|
||||
16
packages/site/public/.htaccess
Normal file
16
packages/site/public/.htaccess
Normal file
@@ -0,0 +1,16 @@
|
||||
# git-arboretum.com — site vitrine statique (Vue 3 / Vite).
|
||||
#
|
||||
# Google PageSpeed (mod_pagespeed) renvoie un corps HTML VIDE sur ce domaine Plesk
|
||||
# (la home répond 200 mais 0 octet → page blanche), alors que les assets statiques
|
||||
# passent. Les bundles Vite sont déjà minifiés, hashés et gzippés : PageSpeed
|
||||
# n'apporte rien ici et casse le rendu. On le désactive pour ce vhost.
|
||||
#
|
||||
# NB : si l'hébergement interdit ces directives en .htaccess (AllowOverride
|
||||
# restrictif), Apache renverra une 500 → dans ce cas, supprimer ce fichier et
|
||||
# désactiver PageSpeed depuis Plesk (Apache & nginx Settings).
|
||||
<IfModule pagespeed_module>
|
||||
ModPagespeed off
|
||||
</IfModule>
|
||||
<IfModule ngx_pagespeed_module>
|
||||
pagespeed off;
|
||||
</IfModule>
|
||||
BIN
packages/site/public/apple-touch-icon.png
Normal file
BIN
packages/site/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
packages/site/public/assets/arboretum-mark.png
Normal file
BIN
packages/site/public/assets/arboretum-mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
packages/site/public/assets/og-cover.png
Normal file
BIN
packages/site/public/assets/og-cover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
packages/site/public/favicon.ico
Normal file
BIN
packages/site/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
46
packages/site/public/icon.svg
Normal file
46
packages/site/public/icon.svg
Normal file
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Arboretum">
|
||||
<defs>
|
||||
<!-- glow néon : flou doux derrière les traits et les nœuds -->
|
||||
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur stdDeviation="6" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
<linearGradient id="branch" x1="256" y1="430" x2="256" y2="90" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#10b981"/>
|
||||
<stop offset="1" stop-color="#34d399"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="512" height="512" fill="#09090b"/>
|
||||
|
||||
<!-- branches / circuit (tronc + fourches symétriques), tracées dans la zone sûre maskable -->
|
||||
<g fill="none" stroke="url(#branch)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round" filter="url(#glow)">
|
||||
<!-- tronc : du nœud sommet jusqu'à la base avec le curseur >_ -->
|
||||
<path d="M256 96 V392"/>
|
||||
<!-- paire haute -->
|
||||
<path d="M256 232 L150 232 V150"/>
|
||||
<path d="M256 232 L362 232 V150"/>
|
||||
<!-- paire médiane -->
|
||||
<path d="M256 300 L104 300 V214"/>
|
||||
<path d="M256 300 L408 300 V214"/>
|
||||
<!-- paire basse -->
|
||||
<path d="M256 356 L150 356 V300"/>
|
||||
<path d="M256 356 L362 356 V300"/>
|
||||
<!-- base : invite de commande >_ -->
|
||||
<path d="M232 404 L246 418 L232 432" stroke-width="14"/>
|
||||
<path d="M258 434 H286" stroke-width="14"/>
|
||||
</g>
|
||||
|
||||
<!-- nœuds (sessions) : anneaux cyan lumineux, centre sombre -->
|
||||
<g filter="url(#glow)">
|
||||
<g fill="#09090b" stroke="#22d3ee" stroke-width="9">
|
||||
<circle cx="256" cy="96" r="20"/>
|
||||
<circle cx="150" cy="150" r="16"/>
|
||||
<circle cx="362" cy="150" r="16"/>
|
||||
<circle cx="104" cy="214" r="16"/>
|
||||
<circle cx="408" cy="214" r="16"/>
|
||||
<circle cx="150" cy="300" r="16"/>
|
||||
<circle cx="362" cy="300" r="16"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
4
packages/site/public/robots.txt
Normal file
4
packages/site/public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://git-arboretum.com/sitemap.xml
|
||||
9
packages/site/public/sitemap.xml
Normal file
9
packages/site/public/sitemap.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://git-arboretum.com/</loc>
|
||||
<lastmod>2026-06-19</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
74
packages/site/src/App.vue
Normal file
74
packages/site/src/App.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AppHeader from './components/AppHeader.vue';
|
||||
import HeroSection from './components/HeroSection.vue';
|
||||
import ProblemSection from './components/ProblemSection.vue';
|
||||
import FeaturesSection from './components/FeaturesSection.vue';
|
||||
import ShowcaseSection from './components/ShowcaseSection.vue';
|
||||
import WorkGroupsSection from './components/WorkGroupsSection.vue';
|
||||
import HowItWorksSection from './components/HowItWorksSection.vue';
|
||||
import SecuritySection from './components/SecuritySection.vue';
|
||||
import FaqSection from './components/FaqSection.vue';
|
||||
import FinalCta from './components/FinalCta.vue';
|
||||
import AppFooter from './components/AppFooter.vue';
|
||||
|
||||
const { locale } = useI18n();
|
||||
|
||||
// Tient l'attribut <html lang> synchronisé avec la langue active.
|
||||
watch(
|
||||
locale,
|
||||
(l) => {
|
||||
document.documentElement.lang = l;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Grain de fond + halo emerald (décoratifs) — portés tels quels du design.
|
||||
const grainStyle = {
|
||||
position: 'fixed',
|
||||
inset: '0',
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
backgroundImage: 'radial-gradient(rgba(255,255,255,.025) 1px,transparent 1.4px)',
|
||||
backgroundSize: '34px 34px',
|
||||
WebkitMaskImage: 'radial-gradient(ellipse 120% 70% at 50% -4%,#000,transparent 70%)',
|
||||
maskImage: 'radial-gradient(ellipse 120% 70% at 50% -4%,#000,transparent 70%)',
|
||||
} as const;
|
||||
|
||||
const glowStyle = {
|
||||
position: 'fixed',
|
||||
top: '-26%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '1100px',
|
||||
height: '680px',
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
background: 'radial-gradient(50% 50% at 50% 50%,rgba(16,185,129,.12),transparent 64%)',
|
||||
filter: 'blur(18px)',
|
||||
} as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative overflow-x-hidden">
|
||||
<div aria-hidden="true" :style="grainStyle"></div>
|
||||
<div aria-hidden="true" :style="glowStyle"></div>
|
||||
|
||||
<AppHeader />
|
||||
|
||||
<main id="top" class="relative z-[1]">
|
||||
<HeroSection />
|
||||
<ProblemSection />
|
||||
<FeaturesSection />
|
||||
<ShowcaseSection />
|
||||
<WorkGroupsSection />
|
||||
<HowItWorksSection />
|
||||
<SecuritySection />
|
||||
<FaqSection />
|
||||
<FinalCta />
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
50
packages/site/src/components/AppFooter.vue
Normal file
50
packages/site/src/components/AppFooter.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||
import { REPO, LICENSE, COFFEE } from '../lib/links';
|
||||
import IconGitea from './icons/IconGitea.vue';
|
||||
import CopyButton from './CopyButton.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="relative z-[1] border-t border-[#1c1c1f]">
|
||||
<div class="mx-auto flex max-w-[1200px] flex-wrap items-center justify-between gap-5 px-6 py-[34px]">
|
||||
<div class="flex items-center gap-[11px]">
|
||||
<img
|
||||
src="/assets/arboretum-mark.png"
|
||||
alt=""
|
||||
width="22"
|
||||
height="22"
|
||||
class="h-[22px] w-[22px] object-contain"
|
||||
style="filter: grayscale(1) brightness(1.5); opacity: 0.4"
|
||||
/>
|
||||
<span class="font-mono text-sm text-zinc-300">git-arboretum.com</span>
|
||||
<span class="ml-1 text-[13px] text-zinc-600">— {{ t('footTag') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-[22px] text-sm">
|
||||
<a :href="REPO" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 text-zinc-400 no-underline transition-colors hover:text-emerald-400">
|
||||
<IconGitea :size="15" />
|
||||
Gitea
|
||||
</a>
|
||||
<a :href="LICENSE" target="_blank" rel="noopener" class="text-zinc-400 no-underline transition-colors hover:text-emerald-400">
|
||||
{{ t('license') }}
|
||||
</a>
|
||||
<a
|
||||
:href="COFFEE"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-1.5 text-zinc-400 no-underline transition-colors hover:text-amber-400 focus-visible:rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-400/70"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2v2" /><path d="M14 2v2" /><path d="M5 8h13a1 1 0 0 1 1 1v2a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1Z" /><path d="M6 15a4 4 0 0 0 4 4h2a4 4 0 0 0 4-4" /><path d="M19 9h1.5a2.5 2.5 0 0 1 0 5H18" /></svg>
|
||||
{{ t('coffee') }}
|
||||
</a>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="font-mono text-[12.5px] text-zinc-600">{{ INSTALL_COMMAND }}</code>
|
||||
<CopyButton variant="icon" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
59
packages/site/src/components/AppHeader.vue
Normal file
59
packages/site/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { REPO } from '../lib/links';
|
||||
import LangToggle from './LangToggle.vue';
|
||||
import IconGitea from './icons/IconGitea.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const navLinks = [
|
||||
{ href: '#features', key: 'navFeatures' },
|
||||
{ href: '#how', key: 'navHow' },
|
||||
{ href: '#security', key: 'navSecurity' },
|
||||
{ href: '#faq', key: 'navFaq' },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-50 border-b border-[#1c1c1f] bg-[rgba(9,9,11,0.72)] backdrop-blur-[14px]"
|
||||
>
|
||||
<div class="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-6 px-6">
|
||||
<a href="#top" class="flex items-center gap-2.5 text-zinc-100 no-underline">
|
||||
<img
|
||||
src="/assets/arboretum-mark.png"
|
||||
alt="Arboretum"
|
||||
width="28"
|
||||
height="28"
|
||||
class="block h-7 w-7 object-contain"
|
||||
/>
|
||||
<span class="font-mono text-[17px] font-semibold tracking-[-0.01em]">Arboretum</span>
|
||||
</a>
|
||||
|
||||
<nav class="hidden items-center gap-[30px] min-[900px]:flex">
|
||||
<a
|
||||
v-for="link in navLinks"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
class="text-[14.5px] text-zinc-400 no-underline transition-colors hover:text-zinc-100"
|
||||
>
|
||||
{{ t(link.key) }}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-3.5">
|
||||
<LangToggle />
|
||||
<a
|
||||
:href="REPO"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="Gitea"
|
||||
class="inline-flex items-center gap-[7px] rounded-lg border border-zinc-800 px-[13px] py-[7px] text-[13.5px] font-medium text-zinc-300 no-underline transition-colors hover:border-emerald-500/50 hover:text-emerald-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
||||
>
|
||||
<IconGitea :size="16" />
|
||||
Gitea
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
57
packages/site/src/components/CopyButton.vue
Normal file
57
packages/site/src/components/CopyButton.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useCopy } from '../composables/useCopy';
|
||||
import IconCopy from './icons/IconCopy.vue';
|
||||
import IconCheck from './icons/IconCheck.vue';
|
||||
|
||||
// Bouton « copier » réutilisable, avec retour visuel (icône ✓ + label) pendant 1800 ms.
|
||||
// Deux variantes :
|
||||
// - 'bar' : bouton étiqueté intégré aux barres de commande (hero, CTA final).
|
||||
// - 'icon' : bouton compact icône seule, pour les blocs de code (étapes, footer).
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
text: string;
|
||||
variant?: 'bar' | 'icon';
|
||||
label?: string; // aria-label décrivant ce qui est copié
|
||||
barClass?: string; // padding/ajustements de la variante 'bar'
|
||||
}>(),
|
||||
{ variant: 'bar', label: '', barClass: 'px-[15px]' },
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { copied, copy } = useCopy();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="variant === 'bar'"
|
||||
type="button"
|
||||
:aria-label="label || t('copy')"
|
||||
:class="[
|
||||
'inline-flex flex-none items-center gap-[7px] border-l border-zinc-800 bg-zinc-900 font-mono text-[13px] font-semibold transition-colors hover:bg-zinc-800 hover:text-emerald-400 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-500/70',
|
||||
barClass,
|
||||
copied ? 'text-emerald-400' : 'text-zinc-300',
|
||||
]"
|
||||
@click="copy(text)"
|
||||
>
|
||||
<component :is="copied ? IconCheck : IconCopy" :size="15" />
|
||||
<span aria-live="polite">{{ copied ? t('copied') : t('copy') }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
:aria-label="label || t('copy')"
|
||||
:title="copied ? t('copied') : label || t('copy')"
|
||||
:class="[
|
||||
'inline-flex flex-none items-center justify-center rounded-md border p-1.5 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70',
|
||||
copied
|
||||
? 'border-emerald-500/40 bg-zinc-800 text-emerald-400'
|
||||
: 'border-zinc-800 bg-zinc-900/80 text-zinc-400 hover:border-emerald-500/40 hover:bg-zinc-800 hover:text-emerald-400',
|
||||
]"
|
||||
@click="copy(text)"
|
||||
>
|
||||
<component :is="copied ? IconCheck : IconCopy" :size="14" />
|
||||
<span aria-live="polite" class="sr-only">{{ copied ? t('copied') : t('copy') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
52
packages/site/src/components/FaqSection.vue
Normal file
52
packages/site/src/components/FaqSection.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { FAQS } from '../i18n/content';
|
||||
import type { AppLocale } from '../i18n';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const open = ref(0);
|
||||
const items = computed(() => FAQS[locale.value as AppLocale]);
|
||||
|
||||
function toggle(i: number): void {
|
||||
open.value = open.value === i ? -1 : i;
|
||||
}
|
||||
|
||||
function panelStyle(i: number) {
|
||||
return {
|
||||
maxHeight: open.value === i ? '300px' : '0px',
|
||||
opacity: open.value === i ? 1 : 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height .4s ease, opacity .35s ease',
|
||||
} as const;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="faq" class="mx-auto max-w-[780px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||
<div v-reveal class="mb-[38px] text-center">
|
||||
<div class="mb-3.5 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('navFaq') }}</div>
|
||||
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-zinc-50">{{ t('faqTitle') }}</h2>
|
||||
</div>
|
||||
|
||||
<div v-reveal>
|
||||
<div v-for="(f, i) in items" :key="i" class="border-t" :class="open === i ? 'border-emerald-400/40' : 'border-zinc-800'">
|
||||
<button
|
||||
type="button"
|
||||
:aria-expanded="open === i"
|
||||
:aria-controls="`faq-panel-${i}`"
|
||||
class="flex w-full cursor-pointer items-center justify-between gap-4 border-none bg-transparent px-1 py-5 text-left text-[clamp(16px,2vw,18px)] font-medium text-zinc-50 focus-visible:rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
||||
@click="toggle(i)"
|
||||
>
|
||||
<span>{{ f.q }}</span>
|
||||
<span class="flex flex-none text-emerald-400 transition-transform duration-300" :class="open === i ? 'rotate-180' : ''">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div :id="`faq-panel-${i}`" :style="panelStyle(i)" :inert="open !== i" :aria-hidden="open !== i">
|
||||
<p class="m-0 max-w-[640px] px-1 pb-5 text-[15.5px] leading-[1.6] text-zinc-400">{{ f.a }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
17
packages/site/src/components/FeatureCard.vue
Normal file
17
packages/site/src/components/FeatureCard.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 transition-[transform,border-color] duration-[180ms] ease-in-out hover:-translate-y-[3px] hover:border-emerald-400/40"
|
||||
>
|
||||
<div
|
||||
class="mb-4 inline-flex h-[42px] w-[42px] items-center justify-center rounded-[10px] bg-emerald-500/10 text-emerald-400"
|
||||
>
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
<h3 class="m-0 mb-[7px] text-lg font-semibold text-zinc-50">{{ title }}</h3>
|
||||
<p class="m-0 text-[14.5px] leading-[1.55] text-zinc-400"><slot /></p>
|
||||
</div>
|
||||
</template>
|
||||
76
packages/site/src/components/FeaturesSection.vue
Normal file
76
packages/site/src/components/FeaturesSection.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import FeatureCard from './FeatureCard.vue';
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="features" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||
<div v-reveal class="mb-[46px] text-center">
|
||||
<div class="mb-3.5 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('featKicker') }}</div>
|
||||
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-zinc-50">
|
||||
{{ t('featTitle') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div v-reveal class="grid grid-cols-[repeat(auto-fit,minmax(264px,1fr))] gap-4">
|
||||
<FeatureCard :title="t('feat1Title')">
|
||||
<template #icon>
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></svg>
|
||||
</template>
|
||||
{{ t('feat1Desc') }}
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard :title="t('feat2Title')">
|
||||
<template #icon>
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" /><rect width="7" height="7" x="3" y="14" rx="1" /></svg>
|
||||
</template>
|
||||
{{ t('feat2Desc') }}
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard :title="t('feat3Title')">
|
||||
<template #icon>
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 12h-4l-3 9L9 3l-3 9H2" /></svg>
|
||||
</template>
|
||||
<span class="text-sky-300">{{ t('busy') }}</span> ·
|
||||
<span class="text-amber-300">{{ t('waiting') }}</span> ·
|
||||
<span class="text-emerald-400">{{ t('idle') }}</span> — {{ t('feat3Desc') }}
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard :title="t('feat4Title')">
|
||||
<template #icon>
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2.5" /><path d="M12 18h.01" /></svg>
|
||||
</template>
|
||||
{{ t('feat4Desc') }}
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard :title="t('feat5Title')">
|
||||
<template #icon>
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.27 21a1.94 1.94 0 0 0 3.46 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></svg>
|
||||
</template>
|
||||
{{ t('feat5Desc') }}
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard :title="t('feat6Title')">
|
||||
<template #icon>
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /></svg>
|
||||
</template>
|
||||
{{ t('feat6Desc') }}
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard :title="t('feat7Title')">
|
||||
<template #icon>
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="m7 16.5-4.74-2.85" /><path d="m7 16.5 5-3" /><path d="M7 16.5v5.17" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z" /></svg>
|
||||
</template>
|
||||
{{ t('feat7Desc') }}
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard :title="t('feat8Title')">
|
||||
<template #icon>
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></svg>
|
||||
</template>
|
||||
{{ t('feat8Desc') }}
|
||||
</FeatureCard>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
52
packages/site/src/components/FinalCta.vue
Normal file
52
packages/site/src/components/FinalCta.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||
import { REPO } from '../lib/links';
|
||||
import IconGitea from './icons/IconGitea.vue';
|
||||
import CopyButton from './CopyButton.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto max-w-[1200px] px-6 pb-[100px]">
|
||||
<div
|
||||
v-reveal
|
||||
class="rounded-[20px] border border-zinc-800 px-6 py-[clamp(36px,5vw,64px)] text-center"
|
||||
style="background: radial-gradient(120% 140% at 50% 0%, rgba(16, 185, 129, 0.1), transparent 60%), #0c0c0e"
|
||||
>
|
||||
<img
|
||||
src="/assets/arboretum-mark.png"
|
||||
alt=""
|
||||
width="52"
|
||||
height="52"
|
||||
class="mx-auto mb-5 block h-[52px] w-[52px] object-contain"
|
||||
/>
|
||||
<h2 class="m-0 mb-3.5 text-[clamp(30px,4vw,46px)] font-bold leading-[1.08] tracking-[-0.03em] text-zinc-50">
|
||||
{{ t('ctaTitle') }}
|
||||
</h2>
|
||||
<p class="mx-auto mb-7 max-w-[480px] text-[17px] text-zinc-400">{{ t('ctaBody') }}</p>
|
||||
|
||||
<div
|
||||
class="mx-auto flex max-w-[460px] items-stretch overflow-hidden rounded-[10px] border border-zinc-800 bg-zinc-950"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto whitespace-nowrap px-4 font-mono text-sm">
|
||||
<span class="text-emerald-400">$</span><span class="text-zinc-200">{{ INSTALL_COMMAND }}</span>
|
||||
</div>
|
||||
<CopyButton variant="bar" :text="INSTALL_COMMAND" :label="t('copyCommand')" bar-class="px-4" />
|
||||
</div>
|
||||
|
||||
<div class="mt-[18px]">
|
||||
<a
|
||||
:href="REPO"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-2 text-[14.5px] text-zinc-400 no-underline transition-colors hover:text-emerald-400"
|
||||
>
|
||||
<IconGitea :size="16" />
|
||||
{{ t('ctaSource') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
212
packages/site/src/components/HeroMockup.vue
Normal file
212
packages/site/src/components/HeroMockup.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useHeroTimeline, type SessionState } from '../composables/useHeroTimeline';
|
||||
import { DLG_OPTS } from '../i18n/content';
|
||||
import type { AppLocale } from '../i18n';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { anim } = useHeroTimeline('hero-stage');
|
||||
|
||||
// Couleurs des lignes de terminal par type (porté de renderVals.C).
|
||||
const LINE_COLORS: Record<string, string> = {
|
||||
cmd: '#52525b',
|
||||
tool: '#a1a1aa',
|
||||
edit: '#34d399',
|
||||
add: '#34d399',
|
||||
del: '#f87171',
|
||||
ok: '#34d399',
|
||||
warn: '#fcd34d',
|
||||
run: '#7dd3fc',
|
||||
};
|
||||
|
||||
const PILL: Record<SessionState, { bg: string; fg: string; dot: string; pulse: string }> = {
|
||||
idle: { bg: 'rgba(2,44,34,.6)', fg: '#34d399', dot: '#34d399', pulse: '' },
|
||||
busy: { bg: 'rgba(8,47,73,.7)', fg: '#7dd3fc', dot: '#7dd3fc', pulse: 'pulseSky 2s ease-in-out infinite' },
|
||||
waiting: { bg: 'rgba(69,26,3,.7)', fg: '#fcd34d', dot: '#fcd34d', pulse: 'pulseAmber 2.2s ease-in-out infinite' },
|
||||
};
|
||||
|
||||
const pill = computed(() => PILL[anim.value.sess]);
|
||||
const sessText = computed(() => t(anim.value.sess));
|
||||
|
||||
const termLines = computed(() =>
|
||||
anim.value.lines.map((l) => ({ text: l.text, color: LINE_COLORS[l.k] ?? '#d4d4d8' })),
|
||||
);
|
||||
|
||||
const dlgOptions = computed(() =>
|
||||
DLG_OPTS[locale.value as AppLocale].map((txt, i) => {
|
||||
const highlighted = anim.value.hl === i;
|
||||
const answered = anim.value.ans === i;
|
||||
const row: Record<string, string> = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '9px',
|
||||
padding: '7px 9px',
|
||||
borderRadius: '7px',
|
||||
border: '1px solid transparent',
|
||||
background: 'rgba(9,9,11,.4)',
|
||||
transition: 'background .2s, border-color .2s',
|
||||
};
|
||||
if (highlighted) {
|
||||
row.background = 'rgba(69,26,3,.5)';
|
||||
row.border = '1px solid rgba(217,119,6,.6)';
|
||||
}
|
||||
if (answered) {
|
||||
row.background = 'rgba(2,44,34,.45)';
|
||||
row.border = '1px solid rgba(5,150,105,.6)';
|
||||
}
|
||||
return {
|
||||
num: answered ? '✓' : String(i + 1),
|
||||
text: txt,
|
||||
rowStyle: row,
|
||||
numColor: answered ? '#34d399' : '#fbbf24',
|
||||
textColor: answered ? '#a7f3d0' : highlighted ? '#fde68a' : '#d4d4d8',
|
||||
};
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="hero-stage" class="relative min-w-[300px] flex-[1_1_480px]">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-[14px] border border-zinc-800 bg-zinc-950 shadow-hero"
|
||||
>
|
||||
<!-- barre de fenêtre -->
|
||||
<div class="flex items-center gap-2 border-b border-[#1c1c1f] bg-[#0c0c0e] px-[14px] py-[11px]">
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
||||
<span class="ml-2 font-mono text-[11.5px] text-zinc-600">arboretum · localhost:7317</span>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-[360px]">
|
||||
<!-- sidebar -->
|
||||
<div
|
||||
class="flex w-[168px] flex-none flex-col border-r border-[#1c1c1f] bg-zinc-900/40 px-2.5 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-1 pb-3">
|
||||
<img
|
||||
src="/assets/arboretum-mark.png"
|
||||
alt=""
|
||||
width="20"
|
||||
height="20"
|
||||
class="h-5 w-5 object-contain"
|
||||
/>
|
||||
<span class="font-mono text-[13px] font-semibold text-zinc-200">Arboretum</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mb-3 flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/50 px-[9px] py-[7px]"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#52525b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
|
||||
<span class="flex-1 text-[11.5px] text-zinc-600">{{ t('mSearch') }}</span>
|
||||
<span class="rounded border border-zinc-800 px-[5px] py-px font-mono text-[10px] text-zinc-500">⌘K</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-[9px] rounded-lg bg-zinc-800 px-[9px] py-[7px] text-zinc-100">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="7" height="9" x="3" y="3" rx="1" /><rect width="7" height="5" x="14" y="3" rx="1" /><rect width="7" height="9" x="14" y="12" rx="1" /><rect width="7" height="5" x="3" y="16" rx="1" /></svg>
|
||||
<span class="text-[12.5px]">{{ t('mDash') }}</span>
|
||||
<span
|
||||
v-show="anim.att > 0"
|
||||
class="ml-auto inline-flex h-[17px] min-w-[17px] items-center justify-center rounded-full px-[5px] font-mono text-[10px] font-bold"
|
||||
style="background: #f59e0b; color: #1c1300"
|
||||
>{{ anim.att }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-[9px] rounded-lg px-[9px] py-[7px] text-zinc-400">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg>
|
||||
<span class="text-[12.5px]">{{ t('mGroups') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<div class="flex flex-col gap-0.5 border-t border-[#1c1c1f] pt-2.5">
|
||||
<div class="flex items-center gap-[9px] rounded-lg px-[9px] py-1.5 text-zinc-400">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" /></svg>
|
||||
<span class="text-[12.5px]">{{ t('mSettings') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-[9px] rounded-lg px-[9px] py-1.5 text-zinc-400">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><path d="M12 17h.01" /></svg>
|
||||
<span class="text-[12.5px]">{{ t('mHelp') }}</span>
|
||||
</div>
|
||||
<div class="px-[9px] pt-1.5 font-mono text-[10px] text-zinc-700">v0.4.2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- main -->
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-[11px] p-[13px]">
|
||||
<!-- terminal pane -->
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] border border-zinc-800 bg-zinc-950"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-[#1c1c1f] px-[11px] py-2">
|
||||
<span class="font-mono text-[11.5px] text-zinc-400">api · feat/auth</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-[9px] py-[3px] font-mono text-[11px]"
|
||||
:style="{ background: pill.bg, color: pill.fg }"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 flex-none rounded-full"
|
||||
:style="{ background: pill.dot, animation: pill.pulse }"
|
||||
></span>
|
||||
{{ sessText }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-[150px] flex-1 flex-col justify-end overflow-hidden p-[11px] font-mono text-xs leading-[1.7] text-zinc-300"
|
||||
>
|
||||
<div v-for="(ln, i) in termLines" :key="i" :style="{ color: ln.color, whiteSpace: 'pre' }">
|
||||
{{ ln.text }}
|
||||
</div>
|
||||
<div v-if="anim.cur">
|
||||
<span class="text-emerald-400">$</span>
|
||||
<span class="ml-1 inline-block h-[13px] w-[7px] animate-blink align-middle bg-zinc-200"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- dialogue de permission -->
|
||||
<div
|
||||
v-if="anim.dlg"
|
||||
class="animate-dlg-in rounded-[10px] border border-[rgba(120,53,15,0.6)] bg-[rgba(69,26,3,0.3)] p-[11px]"
|
||||
>
|
||||
<div class="mb-[9px] flex items-center gap-2">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
|
||||
<span class="text-[12.5px] text-zinc-200">{{ t('dlgQ') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-[5px]">
|
||||
<div v-for="(op, i) in dlgOptions" :key="i" :style="op.rowStyle">
|
||||
<span
|
||||
class="w-[15px] flex-none text-center font-mono text-[11px] font-bold"
|
||||
:style="{ color: op.numColor }"
|
||||
>{{ op.num }}</span
|
||||
>
|
||||
<span class="text-xs" :style="{ color: op.textColor }">{{ op.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- toast push -->
|
||||
<div
|
||||
v-if="anim.toast"
|
||||
class="animate-toast-in absolute bottom-[14px] right-[14px] flex w-[248px] items-start gap-2.5 rounded-[11px] border border-[rgba(120,53,15,0.5)] bg-[#0c0c0e] p-3 shadow-toast"
|
||||
>
|
||||
<span class="inline-flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg bg-emerald-500/15">
|
||||
<img src="/assets/arboretum-mark.png" alt="" width="18" height="18" class="h-[18px] w-[18px] object-contain" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-mono text-[11px] text-emerald-400">Arboretum</span>
|
||||
<span class="text-[10px] text-zinc-600">now</span>
|
||||
</div>
|
||||
<div class="mt-[3px] text-[13px] font-semibold text-zinc-100">{{ t('toastTitle') }}</div>
|
||||
<div class="mt-px text-[11.5px] leading-[1.4] text-zinc-400">{{ t('toastBody') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
71
packages/site/src/components/HeroSection.vue
Normal file
71
packages/site/src/components/HeroSection.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||
import { REPO } from '../lib/links';
|
||||
import HeroMockup from './HeroMockup.vue';
|
||||
import IconGitea from './icons/IconGitea.vue';
|
||||
import CopyButton from './CopyButton.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto flex max-w-[1200px] flex-wrap items-center gap-12 px-6 pb-16 pt-[72px]">
|
||||
<div class="min-w-[300px] flex-[1_1_430px]">
|
||||
<div class="mb-[22px] flex items-center gap-[13px]">
|
||||
<img
|
||||
src="/assets/arboretum-mark.png"
|
||||
alt=""
|
||||
width="46"
|
||||
height="46"
|
||||
class="block h-[46px] w-[46px] object-contain"
|
||||
/>
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/[0.07] px-[11px] py-[5px] font-mono text-[11.5px] uppercase tracking-[0.1em] text-emerald-400"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400 shadow-[0_0_8px_#34d399]"></span>
|
||||
{{ t('heroBadge') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="m-0 text-[clamp(40px,5.6vw,64px)] font-bold leading-[1.04] tracking-[-0.035em] text-zinc-50">
|
||||
{{ t('heroTitle') }}
|
||||
</h1>
|
||||
<p class="mt-[22px] max-w-[520px] text-[clamp(17px,1.7vw,20px)] leading-[1.55] text-zinc-400">
|
||||
{{ t('heroSub') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-[30px] flex max-w-[480px] items-stretch overflow-hidden rounded-[10px] border border-zinc-800 bg-zinc-950/60"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto whitespace-nowrap px-[15px] font-mono text-sm"
|
||||
>
|
||||
<span class="text-emerald-400">$</span><span class="text-zinc-200">{{ INSTALL_COMMAND }}</span>
|
||||
</div>
|
||||
<CopyButton variant="bar" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-[13px]">
|
||||
<a
|
||||
href="#how"
|
||||
class="inline-flex items-center gap-[9px] rounded-[9px] bg-emerald-600 px-6 py-[13px] text-[15.5px] font-semibold text-white no-underline transition-[background,transform] duration-150 ease-in-out hover:-translate-y-px hover:bg-emerald-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
||||
>
|
||||
{{ t('getStarted') }}
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14" /><path d="m12 5 7 7-7 7" /></svg>
|
||||
</a>
|
||||
<a
|
||||
:href="REPO"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-[9px] rounded-[9px] border border-zinc-800 px-[22px] py-[13px] text-[15.5px] font-medium text-zinc-100 no-underline transition-colors hover:border-emerald-500/45 hover:text-emerald-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
||||
>
|
||||
<IconGitea :size="17" />
|
||||
{{ t('gitea') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeroMockup />
|
||||
</section>
|
||||
</template>
|
||||
53
packages/site/src/components/HowItWorksSection.vue
Normal file
53
packages/site/src/components/HowItWorksSection.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||
import CopyButton from './CopyButton.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
// Port par défaut réel du daemon (cf. packages/server/src/config.ts).
|
||||
const LOCAL_URL = 'http://localhost:7317';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="how" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||
<div v-reveal class="mb-[46px] text-center">
|
||||
<div class="mb-3.5 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('howKicker') }}</div>
|
||||
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-zinc-50">{{ t('howTitle') }}</h2>
|
||||
</div>
|
||||
|
||||
<div v-reveal class="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-[26px]">
|
||||
<div class="mb-[18px] font-mono text-[13px] text-emerald-400">01</div>
|
||||
<h3 class="m-0 mb-2.5 text-lg font-semibold text-zinc-50">{{ t('step1Title') }}</h3>
|
||||
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-zinc-400">{{ t('step1Desc') }}</p>
|
||||
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950 p-[11px]">
|
||||
<code class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12.5px] text-zinc-200">
|
||||
<span class="text-emerald-400">$ </span>{{ INSTALL_COMMAND }}
|
||||
</code>
|
||||
<CopyButton variant="icon" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-[26px]">
|
||||
<div class="mb-[18px] font-mono text-[13px] text-emerald-400">02</div>
|
||||
<h3 class="m-0 mb-2.5 text-lg font-semibold text-zinc-50">{{ t('step2Title') }}</h3>
|
||||
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-zinc-400">{{ t('step2Desc') }}</p>
|
||||
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950 p-[11px]">
|
||||
<code class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12.5px] text-sky-300">
|
||||
→ {{ LOCAL_URL }}
|
||||
</code>
|
||||
<CopyButton variant="icon" :text="LOCAL_URL" :label="t('copyUrl')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-[26px]">
|
||||
<div class="mb-[18px] font-mono text-[13px] text-emerald-400">03</div>
|
||||
<h3 class="m-0 mb-2.5 text-lg font-semibold text-zinc-50">{{ t('step3Title') }}</h3>
|
||||
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-zinc-400">{{ t('step3Desc') }}</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span class="rounded-md bg-emerald-500/10 px-[9px] py-1 font-mono text-[11px] text-emerald-400">{{ t('idle') }}</span>
|
||||
<span class="rounded-md bg-[rgba(8,47,73,0.7)] px-[9px] py-1 font-mono text-[11px] text-sky-300">{{ t('busy') }}</span>
|
||||
<span class="rounded-md bg-[rgba(69,26,3,0.6)] px-[9px] py-1 font-mono text-[11px] text-amber-300">{{ t('waiting') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
37
packages/site/src/components/LangToggle.vue
Normal file
37
packages/site/src/components/LangToggle.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { setLocale, type AppLocale } from '../i18n';
|
||||
|
||||
const { locale } = useI18n();
|
||||
|
||||
function set(l: AppLocale): void {
|
||||
setLocale(l);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Language"
|
||||
class="flex items-center overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950/40"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:aria-pressed="locale === 'en'"
|
||||
class="cursor-pointer border-none px-[11px] py-1.5 font-mono text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-emerald-500/70"
|
||||
:class="locale === 'en' ? 'bg-zinc-800 text-emerald-400' : 'bg-transparent text-zinc-500'"
|
||||
@click="set('en')"
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-pressed="locale === 'fr'"
|
||||
class="cursor-pointer border-none px-[11px] py-1.5 font-mono text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-emerald-500/70"
|
||||
:class="locale === 'fr' ? 'bg-zinc-800 text-emerald-400' : 'bg-transparent text-zinc-500'"
|
||||
@click="set('fr')"
|
||||
>
|
||||
FR
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
14
packages/site/src/components/ProblemSection.vue
Normal file
14
packages/site/src/components/ProblemSection.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-reveal class="mx-auto max-w-[920px] px-6 pb-[92px] pt-12 text-center">
|
||||
<div class="mb-4 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('probKicker') }}</div>
|
||||
<h2 class="m-0 text-[clamp(28px,3.6vw,42px)] font-semibold leading-[1.18] tracking-[-0.025em] text-zinc-50">
|
||||
{{ t('probTitle') }}
|
||||
</h2>
|
||||
<p class="mx-auto mt-[22px] max-w-[640px] text-[clamp(17px,1.6vw,19px)] text-zinc-400">{{ t('probBody') }}</p>
|
||||
</section>
|
||||
</template>
|
||||
47
packages/site/src/components/SecuritySection.vue
Normal file
47
packages/site/src/components/SecuritySection.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="security" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||
<div
|
||||
v-reveal
|
||||
class="rounded-[18px] border border-emerald-500/[0.18] p-[clamp(28px,4vw,48px)]"
|
||||
style="background: linear-gradient(180deg, rgba(16, 185, 129, 0.05), rgba(16, 185, 129, 0))"
|
||||
>
|
||||
<div class="mb-[30px] flex flex-wrap items-end justify-between gap-3.5">
|
||||
<div>
|
||||
<div class="mb-3 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('secKicker') }}</div>
|
||||
<h2 class="m-0 text-[clamp(26px,3.2vw,38px)] font-semibold leading-[1.12] tracking-[-0.025em] text-zinc-50">
|
||||
{{ t('secTitle') }}
|
||||
</h2>
|
||||
</div>
|
||||
<p class="m-0 max-w-[380px] text-[15.5px] leading-[1.55] text-zinc-400">{{ t('secIntro') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-3.5">
|
||||
<div class="flex flex-col gap-2.5 rounded-[11px] border border-zinc-800 bg-[#0c0c0e] p-[18px]">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="11" x="3" y="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||
<div class="text-[15px] font-semibold text-zinc-50">{{ t('sec1Title') }}</div>
|
||||
<div class="text-[13.5px] leading-[1.5] text-zinc-400">{{ t('sec1Desc') }}</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5 rounded-[11px] border border-zinc-800 bg-[#0c0c0e] p-[18px]">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" /><path d="m21 2-9.6 9.6" /><circle cx="7.5" cy="15.5" r="5.5" /></svg>
|
||||
<div class="text-[15px] font-semibold text-zinc-50">{{ t('sec2Title') }}</div>
|
||||
<div class="text-[13.5px] leading-[1.5] text-zinc-400">{{ t('sec2Desc') }}</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5 rounded-[11px] border border-zinc-800 bg-[#0c0c0e] p-[18px]">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></svg>
|
||||
<div class="text-[15px] font-semibold text-zinc-50">{{ t('sec3Title') }}</div>
|
||||
<div class="text-[13.5px] leading-[1.5] text-zinc-400">{{ t('sec3Desc') }}</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5 rounded-[11px] border border-zinc-800 bg-[#0c0c0e] p-[18px]">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.55a11 11 0 0 1 14.08 0" /><path d="M1.42 9a16 16 0 0 1 21.16 0" /><path d="M8.53 16.11a6 6 0 0 1 6.95 0" /><path d="M12 20h.01" /></svg>
|
||||
<div class="text-[15px] font-semibold text-zinc-50">{{ t('sec4Title') }}</div>
|
||||
<div class="text-[13.5px] leading-[1.5] text-zinc-400">{{ t('sec4Desc') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
170
packages/site/src/components/ShowcaseSection.vue
Normal file
170
packages/site/src/components/ShowcaseSection.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="showcase" class="mx-auto flex max-w-[1200px] scroll-mt-[84px] flex-col gap-[92px] px-6">
|
||||
<!-- a) dashboard + needs attention -->
|
||||
<div v-reveal class="flex flex-wrap items-center gap-12">
|
||||
<div class="min-w-[280px] flex-[1_1_380px]">
|
||||
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('scAKicker') }}</div>
|
||||
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
||||
{{ t('scATitle') }}
|
||||
</h2>
|
||||
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('scABody') }}</p>
|
||||
</div>
|
||||
<div class="min-w-[280px] flex-[1_1_420px]">
|
||||
<div class="overflow-hidden rounded-xl border border-zinc-800 bg-[#0c0c0e] shadow-card">
|
||||
<div class="flex items-center gap-[9px] border-b border-[#1c1c1f] bg-[rgba(69,26,3,0.18)] px-[15px] py-3">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#fcd34d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
|
||||
<span class="text-sm font-semibold text-zinc-50">{{ t('mAttn') }}</span>
|
||||
<span class="ml-auto rounded-full bg-amber-300/[0.12] px-[9px] py-0.5 font-mono text-[11px] text-amber-300">2</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 p-2.5">
|
||||
<div class="flex items-center gap-3 rounded-[9px] border border-[rgba(120,53,15,0.55)] bg-[rgba(69,26,3,0.3)] px-[13px] py-[11px]">
|
||||
<span class="h-2 w-2 flex-none animate-pulse-amber rounded-full bg-amber-300"></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-mono text-[13px] text-zinc-200">api · feat/auth</div>
|
||||
<div class="text-xs text-[#d4a55a]">Approve change to login.ts?</div>
|
||||
</div>
|
||||
<button type="button" class="flex-none rounded-[7px] border border-amber-300/30 bg-amber-300/[0.12] px-3 py-[5px] text-xs font-semibold text-amber-300">Answer</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-[9px] border border-[rgba(120,53,15,0.55)] bg-[rgba(69,26,3,0.3)] px-[13px] py-[11px]">
|
||||
<span class="h-2 w-2 flex-none animate-pulse-amber rounded-full bg-amber-300"></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-mono text-[13px] text-zinc-200">web · checkout</div>
|
||||
<div class="text-xs text-[#d4a55a]">Run the failing test again?</div>
|
||||
</div>
|
||||
<button type="button" class="flex-none rounded-[7px] border border-amber-300/30 bg-amber-300/[0.12] px-3 py-[5px] text-xs font-semibold text-amber-300">Answer</button>
|
||||
</div>
|
||||
<div class="mx-0.5 my-[3px] h-px bg-[#1c1c1f]"></div>
|
||||
<div class="flex items-center gap-3 rounded-[9px] px-[13px] py-[9px]">
|
||||
<span class="h-2 w-2 flex-none animate-pulse-sky rounded-full bg-sky-300"></span>
|
||||
<div class="min-w-0 flex-1 font-mono text-[13px] text-zinc-400">db · refactor</div>
|
||||
<span class="flex-none font-mono text-[11px] text-sky-300">{{ t('busy') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-[9px] px-[13px] py-[9px]">
|
||||
<span class="h-2 w-2 flex-none rounded-full bg-emerald-400"></span>
|
||||
<div class="min-w-0 flex-1 font-mono text-[13px] text-zinc-400">docs · main</div>
|
||||
<span class="flex-none font-mono text-[11px] text-emerald-400">{{ t('idle') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- b) multi-terminal grid -->
|
||||
<div v-reveal class="flex flex-wrap-reverse items-center gap-12">
|
||||
<div class="min-w-[280px] flex-[1_1_420px]">
|
||||
<div class="grid grid-cols-2 gap-2.5 overflow-hidden rounded-xl border border-zinc-800 bg-[#0c0c0e] p-3 shadow-card">
|
||||
<div class="overflow-hidden rounded-[9px] border border-zinc-800 bg-zinc-950">
|
||||
<div class="flex items-center justify-between border-b border-[#1c1c1f] px-[11px] py-2">
|
||||
<span class="font-mono text-[11px] text-zinc-400">api · feat/auth</span>
|
||||
<span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-sky-300"></span>
|
||||
</div>
|
||||
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-zinc-300">
|
||||
<div class="text-emerald-400">● Edit session.ts</div>
|
||||
<div class="text-emerald-400">+ const token = sign(...)</div>
|
||||
<div class="text-red-400">- legacy cookie auth</div>
|
||||
<div class="text-zinc-500">refactoring guards…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-[9px] border border-zinc-800 bg-zinc-950">
|
||||
<div class="flex items-center justify-between border-b border-[#1c1c1f] px-[11px] py-2">
|
||||
<span class="font-mono text-[11px] text-zinc-400">web · fix/cart</span>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400"></span>
|
||||
</div>
|
||||
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-zinc-300">
|
||||
<div class="text-emerald-400">✓ all tests passing</div>
|
||||
<div class="text-zinc-500">24 passed, 0 failed</div>
|
||||
<div>
|
||||
<span class="text-emerald-400">$</span>
|
||||
<span class="ml-1 inline-block h-[11px] w-1.5 animate-blink align-middle bg-zinc-200"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-[9px] border border-zinc-800 bg-zinc-950">
|
||||
<div class="flex items-center justify-between border-b border-[#1c1c1f] px-[11px] py-2">
|
||||
<span class="font-mono text-[11px] text-zinc-400">db · refactor</span>
|
||||
<span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-sky-300"></span>
|
||||
</div>
|
||||
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-zinc-300">
|
||||
<div class="text-emerald-400">● Write migration</div>
|
||||
<div class="text-zinc-500">0003_add_index.sql</div>
|
||||
<div class="text-zinc-500">applying to schema…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-[9px] border border-zinc-800 bg-zinc-950">
|
||||
<div class="flex items-center justify-between border-b border-[#1c1c1f] px-[11px] py-2">
|
||||
<span class="font-mono text-[11px] text-zinc-400">docs · api</span>
|
||||
<span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-sky-300"></span>
|
||||
</div>
|
||||
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-zinc-300">
|
||||
<div class="text-emerald-400">● Read README.md</div>
|
||||
<div class="text-zinc-500">## Quick start</div>
|
||||
<div class="text-zinc-500">writing examples…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-[280px] flex-[1_1_380px]">
|
||||
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('scBKicker') }}</div>
|
||||
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
||||
{{ t('scBTitle') }}
|
||||
</h2>
|
||||
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('scBBody') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- c) mobile -->
|
||||
<div v-reveal class="flex flex-wrap items-center gap-12">
|
||||
<div class="min-w-[280px] flex-[1_1_380px]">
|
||||
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('scCKicker') }}</div>
|
||||
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
||||
{{ t('scCTitle') }}
|
||||
</h2>
|
||||
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('scCBody') }}</p>
|
||||
</div>
|
||||
<div class="flex min-w-[280px] flex-[1_1_320px] justify-center">
|
||||
<div class="relative w-[288px] overflow-hidden rounded-[40px] border-[9px] border-zinc-900 bg-zinc-950 shadow-phone">
|
||||
<div class="flex h-[30px] items-center justify-center">
|
||||
<div class="h-1.5 w-[90px] rounded-full bg-zinc-800"></div>
|
||||
</div>
|
||||
<div class="px-[13px] pb-[22px]">
|
||||
<div class="flex items-start gap-[11px] rounded-[15px] border border-[rgba(120,53,15,0.5)] bg-[#0c0c0e] p-[13px] shadow-toast-sm">
|
||||
<span class="inline-flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg bg-emerald-500/[0.14]">
|
||||
<img src="/assets/arboretum-mark.png" alt="" width="18" height="18" class="h-[18px] w-[18px] object-contain" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-mono text-[11px] text-emerald-400">Arboretum</span>
|
||||
<span class="text-[10px] text-zinc-600">now</span>
|
||||
</div>
|
||||
<div class="mt-[3px] text-[13.5px] font-semibold text-zinc-50">{{ t('toastTitle') }}</div>
|
||||
<div class="text-xs leading-[1.45] text-zinc-400">{{ t('toastBody') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-[14px] overflow-hidden rounded-[15px] border border-zinc-800 bg-zinc-950">
|
||||
<div class="flex items-center gap-2 border-b border-[#1c1c1f] px-[13px] py-[11px]">
|
||||
<span class="h-[7px] w-[7px] animate-pulse-amber rounded-full bg-amber-300"></span>
|
||||
<span class="font-mono text-[11px] text-zinc-300">feat/auth</span>
|
||||
</div>
|
||||
<div class="p-[13px]">
|
||||
<div class="text-[13px] leading-[1.5] text-zinc-300">{{ t('dlgQ') }}</div>
|
||||
<div class="mt-2 rounded-lg border border-[#1c1c1f] bg-[#0c0c0e] p-[9px] font-mono text-[11px] leading-[1.6] text-zinc-500">
|
||||
<div class="text-emerald-400">+ verifyToken(req)</div>
|
||||
<div class="text-red-400">- skipAuth()</div>
|
||||
</div>
|
||||
<div class="mt-[13px] flex gap-2">
|
||||
<button type="button" class="flex-1 rounded-lg bg-emerald-600 py-2.5 text-[13px] font-semibold text-white">{{ t('approve') }}</button>
|
||||
<button type="button" class="flex-1 rounded-lg border border-zinc-800 bg-transparent py-2.5 text-[13px] font-medium text-zinc-300">{{ t('reject') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
55
packages/site/src/components/WorkGroupsSection.vue
Normal file
55
packages/site/src/components/WorkGroupsSection.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 py-[92px]">
|
||||
<div
|
||||
v-reveal
|
||||
class="flex flex-wrap items-center gap-12 rounded-[18px] border border-zinc-800 p-[clamp(28px,4vw,52px)]"
|
||||
style="background: linear-gradient(180deg, rgba(24, 24, 27, 0.55), rgba(12, 12, 14, 0.55))"
|
||||
>
|
||||
<div class="min-w-[280px] flex-[1_1_360px]">
|
||||
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('grpKicker') }}</div>
|
||||
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,36px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
||||
{{ t('grpTitle') }}
|
||||
</h2>
|
||||
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('grpBody') }}</p>
|
||||
</div>
|
||||
<div class="flex min-w-[280px] flex-[1_1_360px] justify-center">
|
||||
<div class="flex w-full max-w-[380px] items-center">
|
||||
<div class="flex flex-none flex-col items-center gap-2">
|
||||
<div
|
||||
class="inline-flex h-[62px] w-[62px] items-center justify-center rounded-[15px] border border-emerald-500/35 bg-emerald-500/[0.12] text-emerald-400"
|
||||
>
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg>
|
||||
</div>
|
||||
<span class="font-mono text-[11px] text-zinc-300">{{ t('grpHub') }}</span>
|
||||
</div>
|
||||
<div class="relative h-[96px] flex-1">
|
||||
<svg width="100%" height="100%" viewBox="0 0 100 96" preserveAspectRatio="none" class="absolute inset-0" aria-hidden="true">
|
||||
<path d="M0 48 H40 V14 H100" fill="none" stroke="rgba(16,185,129,.45)" stroke-width="1.4" />
|
||||
<path d="M0 48 H40 V48 H100" fill="none" stroke="rgba(16,185,129,.45)" stroke-width="1.4" />
|
||||
<path d="M0 48 H40 V82 H100" fill="none" stroke="rgba(16,185,129,.45)" stroke-width="1.4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-none flex-col gap-2.5">
|
||||
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-[#0c0c0e] px-3 py-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-sky-300"></span>
|
||||
<span class="font-mono text-[11px] text-zinc-300">api</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-[#0c0c0e] px-3 py-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-sky-300"></span>
|
||||
<span class="font-mono text-[11px] text-zinc-300">web</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-[#0c0c0e] px-3 py-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-sky-300"></span>
|
||||
<span class="font-mono text-[11px] text-zinc-300">sdk</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
19
packages/site/src/components/icons/IconCheck.vue
Normal file
19
packages/site/src/components/icons/IconCheck.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 15 });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</template>
|
||||
20
packages/site/src/components/icons/IconCopy.vue
Normal file
20
packages/site/src/components/icons/IconCopy.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 15 });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect width="13" height="13" x="9" y="9" rx="2" />
|
||||
<path d="M5 15c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2h8c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
</template>
|
||||
22
packages/site/src/components/icons/IconGitea.vue
Normal file
22
packages/site/src/components/icons/IconGitea.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 16 });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 10h12v4a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4z" />
|
||||
<path d="M16 11h2.5a2.5 2.5 0 0 1 0 5H16" />
|
||||
<path d="M7 3c-.5.8.5 1.6 0 2.5" />
|
||||
<path d="M11 3c-.5.8.5 1.6 0 2.5" />
|
||||
</svg>
|
||||
</template>
|
||||
24
packages/site/src/composables/useCopy.ts
Normal file
24
packages/site/src/composables/useCopy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const INSTALL_COMMAND = 'npx @johanleroy/git-arboretum';
|
||||
|
||||
// Bouton « copier la commande » : copie + bascule le label 1800 ms (fidèle au design).
|
||||
export function useCopy() {
|
||||
const copied = ref(false);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function copy(text: string = INSTALL_COMMAND): Promise<void> {
|
||||
try {
|
||||
if (navigator.clipboard) await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
/* clipboard indisponible (contexte non sécurisé) */
|
||||
}
|
||||
copied.value = true;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
return { copied, copy };
|
||||
}
|
||||
113
packages/site/src/composables/useHeroTimeline.ts
Normal file
113
packages/site/src/composables/useHeroTimeline.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
// Portage 1:1 de la timeline animée du mockup hero (Arboretum.dc.html).
|
||||
export type SessionState = 'idle' | 'busy' | 'waiting';
|
||||
|
||||
export interface AnimLine {
|
||||
text: string;
|
||||
k: string;
|
||||
}
|
||||
|
||||
export interface HeroAnim {
|
||||
sess: SessionState;
|
||||
lines: AnimLine[];
|
||||
att: number;
|
||||
toast: boolean;
|
||||
dlg: boolean;
|
||||
hl: number;
|
||||
ans: number;
|
||||
cur: boolean;
|
||||
}
|
||||
|
||||
const TOTAL = 13000;
|
||||
const TICK = 80;
|
||||
|
||||
interface ScriptEntry {
|
||||
at: number;
|
||||
text: string;
|
||||
k: string;
|
||||
}
|
||||
|
||||
function script(): ScriptEntry[] {
|
||||
return [
|
||||
{ at: 850, text: '$ claude --resume feat/auth', k: 'cmd' },
|
||||
{ at: 1400, text: '> Reading src/auth/login.ts', k: 'tool' },
|
||||
{ at: 1950, text: '● Edit login.ts', k: 'edit' },
|
||||
{ at: 2400, text: " + import { verifyToken } from './jwt'", k: 'add' },
|
||||
{ at: 2850, text: ' + const user = await verifyToken(req)', k: 'add' },
|
||||
{ at: 3350, text: '● Bash npm test -- auth', k: 'edit' },
|
||||
{ at: 3850, text: ' ✓ 14 passed (1.24s)', k: 'ok' },
|
||||
{ at: 4300, text: '● Permission required to edit login.ts', k: 'warn' },
|
||||
{ at: 7950, text: '✓ approved · applying change', k: 'ok' },
|
||||
{ at: 8500, text: '● Edit login.ts', k: 'edit' },
|
||||
{ at: 9050, text: '● Running…', k: 'run' },
|
||||
];
|
||||
}
|
||||
|
||||
export function deriveAnim(clock: number): HeroAnim {
|
||||
const sess: SessionState = clock < 700 ? 'idle' : clock < 4500 ? 'busy' : clock < 7700 ? 'waiting' : 'busy';
|
||||
const sc = script();
|
||||
const lines: AnimLine[] = [];
|
||||
for (const entry of sc) {
|
||||
if (clock >= entry.at) lines.push({ text: entry.text, k: entry.k });
|
||||
}
|
||||
const att = clock >= 4500 && clock < 7700 ? 1 : 0;
|
||||
const toast = clock >= 4650 && clock < 7700;
|
||||
const dlg = clock >= 4600 && clock < 7700;
|
||||
const hl = clock >= 6000 && clock < 7050 ? 0 : -1;
|
||||
const ans = clock >= 7050 && clock < 7700 ? 0 : -1;
|
||||
const cur = sess !== 'waiting';
|
||||
return { sess, lines, att, toast, dlg, hl, ans, cur };
|
||||
}
|
||||
|
||||
export function useHeroTimeline(stageId = 'hero-stage') {
|
||||
const anim = ref<HeroAnim>(deriveAnim(0));
|
||||
let clock = 0;
|
||||
let visible = true;
|
||||
let everVisible = false;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let io: IntersectionObserver | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
let reduced = false;
|
||||
try {
|
||||
reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (reduced) {
|
||||
anim.value = deriveAnim(5200);
|
||||
return;
|
||||
}
|
||||
const stage = document.getElementById(stageId);
|
||||
if (stage && 'IntersectionObserver' in window) {
|
||||
io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const e = entries[0];
|
||||
if (!e) return;
|
||||
if (e.isIntersecting) {
|
||||
visible = true;
|
||||
everVisible = true;
|
||||
} else if (everVisible) {
|
||||
visible = false;
|
||||
}
|
||||
},
|
||||
{ threshold: 0, rootMargin: '200px 0px 200px 0px' },
|
||||
);
|
||||
io.observe(stage);
|
||||
}
|
||||
timer = setInterval(() => {
|
||||
if (!visible) return;
|
||||
clock += TICK;
|
||||
if (clock >= TOTAL) clock = 0;
|
||||
anim.value = deriveAnim(clock);
|
||||
}, TICK);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
if (io) io.disconnect();
|
||||
});
|
||||
|
||||
return { anim };
|
||||
}
|
||||
52
packages/site/src/composables/useScrollReveal.ts
Normal file
52
packages/site/src/composables/useScrollReveal.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Directive } from 'vue';
|
||||
|
||||
// Directive `v-reveal` : révèle un bloc quand il entre dans le viewport.
|
||||
// Reprend l'intention du setupReveal() du design (seuil ~92% de la hauteur),
|
||||
// via un IntersectionObserver partagé. Respecte prefers-reduced-motion.
|
||||
let reducedMotion = false;
|
||||
try {
|
||||
reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
} catch {
|
||||
/* matchMedia indisponible */
|
||||
}
|
||||
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
function getObserver(): IntersectionObserver | null {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
if (!observer) {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('is-visible');
|
||||
observer?.unobserve(entry.target);
|
||||
}
|
||||
}
|
||||
},
|
||||
// -8% en bas ≈ déclenche quand le haut du bloc franchit ~92% du viewport.
|
||||
{ threshold: 0, rootMargin: '0px 0px -8% 0px' },
|
||||
);
|
||||
}
|
||||
return observer;
|
||||
}
|
||||
|
||||
export const reveal: Directive<HTMLElement> = {
|
||||
mounted(el) {
|
||||
el.classList.add('reveal');
|
||||
if (reducedMotion) {
|
||||
el.classList.add('is-visible');
|
||||
return;
|
||||
}
|
||||
const io = getObserver();
|
||||
if (!io) {
|
||||
// Pas d'IntersectionObserver : on révèle immédiatement (pas de contenu caché).
|
||||
el.classList.add('is-visible');
|
||||
return;
|
||||
}
|
||||
io.observe(el);
|
||||
},
|
||||
unmounted(el) {
|
||||
observer?.unobserve(el);
|
||||
},
|
||||
};
|
||||
7
packages/site/src/env.d.ts
vendored
Normal file
7
packages/site/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
60
packages/site/src/i18n/content.ts
Normal file
60
packages/site/src/i18n/content.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Listes structurées (FAQ, options de dialogue) — gardées hors des messages
|
||||
// vue-i18n et indexées par locale, pour rester du texte pur typé (pas d'innerHTML).
|
||||
import type { AppLocale } from './index';
|
||||
|
||||
export interface FaqItem {
|
||||
q: string;
|
||||
a: string;
|
||||
}
|
||||
|
||||
export const FAQS: Record<AppLocale, FaqItem[]> = {
|
||||
en: [
|
||||
{
|
||||
q: 'Is Arboretum an IDE?',
|
||||
a: "No — it's a command center. It doesn't replace your editor; it supervises the AI coding agents working across your repos: spawning sessions, surfacing their state, and letting you answer them.",
|
||||
},
|
||||
{
|
||||
q: 'Does it need the cloud?',
|
||||
a: 'No. Arboretum is local-first. The daemon runs on your own machine and binds to 127.0.0.1 by default. Your code and sessions never leave your network unless you choose to expose the dashboard.',
|
||||
},
|
||||
{
|
||||
q: 'How do I access it remotely?',
|
||||
a: 'Through Tailscale Serve. Expose the dashboard to your tailnet and reach it securely from any device — no port forwarding, no public endpoint.',
|
||||
},
|
||||
{
|
||||
q: 'Does it work on mobile?',
|
||||
a: 'Yes. The dashboard is an installable PWA. Add it to your home screen, get Web Push the moment a session needs you, and approve or reject a prompt with a single tap.',
|
||||
},
|
||||
{
|
||||
q: 'Is my code safe?',
|
||||
a: 'Arboretum binds to localhost, hashes access tokens at rest, and enforces strict origin checks on every request. Remote access runs through Tailscale rather than an open port.',
|
||||
},
|
||||
],
|
||||
fr: [
|
||||
{
|
||||
q: 'Arboretum est-il un IDE ?',
|
||||
a: "Non — c'est un poste de commandement. Il ne remplace pas votre éditeur ; il supervise les agents de code IA qui travaillent sur vos repos : lancement des sessions, affichage de leur état, et réponse à leurs questions.",
|
||||
},
|
||||
{
|
||||
q: 'A-t-il besoin du cloud ?',
|
||||
a: 'Non. Arboretum est local-first. Le daemon tourne sur votre propre machine et se lie à 127.0.0.1 par défaut. Votre code et vos sessions ne quittent jamais votre réseau sauf si vous exposez le dashboard.',
|
||||
},
|
||||
{
|
||||
q: 'Comment y accéder à distance ?',
|
||||
a: "Via Tailscale Serve. Exposez le dashboard à votre tailnet et accédez-y en sécurité depuis n'importe quel appareil — sans redirection de port ni endpoint public.",
|
||||
},
|
||||
{
|
||||
q: 'Fonctionne-t-il sur mobile ?',
|
||||
a: "Oui. Le dashboard est une PWA installable. Ajoutez-le à l'écran d'accueil, recevez un Web Push dès qu'une session a besoin de vous, et approuvez ou refusez une question d'un toucher.",
|
||||
},
|
||||
{
|
||||
q: 'Mon code est-il en sécurité ?',
|
||||
a: "Arboretum se lie à localhost, hashe les tokens au repos, et applique une vérification d'origine stricte sur chaque requête. L'accès distant passe par Tailscale plutôt qu'un port ouvert.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const DLG_OPTS: Record<AppLocale, string[]> = {
|
||||
en: ['Yes, apply the change', "Yes, and don't ask again", 'No, keep current version'],
|
||||
fr: ['Oui, appliquer le changement', 'Oui, et ne plus demander', 'Non, garder la version actuelle'],
|
||||
};
|
||||
96
packages/site/src/i18n/en.ts
Normal file
96
packages/site/src/i18n/en.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Messages EN — copie fidèle du dict() du design Arboretum.dc.html.
|
||||
export default {
|
||||
navFeatures: 'Features',
|
||||
navHow: 'How it works',
|
||||
navSecurity: 'Security',
|
||||
navFaq: 'FAQ',
|
||||
gitea: 'View on Gitea',
|
||||
heroBadge: 'Mission control for AI coding agents',
|
||||
heroTitle: 'Mission control for your AI coding agents',
|
||||
heroSub:
|
||||
'Run many Claude Code sessions across every git worktree — and supervise them from any device, even your phone.',
|
||||
getStarted: 'Get started',
|
||||
probKicker: 'The problem',
|
||||
probTitle: "Many agents across worktrees is chaos — and you're chained to one terminal.",
|
||||
probBody:
|
||||
'Sessions scattered across tabs. No idea which agent is waiting on you. Step away from the desk and everything stalls. Arboretum is the single pane of glass.',
|
||||
featKicker: 'Everything in one view',
|
||||
featTitle: 'Built to supervise agents — not to be an IDE',
|
||||
feat1Title: 'Worktree-first',
|
||||
feat1Desc: 'Every session is pinned to a git worktree. You see branches, not chaos.',
|
||||
feat2Title: 'Many sessions, one view',
|
||||
feat2Desc: 'Run parallel Claude Code agents side by side in a single live grid.',
|
||||
feat3Title: 'Fine-grained states',
|
||||
feat3Desc: 'tell at a glance what each agent is doing.',
|
||||
feat4Title: 'Supervise anywhere',
|
||||
feat4Desc: 'Installable PWA. Check in from your laptop, tablet, or phone.',
|
||||
feat5Title: 'Web Push alerts',
|
||||
feat5Desc: 'Get pinged the moment a session needs a decision from you.',
|
||||
feat6Title: 'Answer without a terminal',
|
||||
feat6Desc: 'Respond to agent prompts and dialogs right from the dashboard.',
|
||||
feat7Title: 'Work groups',
|
||||
feat7Desc: 'Group repos and run one Claude session across all of them at once.',
|
||||
feat8Title: 'Local-first & secure',
|
||||
feat8Desc: 'Binds to localhost, hashes tokens, remote access via Tailscale.',
|
||||
scAKicker: 'Worktree-first dashboard',
|
||||
scATitle: 'See what needs you — before anything stalls',
|
||||
scABody:
|
||||
'A live list of every worktree and its session state. The Needs attention rail floats the agents waiting on a decision to the top, so nothing sits blocked while you are heads-down elsewhere.',
|
||||
scBKicker: 'Multi-terminal grid',
|
||||
scBTitle: "Every agent's output, streaming live",
|
||||
scBBody:
|
||||
'Watch all your sessions at once — real terminal output, color-coded by state, updating in real time. Click any pane to take over, answer a prompt, or stop a run.',
|
||||
scCKicker: 'On your phone',
|
||||
scCTitle: 'Unblock an agent from the kitchen',
|
||||
scCBody:
|
||||
'Install the PWA and get a Web Push the second a session needs you. Tap the notification, read the prompt, answer the dialog — your agent keeps moving while you are away.',
|
||||
grpHub: '1 session',
|
||||
grpKicker: 'Work groups · cross-repo',
|
||||
grpTitle: 'Drive every repo from one Claude session',
|
||||
grpBody:
|
||||
'Group the repos that move together — a service, its client, its docs. Launch a single Claude session that spans all of them at once: one conversation, one shared context working across every repo — instead of juggling one agent per repo.',
|
||||
howKicker: 'How it works',
|
||||
howTitle: 'Zero install. Three steps.',
|
||||
step1Title: 'Launch the daemon',
|
||||
step1Desc: 'One command. No install, no config, no account.',
|
||||
step2Title: 'Open the dashboard',
|
||||
step2Desc: 'It serves a web dashboard on localhost. Install it as a PWA on any device.',
|
||||
step3Title: 'Spawn & supervise',
|
||||
step3Desc: 'Start Claude Code sessions on any worktree and watch them from anywhere.',
|
||||
secKicker: 'Security',
|
||||
secTitle: 'A web terminal you can actually trust',
|
||||
secIntro: 'Built local-first. Nothing is exposed unless you choose to — and even then, on your terms.',
|
||||
sec1Title: 'Localhost by default',
|
||||
sec1Desc: 'Binds to 127.0.0.1. Nothing leaves your machine unless you say so.',
|
||||
sec2Title: 'Hashed tokens',
|
||||
sec2Desc: 'Access tokens are hashed (sha256) at rest. No plaintext secrets on disk.',
|
||||
sec3Title: 'Strict origin checks',
|
||||
sec3Desc: 'Every request is origin-validated. No cross-site surprises.',
|
||||
sec4Title: 'Remote via Tailscale',
|
||||
sec4Desc: 'Reach it over Tailscale Serve — no public port, no port forwarding.',
|
||||
faqTitle: 'Questions, answered',
|
||||
ctaTitle: 'Take command of your agents',
|
||||
ctaBody: 'One command to launch. Supervise everything from one place.',
|
||||
ctaSource: 'View the source on Gitea',
|
||||
footTag: 'a garden of branches, one pane of glass.',
|
||||
license: 'MIT License',
|
||||
coffee: 'Buy me a coffee',
|
||||
mDash: 'Dashboard',
|
||||
mGroups: 'Groups',
|
||||
mSettings: 'Settings',
|
||||
mHelp: 'Help',
|
||||
mSearch: 'Search',
|
||||
mAttn: 'Needs attention',
|
||||
waiting: 'waiting',
|
||||
busy: 'busy',
|
||||
idle: 'idle',
|
||||
approve: 'Approve',
|
||||
reject: 'Reject',
|
||||
toastTitle: 'Session waiting for you',
|
||||
toastBody: 'api · feat/auth needs a decision',
|
||||
dlgQ: 'Apply this change to login.ts?',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
copyCommand: 'Copy command',
|
||||
copyUrl: 'Copy URL',
|
||||
};
|
||||
96
packages/site/src/i18n/fr.ts
Normal file
96
packages/site/src/i18n/fr.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Messages FR — copie fidèle du dict() du design Arboretum.dc.html.
|
||||
export default {
|
||||
navFeatures: 'Fonctionnalités',
|
||||
navHow: 'Comment ça marche',
|
||||
navSecurity: 'Sécurité',
|
||||
navFaq: 'FAQ',
|
||||
gitea: 'Voir sur Gitea',
|
||||
heroBadge: 'Poste de commandement pour agents de code IA',
|
||||
heroTitle: 'Le poste de commandement de vos agents de code IA',
|
||||
heroSub:
|
||||
"Lancez plusieurs sessions Claude Code à travers chaque worktree git — et supervisez-les depuis n'importe quel appareil, même votre téléphone.",
|
||||
getStarted: 'Commencer',
|
||||
probKicker: 'Le problème',
|
||||
probTitle: "Plein d'agents à travers les worktrees, c'est le chaos — et vous êtes cloué à un seul terminal.",
|
||||
probBody:
|
||||
"Des sessions éparpillées dans des onglets. Aucune idée de quel agent vous attend. Vous quittez le bureau et tout se fige. Arboretum, c'est la vitre unique.",
|
||||
featKicker: 'Tout dans une seule vue',
|
||||
featTitle: 'Conçu pour superviser les agents — pas pour être un IDE',
|
||||
feat1Title: "Worktree d'abord",
|
||||
feat1Desc: 'Chaque session est rattachée à un worktree git. Vous voyez des branches, pas du chaos.',
|
||||
feat2Title: 'Plusieurs sessions, une vue',
|
||||
feat2Desc: 'Lancez des agents Claude Code en parallèle, côte à côte dans une seule grille en direct.',
|
||||
feat3Title: 'États fins',
|
||||
feat3Desc: "sachez d'un coup d'œil ce que fait chaque agent.",
|
||||
feat4Title: 'Supervisez partout',
|
||||
feat4Desc: "PWA installable. Surveillez depuis l'ordinateur, la tablette ou le téléphone.",
|
||||
feat5Title: 'Alertes Web Push',
|
||||
feat5Desc: "Soyez prévenu dès qu'une session attend une décision.",
|
||||
feat6Title: 'Répondre sans terminal',
|
||||
feat6Desc: 'Répondez aux questions et dialogues des agents directement depuis le dashboard.',
|
||||
feat7Title: 'Work groups',
|
||||
feat7Desc: 'Regroupez des repos et pilotez-les via une seule session Claude, tous à la fois.',
|
||||
feat8Title: 'Local-first & sécurisé',
|
||||
feat8Desc: 'Se lie à localhost, hashe les tokens, accès distant via Tailscale.',
|
||||
scAKicker: 'Dashboard worktree-first',
|
||||
scATitle: 'Voyez ce qui vous attend — avant que ça ne bloque',
|
||||
scABody:
|
||||
"Une liste en direct de chaque worktree et de l'état de sa session. Le bandeau À traiter fait remonter les agents en attente d'une décision, pour que rien ne reste bloqué pendant que vous êtes concentré ailleurs.",
|
||||
scBKicker: 'Grille multi-terminaux',
|
||||
scBTitle: 'La sortie de chaque agent, en direct',
|
||||
scBBody:
|
||||
"Surveillez toutes vos sessions en même temps — vraie sortie terminal, couleur selon l'état, mise à jour en temps réel. Cliquez sur un panneau pour reprendre la main, répondre, ou arrêter une exécution.",
|
||||
scCKicker: 'Sur votre téléphone',
|
||||
scCTitle: 'Débloquez un agent depuis la cuisine',
|
||||
scCBody:
|
||||
"Installez la PWA et recevez un Web Push dès qu'une session a besoin de vous. Touchez la notification, lisez la question, répondez au dialogue — votre agent continue pendant que vous êtes loin du bureau.",
|
||||
grpHub: '1 session',
|
||||
grpKicker: 'Work groups · cross-repo',
|
||||
grpTitle: 'Pilotez tous vos repos depuis une seule session Claude',
|
||||
grpBody:
|
||||
'Regroupez les repos qui avancent ensemble — un service, son client, sa doc. Lancez une seule session Claude qui les couvre tous à la fois : une conversation, un contexte partagé travaillant à travers chaque repo — au lieu de jongler avec un agent par repo.',
|
||||
howKicker: 'Comment ça marche',
|
||||
howTitle: 'Zéro install. Trois étapes.',
|
||||
step1Title: 'Lancez le daemon',
|
||||
step1Desc: "Une commande. Pas d'install, pas de config, pas de compte.",
|
||||
step2Title: 'Ouvrez le dashboard',
|
||||
step2Desc: "Il sert un dashboard web sur localhost. Installez-le en PWA sur n'importe quel appareil.",
|
||||
step3Title: 'Lancez & supervisez',
|
||||
step3Desc: "Démarrez des sessions Claude Code sur n'importe quel worktree et surveillez-les de partout.",
|
||||
secKicker: 'Sécurité',
|
||||
secTitle: 'Un terminal web en qui vous pouvez avoir confiance',
|
||||
secIntro: "Conçu local-first. Rien n'est exposé sauf si vous le décidez — et toujours selon vos règles.",
|
||||
sec1Title: 'Localhost par défaut',
|
||||
sec1Desc: 'Se lie à 127.0.0.1. Rien ne quitte votre machine sans votre accord.',
|
||||
sec2Title: 'Tokens hashés',
|
||||
sec2Desc: 'Les tokens sont hashés (sha256) au repos. Aucun secret en clair sur le disque.',
|
||||
sec3Title: "Vérification d'origine stricte",
|
||||
sec3Desc: 'Chaque requête est validée par origine. Pas de surprise cross-site.',
|
||||
sec4Title: 'Distant via Tailscale',
|
||||
sec4Desc: 'Accédez-y via Tailscale Serve — aucun port public, aucune redirection de port.',
|
||||
faqTitle: 'Vos questions, nos réponses',
|
||||
ctaTitle: 'Prenez le commandement de vos agents',
|
||||
ctaBody: 'Une commande pour lancer. Tout superviser depuis un seul endroit.',
|
||||
ctaSource: 'Voir le code sur Gitea',
|
||||
footTag: 'un jardin de branches, une seule vitre.',
|
||||
license: 'Licence MIT',
|
||||
coffee: 'Paye-moi un café',
|
||||
mDash: 'Tableau de bord',
|
||||
mGroups: 'Groupes',
|
||||
mSettings: 'Réglages',
|
||||
mHelp: 'Aide',
|
||||
mSearch: 'Rechercher',
|
||||
mAttn: 'À traiter',
|
||||
waiting: 'en attente',
|
||||
busy: 'occupé',
|
||||
idle: 'au repos',
|
||||
approve: 'Approuver',
|
||||
reject: 'Refuser',
|
||||
toastTitle: 'Une session vous attend',
|
||||
toastBody: 'api · feat/auth attend une décision',
|
||||
dlgQ: 'Appliquer ce changement à login.ts ?',
|
||||
copy: 'Copier',
|
||||
copied: 'Copié',
|
||||
copyCommand: 'Copier la commande',
|
||||
copyUrl: "Copier l'URL",
|
||||
};
|
||||
37
packages/site/src/i18n/index.ts
Normal file
37
packages/site/src/i18n/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from './en';
|
||||
import fr from './fr';
|
||||
|
||||
export type AppLocale = 'en' | 'fr';
|
||||
|
||||
const STORAGE_KEY = 'arb-lang';
|
||||
|
||||
// Détection fidèle au design : localStorage prioritaire, sinon langue navigateur
|
||||
// (fr → FR, sinon EN).
|
||||
function initialLocale(): AppLocale {
|
||||
let saved: string | null = null;
|
||||
try {
|
||||
saved = localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* localStorage indisponible (mode privé, etc.) */
|
||||
}
|
||||
if (saved === 'fr' || saved === 'en') return saved;
|
||||
const nav = (typeof navigator !== 'undefined' && navigator.language ? navigator.language : '').toLowerCase();
|
||||
return nav.indexOf('fr') === 0 ? 'fr' : 'en';
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: initialLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages: { en, fr },
|
||||
});
|
||||
|
||||
export function setLocale(locale: AppLocale): void {
|
||||
i18n.global.locale.value = locale;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, locale);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
6
packages/site/src/lib/links.ts
Normal file
6
packages/site/src/lib/links.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// URLs externes du site vitrine — source unique de vérité.
|
||||
// Le dépôt Gitea est « arboretum » (et non « git-arboretum », qui est le nom du
|
||||
// paquet npm `@johanleroy/git-arboretum`). Centralisé ici pour ne plus dériver.
|
||||
export const REPO = 'https://git.lidge.fr/johanleroy/arboretum';
|
||||
export const LICENSE = `${REPO}/src/branch/main/LICENSE`;
|
||||
export const COFFEE = 'https://buymeacoffee.com/johanleroy';
|
||||
12
packages/site/src/main.ts
Normal file
12
packages/site/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue';
|
||||
// Polices self-host (subset latin) — bundlées par Vite, aucune requête Google Fonts.
|
||||
import '@fontsource/jetbrains-mono/latin-400.css';
|
||||
import '@fontsource/jetbrains-mono/latin-500.css';
|
||||
import '@fontsource/jetbrains-mono/latin-600.css';
|
||||
import '@fontsource/jetbrains-mono/latin-700.css';
|
||||
import App from './App.vue';
|
||||
import { i18n } from './i18n';
|
||||
import { reveal } from './composables/useScrollReveal';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).use(i18n).directive('reveal', reveal).mount('#app');
|
||||
120
packages/site/src/style.css
Normal file
120
packages/site/src/style.css
Normal file
@@ -0,0 +1,120 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* La palette du design correspond à zinc/emerald/amber/sky/red de Tailwind.
|
||||
On ajoute seulement les tokens hors échelle : ombres, police mono, animations. */
|
||||
@theme {
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
--shadow-hero: 0 30px 80px -30px rgb(0 0 0 / 0.85);
|
||||
--shadow-card: 0 24px 60px -30px rgb(0 0 0 / 0.8);
|
||||
--shadow-phone: 0 30px 70px -28px rgb(0 0 0 / 0.9);
|
||||
--shadow-toast: 0 12px 32px -12px rgb(0 0 0 / 0.7);
|
||||
--shadow-toast-sm: 0 10px 24px -12px rgb(0 0 0 / 0.6);
|
||||
|
||||
--animate-blink: blink 1.05s steps(1) infinite;
|
||||
--animate-pulse-sky: pulseSky 2s ease-in-out infinite;
|
||||
--animate-pulse-amber: pulseAmber 2.2s ease-in-out infinite;
|
||||
--animate-toast-in: toastIn 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
--animate-dlg-in: dlgIn 0.35s ease both;
|
||||
}
|
||||
|
||||
/* JetBrains Mono (Open Font License) : @font-face fournis par @fontsource,
|
||||
importés dans main.ts et bundlés par Vite (self-host, pas de CDN Google). */
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #09090b;
|
||||
color: #f4f4f5;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
::selection {
|
||||
background: rgba(16, 185, 129, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
48% {
|
||||
opacity: 1;
|
||||
}
|
||||
49%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes pulseSky {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(125, 211, 252, 0.55);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 6px rgba(125, 211, 252, 0);
|
||||
}
|
||||
}
|
||||
@keyframes pulseAmber {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(252, 211, 77, 0.55);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 6px rgba(252, 211, 77, 0);
|
||||
}
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes dlgIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/* Tailwind v4 ne met plus cursor:pointer sur les boutons → on le rétablit. */
|
||||
button:not(:disabled),
|
||||
[role='button']:not([aria-disabled='true']) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* Révélation au scroll (directive v-reveal) */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(22px);
|
||||
transition:
|
||||
opacity 0.7s ease,
|
||||
transform 0.7s ease;
|
||||
}
|
||||
.reveal.is-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
}
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
16
packages/site/tsconfig.json
Normal file
16
packages/site/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"noEmit": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
15
packages/site/vite.config.ts
Normal file
15
packages/site/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// Site vitrine statique (git-arboretum.com) : aucune dépendance au daemon, donc
|
||||
// pas de proxy /api /ws (contrairement à packages/web). `base: '/'` car servi
|
||||
// à la racine d'un domaine dédié.
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
base: '/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
target: 'es2020',
|
||||
},
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<header class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-zinc-100">{{ t('crossRepo.title') }}</h2>
|
||||
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
|
||||
</header>
|
||||
<p class="text-xs text-zinc-500">{{ t('crossRepo.intro') }}</p>
|
||||
|
||||
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('crossRepo.branchLabel') }}
|
||||
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" required />
|
||||
</label>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="flex items-center gap-1.5 text-xs text-zinc-400">
|
||||
<input v-model="newBranch" type="checkbox" /> {{ t('crossRepo.newBranch') }}
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('crossRepo.baseRefLabel') }}
|
||||
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('crossRepo.startLabel') }}
|
||||
<select v-model="startSession" class="input">
|
||||
<option :value="null">{{ t('crossRepo.startNone') }}</option>
|
||||
<option value="claude">claude</option>
|
||||
<option value="bash">bash</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('crossRepo.reposLabel') }}
|
||||
<div class="flex flex-col gap-1">
|
||||
<label
|
||||
v-for="repo in repos"
|
||||
:key="repo.id"
|
||||
class="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||
>
|
||||
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" :disabled="running" />
|
||||
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
|
||||
<span v-if="results[repo.id]" class="text-[11px]" :class="statusClass(repo.id)">
|
||||
{{ statusText(repo.id) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="submit" class="btn-primary" :disabled="running || branch.trim() === '' || selectedRepoIds.length === 0">
|
||||
{{ running ? t('crossRepo.creating') : t('crossRepo.create', { n: selectedRepoIds.length }) }}
|
||||
</button>
|
||||
<button v-if="hasFailures && !running" type="button" class="btn text-xs" @click="retryFailed">
|
||||
{{ t('crossRepo.retryFailed') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { RepoSummary } from '@arboretum/shared';
|
||||
import { useGroupsStore, type CrossRepoResult } from '../stores/groups';
|
||||
|
||||
const props = defineProps<{ repos: RepoSummary[] }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const groups = useGroupsStore();
|
||||
|
||||
const branch = ref('');
|
||||
const newBranch = ref(true);
|
||||
const baseRef = ref('');
|
||||
const startSession = ref<'claude' | 'bash' | null>(null);
|
||||
const selectedRepoIds = ref<string[]>(props.repos.map((r) => r.id));
|
||||
const results = ref<Record<string, CrossRepoResult>>({});
|
||||
const running = ref(false);
|
||||
|
||||
const hasFailures = computed(() => Object.values(results.value).some((r) => r.status === 'error'));
|
||||
|
||||
function statusClass(repoId: string): string {
|
||||
return results.value[repoId]?.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
|
||||
}
|
||||
function statusText(repoId: string): string {
|
||||
const r = results.value[repoId];
|
||||
if (!r) return '';
|
||||
return r.status === 'ok' ? t('crossRepo.ok') : `${t('crossRepo.error')}: ${r.message ?? ''}`;
|
||||
}
|
||||
|
||||
async function run(repoIds: string[]): Promise<void> {
|
||||
if (repoIds.length === 0) return;
|
||||
running.value = true;
|
||||
for (const id of repoIds) delete results.value[id];
|
||||
try {
|
||||
await groups.createCrossRepoFeature(
|
||||
repoIds,
|
||||
{
|
||||
branch: branch.value.trim(),
|
||||
newBranch: newBranch.value,
|
||||
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
|
||||
startSession: startSession.value,
|
||||
},
|
||||
(result) => {
|
||||
results.value = { ...results.value, [result.repoId]: result };
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
running.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(): void {
|
||||
void run([...selectedRepoIds.value]);
|
||||
}
|
||||
function retryFailed(): void {
|
||||
void run(Object.values(results.value).filter((r) => r.status === 'error').map((r) => r.repoId));
|
||||
}
|
||||
</script>
|
||||
148
packages/web/src/components/GroupSessionModal.vue
Normal file
148
packages/web/src/components/GroupSessionModal.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<header class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-zinc-100">{{ t('crossRepo.title') }}</h2>
|
||||
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
|
||||
</header>
|
||||
<p class="text-xs text-zinc-500">{{ t('crossRepo.intro') }}</p>
|
||||
|
||||
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||
<!-- mode : couvrir une nouvelle branche par repo, ou les checkouts principaux -->
|
||||
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('crossRepo.modeLabel') }}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input v-model="mode" type="radio" value="feature" :disabled="running" /> {{ t('crossRepo.modeFeature') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input v-model="mode" type="radio" value="main" :disabled="running" /> {{ t('crossRepo.modeMain') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mode feature : branche commune créée dans chaque repo -->
|
||||
<template v-if="mode === 'feature'">
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('crossRepo.branchLabel') }}
|
||||
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" :required="mode === 'feature'" />
|
||||
</label>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="flex items-center gap-1.5 text-xs text-zinc-400">
|
||||
<input v-model="newBranch" type="checkbox" /> {{ t('crossRepo.newBranch') }}
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('crossRepo.baseRefLabel') }}
|
||||
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('crossRepo.commandLabel') }}
|
||||
<select v-model="command" class="input">
|
||||
<option value="claude">claude</option>
|
||||
<option value="bash">bash</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('crossRepo.reposLabel') }}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="repo in repos"
|
||||
:key="repo.id"
|
||||
class="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||
>
|
||||
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
|
||||
<span v-if="repoStatus(repo.id)" class="text-[11px]" :class="repoStatusClass(repo.id)">
|
||||
{{ repoStatus(repo.id) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="outcome" class="text-xs" :class="outcome.session ? 'text-emerald-400' : 'text-red-400'">
|
||||
{{ outcome.session ? t('crossRepo.done') : t('crossRepo.sessionFailed') }}
|
||||
</p>
|
||||
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="submit" class="btn-primary" :disabled="running || (mode === 'feature' && branch.trim() === '') || repos.length === 0">
|
||||
{{ running ? t('crossRepo.launching') : t('crossRepo.launch') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { RepoSummary } from '@arboretum/shared';
|
||||
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
|
||||
|
||||
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const groups = useGroupsStore();
|
||||
|
||||
const mode = ref<'feature' | 'main'>('feature');
|
||||
const branch = ref('');
|
||||
const newBranch = ref(true);
|
||||
const baseRef = ref('');
|
||||
const command = ref<'claude' | 'bash'>('claude');
|
||||
const wtResults = ref<Record<string, CrossRepoResult>>({});
|
||||
const outcome = ref<GroupFeatureOutcome | null>(null);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
const running = ref(false);
|
||||
|
||||
const skippedById = computed<Record<string, string>>(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const s of outcome.value?.skipped ?? []) m[s.repoId] = s.reason;
|
||||
return m;
|
||||
});
|
||||
|
||||
function repoStatus(repoId: string): string {
|
||||
const wt = wtResults.value[repoId];
|
||||
if (wt) return wt.status === 'ok' ? t('crossRepo.ok') : `${t('crossRepo.error')}: ${wt.message ?? ''}`;
|
||||
const skip = skippedById.value[repoId];
|
||||
if (skip) return `${t('crossRepo.skippedLabel')}: ${skip}`;
|
||||
return '';
|
||||
}
|
||||
function repoStatusClass(repoId: string): string {
|
||||
const wt = wtResults.value[repoId];
|
||||
if (wt) return wt.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
|
||||
return skippedById.value[repoId] ? 'text-amber-400' : '';
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (running.value || props.repos.length === 0) return;
|
||||
if (mode.value === 'feature' && branch.value.trim() === '') return;
|
||||
running.value = true;
|
||||
wtResults.value = {};
|
||||
outcome.value = null;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
outcome.value = await groups.createGroupFeature(
|
||||
props.groupId,
|
||||
props.repos.map((r) => r.id),
|
||||
{
|
||||
command: command.value,
|
||||
...(mode.value === 'feature'
|
||||
? { branch: branch.value.trim(), newBranch: newBranch.value, ...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}) }
|
||||
: {}),
|
||||
},
|
||||
(result) => {
|
||||
wtResults.value = { ...wtResults.value, [result.repoId]: result };
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
running.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -4,6 +4,13 @@
|
||||
<header class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
|
||||
<SessionStateBadge :session="session" />
|
||||
<span class="truncate font-mono text-xs text-zinc-300" :title="session.cwd">{{ title }}</span>
|
||||
<span
|
||||
v-if="spannedCount > 1"
|
||||
class="shrink-0 rounded-full border border-sky-800/60 bg-sky-950/40 px-1.5 py-0.5 text-[10px] text-sky-300"
|
||||
:title="spannedDirs.join('\n')"
|
||||
>
|
||||
{{ t('groups.spanBadge', spannedCount) }}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
|
||||
</header>
|
||||
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
|
||||
@@ -15,6 +22,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import SessionStateBadge from './SessionStateBadge.vue';
|
||||
@@ -23,6 +31,11 @@ import TerminalView from './TerminalView.vue';
|
||||
|
||||
const props = defineProps<{ session: SessionSummary }>();
|
||||
const worktrees = useWorktreesStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
// session de groupe (P6) : couvre plusieurs répertoires via --add-dir → badge « groupe · N dépôts ».
|
||||
const spannedDirs = computed(() => [props.session.cwd, ...(props.session.addedDirs ?? [])]);
|
||||
const spannedCount = computed(() => spannedDirs.value.length);
|
||||
|
||||
// libellé : « repo · branche » si le worktree est connu, sinon le dernier segment du cwd.
|
||||
const title = computed(() => {
|
||||
|
||||
@@ -96,25 +96,29 @@ export default {
|
||||
viewGrid: 'Terminals',
|
||||
gridEmpty: 'No active session in this group.',
|
||||
gridCapped: 'Showing {shown} of {total} sessions — close some terminals to see the rest.',
|
||||
newFeature: 'New cross-repo feature',
|
||||
newFeature: 'New group session',
|
||||
spanBadge: 'group · {n} repo | group · {n} repos',
|
||||
},
|
||||
crossRepo: {
|
||||
title: 'New cross-repo feature',
|
||||
intro: 'Create the same worktree across the selected repos, in one action.',
|
||||
title: 'New group session',
|
||||
intro: 'Launch one Claude session that spans every repo in this group — a single conversation across all of them.',
|
||||
modeLabel: 'Directories',
|
||||
modeFeature: 'New branch in each repo',
|
||||
modeMain: 'Main checkouts',
|
||||
branchLabel: 'Branch name',
|
||||
branchPlaceholder: 'feature/…',
|
||||
newBranch: 'create branch',
|
||||
baseRefLabel: 'Base ref (optional)',
|
||||
startLabel: 'Start session',
|
||||
startNone: 'no session',
|
||||
reposLabel: 'Apply to',
|
||||
create: 'Create across {n} repos',
|
||||
creating: 'Creating…',
|
||||
retryFailed: 'Retry failed',
|
||||
commandLabel: 'Session',
|
||||
reposLabel: 'Repos in this session',
|
||||
launch: 'Launch session',
|
||||
launching: 'Launching…',
|
||||
close: 'Close',
|
||||
pending: 'pending…',
|
||||
ok: 'created',
|
||||
ok: 'worktree created',
|
||||
error: 'failed',
|
||||
skippedLabel: 'skipped',
|
||||
done: 'Group session started.',
|
||||
sessionFailed: 'Could not start the session.',
|
||||
},
|
||||
repos: {
|
||||
add: 'Add repo',
|
||||
|
||||
@@ -98,25 +98,29 @@ const fr: typeof en = {
|
||||
viewGrid: 'Terminaux',
|
||||
gridEmpty: 'Aucune session active dans ce groupe.',
|
||||
gridCapped: '{shown} sessions affichées sur {total} — fermez des terminaux pour voir le reste.',
|
||||
newFeature: 'Nouvelle feature cross-repo',
|
||||
newFeature: 'Nouvelle session de groupe',
|
||||
spanBadge: 'groupe · {n} dépôt | groupe · {n} dépôts',
|
||||
},
|
||||
crossRepo: {
|
||||
title: 'Nouvelle feature cross-repo',
|
||||
intro: 'Crée le même worktree dans les dépôts sélectionnés, en une seule action.',
|
||||
title: 'Nouvelle session de groupe',
|
||||
intro: 'Lance UNE session Claude couvrant tous les dépôts du groupe — une seule conversation reliant l’ensemble.',
|
||||
modeLabel: 'Répertoires',
|
||||
modeFeature: 'Nouvelle branche dans chaque dépôt',
|
||||
modeMain: 'Checkouts principaux',
|
||||
branchLabel: 'Nom de branche',
|
||||
branchPlaceholder: 'feature/…',
|
||||
newBranch: 'créer la branche',
|
||||
baseRefLabel: 'Réf. de base (optionnel)',
|
||||
startLabel: 'Démarrer une session',
|
||||
startNone: 'aucune session',
|
||||
reposLabel: 'Appliquer à',
|
||||
create: 'Créer dans {n} dépôts',
|
||||
creating: 'Création…',
|
||||
retryFailed: 'Réessayer les échecs',
|
||||
commandLabel: 'Session',
|
||||
reposLabel: 'Dépôts de la session',
|
||||
launch: 'Lancer la session',
|
||||
launching: 'Lancement…',
|
||||
close: 'Fermer',
|
||||
pending: 'en cours…',
|
||||
ok: 'créé',
|
||||
ok: 'worktree créé',
|
||||
error: 'échec',
|
||||
skippedLabel: 'ignoré',
|
||||
done: 'Session de groupe démarrée.',
|
||||
sessionFailed: 'Impossible de démarrer la session.',
|
||||
},
|
||||
repos: {
|
||||
add: 'Ajouter un repo',
|
||||
|
||||
@@ -2,8 +2,9 @@ import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type {
|
||||
CreateGroupRequest,
|
||||
CreateWorktreeRequest,
|
||||
CreateGroupSessionRequest,
|
||||
GroupResponse,
|
||||
GroupSessionResponse,
|
||||
GroupsListResponse,
|
||||
GroupSummary,
|
||||
RepoSummary,
|
||||
@@ -14,17 +15,32 @@ import type {
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import { wsClient, type GroupEvent } from '../lib/ws-client';
|
||||
import { useWorktreesStore } from './worktrees';
|
||||
import { useSessionsStore } from './sessions';
|
||||
|
||||
/** Requête « feature cross-repo » : un worktree (et éventuellement une session) par repo. */
|
||||
export type CrossRepoRequest = Omit<CreateWorktreeRequest, 'path'>;
|
||||
|
||||
/** Résultat par repo d'une action cross-repo (tolérance aux échecs partiels). */
|
||||
/** Résultat par repo de la création de worktree (mode feature, tolérance aux échecs partiels). */
|
||||
export interface CrossRepoResult {
|
||||
repoId: string;
|
||||
status: 'ok' | 'error';
|
||||
message?: string;
|
||||
worktreePath?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/** Options de lancement d'une session de groupe (P6). */
|
||||
export interface GroupFeatureOptions {
|
||||
command: 'claude' | 'bash';
|
||||
/** présent → crée le worktree de cette branche dans chaque repo puis relie ; absent → checkouts principaux. */
|
||||
branch?: string;
|
||||
newBranch?: boolean;
|
||||
baseRef?: string;
|
||||
}
|
||||
|
||||
/** Bilan d'un lancement de session de groupe. */
|
||||
export interface GroupFeatureOutcome {
|
||||
/** résultats de création de worktree par repo ([] en mode checkouts principaux). */
|
||||
worktreeResults: CrossRepoResult[];
|
||||
session: SessionSummary | null;
|
||||
dirs: string[];
|
||||
skipped: Array<{ repoId: string; reason: string }>;
|
||||
}
|
||||
|
||||
export const useGroupsStore = defineStore('groups', () => {
|
||||
@@ -68,7 +84,12 @@ export const useGroupsStore = defineStore('groups', () => {
|
||||
return useWorktreesStore().worktrees.filter((w) => ids.has(w.repoId));
|
||||
}
|
||||
function sessionsInGroup(groupId: string): SessionSummary[] {
|
||||
return worktreesInGroup(groupId).flatMap((w) => w.sessions);
|
||||
// Union { sessions corrélées par cwd aux worktrees du groupe } ∪ { sessions de groupe (groupId) },
|
||||
// dé-dupliquée par id : une session de groupe multi-repo ne doit apparaître qu'une fois.
|
||||
const byId = new Map<string, SessionSummary>();
|
||||
for (const w of worktreesInGroup(groupId)) for (const s of w.sessions) byId.set(s.id, s);
|
||||
for (const s of useSessionsStore().sessions) if (s.groupId === groupId) byId.set(s.id, s);
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
async function fetchGroups(): Promise<void> {
|
||||
@@ -109,27 +130,38 @@ export const useGroupsStore = defineStore('groups', () => {
|
||||
return res.group;
|
||||
}
|
||||
|
||||
/** Lance UNE session Claude couvrant tous les repos du groupe (--add-dir côté serveur). */
|
||||
async function createGroupSession(groupId: string, req: CreateGroupSessionRequest): Promise<GroupSessionResponse> {
|
||||
// La session est upsertée dans le store sessions par le broadcast WS `session_update`.
|
||||
return api.post<GroupSessionResponse>(`/api/v1/groups/${groupId}/session`, req);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée le même worktree (et éventuellement une session) dans chaque repo, en une action.
|
||||
* Orchestration côté client : les worktrees sont indépendants entre repos, donc on tolère
|
||||
* les échecs partiels (un repo en échec n'empêche pas les autres). Feedback par repo.
|
||||
* Lance une session de groupe reliant tous les repos (P6). En mode « feature » (branch fournie),
|
||||
* crée d'abord le même worktree de branche dans chaque repo — orchestration côté client tolérante
|
||||
* aux échecs partiels, feedback par repo — PUIS une seule session couvrant ces worktrees. En mode
|
||||
* « checkouts principaux » (sans branch), la session couvre directement le worktree principal de chaque repo.
|
||||
*/
|
||||
async function createCrossRepoFeature(
|
||||
async function createGroupFeature(
|
||||
groupId: string,
|
||||
repoIds: string[],
|
||||
req: CrossRepoRequest,
|
||||
opts: GroupFeatureOptions,
|
||||
onProgress?: (result: CrossRepoResult) => void,
|
||||
): Promise<CrossRepoResult[]> {
|
||||
): Promise<GroupFeatureOutcome> {
|
||||
const wt = useWorktreesStore();
|
||||
return Promise.all(
|
||||
let worktreeResults: CrossRepoResult[] = [];
|
||||
if (opts.branch) {
|
||||
const branch = opts.branch;
|
||||
worktreeResults = await Promise.all(
|
||||
repoIds.map(async (repoId): Promise<CrossRepoResult> => {
|
||||
try {
|
||||
const res = await wt.createWorktree(repoId, req);
|
||||
const result: CrossRepoResult = {
|
||||
repoId,
|
||||
status: 'ok',
|
||||
worktreePath: res.worktree.path,
|
||||
...(res.session ? { sessionId: res.session.id } : {}),
|
||||
};
|
||||
const res = await wt.createWorktree(repoId, {
|
||||
branch,
|
||||
newBranch: opts.newBranch ?? true,
|
||||
...(opts.baseRef ? { baseRef: opts.baseRef } : {}),
|
||||
startSession: null,
|
||||
});
|
||||
const result: CrossRepoResult = { repoId, status: 'ok', worktreePath: res.worktree.path };
|
||||
onProgress?.(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
@@ -144,6 +176,12 @@ export const useGroupsStore = defineStore('groups', () => {
|
||||
}),
|
||||
);
|
||||
}
|
||||
const res = await createGroupSession(groupId, {
|
||||
command: opts.command,
|
||||
...(opts.branch ? { branch: opts.branch } : {}),
|
||||
});
|
||||
return { worktreeResults, session: res.session, dirs: res.dirs, skipped: res.skipped };
|
||||
}
|
||||
|
||||
function startRealtime(): void {
|
||||
unsubscribe ??= wsClient.subscribeGroups(onEvent);
|
||||
@@ -168,7 +206,8 @@ export const useGroupsStore = defineStore('groups', () => {
|
||||
deleteGroup,
|
||||
addRepoToGroup,
|
||||
removeRepoFromGroup,
|
||||
createCrossRepoFeature,
|
||||
createGroupSession,
|
||||
createGroupFeature,
|
||||
startRealtime,
|
||||
stopRealtime,
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<CrossRepoFeatureModal v-if="showFeatureModal && group" :repos="groupRepos" @close="showFeatureModal = false" />
|
||||
<GroupSessionModal v-if="showFeatureModal && group" :group-id="group.id" :repos="groupRepos" @close="showFeatureModal = false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -91,7 +91,7 @@ import SegmentedControl from '../components/ui/SegmentedControl.vue';
|
||||
import ListToolbar from '../components/ListToolbar.vue';
|
||||
import RepoSection from '../components/RepoSection.vue';
|
||||
import TerminalGrid from '../components/TerminalGrid.vue';
|
||||
import CrossRepoFeatureModal from '../components/CrossRepoFeatureModal.vue';
|
||||
import GroupSessionModal from '../components/GroupSessionModal.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
Reference in New Issue
Block a user