Implement reordering with optimistic updates

This commit is contained in:
2025-11-24 01:03:46 +05:00
parent 133558c4ab
commit 05814a100a
11 changed files with 271 additions and 63 deletions

View File

@ -14,6 +14,7 @@ import { usePlaylists } from '@/composeables/api/playlist-controller/playlist-co
import { ChevronsUpDown, Music4, Plus } from 'lucide-vue-next';
import Button from '../ui/button/Button.vue';
import PlaylistCreateDialog from './playlists/select/PlaylistCreateDialog.vue';
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store';
const {
open: sidebarOpen,
@ -21,18 +22,18 @@ const {
const { isLoading, isError, error, data } = usePlaylists();
const selectedPlaylist = ref(-1);
const currentPlaylistStore = useCurrentPlaylistStore();
watch(data, (value) => {
const newValue = value?.data[0]?.id || -1;
if (selectedPlaylist.value === -1) {
selectedPlaylist.value = newValue;
const newValue = value?.data[0];
if (currentPlaylistStore.id === -1 && newValue) {
currentPlaylistStore.load(newValue);
}
});
</script>
<template>
<Select v-model="selectedPlaylist">
<Select v-model="currentPlaylistStore.id">
<div v-if="sidebarOpen" class="hover:bg-sidebar-muted cursor-pointer rounded-md select-none">
<SelectCustomTrigger class="w-full flex p-2 gap-2 items-center">
<Frame borderRadius="round" background="primary" padding="dense" margin="none">
@ -42,7 +43,7 @@ watch(data, (value) => {
<Skeleton v-if="isLoading" class="w-32 h-5 rounded-full" />
<h4 v-else-if="data" class="text-xl font-semibold tracking-tight truncate">
<!-- TODO: i18n -->
{{data.data.find(playlist => playlist.id === selectedPlaylist)?.title ||
{{data.data.find(playlist => playlist.id === currentPlaylistStore.id)?.title ||
'No playlist selected'}}
</h4>
<p class="text-sm text-muted-foreground">

View File

@ -12,5 +12,8 @@ export * from './playlistReadDTO';
export * from './playlistReadResponse';
export * from './playlistTrackResponse';
export * from './readParams';
export * from './trackBulkReorderRequest';
export * from './trackReoderAfterRequest';
export * from './trackReorderAfterRequest';
export * from './trackResponse';
export * from './uploadBody';

View File

@ -0,0 +1,10 @@
/**
* Generated by orval v7.16.0 🍺
* Do not edit manually.
* OpenAPI definition
* OpenAPI spec version: v0
*/
export interface TrackBulkReorderRequest {
trackIds?: number[];
}

View File

@ -0,0 +1,11 @@
/**
* Generated by orval v7.16.0 🍺
* Do not edit manually.
* OpenAPI definition
* OpenAPI spec version: v0
*/
export interface TrackReorderAfterRequest {
moveTrackId?: number;
targetTrackId?: number;
}

View File

@ -31,6 +31,7 @@ import type {
import type {
AddLocalTrackRequest,
PlaylistTrackResponse,
TrackBulkReorderRequest,
TrackResponse
} from '.././models';
@ -103,6 +104,66 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
return useMutation(mutationOptions, queryClient);
}
export const bulkReorder = (
playlistId: MaybeRef<number>,
trackBulkReorderRequest: MaybeRef<TrackBulkReorderRequest>,
options?: SecondParameter<typeof axiosInstance>,signal?: AbortSignal
) => {
playlistId = unref(playlistId);
trackBulkReorderRequest = unref(trackBulkReorderRequest);
return axiosInstance<void>(
{url: `/playlist/${playlistId}/bulk-reorder`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: trackBulkReorderRequest, signal
},
options);
}
export const getBulkReorderMutationOptions = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof bulkReorder>>, TError,{playlistId: number;data: TrackBulkReorderRequest}, TContext>, request?: SecondParameter<typeof axiosInstance>}
): UseMutationOptions<Awaited<ReturnType<typeof bulkReorder>>, TError,{playlistId: number;data: TrackBulkReorderRequest}, TContext> => {
const mutationKey = ['bulkReorder'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof bulkReorder>>, {playlistId: number;data: TrackBulkReorderRequest}> = (props) => {
const {playlistId,data} = props ?? {};
return bulkReorder(playlistId,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type BulkReorderMutationResult = NonNullable<Awaited<ReturnType<typeof bulkReorder>>>
export type BulkReorderMutationBody = TrackBulkReorderRequest
export type BulkReorderMutationError = unknown
export const useBulkReorder = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof bulkReorder>>, TError,{playlistId: number;data: TrackBulkReorderRequest}, TContext>, request?: SecondParameter<typeof axiosInstance>}
, queryClient?: QueryClient): UseMutationReturnType<
Awaited<ReturnType<typeof bulkReorder>>,
TError,
{playlistId: number;data: TrackBulkReorderRequest},
TContext
> => {
const mutationOptions = getBulkReorderMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
export const getPlaylistTracks = (
playlistId: MaybeRef<number>,
options?: SecondParameter<typeof axiosInstance>,signal?: AbortSignal

View File

@ -7,56 +7,102 @@ import { SidebarTrigger } from '@/components/ui/sidebar'
import MusicCard from '~/components/internal/musiccard/MusicCard.vue'
import { DnDOperations, useDroppable } from '@vue-dnd-kit/core'
import Draggable from '~/components/action/Draggable.vue'
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store'
import { bulkReorder, getGetPlaylistTracksQueryKey } from '~/composeables/api/track-controller/track-controller'
import { useGetPlaylistTracks } from '~/composeables/api/track-controller/track-controller'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
const searchValue = ref('')
// Mock data
const tracks = ref([
const queryClient = useQueryClient();
const currentPlaylistStore = useCurrentPlaylistStore();
const { data: playlistTracks, isLoading, error, refetch } = useGetPlaylistTracks(
computed(() => currentPlaylistStore.id),
{
id: 1,
title: "Best of Chobits OST - Beyond",
author: "ビヨンド",
authorLabel: "Author",
badges: ["mp3", "jpop", "anime"],
imageUrl: "https://github.com/yavuzceliker/sample-images/blob/main/docs/image-1.jpg?raw=true",
date: "about 17 years ago",
selected: true
},
{
id: 2,
title: "Summer Vibessssssssssssssssssssssssssssssssssssfawefjioawefjeaiofjeaoifjeaofejiaofejaiojfoaiss",
author: "Various Artists",
authorLabel: "Artists",
badges: ["mp3", "summer", "mix"],
imageUrl: "https://github.com/yavuzceliker/sample-images/blob/main/docs/image-2.jpg?raw=true",
date: "about 1 hour ago",
selected: false
},
{
id: 3,
title: "Unknown Track",
author: "Unknown Artist",
authorLabel: "Author",
badges: ["wav"],
imageUrl: "https://github.com/yavuzceliker/sample-images/blob/main/docs/image-3.jpg?raw=true",
selected: false
},
{
id: 4,
title: "Single Track",
author: "Solo Artist",
authorLabel: "Author",
badges: [],
imageUrl: "https://github.com/yavuzceliker/sample-images/blob/main/docs/image-5.jpg?raw=true",
date: "recently added",
selected: false
query: {
enabled: computed(() => currentPlaylistStore.id !== -1)
}
}
])
)
const { mutate: reorderTracks } = useMutation({
mutationFn: ({ playlistId, trackIds }: { playlistId: number; trackIds: number[] }) =>
bulkReorder(playlistId, { trackIds }),
onMutate: async ({ playlistId, trackIds }) => {
await queryClient.cancelQueries({ queryKey: getGetPlaylistTracksQueryKey(playlistId) });
const previousTracks = queryClient.getQueryData(getGetPlaylistTracksQueryKey(playlistId));
queryClient.setQueryData(getGetPlaylistTracksQueryKey(playlistId), (old: any) => {
if (!old) return old;
return {
...old,
data: trackIds.map(id => old.data.find((t: any) => t.trackId === id || t.id === id)),
};
});
return { previousTracks };
},
onError: (err, { playlistId }, context) => {
if (context?.previousTracks) {
queryClient.setQueryData(getGetPlaylistTracksQueryKey(playlistId), context.previousTracks);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: getGetPlaylistTracksQueryKey(currentPlaylistStore.id) });
},
});
const trackCount = computed(() => playlistTracks.value?.data.length || 0)
const mappedTracks = computed(() => {
if (!playlistTracks.value) return []
const tracks = playlistTracks.value.data
return tracks.map((track: any, index: number) => ({
id: track.trackId || track.id,
title: track.track?.title || track.title || 'Unknown Track',
author: track.track?.artist?.name || track.artist || 'Unknown Artist',
authorLabel: "Artist",
badges: track.track?.format ? [track.track.format] : ['mp3'],
imageUrl: track.track?.coverUrl || track.coverUrl || getDefaultImage(index),
date: formatDate(track.addedDate || track.createdAt),
selected: false
}))
})
const getDefaultImage = (index: number) => {
const defaultImages = [
"https://github.com/yavuzceliker/sample-images/blob/main/docs/image-1.jpg?raw=true",
"https://github.com/yavuzceliker/sample-images/blob/main/docs/image-2.jpg?raw=true",
"https://github.com/yavuzceliker/sample-images/blob/main/docs/image-3.jpg?raw=true",
"https://github.com/yavuzceliker/sample-images/blob/main/docs/image-5.jpg?raw=true"
]
return defaultImages[index % defaultImages.length]
}
const formatDate = (dateString: string) => {
if (!dateString) return 'recently added'
const date = new Date(dateString)
const now = new Date()
const diffTime = Math.abs(now.getTime() - date.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays === 1) return 'yesterday'
if (diffDays < 7) return `${diffDays} days ago`
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`
return `${Math.floor(diffDays / 365)} years ago`
}
const filteredTracks = computed(() => {
if (!searchValue.value) return tracks.value
if (!searchValue.value) return mappedTracks.value
return tracks.value.filter(track =>
return mappedTracks.value.filter(track =>
track.title.toLowerCase().includes(searchValue.value.toLowerCase()) ||
track.author.toLowerCase().includes(searchValue.value.toLowerCase()) ||
track.badges.some(badge => badge.toLowerCase().includes(searchValue.value.toLowerCase()))
@ -64,19 +110,36 @@ const filteredTracks = computed(() => {
})
const selectTrack = (trackId: number) => {
tracks.value.forEach(track => {
mappedTracks.value.forEach(track => {
track.selected = track.id === trackId
})
}
const { elementRef: tracksRef } = useDroppable({
data: {
source: tracks.value,
source: mappedTracks.value,
},
events: {
onDrop: DnDOperations.applyTransfer,
onDrop: (store, payload) => {
DnDOperations.applyTransfer(store);
if (currentPlaylistStore.id === -1) return;
if (mappedTracks.value.length !== trackCount.value) return;
if (mappedTracks.value.some(t => !t)) return;
const trackIds = mappedTracks.value?.map(t => t.id);
reorderTracks({
playlistId: currentPlaylistStore.id,
trackIds,
});
},
},
});
watch(() => currentPlaylistStore.id, (newId) => {
if (newId !== -1) {
refetch()
}
})
</script>
<template>
@ -99,15 +162,30 @@ const { elementRef: tracksRef } = useDroppable({
<InputWithIcon v-model="searchValue" :icon="Search" placeholder="Search..." type="search" icon-size="5"
id="user-search" class="w-full" />
<div ref="tracksRef" class="space-y-2">
<TransitionGroup name="list" ref="tracksRef">
<Draggable v-for="(track, index) in filteredTracks" :key="track.id" :index="index"
:source="tracks">
<MusicCard :key="track.id" :title="track.title" :author="track.author"
:authorLabel="track.authorLabel" :badges="track.badges" :imageUrl="track.imageUrl"
:date="track.date" :selected="track.selected" @click="selectTrack(track.id)" />
</Draggable>
</TransitionGroup>
<div ref="tracksRef">
<div v-if="isLoading" class="flex justify-center items-center py-8">
<p>Loading tracks...</p>
</div>
<div v-else-if="error" class="flex justify-center items-center py-8">
<p class="text-red-500">Error loading tracks: {{ error }}</p>
</div>
<div v-else-if="!filteredTracks.length" class="flex justify-center items-center py-8">
<p v-if="searchValue">No tracks found matching "{{ searchValue }}"</p>
<p v-else>No tracks in this playlist</p>
</div>
<div v-else ref="tracksRef" class="space-y-2">
<TransitionGroup name="list" ref="tracksRef">
<Draggable v-for="(track, index) in filteredTracks" :key="track.id" :index="index"
:source="mappedTracks">
<MusicCard :key="track.id" :title="track.title" :author="track.author"
:authorLabel="track.authorLabel" :badges="track.badges" :imageUrl="track.imageUrl"
:date="track.date" :selected="track.selected" @click="selectTrack(track.id)" />
</Draggable>
</TransitionGroup>
</div>
</div>
</div>
<template #sidebar>

View File

@ -1,4 +1,3 @@
// ~/plugins/vue-dnd-kit.client.ts
import VueDnDKitPlugin from '@vue-dnd-kit/core';
export default defineNuxtPlugin((nuxtApp) => {

View File

@ -0,0 +1,24 @@
import { defineStore } from 'pinia'
import type { PlaylistReadResponse } from '~/composeables/api/models'
export const useCurrentPlaylistStore = defineStore('current-playlist', {
state: () => ({
id: -1,
ownerId: -1,
title: '',
createdAt: '',
updatedAt: '',
}),
getters: {
getId: (state) => state.id * 2,
},
actions: {
load(response: PlaylistReadResponse) {
this.id = response.id || -1;
this.ownerId = response.ownerId || -1;
this.title = response.title || '';
this.createdAt = response.createdAt || '';
this.updatedAt = response.updatedAt || '';
}
},
})

View File

@ -8,6 +8,7 @@
"@nuxt/fonts": "0.11.4",
"@nuxt/icon": "2.1.0",
"@nuxt/image": "1.11.0",
"@pinia/nuxt": "0.11.3",
"@tailwindcss/vite": "^4.1.16",
"@tanstack/vue-query": "^5.90.7",
"@vee-validate/zod": "^4.15.1",
@ -20,6 +21,7 @@
"lucide-vue-next": "^0.548.0",
"nuxt": "^4.2.0",
"oidc-client-ts": "^3.3.0",
"pinia": "^3.0.4",
"reka-ui": "^2.6.0",
"shadcn-nuxt": "2.3.2",
"tailwind-merge": "^3.3.1",
@ -410,6 +412,8 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
"@pinia/nuxt": ["@pinia/nuxt@0.11.3", "", { "dependencies": { "@nuxt/kit": "^4.2.0" }, "peerDependencies": { "pinia": "^3.0.4" } }, "sha512-7WVNHpWx4qAEzOlnyrRC88kYrwnlR/PrThWT0XI1dSNyUAXu/KBv9oR37uCgYkZroqP5jn8DfzbkNF3BtKvE9w=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
@ -1532,6 +1536,8 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pinia": ["pinia@3.0.4", "", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="],
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"pony-cause": ["pony-cause@1.1.1", "", {}, "sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g=="],
@ -2062,6 +2068,8 @@
"@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="],
"@pinia/nuxt/@nuxt/kit": ["@nuxt/kit@4.2.0", "", { "dependencies": { "c12": "^3.3.1", "consola": "^3.4.2", "defu": "^6.1.4", "destr": "^2.0.5", "errx": "^0.1.0", "exsolve": "^1.0.7", "ignore": "^7.0.5", "jiti": "^2.6.1", "klona": "^2.0.6", "mlly": "^1.8.0", "ohash": "^2.0.11", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "rc9": "^2.1.2", "scule": "^1.3.0", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ufo": "^1.6.1", "unctx": "^2.4.1", "untyped": "^2.0.0" } }, "sha512-1yN3LL6RDN5GjkNLPUYCbNRkaYnat6hqejPyfIBBVzrWOrpiQeNMGxQM/IcVdaSuBJXAnu0sUvTKXpXkmPhljg=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"@rollup/plugin-commonjs/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
@ -2154,6 +2162,8 @@
"oas-validator/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
"pinia/@vue/devtools-api": ["@vue/devtools-api@7.7.8", "", { "dependencies": { "@vue/devtools-kit": "^7.7.8" } }, "sha512-BtFcAmDbtXGwurWUFf8ogIbgZyR+rcVES1TSNEI8Em80fD8Anu+qTRN1Fc3J6vdRHlVM3fzPV1qIo+B4AiqGzw=="],
"postcss-svgo/svgo": ["svgo@4.0.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.4.1" }, "bin": "./bin/svgo.js" }, "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw=="],
"prebuild-install/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@ -2288,6 +2298,8 @@
"nitropack/globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
"pinia/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@7.7.8", "", { "dependencies": { "@vue/devtools-shared": "^7.7.8", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-4Y8op+AoxOJhB9fpcEF6d5vcJXWKgHxC3B0ytUB8zz15KbP9g9WgVzral05xluxi2fOeAy6t140rdQ943GcLRQ=="],
"postcss-svgo/svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
@ -2368,6 +2380,10 @@
"listhen/clipboardy/execa/strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"pinia/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@7.7.8", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-XHpO3jC5nOgYr40M9p8Z4mmKfTvUxKyRcUnpBAYg11pE78eaRFBKb0kG5yKLroMuJeeNH9LWmKp2zMU5LUc7CA=="],
"pinia/@vue/devtools-api/@vue/devtools-kit/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"prebuild-install/tar-fs/tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"vee-validate/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@7.7.8", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-XHpO3jC5nOgYr40M9p8Z4mmKfTvUxKyRcUnpBAYg11pE78eaRFBKb0kG5yKLroMuJeeNH9LWmKp2zMU5LUc7CA=="],

View File

@ -4,7 +4,7 @@ import tailwindcss from "@tailwindcss/vite";
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxt/fonts', '@nuxt/icon', '@nuxt/image', 'shadcn-nuxt'],
modules: ['@nuxt/fonts', '@nuxt/icon', '@nuxt/image', 'shadcn-nuxt', '@pinia/nuxt',],
shadcn: {
prefix: '',
componentDir: './components/ui'
@ -19,5 +19,8 @@ export default defineNuxtConfig({
public: {
apiBaseUrl: process.env.API_BASE_URL
}
}
},
pinia: {
storesDirs: ['./app/stores/**'],
},
})

View File

@ -13,6 +13,7 @@
"@nuxt/fonts": "0.11.4",
"@nuxt/icon": "2.1.0",
"@nuxt/image": "1.11.0",
"@pinia/nuxt": "0.11.3",
"@tailwindcss/vite": "^4.1.16",
"@tanstack/vue-query": "^5.90.7",
"@vee-validate/zod": "^4.15.1",
@ -25,6 +26,7 @@
"lucide-vue-next": "^0.548.0",
"nuxt": "^4.2.0",
"oidc-client-ts": "^3.3.0",
"pinia": "^3.0.4",
"reka-ui": "^2.6.0",
"shadcn-nuxt": "2.3.2",
"tailwind-merge": "^3.3.1",