feat(publish): add a default description shared by every video
A new Publishing tab in settings holds the block of links that every video carries. It fills the description of long videos and shorts until it is edited, and stays out of the channel profile given to Claude.
2739efc parent: 783b8eb modified
backend/studio/modules/projects/publish.py +10 -4 | @@ -13,7 +13,8 @@ project.json, "publish" (modifié par l'interface) : | ||
| 13 | 13 | } |
| 14 | 14 | |
| 15 | 15 | Un champ absent prend sa valeur par défaut (titres et vignettes retenus, titre et accroche du |
| 16 | -short…). L'envoi de chaque vidéo est un job, dans project["jobs"]["upload:<vidéo>"]. | |
| 16 | +short, description par défaut des réglages…). L'envoi de chaque vidéo est un job, dans | |
| 17 | +project["jobs"]["upload:<vidéo>"]. | |
| 17 | 18 | |
| 18 | 19 | L'API ne sait pas créer le test A/B de YouTube : la variante A est envoyée, B et C se posent |
| 19 | 20 | dans YouTube Studio. Les shorts n'ont pas de vignette personnalisée par l'API. |
| @@ -26,7 +27,7 @@ from pathlib import Path | ||
| 26 | 27 | from ...core import jobs, youtube_api |
| 27 | 28 | from ...core.system import tool |
| 28 | 29 | from ...core.tasks import TaskContext |
| 29 | -from ..settings.service import language | |
| 30 | +from ..settings.service import default_description, language | |
| 30 | 31 | from . import montage, render, service |
| 31 | 32 | |
| 32 | 33 | KIND = "project_upload" |
| @@ -72,12 +73,17 @@ def _long_defaults(project: dict) -> dict: | ||
| 72 | 73 | titles = [t["t"] for t in project["titles"] if t.get("pick")] |
| 73 | 74 | others = [t for t in picked if first is None or t["id"] != first["id"]][:MAX_VARIANTS - 1] |
| 74 | 75 | return {"title": (first or {}).get("title") or (titles[0] if titles else project["title"]), |
| 75 | - "thumb": first["id"] if first else None, "desc": "", "tags": [], | |
| 76 | + "thumb": first["id"] if first else None, "desc": _with_footer(""), "tags": [], | |
| 76 | 77 | "ab_on": bool(others), "ab": [{"thumb": t["id"], "title": t.get("title") or ""} for t in others]} |
| 77 | 78 | |
| 78 | 79 | |
| 80 | +def _with_footer(text: str) -> str: | |
| 81 | + """Le texte, suivi de la description par défaut des réglages (le bloc commun à toutes les vidéos).""" | |
| 82 | + return "\n\n".join(x for x in (text.strip(), default_description()) if x) | |
| 83 | + | |
| 84 | + | |
| 79 | 85 | def _short_defaults(short: dict) -> dict: |
| 80 | - return {"title": short["title"], "desc": f"{short['hook']}\n\n#shorts".strip(), "tags": ["shorts"]} | |
| 86 | + return {"title": short["title"], "desc": _with_footer(f"{short['hook']}\n\n#shorts"), "tags": ["shorts"]} | |
| 81 | 87 | |
| 82 | 88 | |
| 83 | 89 | def _check_title(title: str) -> bool: |
| @@ -13,7 +13,8 @@ project.json, "publish" (modifié par l'interface) : | |||
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | Un champ absent prend sa valeur par défaut (titres et vignettes retenus, titre et accroche du | 15 | Un champ absent prend sa valeur par défaut (titres et vignettes retenus, titre et accroche du |
| 16 | -short…). L'envoi de chaque vidéo est un job, dans project["jobs"]["upload:<vidéo>"]. | 16 | +short, description par défaut des réglages…). L'envoi de chaque vidéo est un job, dans |
| 17 | +project["jobs"]["upload:<vidéo>"]. | ||
| 17 | 18 | ||
| 18 | L'API ne sait pas créer le test A/B de YouTube : la variante A est envoyée, B et C se posent | 19 | L'API ne sait pas créer le test A/B de YouTube : la variante A est envoyée, B et C se posent |
| 19 | dans YouTube Studio. Les shorts n'ont pas de vignette personnalisée par l'API. | 20 | dans YouTube Studio. Les shorts n'ont pas de vignette personnalisée par l'API. |
| @@ -26,7 +27,7 @@ from pathlib import Path | |||
| 26 | from ...core import jobs, youtube_api | 27 | from ...core import jobs, youtube_api |
| 27 | from ...core.system import tool | 28 | from ...core.system import tool |
| 28 | from ...core.tasks import TaskContext | 29 | from ...core.tasks import TaskContext |
| 29 | -from ..settings.service import language | 30 | +from ..settings.service import default_description, language |
| 30 | from . import montage, render, service | 31 | from . import montage, render, service |
| 31 | 32 | ||
| 32 | KIND = "project_upload" | 33 | KIND = "project_upload" |
| @@ -72,12 +73,17 @@ def _long_defaults(project: dict) -> dict: | |||
| 72 | titles = [t["t"] for t in project["titles"] if t.get("pick")] | 73 | titles = [t["t"] for t in project["titles"] if t.get("pick")] |
| 73 | others = [t for t in picked if first is None or t["id"] != first["id"]][:MAX_VARIANTS - 1] | 74 | others = [t for t in picked if first is None or t["id"] != first["id"]][:MAX_VARIANTS - 1] |
| 74 | return {"title": (first or {}).get("title") or (titles[0] if titles else project["title"]), | 75 | return {"title": (first or {}).get("title") or (titles[0] if titles else project["title"]), |
| 75 | - "thumb": first["id"] if first else None, "desc": "", "tags": [], | 76 | + "thumb": first["id"] if first else None, "desc": _with_footer(""), "tags": [], |
| 76 | "ab_on": bool(others), "ab": [{"thumb": t["id"], "title": t.get("title") or ""} for t in others]} | 77 | "ab_on": bool(others), "ab": [{"thumb": t["id"], "title": t.get("title") or ""} for t in others]} |
| 77 | 78 | ||
| 78 | 79 | ||
| 80 | +def _with_footer(text: str) -> str: | ||
| 81 | + """Le texte, suivi de la description par défaut des réglages (le bloc commun à toutes les vidéos).""" | ||
| 82 | + return "\n\n".join(x for x in (text.strip(), default_description()) if x) | ||
| 83 | + | ||
| 84 | + | ||
| 79 | def _short_defaults(short: dict) -> dict: | 85 | def _short_defaults(short: dict) -> dict: |
| 80 | - return {"title": short["title"], "desc": f"{short['hook']}\n\n#shorts".strip(), "tags": ["shorts"]} | 86 | + return {"title": short["title"], "desc": _with_footer(f"{short['hook']}\n\n#shorts"), "tags": ["shorts"]} |
| 81 | 87 | ||
| 82 | 88 | ||
| 83 | def _check_title(title: str) -> bool: | 89 | def _check_title(title: str) -> bool: |
modified
backend/studio/modules/settings/api.py +12 -1 | @@ -1,6 +1,7 @@ | ||
| 1 | 1 | """ |
| 2 | 2 | API des reglages : le profil de la chaine, la recherche de la chaine sur YouTube, la |
| 3 | -connexion a ytwatch, l'analyse de la chaine par Claude et le moteur de transcription. | |
| 3 | +connexion a ytwatch, l'analyse de la chaine par Claude, la description par defaut des | |
| 4 | +videos et le moteur de transcription. | |
| 4 | 5 | """ |
| 5 | 6 | from fastapi import APIRouter, File, HTTPException, UploadFile |
| 6 | 7 | from pydantic import BaseModel |
| @@ -31,6 +32,16 @@ def search_channels(q: str): | ||
| 31 | 32 | raise HTTPException(502, f"recherche YouTube impossible : {e}") from e |
| 32 | 33 | |
| 33 | 34 | |
| 35 | +@router.get("/publish") | |
| 36 | +def get_publish(): | |
| 37 | + return service.publish_settings() | |
| 38 | + | |
| 39 | + | |
| 40 | +@router.put("/publish") | |
| 41 | +def put_publish(values: dict): | |
| 42 | + return service.save_publish_settings(values) | |
| 43 | + | |
| 44 | + | |
| 34 | 45 | class Ytwatch(BaseModel): |
| 35 | 46 | url: str = "" |
| 36 | 47 | token: str = "" |
| @@ -1,6 +1,7 @@ | |||
| 1 | """ | 1 | """ |
| 2 | API des reglages : le profil de la chaine, la recherche de la chaine sur YouTube, la | 2 | API des reglages : le profil de la chaine, la recherche de la chaine sur YouTube, la |
| 3 | -connexion a ytwatch, l'analyse de la chaine par Claude et le moteur de transcription. | 3 | +connexion a ytwatch, l'analyse de la chaine par Claude, la description par defaut des |
| 4 | +videos et le moteur de transcription. | ||
| 4 | """ | 5 | """ |
| 5 | from fastapi import APIRouter, File, HTTPException, UploadFile | 6 | from fastapi import APIRouter, File, HTTPException, UploadFile |
| 6 | from pydantic import BaseModel | 7 | from pydantic import BaseModel |
| @@ -31,6 +32,16 @@ def search_channels(q: str): | |||
| 31 | raise HTTPException(502, f"recherche YouTube impossible : {e}") from e | 32 | raise HTTPException(502, f"recherche YouTube impossible : {e}") from e |
| 32 | 33 | ||
| 33 | 34 | ||
| 35 | +@router.get("/publish") | ||
| 36 | +def get_publish(): | ||
| 37 | + return service.publish_settings() | ||
| 38 | + | ||
| 39 | + | ||
| 40 | +@router.put("/publish") | ||
| 41 | +def put_publish(values: dict): | ||
| 42 | + return service.save_publish_settings(values) | ||
| 43 | + | ||
| 44 | + | ||
| 34 | class Ytwatch(BaseModel): | 45 | class Ytwatch(BaseModel): |
| 35 | url: str = "" | 46 | url: str = "" |
| 36 | token: str = "" | 47 | token: str = "" |
modified
backend/studio/modules/settings/service.py +23 -0 | @@ -245,6 +245,29 @@ def _write_transcription(data: dict) -> None: | ||
| 245 | 245 | TRANSCRIPTION_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8") |
| 246 | 246 | |
| 247 | 247 | |
| 248 | +# --- Publication : le bloc commun a toutes les descriptions -------------------------- | |
| 249 | +# Les liens, les outils, les reseaux : propose par defaut sous chaque description, a | |
| 250 | +# completer au-dessus (accroche) ou entre les deux (chapitres). Hors du profil : Claude n'en a pas besoin. | |
| 251 | + | |
| 252 | +PUBLISH_FILE = DATA_DIR / "publish.json" | |
| 253 | + | |
| 254 | + | |
| 255 | +def publish_settings() -> dict: | |
| 256 | + data = json.loads(PUBLISH_FILE.read_text(encoding="utf-8")) if PUBLISH_FILE.is_file() else {} | |
| 257 | + return {"description": str(data.get("description") or "")} | |
| 258 | + | |
| 259 | + | |
| 260 | +def save_publish_settings(values: dict) -> dict: | |
| 261 | + data = {"description": str(values.get("description") or "").strip()} | |
| 262 | + PUBLISH_FILE.parent.mkdir(parents=True, exist_ok=True) | |
| 263 | + PUBLISH_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") | |
| 264 | + return data | |
| 265 | + | |
| 266 | + | |
| 267 | +def default_description() -> str: | |
| 268 | + return publish_settings()["description"] | |
| 269 | + | |
| 270 | + | |
| 248 | 271 | # --- Analyse de la chaine ----------------------------------------------------------- |
| 249 | 272 | # Un job : Claude lit les stats (MCP ytwatch) et quelques transcriptions, et propose une |
| 250 | 273 | # version de chaque champ. Rien n'est ecrit dans le profil : l'auteur accepte champ par champ. |
| @@ -245,6 +245,29 @@ def _write_transcription(data: dict) -> None: | |||
| 245 | TRANSCRIPTION_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8") | 245 | TRANSCRIPTION_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8") |
| 246 | 246 | ||
| 247 | 247 | ||
| 248 | +# --- Publication : le bloc commun a toutes les descriptions -------------------------- | ||
| 249 | +# Les liens, les outils, les reseaux : propose par defaut sous chaque description, a | ||
| 250 | +# completer au-dessus (accroche) ou entre les deux (chapitres). Hors du profil : Claude n'en a pas besoin. | ||
| 251 | + | ||
| 252 | +PUBLISH_FILE = DATA_DIR / "publish.json" | ||
| 253 | + | ||
| 254 | + | ||
| 255 | +def publish_settings() -> dict: | ||
| 256 | + data = json.loads(PUBLISH_FILE.read_text(encoding="utf-8")) if PUBLISH_FILE.is_file() else {} | ||
| 257 | + return {"description": str(data.get("description") or "")} | ||
| 258 | + | ||
| 259 | + | ||
| 260 | +def save_publish_settings(values: dict) -> dict: | ||
| 261 | + data = {"description": str(values.get("description") or "").strip()} | ||
| 262 | + PUBLISH_FILE.parent.mkdir(parents=True, exist_ok=True) | ||
| 263 | + PUBLISH_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") | ||
| 264 | + return data | ||
| 265 | + | ||
| 266 | + | ||
| 267 | +def default_description() -> str: | ||
| 268 | + return publish_settings()["description"] | ||
| 269 | + | ||
| 270 | + | ||
| 248 | # --- Analyse de la chaine ----------------------------------------------------------- | 271 | # --- Analyse de la chaine ----------------------------------------------------------- |
| 249 | # Un job : Claude lit les stats (MCP ytwatch) et quelques transcriptions, et propose une | 272 | # Un job : Claude lit les stats (MCP ytwatch) et quelques transcriptions, et propose une |
| 250 | # version de chaque champ. Rien n'est ecrit dans le profil : l'auteur accepte champ par champ. | 273 | # version de chaque champ. Rien n'est ecrit dans le profil : l'auteur accepte champ par champ. |
modified
web/src/i18n/locales/en/settings.ts +4 -1 | @@ -1,6 +1,9 @@ | ||
| 1 | 1 | export default { |
| 2 | - tabs: { channel: "Channel", services: "Services", interface: "Interface" }, | |
| 2 | + tabs: { channel: "Channel", publish: "Publishing", services: "Services", interface: "Interface" }, | |
| 3 | 3 | unsaved: "Unsaved changes", |
| 4 | + defaultDescription: "Default description", | |
| 5 | + defaultDescriptionHint: "The block shared by all your videos (links, tools, socials…): it fills the description of every new video, long or short. You write above it, and chapters are inserted right before it.", | |
| 6 | + defaultDescriptionPlaceholder: "e.g.\nUseful resources\n--------------------\nMy blog: https://…\n\nWhere to find me?\nBluesky: https://…", | |
| 4 | 7 | title: "Settings", |
| 5 | 8 | 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.", |
| 6 | 9 | language: "Interface language", |
| @@ -1,6 +1,9 @@ | |||
| 1 | export default { | 1 | export default { |
| 2 | - tabs: { channel: "Channel", services: "Services", interface: "Interface" }, | 2 | + tabs: { channel: "Channel", publish: "Publishing", services: "Services", interface: "Interface" }, |
| 3 | unsaved: "Unsaved changes", | 3 | unsaved: "Unsaved changes", |
| 4 | + defaultDescription: "Default description", | ||
| 5 | + defaultDescriptionHint: "The block shared by all your videos (links, tools, socials…): it fills the description of every new video, long or short. You write above it, and chapters are inserted right before it.", | ||
| 6 | + defaultDescriptionPlaceholder: "e.g.\nUseful resources\n--------------------\nMy blog: https://…\n\nWhere to find me?\nBluesky: https://…", | ||
| 4 | title: "Settings", | 7 | title: "Settings", |
| 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.", | 8 | 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.", |
| 6 | language: "Interface language", | 9 | language: "Interface language", |
modified
web/src/i18n/locales/fr/settings.ts +4 -1 | @@ -1,6 +1,9 @@ | ||
| 1 | 1 | export default { |
| 2 | - tabs: { channel: "Chaîne", services: "Services", interface: "Interface" }, | |
| 2 | + tabs: { channel: "Chaîne", publish: "Publication", services: "Services", interface: "Interface" }, | |
| 3 | 3 | unsaved: "Modifications non enregistrées", |
| 4 | + defaultDescription: "Description par défaut", | |
| 5 | + defaultDescriptionHint: "Le bloc commun à toutes tes vidéos (liens, outils, réseaux…) : il remplit la description de chaque nouvelle vidéo, longue ou short. Tu écris au-dessus, et les chapitres s’insèrent juste avant lui.", | |
| 6 | + defaultDescriptionPlaceholder: "Ex. :\nRessources utiles\n--------------------\nMon blog : https://…\n\nOù me trouver ?\nBluesky : https://…", | |
| 4 | 7 | title: "Réglages", |
| 5 | 8 | 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.", |
| 6 | 9 | language: "Langue de l’interface", |
| @@ -1,6 +1,9 @@ | |||
| 1 | export default { | 1 | export default { |
| 2 | - tabs: { channel: "Chaîne", services: "Services", interface: "Interface" }, | 2 | + tabs: { channel: "Chaîne", publish: "Publication", services: "Services", interface: "Interface" }, |
| 3 | unsaved: "Modifications non enregistrées", | 3 | unsaved: "Modifications non enregistrées", |
| 4 | + defaultDescription: "Description par défaut", | ||
| 5 | + defaultDescriptionHint: "Le bloc commun à toutes tes vidéos (liens, outils, réseaux…) : il remplit la description de chaque nouvelle vidéo, longue ou short. Tu écris au-dessus, et les chapitres s’insèrent juste avant lui.", | ||
| 6 | + defaultDescriptionPlaceholder: "Ex. :\nRessources utiles\n--------------------\nMon blog : https://…\n\nOù me trouver ?\nBluesky : https://…", | ||
| 4 | title: "Réglages", | 7 | title: "Réglages", |
| 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.", | 8 | 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.", |
| 6 | language: "Langue de l’interface", | 9 | language: "Langue de l’interface", |
modified
web/src/pages/SettingsPage.vue +44 -2 | @@ -31,7 +31,7 @@ const FIELDS: { key: Exclude<Key, "name">; rows?: number }[] = [ | ||
| 31 | 31 | const { t, locale } = useI18n(); |
| 32 | 32 | |
| 33 | 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; | |
| 34 | +const TABS = ["channel", "publish", "services", "interface"] as const; | |
| 35 | 35 | type Tab = (typeof TABS)[number]; |
| 36 | 36 | const route = useRoute(); |
| 37 | 37 | const router = useRouter(); |
| @@ -130,6 +130,35 @@ function closeSoon() { | ||
| 130 | 130 | setTimeout(() => (open.value = false), 150); |
| 131 | 131 | } |
| 132 | 132 | |
| 133 | +// --- Description par défaut : le bloc commun à toutes les descriptions (liens, outils, réseaux) --- | |
| 134 | + | |
| 135 | +const publish = reactive({ description: "" }); | |
| 136 | +const publishSaved = ref<string | null>(null); | |
| 137 | +const publishBusy = ref(false); | |
| 138 | +const publishDirty = computed(() => publishSaved.value !== null && publish.description !== publishSaved.value); | |
| 139 | + | |
| 140 | +onMounted(async () => { | |
| 141 | + try { | |
| 142 | + Object.assign(publish, await api<typeof publish>("/api/settings/publish")); | |
| 143 | + publishSaved.value = publish.description; | |
| 144 | + } catch (e) { | |
| 145 | + toast((e as Error).message, true); | |
| 146 | + } | |
| 147 | +}); | |
| 148 | + | |
| 149 | +async function savePublish() { | |
| 150 | + publishBusy.value = true; | |
| 151 | + try { | |
| 152 | + Object.assign(publish, await putJson<typeof publish>("/api/settings/publish", publish)); | |
| 153 | + publishSaved.value = publish.description; | |
| 154 | + toast(t("settings.saved")); | |
| 155 | + } catch (e) { | |
| 156 | + toast((e as Error).message, true); | |
| 157 | + } finally { | |
| 158 | + publishBusy.value = false; | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 133 | 162 | // --- Compte YouTube : publication par l'API (étape Upload) --- |
| 134 | 163 | |
| 135 | 164 | type YoutubeAccount = { |
| @@ -285,7 +314,7 @@ function followers(n: number | null): string { | ||
| 285 | 314 | <span class="h1">{{ t("settings.title") }}</span> |
| 286 | 315 | <div class="tabs"> |
| 287 | 316 | <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')" /> | |
| 317 | + {{ t(`settings.tabs.${k}`) }}<span v-if="k === 'channel' ? dirty : k === 'publish' && publishDirty" class="dot" :title="t('settings.unsaved')" /> | |
| 289 | 318 | </button> |
| 290 | 319 | </div> |
| 291 | 320 | |
| @@ -381,6 +410,19 @@ function followers(n: number | null): string { | ||
| 381 | 410 | </template> |
| 382 | 411 | </template> |
| 383 | 412 | |
| 413 | + <template v-else-if="tab === 'publish'"> | |
| 414 | + <div v-if="publishSaved !== null" class="field"> | |
| 415 | + <label>{{ t("settings.defaultDescription") }}</label> | |
| 416 | + <span class="hint">{{ t("settings.defaultDescriptionHint") }}</span> | |
| 417 | + <textarea v-model="publish.description" class="textarea" rows="14" :placeholder="t('settings.defaultDescriptionPlaceholder')" /> | |
| 418 | + </div> | |
| 419 | + <div v-if="publishSaved !== null" class="submit"> | |
| 420 | + <button class="btn xl" :class="publishDirty ? 'red' : 'off'" :disabled="publishBusy || !publishDirty" @click="savePublish"> | |
| 421 | + {{ publishBusy ? t("common.saving") : publishDirty ? t("common.save") : t("settings.savedState") }} | |
| 422 | + </button> | |
| 423 | + </div> | |
| 424 | + </template> | |
| 425 | + | |
| 384 | 426 | <template v-else-if="tab === 'services'"> |
| 385 | 427 | <div class="field"> |
| 386 | 428 | <label>{{ t("settings.account") }}</label> |
| @@ -31,7 +31,7 @@ const FIELDS: { key: Exclude<Key, "name">; rows?: number }[] = [ | |||
| 31 | const { t, locale } = useI18n(); | 31 | const { t, locale } = useI18n(); |
| 32 | 32 | ||
| 33 | /** L'onglet est dans l'URL (/settings/services…) : on peut y renvoyer depuis une autre page. */ | 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; | 34 | +const TABS = ["channel", "publish", "services", "interface"] as const; |
| 35 | type Tab = (typeof TABS)[number]; | 35 | type Tab = (typeof TABS)[number]; |
| 36 | const route = useRoute(); | 36 | const route = useRoute(); |
| 37 | const router = useRouter(); | 37 | const router = useRouter(); |
| @@ -130,6 +130,35 @@ function closeSoon() { | |||
| 130 | setTimeout(() => (open.value = false), 150); | 130 | setTimeout(() => (open.value = false), 150); |
| 131 | } | 131 | } |
| 132 | 132 | ||
| 133 | +// --- Description par défaut : le bloc commun à toutes les descriptions (liens, outils, réseaux) --- | ||
| 134 | + | ||
| 135 | +const publish = reactive({ description: "" }); | ||
| 136 | +const publishSaved = ref<string | null>(null); | ||
| 137 | +const publishBusy = ref(false); | ||
| 138 | +const publishDirty = computed(() => publishSaved.value !== null && publish.description !== publishSaved.value); | ||
| 139 | + | ||
| 140 | +onMounted(async () => { | ||
| 141 | + try { | ||
| 142 | + Object.assign(publish, await api<typeof publish>("/api/settings/publish")); | ||
| 143 | + publishSaved.value = publish.description; | ||
| 144 | + } catch (e) { | ||
| 145 | + toast((e as Error).message, true); | ||
| 146 | + } | ||
| 147 | +}); | ||
| 148 | + | ||
| 149 | +async function savePublish() { | ||
| 150 | + publishBusy.value = true; | ||
| 151 | + try { | ||
| 152 | + Object.assign(publish, await putJson<typeof publish>("/api/settings/publish", publish)); | ||
| 153 | + publishSaved.value = publish.description; | ||
| 154 | + toast(t("settings.saved")); | ||
| 155 | + } catch (e) { | ||
| 156 | + toast((e as Error).message, true); | ||
| 157 | + } finally { | ||
| 158 | + publishBusy.value = false; | ||
| 159 | + } | ||
| 160 | +} | ||
| 161 | + | ||
| 133 | // --- Compte YouTube : publication par l'API (étape Upload) --- | 162 | // --- Compte YouTube : publication par l'API (étape Upload) --- |
| 134 | 163 | ||
| 135 | type YoutubeAccount = { | 164 | type YoutubeAccount = { |
| @@ -285,7 +314,7 @@ function followers(n: number | null): string { | |||
| 285 | <span class="h1">{{ t("settings.title") }}</span> | 314 | <span class="h1">{{ t("settings.title") }}</span> |
| 286 | <div class="tabs"> | 315 | <div class="tabs"> |
| 287 | <button v-for="k in TABS" :key="k" :class="{ on: k === tab }" @click="selectTab(k)"> | 316 | <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')" /> | 317 | + {{ t(`settings.tabs.${k}`) }}<span v-if="k === 'channel' ? dirty : k === 'publish' && publishDirty" class="dot" :title="t('settings.unsaved')" /> |
| 289 | </button> | 318 | </button> |
| 290 | </div> | 319 | </div> |
| 291 | 320 | ||
| @@ -381,6 +410,19 @@ function followers(n: number | null): string { | |||
| 381 | </template> | 410 | </template> |
| 382 | </template> | 411 | </template> |
| 383 | 412 | ||
| 413 | + <template v-else-if="tab === 'publish'"> | ||
| 414 | + <div v-if="publishSaved !== null" class="field"> | ||
| 415 | + <label>{{ t("settings.defaultDescription") }}</label> | ||
| 416 | + <span class="hint">{{ t("settings.defaultDescriptionHint") }}</span> | ||
| 417 | + <textarea v-model="publish.description" class="textarea" rows="14" :placeholder="t('settings.defaultDescriptionPlaceholder')" /> | ||
| 418 | + </div> | ||
| 419 | + <div v-if="publishSaved !== null" class="submit"> | ||
| 420 | + <button class="btn xl" :class="publishDirty ? 'red' : 'off'" :disabled="publishBusy || !publishDirty" @click="savePublish"> | ||
| 421 | + {{ publishBusy ? t("common.saving") : publishDirty ? t("common.save") : t("settings.savedState") }} | ||
| 422 | + </button> | ||
| 423 | + </div> | ||
| 424 | + </template> | ||
| 425 | + | ||
| 384 | <template v-else-if="tab === 'services'"> | 426 | <template v-else-if="tab === 'services'"> |
| 385 | <div class="field"> | 427 | <div class="field"> |
| 386 | <label>{{ t("settings.account") }}</label> | 428 | <label>{{ t("settings.account") }}</label> |
modified
web/src/router.ts +1 -1 | @@ -9,7 +9,7 @@ export const router = createRouter({ | ||
| 9 | 9 | { path: "/", name: "home", component: () => import("./pages/HomePage.vue"), meta: { title: "nav.projects" } }, |
| 10 | 10 | { path: "/nouveau", name: "new", component: () => import("./pages/NewProjectPage.vue"), meta: { title: "nav.newProject" } }, |
| 11 | 11 | { path: "/bibliotheque", name: "library", component: () => import("./pages/LibraryPage.vue"), meta: { title: "nav.library" } }, |
| 12 | - { path: "/settings/:tab(channel|services|interface)?", name: "settings", component: () => import("./pages/SettingsPage.vue"), meta: { title: "nav.settings" } }, | |
| 12 | + { path: "/settings/:tab(channel|publish|services|interface)?", name: "settings", component: () => import("./pages/SettingsPage.vue"), meta: { title: "nav.settings" } }, | |
| 13 | 13 | { path: "/reglages", redirect: "/settings" }, |
| 14 | 14 | { path: "/p/:pid", name: "project", component: () => import("./project/OverviewPage.vue") }, |
| 15 | 15 | { 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/:tab(channel|services|interface)?", name: "settings", component: () => import("./pages/SettingsPage.vue"), meta: { title: "nav.settings" } }, | 12 | + { path: "/settings/:tab(channel|publish|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" } }, |