initiation librodrome

This commit is contained in:
Yvv
2026-02-20 12:55:10 +01:00
commit 35e2897a73
208 changed files with 18951 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
<template>
<div class="field">
<label class="field-label">{{ label }}</label>
<div
v-for="(item, i) in modelValue"
:key="i"
class="list-item"
>
<slot :item="item" :index="i" :update="(val: any) => updateItem(i, val)" />
<button class="list-remove" @click="removeItem(i)" aria-label="Supprimer">
<div class="i-lucide-x h-3.5 w-3.5" />
</button>
</div>
<button class="list-add" @click="addItem">
<div class="i-lucide-plus h-4 w-4" />
{{ addLabel }}
</button>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
label: string
modelValue: any[]
addLabel?: string
defaultItem?: () => any
}>()
const emit = defineEmits<{
'update:modelValue': [value: any[]]
}>()
function updateItem(index: number, value: any) {
const copy = [...props.modelValue]
copy[index] = value
emit('update:modelValue', copy)
}
function removeItem(index: number) {
const copy = [...props.modelValue]
copy.splice(index, 1)
emit('update:modelValue', copy)
}
function addItem() {
const newItem = props.defaultItem ? props.defaultItem() : {}
emit('update:modelValue', [...props.modelValue, newItem])
}
</script>
<style scoped>
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: hsl(20 8% 60%);
}
.list-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid hsl(20 8% 14%);
border-radius: 0.5rem;
background: hsl(20 8% 5%);
}
.list-item > :deep(*:first-child) {
flex: 1;
}
.list-remove {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
border: none;
background: hsl(0 60% 45% / 0.15);
color: hsl(0 60% 65%);
cursor: pointer;
flex-shrink: 0;
margin-top: 0.125rem;
transition: all 0.2s;
}
.list-remove:hover {
background: hsl(0 60% 45% / 0.3);
}
.list-add {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
border: 1px dashed hsl(20 8% 22%);
background: none;
color: hsl(20 8% 50%);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
align-self: flex-start;
}
.list-add:hover {
border-color: hsl(12 76% 48% / 0.4);
color: hsl(12 76% 68%);
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<div class="field">
<label :for="id" class="field-label">{{ label }}</label>
<input
:id="id"
:value="modelValue"
type="text"
class="field-input"
:placeholder="placeholder"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
label: string
modelValue: string
placeholder?: string
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-')}`)
</script>
<style scoped>
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: hsl(20 8% 60%);
}
.field-input {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(20 8% 18%);
background: hsl(20 8% 6%);
color: white;
font-size: 0.875rem;
transition: border-color 0.2s;
}
.field-input:focus {
outline: none;
border-color: hsl(12 76% 48% / 0.5);
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="field">
<label :for="id" class="field-label">{{ label }}</label>
<textarea
:id="id"
:value="modelValue"
class="field-textarea"
:rows="rows"
:placeholder="placeholder"
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
label: string
modelValue: string
rows?: number
placeholder?: string
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-')}`)
</script>
<style scoped>
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: hsl(20 8% 60%);
}
.field-textarea {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(20 8% 18%);
background: hsl(20 8% 6%);
color: white;
font-size: 0.875rem;
resize: vertical;
min-height: 5rem;
transition: border-color 0.2s;
font-family: inherit;
}
.field-textarea:focus {
outline: none;
border-color: hsl(12 76% 48% / 0.5);
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<details class="admin-section" :open="open">
<summary class="admin-section-header">
<div class="i-lucide-chevron-right h-4 w-4 transition-transform section-arrow" />
<span>{{ title }}</span>
</summary>
<div class="admin-section-body">
<slot />
</div>
</details>
</template>
<script setup lang="ts">
defineProps<{
title: string
open?: boolean
}>()
</script>
<style scoped>
.admin-section {
border: 1px solid hsl(20 8% 14%);
border-radius: 0.75rem;
overflow: hidden;
margin-bottom: 1rem;
}
.admin-section-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
font-weight: 600;
font-size: 0.9rem;
color: white;
cursor: pointer;
background: hsl(20 8% 6%);
user-select: none;
}
.admin-section-header:hover {
background: hsl(20 8% 8%);
}
.admin-section[open] .section-arrow {
transform: rotate(90deg);
}
.admin-section-body {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="md-editor">
<div class="md-tabs">
<button
class="md-tab"
:class="{ 'md-tab--active': tab === 'edit' }"
@click="tab = 'edit'"
>
Édition
</button>
<button
class="md-tab"
:class="{ 'md-tab--active': tab === 'preview' }"
@click="tab = 'preview'"
>
Aperçu
</button>
</div>
<textarea
v-if="tab === 'edit'"
:value="modelValue"
class="md-textarea"
:rows="rows"
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/>
<div
v-else
class="md-preview prose"
v-html="renderedHtml"
/>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: string
rows?: number
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
const tab = ref<'edit' | 'preview'>('edit')
const renderedHtml = computed(() => {
// Simple markdown rendering for preview
return props.modelValue
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(?!<[hp])(.+)/gm, '<p>$1</p>')
})
</script>
<style scoped>
.md-editor {
border: 1px solid hsl(20 8% 18%);
border-radius: 0.5rem;
overflow: hidden;
}
.md-tabs {
display: flex;
background: hsl(20 8% 6%);
border-bottom: 1px solid hsl(20 8% 14%);
}
.md-tab {
padding: 0.5rem 1rem;
border: none;
background: none;
color: hsl(20 8% 50%);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.md-tab--active {
color: white;
background: hsl(20 8% 10%);
}
.md-textarea {
width: 100%;
padding: 1rem;
border: none;
background: hsl(20 8% 4%);
color: white;
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
line-height: 1.7;
resize: vertical;
min-height: 20rem;
}
.md-textarea:focus {
outline: none;
}
.md-preview {
padding: 1rem;
min-height: 20rem;
background: hsl(20 8% 4%);
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<div class="media-browser">
<!-- Toolbar -->
<div class="media-toolbar">
<div class="flex items-center gap-2">
<button
v-for="t in types"
:key="t.value"
class="filter-btn"
:class="{ 'filter-btn--active': filter === t.value }"
@click="filter = t.value"
>
{{ t.label }}
</button>
</div>
<span class="text-xs text-white/40">{{ filtered.length }} fichier(s)</span>
</div>
<!-- Grid -->
<div class="media-grid">
<div
v-for="file in filtered"
:key="file.path"
class="media-card"
:class="{ 'media-card--selected': selected === file.path }"
@click="selected = selected === file.path ? null : file.path"
>
<div v-if="file.type === 'image'" class="media-thumb">
<img :src="file.path" :alt="file.name" />
</div>
<div v-else class="media-icon">
<div
:class="file.type === 'audio' ? 'i-lucide-music' : file.type === 'document' ? 'i-lucide-file-text' : 'i-lucide-file'"
class="h-6 w-6"
/>
</div>
<div class="media-info">
<span class="media-name">{{ file.name }}</span>
<span class="media-size">{{ formatSize(file.size) }}</span>
</div>
</div>
</div>
<!-- Actions for selected -->
<div v-if="selected" class="media-actions">
<code class="text-xs text-accent">{{ selected }}</code>
<button class="delete-btn" @click="$emit('delete', selected)">
<div class="i-lucide-trash-2 h-4 w-4" />
Supprimer
</button>
</div>
</div>
</template>
<script setup lang="ts">
interface MediaFile {
name: string
path: string
size: number
type: string
modifiedAt: string
}
const props = defineProps<{
files: MediaFile[]
}>()
defineEmits<{
delete: [path: string]
}>()
const filter = ref('all')
const selected = ref<string | null>(null)
const types = [
{ value: 'all', label: 'Tous' },
{ value: 'image', label: 'Images' },
{ value: 'audio', label: 'Audio' },
{ value: 'document', label: 'Documents' },
]
const filtered = computed(() => {
if (filter.value === 'all') return props.files
return props.files.filter(f => f.type === filter.value)
})
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
</script>
<style scoped>
.media-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.filter-btn {
padding: 0.25rem 0.625rem;
border-radius: 9999px;
border: 1px solid hsl(20 8% 18%);
background: none;
color: hsl(20 8% 55%);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn--active {
background: hsl(12 76% 48% / 0.15);
border-color: hsl(12 76% 48% / 0.3);
color: hsl(12 76% 68%);
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.media-card {
border: 1px solid hsl(20 8% 14%);
border-radius: 0.5rem;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
}
.media-card:hover {
border-color: hsl(20 8% 22%);
}
.media-card--selected {
border-color: hsl(12 76% 48%);
box-shadow: 0 0 0 1px hsl(12 76% 48% / 0.3);
}
.media-thumb {
aspect-ratio: 1;
overflow: hidden;
background: hsl(20 8% 6%);
}
.media-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-icon {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: hsl(20 8% 6%);
color: hsl(20 8% 40%);
}
.media-info {
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.media-name {
font-size: 0.72rem;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-size {
font-size: 0.65rem;
color: hsl(20 8% 40%);
}
.media-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
padding: 0.75rem;
border: 1px solid hsl(20 8% 14%);
border-radius: 0.5rem;
background: hsl(20 8% 5%);
}
.delete-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid hsl(0 60% 45% / 0.3);
background: hsl(0 60% 45% / 0.1);
color: hsl(0 60% 65%);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.delete-btn:hover {
background: hsl(0 60% 45% / 0.2);
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div
class="upload-zone"
:class="{ 'upload-zone--active': isDragging }"
@dragenter.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@dragover.prevent
@drop.prevent="handleDrop"
>
<input
ref="fileInput"
type="file"
multiple
accept="image/*,audio/*,.pdf"
class="hidden"
@change="handleFiles"
/>
<div v-if="uploading" class="upload-progress">
<div class="i-lucide-loader-2 h-6 w-6 animate-spin text-primary" />
<span>Upload en cours...</span>
</div>
<div v-else class="upload-content" @click="fileInput?.click()">
<div class="i-lucide-upload h-8 w-8 text-white/30 mb-2" />
<p class="text-sm text-white/50">Glissez des fichiers ici ou cliquez pour sélectionner</p>
<p class="text-xs text-white/30 mt-1">Images, audio, PDF</p>
</div>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
uploaded: [files: string[]]
}>()
const fileInput = ref<HTMLInputElement>()
const isDragging = ref(false)
const uploading = ref(false)
function handleDrop(e: DragEvent) {
isDragging.value = false
const files = e.dataTransfer?.files
if (files) upload(files)
}
function handleFiles(e: Event) {
const target = e.target as HTMLInputElement
if (target.files) upload(target.files)
}
async function upload(files: FileList) {
uploading.value = true
try {
const formData = new FormData()
for (const file of files) {
formData.append('file', file)
}
const result = await $fetch<{ files: string[] }>('/api/admin/media/upload', {
method: 'POST',
body: formData,
})
emit('uploaded', result.files)
}
finally {
uploading.value = false
if (fileInput.value) fileInput.value.value = ''
}
}
</script>
<style scoped>
.upload-zone {
border: 2px dashed hsl(20 8% 22%);
border-radius: 0.75rem;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.upload-zone:hover {
border-color: hsl(12 76% 48% / 0.4);
}
.upload-zone--active {
border-color: hsl(12 76% 48%);
background: hsl(12 76% 48% / 0.05);
}
.upload-progress {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: hsl(20 8% 55%);
font-size: 0.85rem;
}
.upload-content {
display: flex;
flex-direction: column;
align-items: center;
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<button
class="save-btn"
:class="{ 'save-btn--saving': saving, 'save-btn--saved': saved }"
:disabled="saving"
@click="$emit('save')"
>
<div v-if="saving" class="i-lucide-loader-2 h-4 w-4 animate-spin" />
<div v-else-if="saved" class="i-lucide-check h-4 w-4" />
<div v-else class="i-lucide-save h-4 w-4" />
{{ saving ? 'Sauvegarde...' : saved ? 'Sauvegardé' : 'Sauvegarder' }}
</button>
</template>
<script setup lang="ts">
defineProps<{
saving?: boolean
saved?: boolean
}>()
defineEmits<{
save: []
}>()
</script>
<style scoped>
.save-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 0.5rem;
border: none;
background: hsl(12 76% 48%);
color: white;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.save-btn:hover:not(:disabled) {
background: hsl(12 76% 42%);
}
.save-btn:disabled {
opacity: 0.7;
cursor: wait;
}
.save-btn--saved {
background: hsl(140 50% 40%);
}
</style>

View File

@@ -0,0 +1,148 @@
<template>
<aside class="admin-sidebar">
<div class="sidebar-header">
<NuxtLink to="/admin" class="flex items-center gap-2 font-display text-lg font-bold">
<div class="i-lucide-settings h-5 w-5 text-primary" />
<span class="text-gradient">Admin</span>
</NuxtLink>
</div>
<nav class="sidebar-nav">
<p class="sidebar-section">Contenu</p>
<NuxtLink to="/admin" class="sidebar-link" exact-active-class="sidebar-link--active">
<div class="i-lucide-layout-dashboard h-4 w-4" />
Dashboard
</NuxtLink>
<NuxtLink to="/admin/site" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-globe h-4 w-4" />
Site
</NuxtLink>
<NuxtLink to="/admin/messages" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-message-square h-4 w-4" />
Messages
</NuxtLink>
<p class="sidebar-section">Pages</p>
<NuxtLink to="/admin/pages/home" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-home h-4 w-4" />
Accueil
</NuxtLink>
<NuxtLink to="/admin/pages/lire" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-book-open h-4 w-4" />
Lire
</NuxtLink>
<NuxtLink to="/admin/pages/ecouter" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-headphones h-4 w-4" />
Écouter
</NuxtLink>
<NuxtLink to="/admin/pages/gratewizard" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-sparkles h-4 w-4" />
GrateWizard
</NuxtLink>
<p class="sidebar-section">Livre</p>
<NuxtLink to="/admin/book" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-list h-4 w-4" />
Chapitres
</NuxtLink>
<NuxtLink to="/admin/songs" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-music h-4 w-4" />
Chansons
</NuxtLink>
<p class="sidebar-section">Médias</p>
<NuxtLink to="/admin/media" class="sidebar-link" active-class="sidebar-link--active">
<div class="i-lucide-image h-4 w-4" />
Navigateur
</NuxtLink>
</nav>
<div class="sidebar-footer">
<NuxtLink to="/" class="sidebar-link" target="_blank">
<div class="i-lucide-external-link h-4 w-4" />
Voir le site
</NuxtLink>
<button class="sidebar-link w-full" @click="logout">
<div class="i-lucide-log-out h-4 w-4" />
Déconnexion
</button>
</div>
</aside>
</template>
<script setup lang="ts">
async function logout() {
await $fetch('/api/admin/auth/logout', { method: 'POST' })
navigateTo('/admin/login')
}
</script>
<style scoped>
.admin-sidebar {
background: hsl(20 8% 5%);
border-right: 1px solid hsl(20 8% 14%);
display: flex;
flex-direction: column;
height: 100dvh;
position: sticky;
top: 0;
}
.sidebar-header {
padding: 1.25rem 1rem;
border-bottom: 1px solid hsl(20 8% 14%);
}
.sidebar-nav {
flex: 1;
padding: 0.5rem;
overflow-y: auto;
}
.sidebar-section {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: hsl(20 8% 40%);
padding: 1rem 0.75rem 0.375rem;
margin: 0;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.85rem;
color: hsl(20 8% 60%);
text-decoration: none;
transition: all 0.2s;
border: none;
background: none;
cursor: pointer;
text-align: left;
}
.sidebar-link:hover {
background: hsl(20 8% 10%);
color: white;
}
.sidebar-link--active {
background: hsl(12 76% 48% / 0.12);
color: hsl(12 76% 68%);
}
.sidebar-footer {
padding: 0.5rem;
border-top: 1px solid hsl(20 8% 14%);
}
@media (max-width: 768px) {
.admin-sidebar {
display: none;
}
}
</style>