add media order controls

This commit is contained in:
Yisroel Baum 2026-07-31 11:11:47 +03:00
parent faed935436
commit 543ee6628d
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
2 changed files with 130 additions and 5 deletions

View file

@ -43,6 +43,7 @@ export const useMediaSetsStore = defineStore('mediaSets', () => {
const isCreating = ref(false) const isCreating = ref(false)
const isUpdating = ref(false) const isUpdating = ref(false)
const isDeleting = ref(false) const isDeleting = ref(false)
const isReordering = ref(false)
const createError = ref<string | null>(null) const createError = ref<string | null>(null)
const updateError = ref<string | null>(null) const updateError = ref<string | null>(null)
const deleteError = ref<string | null>(null) const deleteError = ref<string | null>(null)
@ -187,6 +188,31 @@ export const useMediaSetsStore = defineStore('mediaSets', () => {
} }
} }
async function reorderSets(setIds: number[]): Promise<boolean> {
isReordering.value = true
try {
const response = await fetch(`${API_BASE_URL}/api/sets/order`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ setIds }),
})
if (!response.ok) {
return false
}
const data: SetsResponse = await response.json()
sets.value = data.sets
return true
} catch {
return false
} finally {
isReordering.value = false
}
}
async function errorMessage( async function errorMessage(
response: Response, response: Response,
fallbackMessage: string, fallbackMessage: string,
@ -206,6 +232,7 @@ export const useMediaSetsStore = defineStore('mediaSets', () => {
isCreating, isCreating,
isUpdating, isUpdating,
isDeleting, isDeleting,
isReordering,
createError, createError,
updateError, updateError,
deleteError, deleteError,
@ -213,5 +240,6 @@ export const useMediaSetsStore = defineStore('mediaSets', () => {
createSet, createSet,
updateSet, updateSet,
deleteSet, deleteSet,
reorderSets,
} }
}) })

View file

@ -3,6 +3,8 @@ import { storeToRefs } from 'pinia'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { import {
AlertTriangle as AlertTriangleIcon, AlertTriangle as AlertTriangleIcon,
ChevronDown as ChevronDownIcon,
ChevronUp as ChevronUpIcon,
Pencil as PencilIcon, Pencil as PencilIcon,
Plus as PlusIcon, Plus as PlusIcon,
Trash2 as TrashIcon, Trash2 as TrashIcon,
@ -18,6 +20,8 @@ interface CreateSetForm {
description: string description: string
} }
type SetMoveDirection = 'up' | 'down'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const mediaSetsStore = useMediaSetsStore() const mediaSetsStore = useMediaSetsStore()
@ -28,6 +32,7 @@ const {
isCreating, isCreating,
isUpdating, isUpdating,
isDeleting, isDeleting,
isReordering,
createError, createError,
updateError, updateError,
deleteError, deleteError,
@ -42,6 +47,7 @@ const localCreateError = ref<string | null>(null)
const localEditError = ref<string | null>(null) const localEditError = ref<string | null>(null)
const editingSet = ref<MediaSet | null>(null) const editingSet = ref<MediaSet | null>(null)
const deletingSet = ref<MediaSet | null>(null) const deletingSet = ref<MediaSet | null>(null)
const setOrderStatus = ref<string | null>(null)
const createSetForm = reactive<CreateSetForm>({ const createSetForm = reactive<CreateSetForm>({
name: '', name: '',
@ -231,6 +237,42 @@ async function handleDeleteSet(): Promise<void> {
deletingSet.value = null deletingSet.value = null
} }
async function handleMoveSet(
setId: number,
direction: SetMoveDirection,
): Promise<void> {
const currentIndex = sets.value.findIndex((mediaSet) => {
return mediaSet.id === setId
})
if (currentIndex === -1) {
return
}
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1
if (targetIndex < 0 || targetIndex >= sets.value.length) {
return
}
const reorderedSets = [...sets.value]
const movingSet = reorderedSets[currentIndex]
const targetSet = reorderedSets[targetIndex]
if (movingSet === undefined || targetSet === undefined) {
return
}
reorderedSets[currentIndex] = targetSet
reorderedSets[targetIndex] = movingSet
const setIds = reorderedSets.map((mediaSet) => {
return mediaSet.id
})
setOrderStatus.value = null
const saved = await mediaSetsStore.reorderSets(setIds)
if (saved) {
setOrderStatus.value = 'Set order saved'
}
}
function openSet(mediaSet: MediaSet): void { function openSet(mediaSet: MediaSet): void {
if (mediaSet.rootElementId === null) { if (mediaSet.rootElementId === null) {
return return
@ -287,6 +329,14 @@ function resetEditSetForm(): void {
</header> </header>
<section class="media-page__sets" aria-label="Featured sets"> <section class="media-page__sets" aria-label="Featured sets">
<p
v-if="setOrderStatus !== null"
class="media-page__status media-page__order-status"
data-cy="media-set-order-status"
aria-live="polite"
>
{{ setOrderStatus }}
</p>
<p v-if="isLoading" class="media-page__status">Loading media sets...</p> <p v-if="isLoading" class="media-page__status">Loading media sets...</p>
<p <p
v-else-if="error" v-else-if="error"
@ -302,7 +352,7 @@ function resetEditSetForm(): void {
No media sets are available yet. No media sets are available yet.
</p> </p>
<div v-else class="media-page__grid" data-cy="media-set-grid"> <div v-else class="media-page__grid" data-cy="media-set-grid">
<template v-for="mediaSet in sets" :key="mediaSet.id"> <template v-for="(mediaSet, setIndex) in sets" :key="mediaSet.id">
<article <article
v-if="authStore.isAuthenticated" v-if="authStore.isAuthenticated"
class="media-page__card media-page__card--managed" class="media-page__card media-page__card--managed"
@ -350,6 +400,34 @@ function resetEditSetForm(): void {
<TrashIcon :size="18" aria-hidden="true" /> <TrashIcon :size="18" aria-hidden="true" />
</button> </button>
</div> </div>
<div
class="media-page__card-order-actions"
aria-label="Set order actions"
@click.stop
>
<button
type="button"
class="media-page__card-action"
data-cy="media-set-move-up"
:disabled="isReordering || setIndex === 0"
:aria-label="`Move ${mediaSet.name} up`"
:title="`Move ${mediaSet.name} up`"
@click="handleMoveSet(mediaSet.id, 'up')"
>
<ChevronUpIcon :size="18" aria-hidden="true" />
</button>
<button
type="button"
class="media-page__card-action"
data-cy="media-set-move-down"
:disabled="isReordering || setIndex === sets.length - 1"
:aria-label="`Move ${mediaSet.name} down`"
:title="`Move ${mediaSet.name} down`"
@click="handleMoveSet(mediaSet.id, 'down')"
>
<ChevronDownIcon :size="18" aria-hidden="true" />
</button>
</div>
</article> </article>
<RouterLink <RouterLink
v-else-if="mediaSet.rootElementId !== null" v-else-if="mediaSet.rootElementId !== null"
@ -697,6 +775,12 @@ function resetEditSetForm(): void {
border-color: #e5b8b8; border-color: #e5b8b8;
} }
.media-page__order-status {
margin-bottom: 1.25rem;
color: var(--color-olive);
border-color: #c8d0ba;
}
.media-page__grid { .media-page__grid {
display: grid; display: grid;
max-width: 1745px; max-width: 1745px;
@ -762,6 +846,14 @@ a.media-page__card:focus-visible {
gap: 0.5rem; gap: 0.5rem;
} }
.media-page__card-order-actions {
position: absolute;
top: 1rem;
left: 1rem;
display: flex;
gap: 0.5rem;
}
.media-page__card-action { .media-page__card-action {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -776,21 +868,26 @@ a.media-page__card:focus-visible {
box-shadow: 0 8px 18px rgb(44 44 44 / 8%); box-shadow: 0 8px 18px rgb(44 44 44 / 8%);
} }
.media-page__card-action:hover, .media-page__card-action:not(:disabled):hover,
.media-page__card-action:focus-visible { .media-page__card-action:not(:disabled):focus-visible {
color: var(--color-olive); color: var(--color-olive);
border-color: #d4ad5f; border-color: #d4ad5f;
outline: 3px solid rgb(212 173 95 / 24%); outline: 3px solid rgb(212 173 95 / 24%);
outline-offset: 2px; outline-offset: 2px;
} }
.media-page__card-action--danger:hover, .media-page__card-action--danger:not(:disabled):hover,
.media-page__card-action--danger:focus-visible { .media-page__card-action--danger:not(:disabled):focus-visible {
color: #9f2d2d; color: #9f2d2d;
border-color: #d9a3a3; border-color: #d9a3a3;
outline-color: rgb(159 45 45 / 18%); outline-color: rgb(159 45 45 / 18%);
} }
.media-page__card-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.media-page__card-icon { .media-page__card-icon {
display: block; display: block;
width: 140px; width: 140px;