Implement reordering with optimistic updates
This commit is contained in:
@ -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">
|
||||
|
||||
@ -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';
|
||||
10
app/composeables/api/models/trackBulkReorderRequest.ts
Normal file
10
app/composeables/api/models/trackBulkReorderRequest.ts
Normal 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[];
|
||||
}
|
||||
11
app/composeables/api/models/trackReorderAfterRequest.ts
Normal file
11
app/composeables/api/models/trackReorderAfterRequest.ts
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,10 +162,24 @@ 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">
|
||||
<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="tracks">
|
||||
: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)" />
|
||||
@ -110,6 +187,7 @@ const { elementRef: tracksRef } = useDroppable({
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #sidebar>
|
||||
<Outline padding="none" class="h-full" side="left">
|
||||
<Outline padding="dense" side="bottom">
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// ~/plugins/vue-dnd-kit.client.ts
|
||||
import VueDnDKitPlugin from '@vue-dnd-kit/core';
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
|
||||
24
app/stores/use-current-playlist-store.ts
Normal file
24
app/stores/use-current-playlist-store.ts
Normal 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 || '';
|
||||
}
|
||||
},
|
||||
})
|
||||
16
bun.lock
16
bun.lock
@ -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=="],
|
||||
|
||||
@ -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/**'],
|
||||
},
|
||||
})
|
||||
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user