feat(thumbnail): import a thumbnail made outside the studio
A variant's gallery accepts an uploaded png, jpg or gif (button or drag and drop); the image becomes the variant's chosen thumbnail and can be touched up like a generated one.
c57e0c2 parent: 326702f modified
backend/studio/modules/projects/api.py +21 -0 | @@ -788,6 +788,27 @@ def propose_thumbs(pid: str, tid: str, req: ThumbIdeasRequest): | ||
| 788 | 788 | return _view(pid) |
| 789 | 789 | |
| 790 | 790 | |
| 791 | +@router.post("/{pid}/thumbs/{tid}/upload") | |
| 792 | +def upload_thumb_image(pid: str, tid: str, file: UploadFile = File(...)): | |
| 793 | + """Une vignette faite hors du studio, ajoutée à la galerie de la variante (et retenue pour elle).""" | |
| 794 | + _check(pid) | |
| 795 | + ext = Path(file.filename or "").suffix.lower() | |
| 796 | + # envoyée telle quelle à YouTube si elle est assez légère : seulement les formats qu'il accepte | |
| 797 | + if ext not in (".png", ".jpg", ".jpeg", ".gif"): | |
| 798 | + raise HTTPException(400, "il faut une image png, jpg ou gif") | |
| 799 | + if not any(t.get("id") == tid for t in service.load(pid)["thumbs"]): | |
| 800 | + raise HTTPException(404, "variante inconnue") | |
| 801 | + dest = _save_upload(file, service.project_dir(pid) / service.VIGNETTES_DIR) | |
| 802 | + with service.editing(pid) as project: | |
| 803 | + runtime.sync(project) | |
| 804 | + try: | |
| 805 | + thumbs.add_upload(project, tid, dest.name) | |
| 806 | + except ValueError as e: | |
| 807 | + dest.unlink(missing_ok=True) | |
| 808 | + raise HTTPException(404, str(e)) from e | |
| 809 | + return _view(pid) | |
| 810 | + | |
| 811 | + | |
| 791 | 812 | @router.delete("/{pid}/thumbs/{tid}/images/{name}") |
| 792 | 813 | def remove_thumb_image(pid: str, tid: str, name: str): |
| 793 | 814 | _check(pid) |
| @@ -788,6 +788,27 @@ def propose_thumbs(pid: str, tid: str, req: ThumbIdeasRequest): | |||
| 788 | return _view(pid) | 788 | return _view(pid) |
| 789 | 789 | ||
| 790 | 790 | ||
| 791 | +@router.post("/{pid}/thumbs/{tid}/upload") | ||
| 792 | +def upload_thumb_image(pid: str, tid: str, file: UploadFile = File(...)): | ||
| 793 | + """Une vignette faite hors du studio, ajoutée à la galerie de la variante (et retenue pour elle).""" | ||
| 794 | + _check(pid) | ||
| 795 | + ext = Path(file.filename or "").suffix.lower() | ||
| 796 | + # envoyée telle quelle à YouTube si elle est assez légère : seulement les formats qu'il accepte | ||
| 797 | + if ext not in (".png", ".jpg", ".jpeg", ".gif"): | ||
| 798 | + raise HTTPException(400, "il faut une image png, jpg ou gif") | ||
| 799 | + if not any(t.get("id") == tid for t in service.load(pid)["thumbs"]): | ||
| 800 | + raise HTTPException(404, "variante inconnue") | ||
| 801 | + dest = _save_upload(file, service.project_dir(pid) / service.VIGNETTES_DIR) | ||
| 802 | + with service.editing(pid) as project: | ||
| 803 | + runtime.sync(project) | ||
| 804 | + try: | ||
| 805 | + thumbs.add_upload(project, tid, dest.name) | ||
| 806 | + except ValueError as e: | ||
| 807 | + dest.unlink(missing_ok=True) | ||
| 808 | + raise HTTPException(404, str(e)) from e | ||
| 809 | + return _view(pid) | ||
| 810 | + | ||
| 811 | + | ||
| 791 | @router.delete("/{pid}/thumbs/{tid}/images/{name}") | 812 | @router.delete("/{pid}/thumbs/{tid}/images/{name}") |
| 792 | def remove_thumb_image(pid: str, tid: str, name: str): | 813 | def remove_thumb_image(pid: str, tid: str, name: str): |
| 793 | _check(pid) | 814 | _check(pid) |
modified
backend/studio/modules/projects/thumbs.py +12 -0 | @@ -207,6 +207,18 @@ def sync(project: dict) -> bool: | ||
| 207 | 207 | return changed |
| 208 | 208 | |
| 209 | 209 | |
| 210 | +# le « modèle » d'une image faite hors du studio et importée telle quelle | |
| 211 | +UPLOAD_MODEL = "upload" | |
| 212 | + | |
| 213 | + | |
| 214 | +def add_upload(project: dict, tid: str, file: str) -> None: | |
| 215 | + """Une image importée (déjà copiée dans vignettes/) rejoint la galerie de la variante et devient | |
| 216 | + son image retenue : on l'importe pour s'en servir.""" | |
| 217 | + thumb = _thumb(project, tid) | |
| 218 | + project.setdefault("thumb_images", {}).setdefault(tid, []).insert(0, {"file": file, "model": UPLOAD_MODEL}) | |
| 219 | + thumb["image"] = file | |
| 220 | + | |
| 221 | + | |
| 210 | 222 | def remove_image(project: dict, tid: str, file: str) -> None: |
| 211 | 223 | """Retire une image de la galerie de la variante (le fichier reste dans vignettes/).""" |
| 212 | 224 | images = project.setdefault("thumb_images", {}) |
| @@ -207,6 +207,18 @@ def sync(project: dict) -> bool: | |||
| 207 | return changed | 207 | return changed |
| 208 | 208 | ||
| 209 | 209 | ||
| 210 | +# le « modèle » d'une image faite hors du studio et importée telle quelle | ||
| 211 | +UPLOAD_MODEL = "upload" | ||
| 212 | + | ||
| 213 | + | ||
| 214 | +def add_upload(project: dict, tid: str, file: str) -> None: | ||
| 215 | + """Une image importée (déjà copiée dans vignettes/) rejoint la galerie de la variante et devient | ||
| 216 | + son image retenue : on l'importe pour s'en servir.""" | ||
| 217 | + thumb = _thumb(project, tid) | ||
| 218 | + project.setdefault("thumb_images", {}).setdefault(tid, []).insert(0, {"file": file, "model": UPLOAD_MODEL}) | ||
| 219 | + thumb["image"] = file | ||
| 220 | + | ||
| 221 | + | ||
| 210 | def remove_image(project: dict, tid: str, file: str) -> None: | 222 | def remove_image(project: dict, tid: str, file: str) -> None: |
| 211 | """Retire une image de la galerie de la variante (le fichier reste dans vignettes/).""" | 223 | """Retire une image de la galerie de la variante (le fichier reste dans vignettes/).""" |
| 212 | images = project.setdefault("thumb_images", {}) | 224 | images = project.setdefault("thumb_images", {}) |
modified
web/src/i18n/locales/en/thumbnail.ts +4 -0 | @@ -26,6 +26,10 @@ export default { | ||
| 26 | 26 | pickModel: "Pick at least one model.", |
| 27 | 27 | results: "Results", |
| 28 | 28 | resultsEmpty: "Images generated for {v} will show up here", |
| 29 | + import: "↑ Import an image", | |
| 30 | + importing: "Importing…", | |
| 31 | + importTitle: "Add a thumbnail made elsewhere (png, jpg, gif): it becomes the chosen image. You can also drop it here.", | |
| 32 | + uploaded: "Imported", | |
| 29 | 33 | zoom: "View full size, touch up", |
| 30 | 34 | retouchOf: "Touch-up: {edit}", |
| 31 | 35 | retouchSuffix: " · touch-up", |
| @@ -26,6 +26,10 @@ export default { | |||
| 26 | pickModel: "Pick at least one model.", | 26 | pickModel: "Pick at least one model.", |
| 27 | results: "Results", | 27 | results: "Results", |
| 28 | resultsEmpty: "Images generated for {v} will show up here", | 28 | resultsEmpty: "Images generated for {v} will show up here", |
| 29 | + import: "↑ Import an image", | ||
| 30 | + importing: "Importing…", | ||
| 31 | + importTitle: "Add a thumbnail made elsewhere (png, jpg, gif): it becomes the chosen image. You can also drop it here.", | ||
| 32 | + uploaded: "Imported", | ||
| 29 | zoom: "View full size, touch up", | 33 | zoom: "View full size, touch up", |
| 30 | retouchOf: "Touch-up: {edit}", | 34 | retouchOf: "Touch-up: {edit}", |
| 31 | retouchSuffix: " · touch-up", | 35 | retouchSuffix: " · touch-up", |
modified
web/src/i18n/locales/fr/thumbnail.ts +4 -0 | @@ -26,6 +26,10 @@ export default { | ||
| 26 | 26 | pickModel: "Choisis au moins un modèle.", |
| 27 | 27 | results: "Résultats", |
| 28 | 28 | resultsEmpty: "Les images générées pour {v} arriveront ici", |
| 29 | + import: "↑ Importer une image", | |
| 30 | + importing: "Import…", | |
| 31 | + importTitle: "Ajouter une vignette faite ailleurs (png, jpg, gif) : elle devient l’image choisie. Tu peux aussi la glisser ici.", | |
| 32 | + uploaded: "Importée", | |
| 29 | 33 | zoom: "Voir en grand, retoucher", |
| 30 | 34 | retouchOf: "Retouche : {edit}", |
| 31 | 35 | retouchSuffix: " · retouche", |
| @@ -26,6 +26,10 @@ export default { | |||
| 26 | pickModel: "Choisis au moins un modèle.", | 26 | pickModel: "Choisis au moins un modèle.", |
| 27 | results: "Résultats", | 27 | results: "Résultats", |
| 28 | resultsEmpty: "Les images générées pour {v} arriveront ici", | 28 | resultsEmpty: "Les images générées pour {v} arriveront ici", |
| 29 | + import: "↑ Importer une image", | ||
| 30 | + importing: "Import…", | ||
| 31 | + importTitle: "Ajouter une vignette faite ailleurs (png, jpg, gif) : elle devient l’image choisie. Tu peux aussi la glisser ici.", | ||
| 32 | + uploaded: "Importée", | ||
| 29 | zoom: "Voir en grand, retoucher", | 33 | zoom: "Voir en grand, retoucher", |
| 30 | retouchOf: "Retouche : {edit}", | 34 | retouchOf: "Retouche : {edit}", |
| 31 | retouchSuffix: " · retouche", | 35 | retouchSuffix: " · retouche", |
modified
web/src/project/steps/VignetteStep.vue +46 -5 | @@ -12,8 +12,9 @@ import ConfirmX from "../../components/ConfirmX.vue"; | ||
| 12 | 12 | import Seg from "../../components/Seg.vue"; |
| 13 | 13 | import { post } from "../../lib/api"; |
| 14 | 14 | import { toast } from "../../lib/toast"; |
| 15 | -import { act, failed, running, store, update } from "../store"; | |
| 16 | -import type { Thumb, ThumbIdea, ThumbImage } from "../types"; | |
| 15 | +import { droppedFiles, upload } from "../../lib/upload"; | |
| 16 | +import { act, failed, receive, running, store, update } from "../store"; | |
| 17 | +import type { Project, Thumb, ThumbIdea, ThumbImage } from "../types"; | |
| 17 | 18 | import LibraryPicker from "./vignette/LibraryPicker.vue"; |
| 18 | 19 | import VariantTile from "./vignette/VariantTile.vue"; |
| 19 | 20 | import { refUrl } from "./vignette/refs"; |
| @@ -134,7 +135,8 @@ function ideaAsVariant(idea: ThumbIdea) { | ||
| 134 | 135 | |
| 135 | 136 | // --- génération ----------------------------------------------------------------------- |
| 136 | 137 | const models = computed(() => project.value.runtime.thumbs.models); |
| 137 | -const modelLabel = (id: string) => models.value.find((m) => m.id === id)?.label ?? id; | |
| 138 | +// « upload » : une image faite hors du studio (backend thumbs.UPLOAD_MODEL) | |
| 139 | +const modelLabel = (id: string) => id === "upload" ? tr("thumbnail.uploaded") : models.value.find((m) => m.id === id)?.label ?? id; | |
| 138 | 140 | const chosen = ref<string[]>(JSON.parse(localStorage.getItem(MODELS_KEY) ?? '["nano-banana-pro", "seedream-5-pro"]')); |
| 139 | 141 | watch(chosen, (v) => localStorage.setItem(MODELS_KEY, JSON.stringify(v))); |
| 140 | 142 | |
| @@ -236,6 +238,35 @@ function onKey(e: KeyboardEvent) { | ||
| 236 | 238 | onMounted(() => window.addEventListener("keydown", onKey)); |
| 237 | 239 | onUnmounted(() => window.removeEventListener("keydown", onKey)); |
| 238 | 240 | |
| 241 | +// --- une vignette faite ailleurs : importée dans la galerie, et retenue pour la variante --- | |
| 242 | +const fileInput = ref<HTMLInputElement | null>(null); | |
| 243 | +const uploading = ref(false); | |
| 244 | +const dragging = ref(false); | |
| 245 | + | |
| 246 | +async function importImage(file: File | undefined) { | |
| 247 | + const t = current.value; | |
| 248 | + if (!t?.id || !file || uploading.value) return; | |
| 249 | + uploading.value = true; | |
| 250 | + try { | |
| 251 | + receive(await upload<Project>(`/api/projects/${project.value.id}/thumbs/${t.id}/upload`, [file], "file")); | |
| 252 | + } catch (e) { | |
| 253 | + toast((e as Error).message, true); | |
| 254 | + } finally { | |
| 255 | + uploading.value = false; | |
| 256 | + } | |
| 257 | +} | |
| 258 | + | |
| 259 | +function onFile(e: Event) { | |
| 260 | + const input = e.target as HTMLInputElement; | |
| 261 | + importImage(input.files?.[0]); | |
| 262 | + input.value = ""; | |
| 263 | +} | |
| 264 | + | |
| 265 | +function onDrop(e: DragEvent) { | |
| 266 | + dragging.value = false; | |
| 267 | + importImage(droppedFiles(e)[0]); | |
| 268 | +} | |
| 269 | + | |
| 239 | 270 | async function toLibrary(file: string) { |
| 240 | 271 | try { |
| 241 | 272 | await post(`/api/projects/${project.value.id}/resources/to-library`, { rel: `vignettes/${file}`, category: "vignettes" }); |
| @@ -367,8 +398,16 @@ const big = computed(() => feed.value === "desktop"); | ||
| 367 | 398 | <span v-else-if="!chosen.length" class="hint">{{ tr("thumbnail.pickModel") }}</span> |
| 368 | 399 | </div> |
| 369 | 400 | |
| 370 | - <div class="col results"> | |
| 371 | - <span class="col-title">{{ tr("thumbnail.results") }}<span v-if="images(current).length" class="faint"> · {{ images(current).length }}</span></span> | |
| 401 | + <div | |
| 402 | + class="col results" :class="{ dragging }" | |
| 403 | + @dragover.prevent="dragging = true" @dragleave.self="dragging = false" @drop.prevent="onDrop"> | |
| 404 | + <div class="results-head"> | |
| 405 | + <span class="col-title">{{ tr("thumbnail.results") }}<span v-if="images(current).length" class="faint"> · {{ images(current).length }}</span></span> | |
| 406 | + <button class="link-btn small" :disabled="uploading" :title="tr('thumbnail.importTitle')" @click="fileInput?.click()"> | |
| 407 | + {{ uploading ? tr("thumbnail.importing") : tr("thumbnail.import") }} | |
| 408 | + </button> | |
| 409 | + <input ref="fileInput" type="file" accept=".png,.jpg,.jpeg,.gif" hidden @change="onFile"> | |
| 410 | + </div> | |
| 372 | 411 | <div v-if="!images(current).length && !statuses.length" class="empty hatch"> |
| 373 | 412 | <span class="faint">{{ tr("thumbnail.resultsEmpty", { v: current.v }) }}</span> |
| 374 | 413 | </div> |
| @@ -560,6 +599,8 @@ const big = computed(() => feed.value === "desktop"); | ||
| 560 | 599 | .brief { padding-right: 28px; border-right: 1px solid var(--line-soft); } |
| 561 | 600 | @media (max-width: 1100px) { .brief { padding-right: 0; border-right: 0; } } |
| 562 | 601 | .col-title { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); } |
| 602 | +.results-head { display: flex; justify-content: space-between; align-items: baseline; } | |
| 603 | +.results.dragging { outline: 2px dashed var(--ink); outline-offset: 6px; border-radius: 4px; } | |
| 563 | 604 | .ideas-link { align-self: flex-start; font-size: 12.5px; margin-top: 4px; } |
| 564 | 605 | .brief .run { padding-top: 14px; margin-top: 4px; border-top: 1px solid var(--line-soft); } |
| 565 | 606 | .empty { aspect-ratio: 16 / 9; max-width: 480px; display: grid; place-items: center; border-radius: 4px; font-size: 13px; } |
| @@ -12,8 +12,9 @@ import ConfirmX from "../../components/ConfirmX.vue"; | |||
| 12 | import Seg from "../../components/Seg.vue"; | 12 | import Seg from "../../components/Seg.vue"; |
| 13 | import { post } from "../../lib/api"; | 13 | import { post } from "../../lib/api"; |
| 14 | import { toast } from "../../lib/toast"; | 14 | import { toast } from "../../lib/toast"; |
| 15 | -import { act, failed, running, store, update } from "../store"; | 15 | +import { droppedFiles, upload } from "../../lib/upload"; |
| 16 | -import type { Thumb, ThumbIdea, ThumbImage } from "../types"; | 16 | +import { act, failed, receive, running, store, update } from "../store"; |
| 17 | +import type { Project, Thumb, ThumbIdea, ThumbImage } from "../types"; | ||
| 17 | import LibraryPicker from "./vignette/LibraryPicker.vue"; | 18 | import LibraryPicker from "./vignette/LibraryPicker.vue"; |
| 18 | import VariantTile from "./vignette/VariantTile.vue"; | 19 | import VariantTile from "./vignette/VariantTile.vue"; |
| 19 | import { refUrl } from "./vignette/refs"; | 20 | import { refUrl } from "./vignette/refs"; |
| @@ -134,7 +135,8 @@ function ideaAsVariant(idea: ThumbIdea) { | |||
| 134 | 135 | ||
| 135 | // --- génération ----------------------------------------------------------------------- | 136 | // --- génération ----------------------------------------------------------------------- |
| 136 | const models = computed(() => project.value.runtime.thumbs.models); | 137 | const models = computed(() => project.value.runtime.thumbs.models); |
| 137 | -const modelLabel = (id: string) => models.value.find((m) => m.id === id)?.label ?? id; | 138 | +// « upload » : une image faite hors du studio (backend thumbs.UPLOAD_MODEL) |
| 139 | +const modelLabel = (id: string) => id === "upload" ? tr("thumbnail.uploaded") : models.value.find((m) => m.id === id)?.label ?? id; | ||
| 138 | const chosen = ref<string[]>(JSON.parse(localStorage.getItem(MODELS_KEY) ?? '["nano-banana-pro", "seedream-5-pro"]')); | 140 | const chosen = ref<string[]>(JSON.parse(localStorage.getItem(MODELS_KEY) ?? '["nano-banana-pro", "seedream-5-pro"]')); |
| 139 | watch(chosen, (v) => localStorage.setItem(MODELS_KEY, JSON.stringify(v))); | 141 | watch(chosen, (v) => localStorage.setItem(MODELS_KEY, JSON.stringify(v))); |
| 140 | 142 | ||
| @@ -236,6 +238,35 @@ function onKey(e: KeyboardEvent) { | |||
| 236 | onMounted(() => window.addEventListener("keydown", onKey)); | 238 | onMounted(() => window.addEventListener("keydown", onKey)); |
| 237 | onUnmounted(() => window.removeEventListener("keydown", onKey)); | 239 | onUnmounted(() => window.removeEventListener("keydown", onKey)); |
| 238 | 240 | ||
| 241 | +// --- une vignette faite ailleurs : importée dans la galerie, et retenue pour la variante --- | ||
| 242 | +const fileInput = ref<HTMLInputElement | null>(null); | ||
| 243 | +const uploading = ref(false); | ||
| 244 | +const dragging = ref(false); | ||
| 245 | + | ||
| 246 | +async function importImage(file: File | undefined) { | ||
| 247 | + const t = current.value; | ||
| 248 | + if (!t?.id || !file || uploading.value) return; | ||
| 249 | + uploading.value = true; | ||
| 250 | + try { | ||
| 251 | + receive(await upload<Project>(`/api/projects/${project.value.id}/thumbs/${t.id}/upload`, [file], "file")); | ||
| 252 | + } catch (e) { | ||
| 253 | + toast((e as Error).message, true); | ||
| 254 | + } finally { | ||
| 255 | + uploading.value = false; | ||
| 256 | + } | ||
| 257 | +} | ||
| 258 | + | ||
| 259 | +function onFile(e: Event) { | ||
| 260 | + const input = e.target as HTMLInputElement; | ||
| 261 | + importImage(input.files?.[0]); | ||
| 262 | + input.value = ""; | ||
| 263 | +} | ||
| 264 | + | ||
| 265 | +function onDrop(e: DragEvent) { | ||
| 266 | + dragging.value = false; | ||
| 267 | + importImage(droppedFiles(e)[0]); | ||
| 268 | +} | ||
| 269 | + | ||
| 239 | async function toLibrary(file: string) { | 270 | async function toLibrary(file: string) { |
| 240 | try { | 271 | try { |
| 241 | await post(`/api/projects/${project.value.id}/resources/to-library`, { rel: `vignettes/${file}`, category: "vignettes" }); | 272 | await post(`/api/projects/${project.value.id}/resources/to-library`, { rel: `vignettes/${file}`, category: "vignettes" }); |
| @@ -367,8 +398,16 @@ const big = computed(() => feed.value === "desktop"); | |||
| 367 | <span v-else-if="!chosen.length" class="hint">{{ tr("thumbnail.pickModel") }}</span> | 398 | <span v-else-if="!chosen.length" class="hint">{{ tr("thumbnail.pickModel") }}</span> |
| 368 | </div> | 399 | </div> |
| 369 | 400 | ||
| 370 | - <div class="col results"> | 401 | + <div |
| 371 | - <span class="col-title">{{ tr("thumbnail.results") }}<span v-if="images(current).length" class="faint"> · {{ images(current).length }}</span></span> | 402 | + class="col results" :class="{ dragging }" |
| 403 | + @dragover.prevent="dragging = true" @dragleave.self="dragging = false" @drop.prevent="onDrop"> | ||
| 404 | + <div class="results-head"> | ||
| 405 | + <span class="col-title">{{ tr("thumbnail.results") }}<span v-if="images(current).length" class="faint"> · {{ images(current).length }}</span></span> | ||
| 406 | + <button class="link-btn small" :disabled="uploading" :title="tr('thumbnail.importTitle')" @click="fileInput?.click()"> | ||
| 407 | + {{ uploading ? tr("thumbnail.importing") : tr("thumbnail.import") }} | ||
| 408 | + </button> | ||
| 409 | + <input ref="fileInput" type="file" accept=".png,.jpg,.jpeg,.gif" hidden @change="onFile"> | ||
| 410 | + </div> | ||
| 372 | <div v-if="!images(current).length && !statuses.length" class="empty hatch"> | 411 | <div v-if="!images(current).length && !statuses.length" class="empty hatch"> |
| 373 | <span class="faint">{{ tr("thumbnail.resultsEmpty", { v: current.v }) }}</span> | 412 | <span class="faint">{{ tr("thumbnail.resultsEmpty", { v: current.v }) }}</span> |
| 374 | </div> | 413 | </div> |
| @@ -560,6 +599,8 @@ const big = computed(() => feed.value === "desktop"); | |||
| 560 | .brief { padding-right: 28px; border-right: 1px solid var(--line-soft); } | 599 | .brief { padding-right: 28px; border-right: 1px solid var(--line-soft); } |
| 561 | @media (max-width: 1100px) { .brief { padding-right: 0; border-right: 0; } } | 600 | @media (max-width: 1100px) { .brief { padding-right: 0; border-right: 0; } } |
| 562 | .col-title { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); } | 601 | .col-title { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); } |
| 602 | +.results-head { display: flex; justify-content: space-between; align-items: baseline; } | ||
| 603 | +.results.dragging { outline: 2px dashed var(--ink); outline-offset: 6px; border-radius: 4px; } | ||
| 563 | .ideas-link { align-self: flex-start; font-size: 12.5px; margin-top: 4px; } | 604 | .ideas-link { align-self: flex-start; font-size: 12.5px; margin-top: 4px; } |
| 564 | .brief .run { padding-top: 14px; margin-top: 4px; border-top: 1px solid var(--line-soft); } | 605 | .brief .run { padding-top: 14px; margin-top: 4px; border-top: 1px solid var(--line-soft); } |
| 565 | .empty { aspect-ratio: 16 / 9; max-width: 480px; display: grid; place-items: center; border-radius: 4px; font-size: 13px; } | 606 | .empty { aspect-ratio: 16 / 9; max-width: 480px; display: grid; place-items: center; border-radius: 4px; font-size: 13px; } |