hlassiege/darylpublic Fork
⑂ 65f4be9
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/daryl.git
git clone ssh://git@rickub.com/hlassiege/daryl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(web): split settings into channel, services and interface tabs

The tab lives in the URL so the upload step can link straight to the
YouTube account. A dot on the channel tab flags unsaved profile edits.
hlassiege committed 2026-10-13T15:30:22+02:00 Browse files
65f4be9 parent: 8fabaaa
modified web/src/i18n/locales/en/settings.ts +2 -0
@@ -1,4 +1,6 @@
11 export default {
2+ tabs: { channel: "Channel", services: "Services", interface: "Interface" },
3+ unsaved: "Unsaved changes",
24 title: "Settings",
35 intro: "Your channel profile is given to Daryl at every writing step (angles, titles, script, assistant) as background: the brief, the kept angle and the skill’s method come first. All fields are optional.",
46 language: "Interface language",
@@ -1,4 +1,6 @@
1 export default {1 export default {
2+ tabs: { channel: "Channel", services: "Services", interface: "Interface" },
3+ unsaved: "Unsaved changes",
2 title: "Settings",4 title: "Settings",
3 intro: "Your channel profile is given to Daryl at every writing step (angles, titles, script, assistant) as background: the brief, the kept angle and the skill’s method come first. All fields are optional.",5 intro: "Your channel profile is given to Daryl at every writing step (angles, titles, script, assistant) as background: the brief, the kept angle and the skill’s method come first. All fields are optional.",
4 language: "Interface language",6 language: "Interface language",
modified web/src/i18n/locales/fr/settings.ts +2 -0
@@ -1,4 +1,6 @@
11 export default {
2+ tabs: { channel: "Chaîne", services: "Services", interface: "Interface" },
3+ unsaved: "Modifications non enregistrées",
24 title: "Réglages",
35 intro: "Le profil de ta chaîne est donné à Daryl à chaque étape d’écriture (angles, titres, script, assistant), comme toile de fond : le brief, l’angle retenu et la méthode du skill passent avant. Tous les champs sont facultatifs.",
46 language: "Langue de l’interface",
@@ -1,4 +1,6 @@
1 export default {1 export default {
2+ tabs: { channel: "Chaîne", services: "Services", interface: "Interface" },
3+ unsaved: "Modifications non enregistrées",
2 title: "Réglages",4 title: "Réglages",
3 intro: "Le profil de ta chaîne est donné à Daryl à chaque étape d’écriture (angles, titres, script, assistant), comme toile de fond : le brief, l’angle retenu et la méthode du skill passent avant. Tous les champs sont facultatifs.",5 intro: "Le profil de ta chaîne est donné à Daryl à chaque étape d’écriture (angles, titres, script, assistant), comme toile de fond : le brief, l’angle retenu et la méthode du skill passent avant. Tous les champs sont facultatifs.",
4 language: "Langue de l’interface",6 language: "Langue de l’interface",
modified web/src/pages/SettingsPage.vue +113 -88
@@ -1,7 +1,11 @@
11 <script setup lang="ts">
2-/** Réglages : le profil de la chaîne, donné à Claude dans toutes les étapes d'écriture. */
2+/**
3+ * Réglages, en onglets : le profil de la chaîne (donné à Claude dans toutes les étapes d'écriture),
4+ * les services (publication YouTube, transcription) et l'interface.
5+ */
36 import { computed, onMounted, reactive, ref } from "vue";
47 import { useI18n } from "vue-i18n";
8+import { useRoute, useRouter } from "vue-router";
59 import Seg from "../components/Seg.vue";
610 import { LOCALES, setLocale, type Locale } from "../i18n";
711 import { api, del, post, putJson } from "../lib/api";
@@ -25,6 +29,15 @@ const FIELDS: { key: Exclude<Key, "name">; rows?: number }[] = [
2529 ];
2630
2731 const { t, locale } = useI18n();
32+
33+/** L'onglet est dans l'URL (/settings/services…) : on peut y renvoyer depuis une autre page. */
34+const TABS = ["channel", "services", "interface"] as const;
35+type Tab = (typeof TABS)[number];
36+const route = useRoute();
37+const router = useRouter();
38+const tab = computed<Tab>(() => (TABS as readonly string[]).includes(route.params.tab as string) ? route.params.tab as Tab : "channel");
39+const selectTab = (k: Tab) => router.replace(k === "channel" ? "/settings" : `/settings/${k}`);
40+
2841 /** La langue de l'interface : une préférence du navigateur, pas du profil de la chaîne. */
2942 const uiLocale = computed<Locale>({ get: () => locale.value as Locale, set: (l) => setLocale(l) });
3043 const localeOptions = Object.entries(LOCALES) as [Locale, string][];
@@ -269,83 +282,106 @@ function followers(n: number | null): string {
269282 <template>
270283 <div class="page">
271284 <div class="form">
272- <div class="intro">
273- <span class="h1">{{ t("settings.title") }}</span>
274- <span class="muted">{{ t("settings.intro") }}</span>
275- </div>
276-
277- <div class="field">
278- <label>{{ t("settings.language") }}</label>
279- <span class="hint">{{ t("settings.languageHint") }}</span>
280- <Seg v-model="uiLocale" :options="localeOptions" />
285+ <span class="h1">{{ t("settings.title") }}</span>
286+ <div class="tabs">
287+ <button v-for="k in TABS" :key="k" :class="{ on: k === tab }" @click="selectTab(k)">
288+ {{ t(`settings.tabs.${k}`) }}<span v-if="k === 'channel' && dirty" class="dot" :title="t('settings.unsaved')" />
289+ </button>
281290 </div>
282291
283- <template v-if="loaded">
284- <div class="field">
285- <label>{{ t("settings.channel") }}</label>
286- <span class="hint">{{ t("settings.channelHint") }}</span>
287- <div class="search">
288- <input v-model="form.name" class="input name-input" :placeholder="t('settings.channelPlaceholder')"
289- @input="onNameInput" @focus="results.length && (open = true)" @blur="closeSoon" @keydown.esc="open = false">
290- <span v-if="searching" class="spinner in-input" />
291- <div v-if="open && (searching || searchError || results.length || form.name.trim().length >= 2)" class="results">
292- <div v-if="searchError" class="state err">{{ searchError }}</div>
293- <div v-else-if="searching && !results.length" class="state">{{ t("settings.searching") }}</div>
294- <div v-else-if="!results.length" class="state">{{ t("settings.noChannel") }}</div>
295- <button v-for="c in results" :key="c.id" class="result" @mousedown.prevent="pick(c)">
296- <img v-if="c.thumbnail" :src="c.thumbnail" class="avatar" alt="" referrerpolicy="no-referrer">
297- <span v-else class="avatar hatch" />
298- <span class="meta">
299- <span class="t">{{ c.title }}</span>
300- <span class="s">{{ [c.handle, followers(c.followers)].filter(Boolean).join(" · ") }}</span>
301- <span v-if="c.description" class="d">{{ c.description }}</span>
302- </span>
303- </button>
292+ <template v-if="tab === 'channel'">
293+ <span class="muted intro">{{ t("settings.intro") }}</span>
294+ <template v-if="loaded">
295+ <div class="field">
296+ <label>{{ t("settings.channel") }}</label>
297+ <span class="hint">{{ t("settings.channelHint") }}</span>
298+ <div class="search">
299+ <input v-model="form.name" class="input name-input" :placeholder="t('settings.channelPlaceholder')"
300+ @input="onNameInput" @focus="results.length && (open = true)" @blur="closeSoon" @keydown.esc="open = false">
301+ <span v-if="searching" class="spinner in-input" />
302+ <div v-if="open && (searching || searchError || results.length || form.name.trim().length >= 2)" class="results">
303+ <div v-if="searchError" class="state err">{{ searchError }}</div>
304+ <div v-else-if="searching && !results.length" class="state">{{ t("settings.searching") }}</div>
305+ <div v-else-if="!results.length" class="state">{{ t("settings.noChannel") }}</div>
306+ <button v-for="c in results" :key="c.id" class="result" @mousedown.prevent="pick(c)">
307+ <img v-if="c.thumbnail" :src="c.thumbnail" class="avatar" alt="" referrerpolicy="no-referrer">
308+ <span v-else class="avatar hatch" />
309+ <span class="meta">
310+ <span class="t">{{ c.title }}</span>
311+ <span class="s">{{ [c.handle, followers(c.followers)].filter(Boolean).join(" · ") }}</span>
312+ <span v-if="c.description" class="d">{{ c.description }}</span>
313+ </span>
314+ </button>
315+ </div>
316+ </div>
317+ <div v-if="form.youtube" class="linked">
318+ <img v-if="form.youtube.thumbnail" :src="form.youtube.thumbnail" class="avatar sm" alt="" referrerpolicy="no-referrer">
319+ <a :href="form.youtube.url" target="_blank" rel="noopener" class="l">{{ form.youtube.title }}</a>
320+ <span class="muted">{{ [form.youtube.handle, followers(form.youtube.followers)].filter(Boolean).join(" · ") }}</span>
321+ <button class="x-btn" :title="t('settings.unlink')" @click="form.youtube = null">×</button>
304322 </div>
305323 </div>
306- <div v-if="form.youtube" class="linked">
307- <img v-if="form.youtube.thumbnail" :src="form.youtube.thumbnail" class="avatar sm" alt="" referrerpolicy="no-referrer">
308- <a :href="form.youtube.url" target="_blank" rel="noopener" class="l">{{ form.youtube.title }}</a>
309- <span class="muted">{{ [form.youtube.handle, followers(form.youtube.followers)].filter(Boolean).join(" · ") }}</span>
310- <button class="x-btn" :title="t('settings.unlink')" @click="form.youtube = null">×</button>
311- </div>
312- </div>
313-
314- <div class="field">
315- <label>{{ t("settings.channelLanguage") }}</label>
316- <span class="hint">{{ t("settings.channelLanguageHint") }}</span>
317- <Seg v-model="form.language" :options="channelLanguages" />
318- </div>
319324
320- <div class="field">
321- <label>{{ t("settings.analysis") }}</label>
322- <span class="hint">{{ t("settings.analysisHint") }}</span>
323- <div v-if="ytwatch && !ytwatch.connected" class="row">
324- <input v-model="token" type="password" class="input" :placeholder="t('settings.tokenPlaceholder')" @keydown.enter="connect">
325- <button class="btn lg" :disabled="!token.trim()" @click="connect">{{ t("settings.connectYtwatch") }}</button>
325+ <div class="field">
326+ <label>{{ t("settings.channelLanguage") }}</label>
327+ <span class="hint">{{ t("settings.channelLanguageHint") }}</span>
328+ <Seg v-model="form.language" :options="channelLanguages" />
326329 </div>
327- <div v-else-if="ytwatch" class="analysis">
328- <div class="row center">
329- <span class="muted small">{{ t("settings.ytwatchConnected") }} · <span class="mono">{{ ytwatch.token_hint }}</span></span>
330- <button class="link-btn small" @click="disconnect">{{ t("settings.disconnect") }}</button>
331- <span class="spacer" />
332- <template v-if="running">
333- <span class="spinner" />
334- <span class="muted small">{{ analysis?.activity ? t("settings.activity", { activity: analysis.activity }) : t("settings.waitingWorker") }}</span>
335- <button class="btn" @click="cancelAnalysis">{{ t("common.cancel") }}</button>
336- </template>
337- <button v-else class="btn lg dark" @click="analyse">{{ analysis?.status === "done" ? t("settings.rerunAnalysis") : t("settings.analyse") }}</button>
330+
331+ <div class="field">
332+ <label>{{ t("settings.analysis") }}</label>
333+ <span class="hint">{{ t("settings.analysisHint") }}</span>
334+ <div v-if="ytwatch && !ytwatch.connected" class="row">
335+ <input v-model="token" type="password" class="input" :placeholder="t('settings.tokenPlaceholder')" @keydown.enter="connect">
336+ <button class="btn lg" :disabled="!token.trim()" @click="connect">{{ t("settings.connectYtwatch") }}</button>
338337 </div>
339- <div v-if="analysis?.status === 'error'" class="state err">{{ t("settings.analysisFailed", { error: analysis.error }) }}</div>
340- <div v-if="!form.youtube && !running" class="state">{{ t("settings.pickChannelFirst") }}</div>
341- <div v-if="proposal('message')" class="proposal">
342- <div class="p-head"><span class="eyebrow">{{ t("settings.takeaway") }}</span>
343- <button class="x-btn" :title="t('settings.hide')" @click="settle('message', false)">×</button></div>
344- <p>{{ proposal("message") }}</p>
338+ <div v-else-if="ytwatch" class="analysis">
339+ <div class="row center">
340+ <span class="muted small">{{ t("settings.ytwatchConnected") }} · <span class="mono">{{ ytwatch.token_hint }}</span></span>
341+ <button class="link-btn small" @click="disconnect">{{ t("settings.disconnect") }}</button>
342+ <span class="spacer" />
343+ <template v-if="running">
344+ <span class="spinner" />
345+ <span class="muted small">{{ analysis?.activity ? t("settings.activity", { activity: analysis.activity }) : t("settings.waitingWorker") }}</span>
346+ <button class="btn" @click="cancelAnalysis">{{ t("common.cancel") }}</button>
347+ </template>
348+ <button v-else class="btn lg dark" @click="analyse">{{ analysis?.status === "done" ? t("settings.rerunAnalysis") : t("settings.analyse") }}</button>
349+ </div>
350+ <div v-if="analysis?.status === 'error'" class="state err">{{ t("settings.analysisFailed", { error: analysis.error }) }}</div>
351+ <div v-if="!form.youtube && !running" class="state">{{ t("settings.pickChannelFirst") }}</div>
352+ <div v-if="proposal('message')" class="proposal">
353+ <div class="p-head"><span class="eyebrow">{{ t("settings.takeaway") }}</span>
354+ <button class="x-btn" :title="t('settings.hide')" @click="settle('message', false)">×</button></div>
355+ <p>{{ proposal("message") }}</p>
356+ </div>
345357 </div>
346358 </div>
347- </div>
348359
360+
361+ <div v-for="f in FIELDS" :key="f.key" class="field">
362+ <label>{{ t(`settings.fields.${f.key}.label`) }}</label>
363+ <span class="hint">{{ t(`settings.fields.${f.key}.hint`) }}</span>
364+ <textarea v-model="form[f.key]" class="textarea" :rows="f.rows ?? 3" :placeholder="t(`settings.fields.${f.key}.placeholder`)" />
365+ <div v-if="proposal(f.key)" class="proposal">
366+ <div class="p-head">
367+ <span class="eyebrow">{{ t("settings.proposal") }}</span>
368+ <span class="spacer" />
369+ <button class="btn" @click="settle(f.key, false)">{{ t("common.dismiss") }}</button>
370+ <button class="btn dark" @click="settle(f.key, true)">{{ form[f.key].trim() ? t("settings.replace") : t("settings.use") }}</button>
371+ </div>
372+ <p>{{ proposal(f.key) }}</p>
373+ </div>
374+ </div>
375+
376+ <div class="submit">
377+ <button class="btn xl" :class="dirty ? 'red' : 'off'" :disabled="busy || !dirty" @click="save">
378+ {{ busy ? t("common.saving") : dirty ? t("common.save") : t("settings.savedState") }}
379+ </button>
380+ </div>
381+ </template>
382+ </template>
383+
384+ <template v-else-if="tab === 'services'">
349385 <div class="field">
350386 <label>{{ t("settings.account") }}</label>
351387 <span class="hint">{{ t("settings.accountHint") }}</span>
@@ -386,26 +422,13 @@ function followers(n: number | null): string {
386422 <span v-if="transcription.engine !== engine" class="state err">{{ t("settings.engineInactive") }}</span>
387423 </template>
388424 </div>
425+ </template>
389426
390- <div v-for="f in FIELDS" :key="f.key" class="field">
391- <label>{{ t(`settings.fields.${f.key}.label`) }}</label>
392- <span class="hint">{{ t(`settings.fields.${f.key}.hint`) }}</span>
393- <textarea v-model="form[f.key]" class="textarea" :rows="f.rows ?? 3" :placeholder="t(`settings.fields.${f.key}.placeholder`)" />
394- <div v-if="proposal(f.key)" class="proposal">
395- <div class="p-head">
396- <span class="eyebrow">{{ t("settings.proposal") }}</span>
397- <span class="spacer" />
398- <button class="btn" @click="settle(f.key, false)">{{ t("common.dismiss") }}</button>
399- <button class="btn dark" @click="settle(f.key, true)">{{ form[f.key].trim() ? t("settings.replace") : t("settings.use") }}</button>
400- </div>
401- <p>{{ proposal(f.key) }}</p>
402- </div>
403- </div>
404-
405- <div class="submit">
406- <button class="btn xl" :class="dirty ? 'red' : 'off'" :disabled="busy || !dirty" @click="save">
407- {{ busy ? t("common.saving") : dirty ? t("common.save") : t("settings.savedState") }}
408- </button>
427+ <template v-else>
428+ <div class="field">
429+ <label>{{ t("settings.language") }}</label>
430+ <span class="hint">{{ t("settings.languageHint") }}</span>
431+ <Seg v-model="uiLocale" :options="localeOptions" />
409432 </div>
410433 </template>
411434 </div>
@@ -415,7 +438,9 @@ function followers(n: number | null): string {
415438 <style scoped>
416439 .page { flex: 1; overflow: auto; padding: 32px 40px 56px; }
417440 .form { max-width: 720px; display: flex; flex-direction: column; gap: 26px; }
418-.intro { display: flex; flex-direction: column; gap: 6px; font-size: 14px; line-height: 1.5; }
441+.intro { font-size: 14px; line-height: 1.5; margin-top: -8px; }
442+.tabs { margin-top: -12px; }
443+.tabs .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--red); align-self: center; }
419444 .h1 { font-size: 26px; font-weight: 600; letter-spacing: -0.015em; }
420445 .field { display: flex; flex-direction: column; gap: 8px; }
421446 .field label { font-size: 13.5px; font-weight: 600; }
@@ -1,7 +1,11 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2-/** Réglages : le profil de la chaîne, donné à Claude dans toutes les étapes d'écriture. */2+/**
3+ * Réglages, en onglets : le profil de la chaîne (donné à Claude dans toutes les étapes d'écriture),
4+ * les services (publication YouTube, transcription) et l'interface.
5+ */
3 import { computed, onMounted, reactive, ref } from "vue";6 import { computed, onMounted, reactive, ref } from "vue";
4 import { useI18n } from "vue-i18n";7 import { useI18n } from "vue-i18n";
8+import { useRoute, useRouter } from "vue-router";
5 import Seg from "../components/Seg.vue";9 import Seg from "../components/Seg.vue";
6 import { LOCALES, setLocale, type Locale } from "../i18n";10 import { LOCALES, setLocale, type Locale } from "../i18n";
7 import { api, del, post, putJson } from "../lib/api";11 import { api, del, post, putJson } from "../lib/api";
@@ -25,6 +29,15 @@ const FIELDS: { key: Exclude<Key, "name">; rows?: number }[] = [
25 ];29 ];
26 30
27 const { t, locale } = useI18n();31 const { t, locale } = useI18n();
32+
33+/** L'onglet est dans l'URL (/settings/services…) : on peut y renvoyer depuis une autre page. */
34+const TABS = ["channel", "services", "interface"] as const;
35+type Tab = (typeof TABS)[number];
36+const route = useRoute();
37+const router = useRouter();
38+const tab = computed<Tab>(() => (TABS as readonly string[]).includes(route.params.tab as string) ? route.params.tab as Tab : "channel");
39+const selectTab = (k: Tab) => router.replace(k === "channel" ? "/settings" : `/settings/${k}`);
40+
28 /** La langue de l'interface : une préférence du navigateur, pas du profil de la chaîne. */41 /** La langue de l'interface : une préférence du navigateur, pas du profil de la chaîne. */
29 const uiLocale = computed<Locale>({ get: () => locale.value as Locale, set: (l) => setLocale(l) });42 const uiLocale = computed<Locale>({ get: () => locale.value as Locale, set: (l) => setLocale(l) });
30 const localeOptions = Object.entries(LOCALES) as [Locale, string][];43 const localeOptions = Object.entries(LOCALES) as [Locale, string][];
@@ -269,83 +282,106 @@ function followers(n: number | null): string {
269 <template>282 <template>
270 <div class="page">283 <div class="page">
271 <div class="form">284 <div class="form">
272- <div class="intro">285+ <span class="h1">{{ t("settings.title") }}</span>
273- <span class="h1">{{ t("settings.title") }}</span>286+ <div class="tabs">
274- <span class="muted">{{ t("settings.intro") }}</span>287+ <button v-for="k in TABS" :key="k" :class="{ on: k === tab }" @click="selectTab(k)">
275- </div>288+ {{ t(`settings.tabs.${k}`) }}<span v-if="k === 'channel' && dirty" class="dot" :title="t('settings.unsaved')" />
276-289+ </button>
277- <div class="field">
278- <label>{{ t("settings.language") }}</label>
279- <span class="hint">{{ t("settings.languageHint") }}</span>
280- <Seg v-model="uiLocale" :options="localeOptions" />
281 </div>290 </div>
282 291
283- <template v-if="loaded">292+ <template v-if="tab === 'channel'">
284- <div class="field">293+ <span class="muted intro">{{ t("settings.intro") }}</span>
285- <label>{{ t("settings.channel") }}</label>294+ <template v-if="loaded">
286- <span class="hint">{{ t("settings.channelHint") }}</span>295+ <div class="field">
287- <div class="search">296+ <label>{{ t("settings.channel") }}</label>
288- <input v-model="form.name" class="input name-input" :placeholder="t('settings.channelPlaceholder')"297+ <span class="hint">{{ t("settings.channelHint") }}</span>
289- @input="onNameInput" @focus="results.length && (open = true)" @blur="closeSoon" @keydown.esc="open = false">298+ <div class="search">
290- <span v-if="searching" class="spinner in-input" />299+ <input v-model="form.name" class="input name-input" :placeholder="t('settings.channelPlaceholder')"
291- <div v-if="open && (searching || searchError || results.length || form.name.trim().length >= 2)" class="results">300+ @input="onNameInput" @focus="results.length && (open = true)" @blur="closeSoon" @keydown.esc="open = false">
292- <div v-if="searchError" class="state err">{{ searchError }}</div>301+ <span v-if="searching" class="spinner in-input" />
293- <div v-else-if="searching && !results.length" class="state">{{ t("settings.searching") }}</div>302+ <div v-if="open && (searching || searchError || results.length || form.name.trim().length >= 2)" class="results">
294- <div v-else-if="!results.length" class="state">{{ t("settings.noChannel") }}</div>303+ <div v-if="searchError" class="state err">{{ searchError }}</div>
295- <button v-for="c in results" :key="c.id" class="result" @mousedown.prevent="pick(c)">304+ <div v-else-if="searching && !results.length" class="state">{{ t("settings.searching") }}</div>
296- <img v-if="c.thumbnail" :src="c.thumbnail" class="avatar" alt="" referrerpolicy="no-referrer">305+ <div v-else-if="!results.length" class="state">{{ t("settings.noChannel") }}</div>
297- <span v-else class="avatar hatch" />306+ <button v-for="c in results" :key="c.id" class="result" @mousedown.prevent="pick(c)">
298- <span class="meta">307+ <img v-if="c.thumbnail" :src="c.thumbnail" class="avatar" alt="" referrerpolicy="no-referrer">
299- <span class="t">{{ c.title }}</span>308+ <span v-else class="avatar hatch" />
300- <span class="s">{{ [c.handle, followers(c.followers)].filter(Boolean).join(" · ") }}</span>309+ <span class="meta">
301- <span v-if="c.description" class="d">{{ c.description }}</span>310+ <span class="t">{{ c.title }}</span>
302- </span>311+ <span class="s">{{ [c.handle, followers(c.followers)].filter(Boolean).join(" · ") }}</span>
303- </button>312+ <span v-if="c.description" class="d">{{ c.description }}</span>
313+ </span>
314+ </button>
315+ </div>
316+ </div>
317+ <div v-if="form.youtube" class="linked">
318+ <img v-if="form.youtube.thumbnail" :src="form.youtube.thumbnail" class="avatar sm" alt="" referrerpolicy="no-referrer">
319+ <a :href="form.youtube.url" target="_blank" rel="noopener" class="l">{{ form.youtube.title }}</a>
320+ <span class="muted">{{ [form.youtube.handle, followers(form.youtube.followers)].filter(Boolean).join(" · ") }}</span>
321+ <button class="x-btn" :title="t('settings.unlink')" @click="form.youtube = null">×</button>
304 </div>322 </div>
305 </div>323 </div>
306- <div v-if="form.youtube" class="linked">
307- <img v-if="form.youtube.thumbnail" :src="form.youtube.thumbnail" class="avatar sm" alt="" referrerpolicy="no-referrer">
308- <a :href="form.youtube.url" target="_blank" rel="noopener" class="l">{{ form.youtube.title }}</a>
309- <span class="muted">{{ [form.youtube.handle, followers(form.youtube.followers)].filter(Boolean).join(" · ") }}</span>
310- <button class="x-btn" :title="t('settings.unlink')" @click="form.youtube = null">×</button>
311- </div>
312- </div>
313-
314- <div class="field">
315- <label>{{ t("settings.channelLanguage") }}</label>
316- <span class="hint">{{ t("settings.channelLanguageHint") }}</span>
317- <Seg v-model="form.language" :options="channelLanguages" />
318- </div>
319 324
320- <div class="field">325+ <div class="field">
321- <label>{{ t("settings.analysis") }}</label>326+ <label>{{ t("settings.channelLanguage") }}</label>
322- <span class="hint">{{ t("settings.analysisHint") }}</span>327+ <span class="hint">{{ t("settings.channelLanguageHint") }}</span>
323- <div v-if="ytwatch && !ytwatch.connected" class="row">328+ <Seg v-model="form.language" :options="channelLanguages" />
324- <input v-model="token" type="password" class="input" :placeholder="t('settings.tokenPlaceholder')" @keydown.enter="connect">
325- <button class="btn lg" :disabled="!token.trim()" @click="connect">{{ t("settings.connectYtwatch") }}</button>
326 </div>329 </div>
327- <div v-else-if="ytwatch" class="analysis">330+
328- <div class="row center">331+ <div class="field">
329- <span class="muted small">{{ t("settings.ytwatchConnected") }} · <span class="mono">{{ ytwatch.token_hint }}</span></span>332+ <label>{{ t("settings.analysis") }}</label>
330- <button class="link-btn small" @click="disconnect">{{ t("settings.disconnect") }}</button>333+ <span class="hint">{{ t("settings.analysisHint") }}</span>
331- <span class="spacer" />334+ <div v-if="ytwatch && !ytwatch.connected" class="row">
332- <template v-if="running">335+ <input v-model="token" type="password" class="input" :placeholder="t('settings.tokenPlaceholder')" @keydown.enter="connect">
333- <span class="spinner" />336+ <button class="btn lg" :disabled="!token.trim()" @click="connect">{{ t("settings.connectYtwatch") }}</button>
334- <span class="muted small">{{ analysis?.activity ? t("settings.activity", { activity: analysis.activity }) : t("settings.waitingWorker") }}</span>
335- <button class="btn" @click="cancelAnalysis">{{ t("common.cancel") }}</button>
336- </template>
337- <button v-else class="btn lg dark" @click="analyse">{{ analysis?.status === "done" ? t("settings.rerunAnalysis") : t("settings.analyse") }}</button>
338 </div>337 </div>
339- <div v-if="analysis?.status === 'error'" class="state err">{{ t("settings.analysisFailed", { error: analysis.error }) }}</div>338+ <div v-else-if="ytwatch" class="analysis">
340- <div v-if="!form.youtube && !running" class="state">{{ t("settings.pickChannelFirst") }}</div>339+ <div class="row center">
341- <div v-if="proposal('message')" class="proposal">340+ <span class="muted small">{{ t("settings.ytwatchConnected") }} · <span class="mono">{{ ytwatch.token_hint }}</span></span>
342- <div class="p-head"><span class="eyebrow">{{ t("settings.takeaway") }}</span>341+ <button class="link-btn small" @click="disconnect">{{ t("settings.disconnect") }}</button>
343- <button class="x-btn" :title="t('settings.hide')" @click="settle('message', false)">×</button></div>342+ <span class="spacer" />
344- <p>{{ proposal("message") }}</p>343+ <template v-if="running">
344+ <span class="spinner" />
345+ <span class="muted small">{{ analysis?.activity ? t("settings.activity", { activity: analysis.activity }) : t("settings.waitingWorker") }}</span>
346+ <button class="btn" @click="cancelAnalysis">{{ t("common.cancel") }}</button>
347+ </template>
348+ <button v-else class="btn lg dark" @click="analyse">{{ analysis?.status === "done" ? t("settings.rerunAnalysis") : t("settings.analyse") }}</button>
349+ </div>
350+ <div v-if="analysis?.status === 'error'" class="state err">{{ t("settings.analysisFailed", { error: analysis.error }) }}</div>
351+ <div v-if="!form.youtube && !running" class="state">{{ t("settings.pickChannelFirst") }}</div>
352+ <div v-if="proposal('message')" class="proposal">
353+ <div class="p-head"><span class="eyebrow">{{ t("settings.takeaway") }}</span>
354+ <button class="x-btn" :title="t('settings.hide')" @click="settle('message', false)">×</button></div>
355+ <p>{{ proposal("message") }}</p>
356+ </div>
345 </div>357 </div>
346 </div>358 </div>
347- </div>
348 359
360+
361+ <div v-for="f in FIELDS" :key="f.key" class="field">
362+ <label>{{ t(`settings.fields.${f.key}.label`) }}</label>
363+ <span class="hint">{{ t(`settings.fields.${f.key}.hint`) }}</span>
364+ <textarea v-model="form[f.key]" class="textarea" :rows="f.rows ?? 3" :placeholder="t(`settings.fields.${f.key}.placeholder`)" />
365+ <div v-if="proposal(f.key)" class="proposal">
366+ <div class="p-head">
367+ <span class="eyebrow">{{ t("settings.proposal") }}</span>
368+ <span class="spacer" />
369+ <button class="btn" @click="settle(f.key, false)">{{ t("common.dismiss") }}</button>
370+ <button class="btn dark" @click="settle(f.key, true)">{{ form[f.key].trim() ? t("settings.replace") : t("settings.use") }}</button>
371+ </div>
372+ <p>{{ proposal(f.key) }}</p>
373+ </div>
374+ </div>
375+
376+ <div class="submit">
377+ <button class="btn xl" :class="dirty ? 'red' : 'off'" :disabled="busy || !dirty" @click="save">
378+ {{ busy ? t("common.saving") : dirty ? t("common.save") : t("settings.savedState") }}
379+ </button>
380+ </div>
381+ </template>
382+ </template>
383+
384+ <template v-else-if="tab === 'services'">
349 <div class="field">385 <div class="field">
350 <label>{{ t("settings.account") }}</label>386 <label>{{ t("settings.account") }}</label>
351 <span class="hint">{{ t("settings.accountHint") }}</span>387 <span class="hint">{{ t("settings.accountHint") }}</span>
@@ -386,26 +422,13 @@ function followers(n: number | null): string {
386 <span v-if="transcription.engine !== engine" class="state err">{{ t("settings.engineInactive") }}</span>422 <span v-if="transcription.engine !== engine" class="state err">{{ t("settings.engineInactive") }}</span>
387 </template>423 </template>
388 </div>424 </div>
425+ </template>
389 426
390- <div v-for="f in FIELDS" :key="f.key" class="field">427+ <template v-else>
391- <label>{{ t(`settings.fields.${f.key}.label`) }}</label>428+ <div class="field">
392- <span class="hint">{{ t(`settings.fields.${f.key}.hint`) }}</span>429+ <label>{{ t("settings.language") }}</label>
393- <textarea v-model="form[f.key]" class="textarea" :rows="f.rows ?? 3" :placeholder="t(`settings.fields.${f.key}.placeholder`)" />430+ <span class="hint">{{ t("settings.languageHint") }}</span>
394- <div v-if="proposal(f.key)" class="proposal">431+ <Seg v-model="uiLocale" :options="localeOptions" />
395- <div class="p-head">
396- <span class="eyebrow">{{ t("settings.proposal") }}</span>
397- <span class="spacer" />
398- <button class="btn" @click="settle(f.key, false)">{{ t("common.dismiss") }}</button>
399- <button class="btn dark" @click="settle(f.key, true)">{{ form[f.key].trim() ? t("settings.replace") : t("settings.use") }}</button>
400- </div>
401- <p>{{ proposal(f.key) }}</p>
402- </div>
403- </div>
404-
405- <div class="submit">
406- <button class="btn xl" :class="dirty ? 'red' : 'off'" :disabled="busy || !dirty" @click="save">
407- {{ busy ? t("common.saving") : dirty ? t("common.save") : t("settings.savedState") }}
408- </button>
409 </div>432 </div>
410 </template>433 </template>
411 </div>434 </div>
@@ -415,7 +438,9 @@ function followers(n: number | null): string {
415 <style scoped>438 <style scoped>
416 .page { flex: 1; overflow: auto; padding: 32px 40px 56px; }439 .page { flex: 1; overflow: auto; padding: 32px 40px 56px; }
417 .form { max-width: 720px; display: flex; flex-direction: column; gap: 26px; }440 .form { max-width: 720px; display: flex; flex-direction: column; gap: 26px; }
418-.intro { display: flex; flex-direction: column; gap: 6px; font-size: 14px; line-height: 1.5; }441+.intro { font-size: 14px; line-height: 1.5; margin-top: -8px; }
442+.tabs { margin-top: -12px; }
443+.tabs .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--red); align-self: center; }
419 .h1 { font-size: 26px; font-weight: 600; letter-spacing: -0.015em; }444 .h1 { font-size: 26px; font-weight: 600; letter-spacing: -0.015em; }
420 .field { display: flex; flex-direction: column; gap: 8px; }445 .field { display: flex; flex-direction: column; gap: 8px; }
421 .field label { font-size: 13.5px; font-weight: 600; }446 .field label { font-size: 13.5px; font-weight: 600; }
modified web/src/project/steps/UploadStep.vue +1 -1
@@ -46,7 +46,7 @@ const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k =
4646 <div class="screen">
4747 <div v-if="P.account.value && !P.account.value.connected" class="banner">
4848 {{ t("upload.notConnected") }}
49- <RouterLink to="/settings">{{ t("upload.connect") }}</RouterLink>
49+ <RouterLink to="/settings/services">{{ t("upload.connect") }}</RouterLink>
5050 </div>
5151 <div v-else-if="P.account.value?.channel" class="summary">
5252 <span>{{ t("upload.summary", { sent, n: P.videos.value.length }) }}{{ blocked ? t("upload.blocked", { n: blocked }) : "" }}</span>
@@ -46,7 +46,7 @@ const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k =
46 <div class="screen">46 <div class="screen">
47 <div v-if="P.account.value && !P.account.value.connected" class="banner">47 <div v-if="P.account.value && !P.account.value.connected" class="banner">
48 {{ t("upload.notConnected") }}48 {{ t("upload.notConnected") }}
49- <RouterLink to="/settings">{{ t("upload.connect") }}</RouterLink>49+ <RouterLink to="/settings/services">{{ t("upload.connect") }}</RouterLink>
50 </div>50 </div>
51 <div v-else-if="P.account.value?.channel" class="summary">51 <div v-else-if="P.account.value?.channel" class="summary">
52 <span>{{ t("upload.summary", { sent, n: P.videos.value.length }) }}{{ blocked ? t("upload.blocked", { n: blocked }) : "" }}</span>52 <span>{{ t("upload.summary", { sent, n: P.videos.value.length }) }}{{ blocked ? t("upload.blocked", { n: blocked }) : "" }}</span>
modified web/src/router.ts +1 -1
@@ -9,7 +9,7 @@ export const router = createRouter({
99 { path: "/", name: "home", component: () => import("./pages/HomePage.vue"), meta: { title: "nav.projects" } },
1010 { path: "/nouveau", name: "new", component: () => import("./pages/NewProjectPage.vue"), meta: { title: "nav.newProject" } },
1111 { path: "/bibliotheque", name: "library", component: () => import("./pages/LibraryPage.vue"), meta: { title: "nav.library" } },
12- { path: "/settings", name: "settings", component: () => import("./pages/SettingsPage.vue"), meta: { title: "nav.settings" } },
12+ { path: "/settings/:tab(channel|services|interface)?", name: "settings", component: () => import("./pages/SettingsPage.vue"), meta: { title: "nav.settings" } },
1313 { path: "/reglages", redirect: "/settings" },
1414 { path: "/p/:pid", name: "project", component: () => import("./project/OverviewPage.vue") },
1515 { path: "/p/:pid/brief", name: "brief", component: () => import("./pages/NewProjectPage.vue"), meta: { title: "nav.brief" } },
@@ -9,7 +9,7 @@ export const router = createRouter({
9 { path: "/", name: "home", component: () => import("./pages/HomePage.vue"), meta: { title: "nav.projects" } },9 { path: "/", name: "home", component: () => import("./pages/HomePage.vue"), meta: { title: "nav.projects" } },
10 { path: "/nouveau", name: "new", component: () => import("./pages/NewProjectPage.vue"), meta: { title: "nav.newProject" } },10 { path: "/nouveau", name: "new", component: () => import("./pages/NewProjectPage.vue"), meta: { title: "nav.newProject" } },
11 { path: "/bibliotheque", name: "library", component: () => import("./pages/LibraryPage.vue"), meta: { title: "nav.library" } },11 { path: "/bibliotheque", name: "library", component: () => import("./pages/LibraryPage.vue"), meta: { title: "nav.library" } },
12- { path: "/settings", name: "settings", component: () => import("./pages/SettingsPage.vue"), meta: { title: "nav.settings" } },12+ { path: "/settings/:tab(channel|services|interface)?", name: "settings", component: () => import("./pages/SettingsPage.vue"), meta: { title: "nav.settings" } },
13 { path: "/reglages", redirect: "/settings" },13 { path: "/reglages", redirect: "/settings" },
14 { path: "/p/:pid", name: "project", component: () => import("./project/OverviewPage.vue") },14 { path: "/p/:pid", name: "project", component: () => import("./project/OverviewPage.vue") },
15 { path: "/p/:pid/brief", name: "brief", component: () => import("./pages/NewProjectPage.vue"), meta: { title: "nav.brief" } },15 { path: "/p/:pid/brief", name: "brief", component: () => import("./pages/NewProjectPage.vue"), meta: { title: "nav.brief" } },