initiation librodrome
This commit is contained in:
117
app/components/admin/AdminFieldList.vue
Normal file
117
app/components/admin/AdminFieldList.vue
Normal 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>
|
||||
56
app/components/admin/AdminFieldText.vue
Normal file
56
app/components/admin/AdminFieldText.vue
Normal 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>
|
||||
60
app/components/admin/AdminFieldTextarea.vue
Normal file
60
app/components/admin/AdminFieldTextarea.vue
Normal 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>
|
||||
55
app/components/admin/AdminFormSection.vue
Normal file
55
app/components/admin/AdminFormSection.vue
Normal 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>
|
||||
110
app/components/admin/AdminMarkdownEditor.vue
Normal file
110
app/components/admin/AdminMarkdownEditor.vue
Normal 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>
|
||||
211
app/components/admin/AdminMediaBrowser.vue
Normal file
211
app/components/admin/AdminMediaBrowser.vue
Normal 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>
|
||||
107
app/components/admin/AdminMediaUpload.vue
Normal file
107
app/components/admin/AdminMediaUpload.vue
Normal 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>
|
||||
54
app/components/admin/AdminSaveButton.vue
Normal file
54
app/components/admin/AdminSaveButton.vue
Normal 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>
|
||||
148
app/components/admin/AdminSidebar.vue
Normal file
148
app/components/admin/AdminSidebar.vue
Normal 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>
|
||||
Reference in New Issue
Block a user