- PWA: public/manifest.webmanifest + icon.svg + meta index.html (theme-color, apple-touch-icon) - public/sw.js: service worker push-only (push → showNotification tag=sessionId ; notificationclick → focus/ouvre /sessions/<id>) — pas de précache (hors périmètre) - lib/push.ts + stores/push.ts: enable/disable, abonnement VAPID, enregistrement SW au boot - DashboardView: toggle « Notifications » (gardé par pushSupported) + i18n en/fr - campagne de fiabilité (verdict S3): classification de tous les types de dialogue sur captures réelles (trust/permission×2/question) + refus Esc (deny-esc2) + plan (synthétique) - acceptance-p4.mjs: VAPID/auth/Origin, subscribe idempotent/unsubscribe, answer (rejets) Choix: SW écrit à la main plutôt que vite-plugin-pwa (qui tirait ~295 paquets Workbox + 2 vulns esbuild high pour un SW push-only). Zéro dépendance front nouvelle. 175 tests verts, acceptance p1..p4 ALL GREEN.
105 lines
4.1 KiB
Vue
105 lines
4.1 KiB
Vue
<template>
|
|
<div class="overflow-y-auto">
|
|
<div class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6">
|
|
<header class="flex items-center gap-3">
|
|
<h1 class="text-lg font-semibold text-zinc-100">{{ t('dashboard.title') }}</h1>
|
|
<span v-if="auth.serverVersion" class="text-xs text-zinc-600">v{{ auth.serverVersion }}</span>
|
|
<div class="ml-auto flex items-center gap-2">
|
|
<RouterLink :to="{ name: 'sessions' }" class="btn">{{ t('dashboard.allSessions') }}</RouterLink>
|
|
<button class="btn" :disabled="store.loading" @click="store.fetchAll()">{{ t('sessions.refresh') }}</button>
|
|
<button
|
|
v-if="push.supported"
|
|
class="btn"
|
|
:class="push.enabled ? 'border-emerald-700 text-emerald-300' : ''"
|
|
:disabled="push.busy"
|
|
:title="pushErrorText"
|
|
@click="push.toggle()"
|
|
>
|
|
{{ push.enabled ? t('push.disable') : t('push.enable') }}
|
|
</button>
|
|
<LanguageSwitcher />
|
|
<button class="btn" @click="onLogout">{{ t('common.logout') }}</button>
|
|
</div>
|
|
</header>
|
|
<p v-if="pushErrorText" class="text-xs text-amber-400">{{ pushErrorText }}</p>
|
|
|
|
<form class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 sm:flex-row sm:items-end" @submit.prevent="onAddRepo">
|
|
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
|
{{ t('repos.pathLabel') }}
|
|
<input v-model="newPath" type="text" class="input font-mono" :placeholder="t('repos.pathPlaceholder')" required />
|
|
</label>
|
|
<button type="submit" class="btn-primary whitespace-nowrap" :disabled="adding || newPath.trim() === ''">
|
|
{{ adding ? t('repos.adding') : t('repos.add') }}
|
|
</button>
|
|
</form>
|
|
<p v-if="addError" class="text-sm text-red-400">{{ addError }}</p>
|
|
|
|
<p v-if="store.loadError" class="text-sm text-red-400">{{ store.loadError }}</p>
|
|
<p v-else-if="store.loading && store.repos.length === 0" class="text-sm text-zinc-500">{{ t('common.loading') }}</p>
|
|
<p v-else-if="store.repos.length === 0" class="text-sm text-zinc-500">{{ t('repos.empty') }}</p>
|
|
|
|
<RepoSection v-for="repo in store.repos" :key="repo.id" :repo="repo" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useAuthStore } from '../stores/auth';
|
|
import { useWorktreesStore } from '../stores/worktrees';
|
|
import { useSessionsStore } from '../stores/sessions';
|
|
import { usePushStore } from '../stores/push';
|
|
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
|
import RepoSection from '../components/RepoSection.vue';
|
|
|
|
const { t } = useI18n();
|
|
const router = useRouter();
|
|
const auth = useAuthStore();
|
|
const store = useWorktreesStore();
|
|
const sessions = useSessionsStore();
|
|
const push = usePushStore();
|
|
|
|
const newPath = ref('');
|
|
const adding = ref(false);
|
|
const addError = ref<string | null>(null);
|
|
|
|
// 'denied'/'unsupported' sont des clés i18n ; tout autre message d'erreur est affiché tel quel.
|
|
const pushErrorText = computed(() => {
|
|
const e = push.error;
|
|
if (!e) return '';
|
|
return e === 'denied' || e === 'unsupported' ? t(`push.${e}`) : e;
|
|
});
|
|
|
|
onMounted(() => {
|
|
void store.fetchAll();
|
|
store.startRealtime();
|
|
// les sessions alimentent la corrélation worktree↔session (cwd) côté serveur ; on charge et on
|
|
// suit leurs mises à jour temps réel pour que les badges d'état (busy/waiting/idle) restent live.
|
|
void sessions.fetchSessions();
|
|
sessions.startRealtime();
|
|
void push.refresh();
|
|
});
|
|
|
|
async function onAddRepo(): Promise<void> {
|
|
adding.value = true;
|
|
addError.value = null;
|
|
try {
|
|
await store.addRepo(newPath.value.trim());
|
|
newPath.value = '';
|
|
} catch (err) {
|
|
addError.value = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
adding.value = false;
|
|
}
|
|
}
|
|
|
|
async function onLogout(): Promise<void> {
|
|
store.stopRealtime();
|
|
sessions.stopRealtime();
|
|
await auth.logout();
|
|
await router.replace({ name: 'login' });
|
|
}
|
|
</script>
|