add media order controls
This commit is contained in:
parent
faed935436
commit
543ee6628d
2 changed files with 130 additions and 5 deletions
|
|
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue