fix(vignette): paginate the reference picker so cells stop overlapping
The grid scrolled inside the flex modal, which squashed its rows while the square cells kept their height. Show 15 images per page instead.
c803802 parent: 9fc5f05 modified
web/src/i18n/locales/en/thumbnail.ts +1 -0 | @@ -76,6 +76,7 @@ export default { | ||
| 76 | 76 | all: "All · {n}", |
| 77 | 77 | noMatch: "No matching images.", |
| 78 | 78 | emptyCategory: "No images in this category yet.", |
| 79 | + page: "Page {n} of {total}", | |
| 79 | 80 | uploading: "Uploading · {n}%", |
| 80 | 81 | drop: "Drop images here to add them to the library", |
| 81 | 82 | }, |
| @@ -76,6 +76,7 @@ export default { | |||
| 76 | all: "All · {n}", | 76 | all: "All · {n}", |
| 77 | noMatch: "No matching images.", | 77 | noMatch: "No matching images.", |
| 78 | emptyCategory: "No images in this category yet.", | 78 | emptyCategory: "No images in this category yet.", |
| 79 | + page: "Page {n} of {total}", | ||
| 79 | uploading: "Uploading · {n}%", | 80 | uploading: "Uploading · {n}%", |
| 80 | drop: "Drop images here to add them to the library", | 81 | drop: "Drop images here to add them to the library", |
| 81 | }, | 82 | }, |
modified
web/src/i18n/locales/fr/thumbnail.ts +1 -0 | @@ -76,6 +76,7 @@ export default { | ||
| 76 | 76 | all: "Toutes · {n}", |
| 77 | 77 | noMatch: "Aucune image ne correspond.", |
| 78 | 78 | emptyCategory: "Aucune image dans cette catégorie pour l’instant.", |
| 79 | + page: "Page {n} / {total}", | |
| 79 | 80 | uploading: "Envoi · {n} %", |
| 80 | 81 | drop: "Glisse des images ici pour les ajouter à la bibliothèque", |
| 81 | 82 | }, |
| @@ -76,6 +76,7 @@ export default { | |||
| 76 | all: "Toutes · {n}", | 76 | all: "Toutes · {n}", |
| 77 | noMatch: "Aucune image ne correspond.", | 77 | noMatch: "Aucune image ne correspond.", |
| 78 | emptyCategory: "Aucune image dans cette catégorie pour l’instant.", | 78 | emptyCategory: "Aucune image dans cette catégorie pour l’instant.", |
| 79 | + page: "Page {n} / {total}", | ||
| 79 | uploading: "Envoi · {n} %", | 80 | uploading: "Envoi · {n} %", |
| 80 | drop: "Glisse des images ici pour les ajouter à la bibliothèque", | 81 | drop: "Glisse des images ici pour les ajouter à la bibliothèque", |
| 81 | }, | 82 | }, |
modified
web/src/project/steps/vignette/LibraryPicker.vue +21 -6 | @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * (assets et fichiers ajoutés) ; on peut y glisser de nouvelles images, rangées dans la catégorie et |
| 5 | 5 | * cochées d'emblée. |
| 6 | 6 | */ |
| 7 | -import { computed, onMounted, ref } from "vue"; | |
| 7 | +import { computed, onMounted, ref, watch } from "vue"; | |
| 8 | 8 | import { useI18n } from "vue-i18n"; |
| 9 | 9 | import { api } from "../../../lib/api"; |
| 10 | 10 | import { toast } from "../../../lib/toast"; |
| @@ -41,6 +41,13 @@ const shown = computed(() => { | ||
| 41 | 41 | && (!q || `${x.name} ${x.project ?? ""} ${x.type}`.toLowerCase().includes(q))); |
| 42 | 42 | }); |
| 43 | 43 | |
| 44 | +// trois lignes de cinq : la page tient dans la fenêtre sans défiler | |
| 45 | +const PAGE = 15; | |
| 46 | +const page = ref(0); | |
| 47 | +const pages = computed(() => Math.max(1, Math.ceil(shown.value.length / PAGE))); | |
| 48 | +const paged = computed(() => shown.value.slice(page.value * PAGE, (page.value + 1) * PAGE)); | |
| 49 | +watch([filter, query], () => (page.value = 0)); | |
| 50 | + | |
| 44 | 51 | // les médias du projet, comme le panneau Médias du script |
| 45 | 52 | const PROJECT_GROUPS = ["Assets", "Fichiers ajoutés"]; |
| 46 | 53 | |
| @@ -101,8 +108,8 @@ async function onDrop(e: DragEvent) { | ||
| 101 | 108 | {{ c.type }} · {{ c.n }} |
| 102 | 109 | </button> |
| 103 | 110 | </div> |
| 104 | - <div class="grid scroll"> | |
| 105 | - <button v-for="x in shown" :key="x.rel" class="cell" :class="{ on: picked.includes(x.rel) }" :title="`${x.type} · ${x.name}`" @click="toggle(x.rel)"> | |
| 111 | + <div class="grid"> | |
| 112 | + <button v-for="x in paged" :key="x.rel" class="cell" :class="{ on: picked.includes(x.rel) }" :title="`${x.type} · ${x.name}`" @click="toggle(x.rel)"> | |
| 106 | 113 | <img :src="refUrl(pid, x.rel)" alt=""> |
| 107 | 114 | <span v-if="picked.includes(x.rel)" class="n">{{ picked.indexOf(x.rel) + 1 }}</span> |
| 108 | 115 | </button> |
| @@ -110,6 +117,11 @@ async function onDrop(e: DragEvent) { | ||
| 110 | 117 | {{ query ? t("thumbnail.picker.noMatch") : t("thumbnail.picker.emptyCategory") }} |
| 111 | 118 | </span> |
| 112 | 119 | </div> |
| 120 | + <div v-if="pages > 1" class="pager"> | |
| 121 | + <button class="btn" :disabled="page === 0" @click="page--">‹</button> | |
| 122 | + <span class="mono">{{ t("thumbnail.picker.page", { n: page + 1, total: pages }) }}</span> | |
| 123 | + <button class="btn" :disabled="page >= pages - 1" @click="page++">›</button> | |
| 124 | + </div> | |
| 113 | 125 | <div class="drop-zone" :class="{ over }"> |
| 114 | 126 | <template v-if="uploading != null">{{ t("thumbnail.picker.uploading", { n: uploading }) }}</template> |
| 115 | 127 | <template v-else>{{ t("thumbnail.picker.drop") }}</template> |
| @@ -126,7 +138,7 @@ async function onDrop(e: DragEvent) { | ||
| 126 | 138 | .picker { |
| 127 | 139 | position: fixed; z-index: 41; top: 8vh; left: 50%; transform: translateX(-50%); width: min(880px, 92vw); |
| 128 | 140 | max-height: 84vh; background: #fff; border-radius: 6px; box-shadow: 0 16px 48px rgba(0, 0, 0, .2); |
| 129 | - padding: 20px 22px; display: flex; flex-direction: column; gap: 14px; | |
| 141 | + padding: 20px 22px; display: flex; flex-direction: column; gap: 14px; overflow-y: auto; | |
| 130 | 142 | } |
| 131 | 143 | .head { display: flex; align-items: baseline; gap: 12px; } |
| 132 | 144 | .h { font-size: 16px; font-weight: 600; } |
| @@ -135,18 +147,21 @@ async function onDrop(e: DragEvent) { | ||
| 135 | 147 | .chip { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 4px 9px; font-size: 12px; cursor: pointer; } |
| 136 | 148 | .chip:hover { border-color: var(--border-hover); } |
| 137 | 149 | .chip.on { background: var(--ink); border-color: var(--ink); color: #fff; } |
| 138 | -.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; align-content: start; } | |
| 150 | +.grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; } | |
| 139 | 151 | .cell { |
| 140 | 152 | position: relative; aspect-ratio: 1; padding: 0; border: none; border-radius: 4px; overflow: hidden; |
| 141 | 153 | cursor: pointer; background: var(--bg-soft); outline-offset: 2px; |
| 142 | 154 | } |
| 143 | -.cell img { width: 100%; height: 100%; object-fit: cover; display: block; } | |
| 155 | +/* hors du flux : une capture très haute ne doit pas agrandir sa cellule et déborder sur la ligne suivante */ | |
| 156 | +.cell img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: top; display: block; } | |
| 144 | 157 | .cell.on { outline: 2px solid var(--red); } |
| 145 | 158 | .cell .n { |
| 146 | 159 | position: absolute; top: 6px; right: 6px; min-width: 20px; height: 20px; border-radius: 10px; padding: 0 5px; |
| 147 | 160 | background: var(--red); color: #fff; font-family: var(--mono); font-size: 11px; line-height: 20px; |
| 148 | 161 | } |
| 149 | 162 | .nothing { grid-column: 1 / -1; padding: 10px 0; font-size: 13.5px; color: var(--faint); } |
| 163 | +.pager { display: flex; align-items: center; justify-content: center; gap: 12px; font-size: 12px; color: var(--muted); } | |
| 164 | +.pager .btn { padding: 2px 10px; } | |
| 150 | 165 | .drop-zone { padding: 14px; } |
| 151 | 166 | .foot { display: flex; justify-content: flex-end; gap: 8px; } |
| 152 | 167 | </style> |
| @@ -4,7 +4,7 @@ | |||
| 4 | * (assets et fichiers ajoutés) ; on peut y glisser de nouvelles images, rangées dans la catégorie et | 4 | * (assets et fichiers ajoutés) ; on peut y glisser de nouvelles images, rangées dans la catégorie et |
| 5 | * cochées d'emblée. | 5 | * cochées d'emblée. |
| 6 | */ | 6 | */ |
| 7 | -import { computed, onMounted, ref } from "vue"; | 7 | +import { computed, onMounted, ref, watch } from "vue"; |
| 8 | import { useI18n } from "vue-i18n"; | 8 | import { useI18n } from "vue-i18n"; |
| 9 | import { api } from "../../../lib/api"; | 9 | import { api } from "../../../lib/api"; |
| 10 | import { toast } from "../../../lib/toast"; | 10 | import { toast } from "../../../lib/toast"; |
| @@ -41,6 +41,13 @@ const shown = computed(() => { | |||
| 41 | && (!q || `${x.name} ${x.project ?? ""} ${x.type}`.toLowerCase().includes(q))); | 41 | && (!q || `${x.name} ${x.project ?? ""} ${x.type}`.toLowerCase().includes(q))); |
| 42 | }); | 42 | }); |
| 43 | 43 | ||
| 44 | +// trois lignes de cinq : la page tient dans la fenêtre sans défiler | ||
| 45 | +const PAGE = 15; | ||
| 46 | +const page = ref(0); | ||
| 47 | +const pages = computed(() => Math.max(1, Math.ceil(shown.value.length / PAGE))); | ||
| 48 | +const paged = computed(() => shown.value.slice(page.value * PAGE, (page.value + 1) * PAGE)); | ||
| 49 | +watch([filter, query], () => (page.value = 0)); | ||
| 50 | + | ||
| 44 | // les médias du projet, comme le panneau Médias du script | 51 | // les médias du projet, comme le panneau Médias du script |
| 45 | const PROJECT_GROUPS = ["Assets", "Fichiers ajoutés"]; | 52 | const PROJECT_GROUPS = ["Assets", "Fichiers ajoutés"]; |
| 46 | 53 | ||
| @@ -101,8 +108,8 @@ async function onDrop(e: DragEvent) { | |||
| 101 | {{ c.type }} · {{ c.n }} | 108 | {{ c.type }} · {{ c.n }} |
| 102 | </button> | 109 | </button> |
| 103 | </div> | 110 | </div> |
| 104 | - <div class="grid scroll"> | 111 | + <div class="grid"> |
| 105 | - <button v-for="x in shown" :key="x.rel" class="cell" :class="{ on: picked.includes(x.rel) }" :title="`${x.type} · ${x.name}`" @click="toggle(x.rel)"> | 112 | + <button v-for="x in paged" :key="x.rel" class="cell" :class="{ on: picked.includes(x.rel) }" :title="`${x.type} · ${x.name}`" @click="toggle(x.rel)"> |
| 106 | <img :src="refUrl(pid, x.rel)" alt=""> | 113 | <img :src="refUrl(pid, x.rel)" alt=""> |
| 107 | <span v-if="picked.includes(x.rel)" class="n">{{ picked.indexOf(x.rel) + 1 }}</span> | 114 | <span v-if="picked.includes(x.rel)" class="n">{{ picked.indexOf(x.rel) + 1 }}</span> |
| 108 | </button> | 115 | </button> |
| @@ -110,6 +117,11 @@ async function onDrop(e: DragEvent) { | |||
| 110 | {{ query ? t("thumbnail.picker.noMatch") : t("thumbnail.picker.emptyCategory") }} | 117 | {{ query ? t("thumbnail.picker.noMatch") : t("thumbnail.picker.emptyCategory") }} |
| 111 | </span> | 118 | </span> |
| 112 | </div> | 119 | </div> |
| 120 | + <div v-if="pages > 1" class="pager"> | ||
| 121 | + <button class="btn" :disabled="page === 0" @click="page--">‹</button> | ||
| 122 | + <span class="mono">{{ t("thumbnail.picker.page", { n: page + 1, total: pages }) }}</span> | ||
| 123 | + <button class="btn" :disabled="page >= pages - 1" @click="page++">›</button> | ||
| 124 | + </div> | ||
| 113 | <div class="drop-zone" :class="{ over }"> | 125 | <div class="drop-zone" :class="{ over }"> |
| 114 | <template v-if="uploading != null">{{ t("thumbnail.picker.uploading", { n: uploading }) }}</template> | 126 | <template v-if="uploading != null">{{ t("thumbnail.picker.uploading", { n: uploading }) }}</template> |
| 115 | <template v-else>{{ t("thumbnail.picker.drop") }}</template> | 127 | <template v-else>{{ t("thumbnail.picker.drop") }}</template> |
| @@ -126,7 +138,7 @@ async function onDrop(e: DragEvent) { | |||
| 126 | .picker { | 138 | .picker { |
| 127 | position: fixed; z-index: 41; top: 8vh; left: 50%; transform: translateX(-50%); width: min(880px, 92vw); | 139 | position: fixed; z-index: 41; top: 8vh; left: 50%; transform: translateX(-50%); width: min(880px, 92vw); |
| 128 | max-height: 84vh; background: #fff; border-radius: 6px; box-shadow: 0 16px 48px rgba(0, 0, 0, .2); | 140 | max-height: 84vh; background: #fff; border-radius: 6px; box-shadow: 0 16px 48px rgba(0, 0, 0, .2); |
| 129 | - padding: 20px 22px; display: flex; flex-direction: column; gap: 14px; | 141 | + padding: 20px 22px; display: flex; flex-direction: column; gap: 14px; overflow-y: auto; |
| 130 | } | 142 | } |
| 131 | .head { display: flex; align-items: baseline; gap: 12px; } | 143 | .head { display: flex; align-items: baseline; gap: 12px; } |
| 132 | .h { font-size: 16px; font-weight: 600; } | 144 | .h { font-size: 16px; font-weight: 600; } |
| @@ -135,18 +147,21 @@ async function onDrop(e: DragEvent) { | |||
| 135 | .chip { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 4px 9px; font-size: 12px; cursor: pointer; } | 147 | .chip { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 4px 9px; font-size: 12px; cursor: pointer; } |
| 136 | .chip:hover { border-color: var(--border-hover); } | 148 | .chip:hover { border-color: var(--border-hover); } |
| 137 | .chip.on { background: var(--ink); border-color: var(--ink); color: #fff; } | 149 | .chip.on { background: var(--ink); border-color: var(--ink); color: #fff; } |
| 138 | -.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; align-content: start; } | 150 | +.grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; } |
| 139 | .cell { | 151 | .cell { |
| 140 | position: relative; aspect-ratio: 1; padding: 0; border: none; border-radius: 4px; overflow: hidden; | 152 | position: relative; aspect-ratio: 1; padding: 0; border: none; border-radius: 4px; overflow: hidden; |
| 141 | cursor: pointer; background: var(--bg-soft); outline-offset: 2px; | 153 | cursor: pointer; background: var(--bg-soft); outline-offset: 2px; |
| 142 | } | 154 | } |
| 143 | -.cell img { width: 100%; height: 100%; object-fit: cover; display: block; } | 155 | +/* hors du flux : une capture très haute ne doit pas agrandir sa cellule et déborder sur la ligne suivante */ |
| 156 | +.cell img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: top; display: block; } | ||
| 144 | .cell.on { outline: 2px solid var(--red); } | 157 | .cell.on { outline: 2px solid var(--red); } |
| 145 | .cell .n { | 158 | .cell .n { |
| 146 | position: absolute; top: 6px; right: 6px; min-width: 20px; height: 20px; border-radius: 10px; padding: 0 5px; | 159 | position: absolute; top: 6px; right: 6px; min-width: 20px; height: 20px; border-radius: 10px; padding: 0 5px; |
| 147 | background: var(--red); color: #fff; font-family: var(--mono); font-size: 11px; line-height: 20px; | 160 | background: var(--red); color: #fff; font-family: var(--mono); font-size: 11px; line-height: 20px; |
| 148 | } | 161 | } |
| 149 | .nothing { grid-column: 1 / -1; padding: 10px 0; font-size: 13.5px; color: var(--faint); } | 162 | .nothing { grid-column: 1 / -1; padding: 10px 0; font-size: 13.5px; color: var(--faint); } |
| 163 | +.pager { display: flex; align-items: center; justify-content: center; gap: 12px; font-size: 12px; color: var(--muted); } | ||
| 164 | +.pager .btn { padding: 2px 10px; } | ||
| 150 | .drop-zone { padding: 14px; } | 165 | .drop-zone { padding: 14px; } |
| 151 | .foot { display: flex; justify-content: flex-end; gap: 8px; } | 166 | .foot { display: flex; justify-content: flex-end; gap: 8px; } |
| 152 | </style> | 167 | </style> |