-
@@ -19,10 +21,53 @@ async function disconnect() {
const result = await apiClient.post("/auth/logout");
if (result.data) {
console.log("Déconnexion réussie");
- user.value = undefined; // Clear user data
- window.location.reload(); // Reload to reset state
+ user.value = undefined;
+ window.location.reload();
} else {
console.error("Erreur lors de la déconnexion :", result.error);
}
}
+
+
diff --git a/webapp/src/lib/audio/engines/BaseEngine.ts b/webapp/src/lib/audio/engines/BaseEngine.ts
index f62cf8c..d5e49b6 100644
--- a/webapp/src/lib/audio/engines/BaseEngine.ts
+++ b/webapp/src/lib/audio/engines/BaseEngine.ts
@@ -7,6 +7,8 @@ import type {
export abstract class BaseEngine implements InstrumentEngine {
abstract readonly type: string;
+ abstract readonly resourceKey: string | null;
+ abstract readonly resourceLabel: string;
config: InstrumentConfig;
protected _state: EngineState = "idle";
diff --git a/webapp/src/lib/audio/engines/audio-clip/AudioClipEngine.ts b/webapp/src/lib/audio/engines/audio-clip/AudioClipEngine.ts
new file mode 100644
index 0000000..7d6d7ee
--- /dev/null
+++ b/webapp/src/lib/audio/engines/audio-clip/AudioClipEngine.ts
@@ -0,0 +1,133 @@
+import type {
+ AudioTrackConfig,
+ InstrumentConfigUpdate,
+ NoteName,
+} from "../../../utils/types";
+import { BaseEngine } from "../BaseEngine";
+
+interface ActiveSource {
+ source: AudioBufferSourceNode;
+ gainNode: GainNode;
+}
+
+export class AudioClipEngine extends BaseEngine {
+ readonly type = "audioTrack";
+ readonly resourceKey = null;
+ readonly resourceLabel = "Audio Track";
+
+ private activeSources: Map
= new Map();
+ private gain: number;
+
+ constructor(
+ audioContext: AudioContext,
+ destination: AudioNode,
+ config: AudioTrackConfig,
+ ) {
+ super(audioContext, destination, config);
+ this.gain = config.gain ?? 1;
+ this._state = "ready";
+ }
+
+ async preload(): Promise {
+ // No-op: AudioClipEngine is always ready (buffers loaded externally)
+ }
+
+ // MIDI methods are no-op for audio tracks
+ playNote(_noteName: NoteName, _noteId: string, _velocity?: number): void {
+ // No-op: Audio tracks don't play MIDI notes
+ }
+
+ stopNote(_noteId: string): void {
+ // No-op: Audio tracks don't play MIDI notes
+ }
+
+ stopAllNotes(): void {
+ this.stopAllClips();
+ }
+
+ // Audio clip methods
+ playClip(
+ clipId: string,
+ buffer: AudioBuffer,
+ offsetSeconds: number = 0,
+ durationSeconds?: number,
+ ): void {
+ if (this.activeSources.has(clipId)) {
+ this.stopClip(clipId);
+ }
+
+ const source = this.audioContext.createBufferSource();
+ source.buffer = buffer;
+
+ const gainNode = this.audioContext.createGain();
+ gainNode.gain.setValueAtTime(this.gain, this.audioContext.currentTime);
+
+ source.connect(gainNode);
+ gainNode.connect(this.destination);
+
+ const actualOffset = Math.max(0, Math.min(offsetSeconds, buffer.duration));
+ const remainingDuration = buffer.duration - actualOffset;
+ const actualDuration =
+ durationSeconds !== undefined
+ ? Math.min(durationSeconds, remainingDuration)
+ : remainingDuration;
+
+ source.start(0, actualOffset, actualDuration);
+
+ source.onended = () => {
+ this.activeSources.delete(clipId);
+ try {
+ source.disconnect();
+ gainNode.disconnect();
+ } catch {
+ // Ignore if already disconnected
+ }
+ };
+
+ this.activeSources.set(clipId, { source, gainNode });
+ }
+
+ stopClip(clipId: string): void {
+ const active = this.activeSources.get(clipId);
+ if (active) {
+ try {
+ active.gainNode.gain.exponentialRampToValueAtTime(
+ 0.001,
+ this.audioContext.currentTime + 0.02,
+ );
+ setTimeout(() => {
+ try {
+ active.source.stop();
+ active.source.disconnect();
+ active.gainNode.disconnect();
+ } catch {
+ // Ignore if already stopped
+ }
+ }, 30);
+ } catch {
+ // Ignore errors
+ }
+ this.activeSources.delete(clipId);
+ }
+ }
+
+ stopAllClips(): void {
+ for (const clipId of this.activeSources.keys()) {
+ this.stopClip(clipId);
+ }
+ }
+
+ updateConfig(config: InstrumentConfigUpdate): void {
+ if (config.gain !== undefined) {
+ this.gain = config.gain;
+ }
+ this.config = { ...this.config, ...config } as AudioTrackConfig;
+ }
+
+ dispose(): void {
+ this.stopAllClips();
+ this.activeSources.clear();
+ this.stateCallbacks.clear();
+ this._state = "idle";
+ }
+}
diff --git a/webapp/src/lib/audio/engines/audio-clip/index.ts b/webapp/src/lib/audio/engines/audio-clip/index.ts
new file mode 100644
index 0000000..3a445c6
--- /dev/null
+++ b/webapp/src/lib/audio/engines/audio-clip/index.ts
@@ -0,0 +1 @@
+export { AudioClipEngine } from "./AudioClipEngine";
diff --git a/webapp/src/lib/audio/engines/basic-synth/BasicSynthEngine.ts b/webapp/src/lib/audio/engines/basic-synth/BasicSynthEngine.ts
index e95200b..d003530 100644
--- a/webapp/src/lib/audio/engines/basic-synth/BasicSynthEngine.ts
+++ b/webapp/src/lib/audio/engines/basic-synth/BasicSynthEngine.ts
@@ -14,6 +14,8 @@ interface ActiveOscillator {
export class BasicSynthEngine extends BaseEngine {
readonly type = "basicSynth";
+ readonly resourceKey = null;
+ readonly resourceLabel = "Basic Synth";
private activeOscillators: Map = new Map();
private oscillatorType: OscillatorType;
diff --git a/webapp/src/lib/audio/engines/index.ts b/webapp/src/lib/audio/engines/index.ts
index fedda40..7def987 100644
--- a/webapp/src/lib/audio/engines/index.ts
+++ b/webapp/src/lib/audio/engines/index.ts
@@ -16,3 +16,4 @@ export { noteNameToFrequency } from "./noteUtils";
export { BasicSynthEngine } from "./basic-synth";
export { SmplrEngine, SOUNDFONT_LIST, type SoundfontName } from "./smplr";
export { UndertaleEngine } from "./undertale";
+export { AudioClipEngine } from "./audio-clip";
diff --git a/webapp/src/lib/audio/engines/smplr/SmplrEngine.ts b/webapp/src/lib/audio/engines/smplr/SmplrEngine.ts
index ae17466..2372ad9 100644
--- a/webapp/src/lib/audio/engines/smplr/SmplrEngine.ts
+++ b/webapp/src/lib/audio/engines/smplr/SmplrEngine.ts
@@ -23,6 +23,16 @@ export class SmplrEngine extends BaseEngine {
this.currentSoundfontName = config.soundfont;
}
+ get resourceKey(): string {
+ return `smplr:${this.currentSoundfontName}`;
+ }
+
+ get resourceLabel(): string {
+ return this.currentSoundfontName
+ .replace(/_/g, " ")
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+ }
+
async preload(): Promise {
if (this._state === "ready") return;
if (this._state === "loading" && this.loadPromise) {
diff --git a/webapp/src/lib/audio/engines/types.ts b/webapp/src/lib/audio/engines/types.ts
index 5c4a51e..2ae854e 100644
--- a/webapp/src/lib/audio/engines/types.ts
+++ b/webapp/src/lib/audio/engines/types.ts
@@ -14,6 +14,18 @@ export interface InstrumentEngine {
readonly state: EngineState;
+ /**
+ * Clé unique identifiant la ressource à précharger.
+ * Les engines avec la même clé partagent la même ressource (un seul chargement).
+ * null = pas de préchargement nécessaire.
+ */
+ readonly resourceKey: string | null;
+
+ /**
+ * Label lisible pour l'affichage pendant le chargement.
+ */
+ readonly resourceLabel: string;
+
onStateChange(callback: EngineStateCallback): () => void;
preload(): Promise;
diff --git a/webapp/src/lib/audio/engines/undertale/UndertaleEngine.ts b/webapp/src/lib/audio/engines/undertale/UndertaleEngine.ts
index 4294ada..aa2f484 100644
--- a/webapp/src/lib/audio/engines/undertale/UndertaleEngine.ts
+++ b/webapp/src/lib/audio/engines/undertale/UndertaleEngine.ts
@@ -16,6 +16,8 @@ interface ActiveNote {
export class UndertaleEngine extends BaseEngine {
readonly type = "undertale";
+ readonly resourceKey = "undertale:sf2";
+ readonly resourceLabel = "Undertale Soundfont";
private sampler: Soundfont2Sampler | null = null;
private activeNotes: Map = new Map();
@@ -170,9 +172,12 @@ export class UndertaleEngine extends BaseEngine {
if (activeNote) {
try {
activeNote.stopFn();
- setTimeout(() => {
- activeNote.envelopeNode.disconnect();
- }, this.release * 1000 + 100);
+ setTimeout(
+ () => {
+ activeNote.envelopeNode.disconnect();
+ },
+ this.release * 1000 + 100,
+ );
} catch {
// Ignore errors
}
diff --git a/webapp/src/lib/audio/instrumentFactory.ts b/webapp/src/lib/audio/instrumentFactory.ts
index ff14b44..e4bba09 100644
--- a/webapp/src/lib/audio/instrumentFactory.ts
+++ b/webapp/src/lib/audio/instrumentFactory.ts
@@ -1,5 +1,6 @@
import type { InstrumentConfig } from "../utils/types";
import {
+ AudioClipEngine,
BasicSynthEngine,
SmplrEngine,
UndertaleEngine,
@@ -31,6 +32,9 @@ export function createInstrumentEngine(
case "undertale":
return new UndertaleEngine(audioContext, destination, config);
+ case "audioTrack":
+ return new AudioClipEngine(audioContext, destination, config);
+
default:
throw new Error(`Unknown instrument type: ${(config as any).type}`);
}
@@ -72,6 +76,12 @@ export function getDefaultConfigForType(
release: 0.3,
};
+ case "audioTrack":
+ return {
+ type: "audioTrack",
+ gain: 1,
+ };
+
default:
throw new Error(`Unknown instrument type: ${type}`);
}
diff --git a/webapp/src/lib/canvas/pianoKeysRenderer.ts b/webapp/src/lib/canvas/pianoKeysRenderer.ts
index 342a510..4b354a8 100644
--- a/webapp/src/lib/canvas/pianoKeysRenderer.ts
+++ b/webapp/src/lib/canvas/pianoKeysRenderer.ts
@@ -3,7 +3,6 @@ import {
NOTE_ROW_HEIGHT,
WHITE_KEY_MULTIPLIERS,
isOctaveStart,
- getOctaveNumber,
getWhiteKeys,
getBlackKeys,
ALL_NOTES,
@@ -54,11 +53,7 @@ export class PianoKeysRenderer {
private allNotes: NoteName[];
private hoveredKey: NoteName | null = null;
- constructor(
- ctx: CanvasRenderingContext2D,
- width: number,
- height: number,
- ) {
+ constructor(ctx: CanvasRenderingContext2D, width: number, height: number) {
this.ctx = ctx;
this.width = width;
this.height = height;
@@ -155,7 +150,9 @@ export class PianoKeysRenderer {
ctx.roundRect(key.x, key.y, key.w - 1, key.h - 1, [0, 0, 3, 3]);
ctx.fill();
- ctx.strokeStyle = isOctave ? COLORS.whiteKey.octaveBorder : COLORS.whiteKey.border;
+ ctx.strokeStyle = isOctave
+ ? COLORS.whiteKey.octaveBorder
+ : COLORS.whiteKey.border;
ctx.lineWidth = isOctave ? 2 : 1;
ctx.beginPath();
ctx.moveTo(key.x, key.y + key.h - 1);
@@ -165,7 +162,9 @@ export class PianoKeysRenderer {
ctx.font = "bold 10px system-ui, sans-serif";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
- ctx.fillStyle = isActive ? COLORS.whiteKey.textActive : COLORS.whiteKey.text;
+ ctx.fillStyle = isActive
+ ? COLORS.whiteKey.textActive
+ : COLORS.whiteKey.text;
ctx.fillText(key.note, key.w - 6, key.y + key.h / 2);
}
@@ -173,7 +172,12 @@ export class PianoKeysRenderer {
const { ctx } = this;
const isHovered = this.hoveredKey === key.note;
- const gradient = ctx.createLinearGradient(key.x, key.y, key.x, key.y + key.h);
+ const gradient = ctx.createLinearGradient(
+ key.x,
+ key.y,
+ key.x,
+ key.y + key.h,
+ );
if (isActive) {
gradient.addColorStop(0, COLORS.blackKey.fillActive);
gradient.addColorStop(1, COLORS.blackKey.fillActive);
@@ -202,7 +206,9 @@ export class PianoKeysRenderer {
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
- ctx.fillStyle = isActive ? COLORS.blackKey.textActive : COLORS.blackKey.text;
+ ctx.fillStyle = isActive
+ ? COLORS.blackKey.textActive
+ : COLORS.blackKey.text;
ctx.font = "bold 8px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
diff --git a/webapp/src/lib/utils/types.ts b/webapp/src/lib/utils/types.ts
index 20d7b71..e2c0c7c 100644
--- a/webapp/src/lib/utils/types.ts
+++ b/webapp/src/lib/utils/types.ts
@@ -95,7 +95,8 @@ export type InstrumentType =
| "basicSynth"
| "elementarySynth"
| "smplr"
- | "undertale";
+ | "undertale"
+ | "audioTrack";
export type OscillatorType = "sine" | "square" | "sawtooth" | "triangle";
@@ -127,11 +128,17 @@ export interface UndertaleConfig {
release?: number;
}
+export interface AudioTrackConfig {
+ type: "audioTrack";
+ gain?: number;
+}
+
export type InstrumentConfig =
| BasicSynthConfig
| SmplrConfig
| ElementarySynthConfig
- | UndertaleConfig;
+ | UndertaleConfig
+ | AudioTrackConfig;
export interface InstrumentConfigUpdate {
oscillatorType?: OscillatorType;
@@ -145,6 +152,40 @@ export interface InstrumentConfigUpdate {
release?: number;
}
+export interface AudioClip {
+ id: string;
+ sampleId: string;
+ x: number;
+ w: number;
+ startOffset: number;
+}
+
+export interface AudioSample {
+ id: string;
+ name: string;
+ packId: string;
+ folder: string;
+ filename: string;
+ duration: number;
+ waveformData?: number[];
+ fullUrl: string;
+}
+
+export interface SampleFolder {
+ id?: string;
+ name: string;
+ samples: AudioSample[];
+}
+
+export interface SamplePack {
+ id: string;
+ name: string;
+ author?: string;
+ featured?: boolean;
+ cover?: string;
+ folders: SampleFolder[];
+}
+
export interface Track {
id: string;
name: string;
@@ -156,7 +197,8 @@ export interface Track {
muted: boolean;
solo: boolean;
order: number;
- notes: MidiNote[]; // Notes avec positions absolues sur la timeline
+ notes: MidiNote[]; // Notes avec positions absolues sur la timeline (MIDI tracks)
+ clips?: AudioClip[]; // Audio clips (audio tracks)
createdAt: Date;
updatedAt: Date;
}
@@ -181,6 +223,7 @@ export interface TimelineProject {
volume: number; // Volume master (0-100)
reverb: number; // Reverb master (0-100)
eqBands?: EQBand[];
+ usedSamples?: Record; // Samples utilisés par les audio clips
version: string;
createdAt: Date;
updatedAt: Date;
diff --git a/webapp/src/router.ts b/webapp/src/router.ts
index 39522e3..9e7b50d 100644
--- a/webapp/src/router.ts
+++ b/webapp/src/router.ts
@@ -26,6 +26,25 @@ async function authGuard(to: any, from: any, next: any) {
next({ name: "app-login", query: { redirect: to.name } });
}
}
+
+async function adminGuard(to: any, from: any, next: any) {
+ const authStore = useAuthStore();
+ const check = await apiClient.get<{ user: User }>("/auth/check");
+
+ if (check.error || !check.data?.user) {
+ next({ name: "app-login", query: { redirect: to.name } });
+ return;
+ }
+
+ authStore.user = check.data.user;
+
+ if (check.data.user.role !== "ROLE_ADMIN") {
+ next({ name: "landing-main" });
+ return;
+ }
+
+ next();
+}
import BlogApp from "./views/blog/BlogApp.vue";
import BlogSearchResults from "./views/blog/BlogSearchResults.vue";
import BlogPostDetail from "./views/blog/BlogPostDetail.vue";
@@ -61,11 +80,48 @@ const routes = [
},
{ path: "/profile", component: ProfileView, name: "profile" },
{ path: "/messages", component: MessagesView, name: "messages" },
+ // Admin routes
+ {
+ path: "/admin",
+ component: () => import("./views/admin/AdminDashboard.vue"),
+ name: "admin-dashboard",
+ meta: { requiresAdmin: true },
+ },
+ {
+ path: "/admin/users",
+ component: () => import("./views/admin/AdminUsers.vue"),
+ name: "admin-users",
+ meta: { requiresAdmin: true },
+ },
+ {
+ path: "/admin/samples",
+ component: () => import("./views/admin/AdminSamples.vue"),
+ name: "admin-samples",
+ meta: { requiresAdmin: true },
+ },
+ {
+ path: "/admin/samples/:packId",
+ component: () => import("./views/admin/AdminPackDetail.vue"),
+ name: "admin-pack-detail",
+ meta: { requiresAdmin: true },
+ },
+ {
+ path: "/admin/samples/:packId/:folderId",
+ component: () => import("./views/admin/AdminFolderDetail.vue"),
+ name: "admin-folder-detail",
+ meta: { requiresAdmin: true },
+ },
];
const getGuardedRoutes = () => {
const guardedMatches = ["app", "blog", "settings", "profile", "messages"];
- return routes.map((route) => {
+ return routes.map((route: any) => {
+ if (route.meta?.requiresAdmin) {
+ return {
+ ...route,
+ beforeEnter: adminGuard,
+ };
+ }
if (guardedMatches.some((match) => route.path.includes(match))) {
return {
...route,
diff --git a/webapp/src/stores/adminStore.ts b/webapp/src/stores/adminStore.ts
new file mode 100644
index 0000000..4ad9e19
--- /dev/null
+++ b/webapp/src/stores/adminStore.ts
@@ -0,0 +1,437 @@
+import { defineStore } from "pinia";
+import { ref } from "vue";
+import apiClient from "../lib/utils/apiClient";
+
+interface AdminUser {
+ id: string;
+ email: string;
+ firstName: string;
+ lastName: string;
+ role: string;
+ isActive: boolean;
+ createdAt: string;
+ profilePicture?: string;
+}
+
+interface AdminPack {
+ id: string;
+ slug: string;
+ name: string;
+ author: string | null;
+ cover: string | null;
+ featured: boolean;
+ isActive: boolean;
+ createdAt: string;
+}
+
+interface AdminFolder {
+ id: string;
+ name: string;
+ order: number;
+ packId: string;
+}
+
+interface AdminSample {
+ id: string;
+ name: string;
+ filename: string;
+ duration: number;
+ waveform: number[] | null;
+ folderId: string;
+ previewUrl: string | null;
+ fullUrl: string | null;
+}
+
+interface Pagination {
+ page: number;
+ limit: number;
+ total: number;
+ pages: number;
+}
+
+interface ApiResponse {
+ body: T;
+}
+
+export const useAdminStore = defineStore("admin", () => {
+ // Users state
+ const users = ref([]);
+ const usersPagination = ref({
+ page: 1,
+ limit: 20,
+ total: 0,
+ pages: 0,
+ });
+ const usersLoading = ref(false);
+
+ // Packs state
+ const packs = ref([]);
+ const packsPagination = ref({
+ page: 1,
+ limit: 20,
+ total: 0,
+ pages: 0,
+ });
+ const packsLoading = ref(false);
+
+ // Current pack detail
+ const currentPack = ref(null);
+ const currentFolders = ref([]);
+
+ // Current folder detail
+ const currentFolder = ref(null);
+ const currentSamples = ref([]);
+
+ // Stats
+ const stats = ref({
+ totalUsers: 0,
+ totalPacks: 0,
+ totalSamples: 0,
+ });
+
+ // ===== USER ACTIONS =====
+
+ async function fetchUsers(page = 1, search?: string) {
+ usersLoading.value = true;
+ const params = new URLSearchParams({ page: String(page), limit: "20" });
+ if (search) params.append("search", search);
+
+ const result = await apiClient.get<
+ ApiResponse<{ users: AdminUser[]; pagination: Pagination }>
+ >(`/admin/users?${params}`);
+
+ if (result.data?.body) {
+ users.value = result.data.body.users;
+ usersPagination.value = result.data.body.pagination;
+ }
+ usersLoading.value = false;
+ }
+
+ async function updateUser(
+ id: string,
+ data: { role?: string; isActive?: boolean },
+ ) {
+ const result = await apiClient.patch>(
+ `/admin/users/${id}`,
+ data,
+ );
+ if (!result.error && result.data?.body) {
+ const index = users.value.findIndex((u) => u.id === id);
+ if (index >= 0) {
+ users.value[index] = result.data.body;
+ }
+ }
+ return result;
+ }
+
+ async function deleteUser(id: string) {
+ const result = await apiClient.delete<{ message: string }>(
+ `/admin/users/${id}`,
+ );
+ if (!result.error) {
+ const index = users.value.findIndex((u) => u.id === id);
+ if (index >= 0) {
+ users.value[index].isActive = false;
+ }
+ }
+ return result;
+ }
+
+ // ===== PACK ACTIONS =====
+
+ async function fetchPacks(page = 1) {
+ packsLoading.value = true;
+ const result = await apiClient.get<
+ ApiResponse<{ packs: AdminPack[]; pagination: Pagination }>
+ >(`/admin/samples/packs?page=${page}`);
+
+ if (result.data?.body) {
+ packs.value = result.data.body.packs;
+ packsPagination.value = result.data.body.pagination;
+ }
+ packsLoading.value = false;
+ }
+
+ async function fetchPackDetail(id: string) {
+ const result = await apiClient.get<
+ ApiResponse
+ >(`/admin/samples/packs/${id}`);
+
+ if (result.data?.body) {
+ currentPack.value = result.data.body;
+ currentFolders.value = result.data.body.folders || [];
+ }
+ return result.data?.body || null;
+ }
+
+ async function createPack(data: {
+ name: string;
+ slug: string;
+ author?: string;
+ cover?: string;
+ featured?: boolean;
+ }) {
+ const result = await apiClient.post>(
+ "/admin/samples/packs",
+ data,
+ );
+ if (!result.error && result.data?.body) {
+ packs.value.unshift(result.data.body);
+ }
+ return result;
+ }
+
+ async function updatePack(id: string, data: Partial) {
+ const result = await apiClient.put>(
+ `/admin/samples/packs/${id}`,
+ data,
+ );
+ if (!result.error && result.data?.body) {
+ const index = packs.value.findIndex((p) => p.id === id);
+ if (index >= 0) {
+ packs.value[index] = result.data.body;
+ }
+ if (currentPack.value?.id === id) {
+ currentPack.value = result.data.body;
+ }
+ }
+ return result;
+ }
+
+ async function deletePack(id: string) {
+ const result = await apiClient.delete<{ message: string }>(
+ `/admin/samples/packs/${id}`,
+ );
+ if (!result.error) {
+ packs.value = packs.value.filter((p) => p.id !== id);
+ }
+ return result;
+ }
+
+ // ===== FOLDER ACTIONS =====
+
+ async function fetchFolders(packId: string) {
+ const result = await apiClient.get>(
+ `/admin/samples/packs/${packId}/folders`,
+ );
+
+ if (result.data?.body) {
+ currentFolders.value = result.data.body;
+ }
+ return result.data?.body || [];
+ }
+
+ async function createFolder(
+ packId: string,
+ data: { name: string; order?: number },
+ ) {
+ const result = await apiClient.post>(
+ `/admin/samples/packs/${packId}/folders`,
+ data,
+ );
+ if (!result.error && result.data?.body) {
+ currentFolders.value.push(result.data.body);
+ }
+ return result;
+ }
+
+ async function updateFolder(
+ id: string,
+ data: { name?: string; order?: number },
+ ) {
+ const result = await apiClient.put>(
+ `/admin/samples/folders/${id}`,
+ data,
+ );
+ if (!result.error && result.data?.body) {
+ const index = currentFolders.value.findIndex((f) => f.id === id);
+ if (index >= 0) {
+ currentFolders.value[index] = result.data.body;
+ }
+ }
+ return result;
+ }
+
+ async function deleteFolder(id: string) {
+ const result = await apiClient.delete<{ message: string }>(
+ `/admin/samples/folders/${id}`,
+ );
+ if (!result.error) {
+ currentFolders.value = currentFolders.value.filter((f) => f.id !== id);
+ }
+ return result;
+ }
+
+ // ===== SAMPLE ACTIONS =====
+
+ async function fetchSamples(folderId: string) {
+ const result = await apiClient.get>(
+ `/admin/samples/folders/${folderId}/samples`,
+ );
+
+ if (result.data?.body) {
+ currentSamples.value = result.data.body;
+ }
+ return result.data?.body || [];
+ }
+
+ async function createSample(
+ folderId: string,
+ data: {
+ name: string;
+ filename: string;
+ duration?: number;
+ waveform?: number[];
+ previewUrl?: string;
+ fullUrl?: string;
+ },
+ ) {
+ const result = await apiClient.post>(
+ `/admin/samples/folders/${folderId}/samples`,
+ data,
+ );
+ if (!result.error && result.data?.body) {
+ currentSamples.value.push(result.data.body);
+ }
+ return result;
+ }
+
+ async function updateSample(id: string, data: Partial) {
+ const result = await apiClient.put>(
+ `/admin/samples/samples/${id}`,
+ data,
+ );
+ if (!result.error && result.data?.body) {
+ const index = currentSamples.value.findIndex((s) => s.id === id);
+ if (index >= 0) {
+ currentSamples.value[index] = result.data.body;
+ }
+ }
+ return result;
+ }
+
+ async function deleteSample(id: string) {
+ const result = await apiClient.delete<{ message: string }>(
+ `/admin/samples/samples/${id}`,
+ );
+ if (!result.error) {
+ currentSamples.value = currentSamples.value.filter((s) => s.id !== id);
+ }
+ return result;
+ }
+
+ // ===== UPLOAD =====
+
+ async function uploadFile(
+ file: File,
+ packSlug: string,
+ folderName?: string,
+ ): Promise<{
+ filename: string;
+ key: string;
+ url: string;
+ size: number;
+ mimetype: string;
+ } | null> {
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("packSlug", packSlug);
+ if (folderName) formData.append("folderName", folderName);
+
+ try {
+ const response = await fetch("/api/admin/upload", {
+ method: "POST",
+ body: formData,
+ credentials: "include",
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || "Upload failed");
+ }
+
+ const data = await response.json();
+ return data.body;
+ } catch (error) {
+ console.error("Upload error:", error);
+ return null;
+ }
+ }
+
+ // ===== STATS =====
+
+ async function fetchStats() {
+ const result = await apiClient.get<
+ ApiResponse<{
+ totalUsers: number;
+ totalPacks: number;
+ totalSamples: number;
+ }>
+ >("/admin/stats");
+
+ if (result.data?.body) {
+ stats.value = result.data.body;
+ }
+ }
+
+ // ===== RESET =====
+
+ function resetPackDetail() {
+ currentPack.value = null;
+ currentFolders.value = [];
+ }
+
+ function resetFolderDetail() {
+ currentFolder.value = null;
+ currentSamples.value = [];
+ }
+
+ return {
+ // State
+ users,
+ usersPagination,
+ usersLoading,
+ packs,
+ packsPagination,
+ packsLoading,
+ currentPack,
+ currentFolders,
+ currentFolder,
+ currentSamples,
+ stats,
+
+ // User actions
+ fetchUsers,
+ updateUser,
+ deleteUser,
+
+ // Pack actions
+ fetchPacks,
+ fetchPackDetail,
+ createPack,
+ updatePack,
+ deletePack,
+
+ // Folder actions
+ fetchFolders,
+ createFolder,
+ updateFolder,
+ deleteFolder,
+
+ // Sample actions
+ fetchSamples,
+ createSample,
+ updateSample,
+ deleteSample,
+
+ // Upload
+ uploadFile,
+
+ // Stats
+ fetchStats,
+
+ // Reset
+ resetPackDetail,
+ resetFolderDetail,
+ };
+});
diff --git a/webapp/src/stores/audioLibraryStore.ts b/webapp/src/stores/audioLibraryStore.ts
new file mode 100644
index 0000000..4375a45
--- /dev/null
+++ b/webapp/src/stores/audioLibraryStore.ts
@@ -0,0 +1,367 @@
+import { defineStore } from "pinia";
+import { ref, markRaw } from "vue";
+import type { AudioSample, SamplePack, SampleFolder } from "../lib/utils/types";
+import { useAudioBusStore } from "./audioBusStore";
+import { useSampleCacheStore } from "./sampleCacheStore";
+import apiClient from "../lib/utils/apiClient";
+
+type LoadingState = "idle" | "loading" | "ready" | "error";
+
+interface ApiPack {
+ id: string;
+ slug: string;
+ name: string;
+ author: string | null;
+ cover: string | null;
+ featured: boolean;
+ isActive: boolean;
+ createdAt: string;
+ folders?: ApiFolder[];
+}
+
+interface ApiFolder {
+ id: string;
+ name: string;
+ order: number;
+ packId: string;
+}
+
+interface ApiSample {
+ id: string;
+ name: string;
+ filename: string;
+ duration: number;
+ waveform: number[] | null;
+ folderId: string;
+ previewUrl: string | null;
+ fullUrl: string | null;
+}
+
+interface ApiResponse {
+ status: number;
+ message: string;
+ body: T;
+}
+
+interface PaginatedPacksResponse {
+ packs: ApiPack[];
+ pagination: {
+ page: number;
+ limit: number;
+ total: number;
+ pages: number;
+ };
+}
+
+export const useAudioLibraryStore = defineStore("audioLibrary", () => {
+ const audioBusStore = useAudioBusStore();
+ const cacheStore = useSampleCacheStore();
+
+ const packs = ref([]);
+ const samples = ref