first commit
This commit is contained in:
commit
b47f825d54
83 changed files with 10016 additions and 0 deletions
7
frontend/src/App.vue
Normal file
7
frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import HomePage from "@/views/HomePage.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HomePage />
|
||||
</template>
|
||||
179
frontend/src/components/admin/AddSegmentModal.vue
Normal file
179
frontend/src/components/admin/AddSegmentModal.vue
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import type { SegmentType } from "@/types/segment";
|
||||
import AssetUploader from "@/components/admin/AssetUploader.vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [data: { type: SegmentType; title: string; content: string; metadata?: Record<string, unknown> }];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const segmentTypes: { value: SegmentType; label: string }[] = [
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "pdf", label: "PDF" },
|
||||
{ value: "video", label: "Video" },
|
||||
{ value: "audio", label: "Audio" },
|
||||
{ value: "iframe", label: "Iframe" },
|
||||
{ value: "gallery", label: "Gallery" },
|
||||
{ value: "link", label: "Link" },
|
||||
];
|
||||
|
||||
const selectedType = ref<SegmentType>("markdown");
|
||||
const title = ref("");
|
||||
const content = ref("");
|
||||
const galleryImages = ref<string[]>([]);
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (!title.value.trim()) return false;
|
||||
if (selectedType.value === "gallery") return galleryImages.value.length > 0;
|
||||
if (selectedType.value === "markdown") return true;
|
||||
return content.value.trim().length > 0;
|
||||
});
|
||||
|
||||
function handleAssetUploaded(filename: string) {
|
||||
content.value = `/api/assets/${filename}`;
|
||||
}
|
||||
|
||||
function handleGalleryImageUploaded(filename: string) {
|
||||
galleryImages.value = [...galleryImages.value, `/api/assets/${filename}`];
|
||||
}
|
||||
|
||||
function removeGalleryImage(index: number) {
|
||||
galleryImages.value = galleryImages.value.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const data: { type: SegmentType; title: string; content: string; metadata?: Record<string, unknown> } = {
|
||||
type: selectedType.value,
|
||||
title: title.value.trim(),
|
||||
content: content.value,
|
||||
};
|
||||
if (selectedType.value === "gallery") {
|
||||
data.metadata = { images: galleryImages.value };
|
||||
}
|
||||
emit("create", data);
|
||||
}
|
||||
|
||||
const assetAcceptMap: Record<string, string> = {
|
||||
pdf: "application/pdf",
|
||||
video: "video/*",
|
||||
audio: "audio/*",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
@click.self="$emit('cancel')"
|
||||
>
|
||||
<div class="mx-4 w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-slate-900 dark:text-slate-100">Add New Segment</h2>
|
||||
|
||||
<!-- Type selector: button group -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">Type</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="st in segmentTypes"
|
||||
:key="st.value"
|
||||
class="rounded-full px-3 py-1 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
selectedType === st.value
|
||||
? 'bg-primary-300 text-slate-900 dark:bg-primary-600 dark:text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'
|
||||
"
|
||||
@click="selectedType = st.value; content = ''; galleryImages = []"
|
||||
>
|
||||
{{ st.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Title</label>
|
||||
<input
|
||||
v-model="title"
|
||||
type="text"
|
||||
placeholder="Segment title"
|
||||
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content: type-dependent -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label>
|
||||
|
||||
<!-- Markdown: textarea -->
|
||||
<textarea
|
||||
v-if="selectedType === 'markdown'"
|
||||
v-model="content"
|
||||
rows="6"
|
||||
placeholder="Markdown content..."
|
||||
class="w-full rounded border border-slate-300 px-3 py-2 font-mono text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
|
||||
/>
|
||||
|
||||
<!-- Iframe / Link: URL input -->
|
||||
<input
|
||||
v-else-if="selectedType === 'iframe' || selectedType === 'link'"
|
||||
v-model="content"
|
||||
type="url"
|
||||
:placeholder="selectedType === 'link' ? 'https://external-site.com' : 'https://...'"
|
||||
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
|
||||
/>
|
||||
|
||||
<!-- PDF / Video / Audio -->
|
||||
<div v-else-if="selectedType === 'pdf' || selectedType === 'video' || selectedType === 'audio'">
|
||||
<div v-if="content" class="mb-2 flex items-center gap-2 rounded bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||||
<span class="truncate">{{ content }}</span>
|
||||
<button class="shrink-0 text-red-400 hover:text-red-600" @click="content = ''">×</button>
|
||||
</div>
|
||||
<AssetUploader
|
||||
:accept="assetAcceptMap[selectedType]"
|
||||
:label="`Upload ${selectedType} file`"
|
||||
@uploaded="handleAssetUploaded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Gallery -->
|
||||
<div v-else-if="selectedType === 'gallery'">
|
||||
<div v-if="galleryImages.length > 0" class="mb-3 grid grid-cols-4 gap-2">
|
||||
<div
|
||||
v-for="(img, i) in galleryImages"
|
||||
:key="i"
|
||||
class="group relative aspect-square overflow-hidden rounded bg-slate-100"
|
||||
>
|
||||
<img :src="img" class="h-full w-full object-cover" />
|
||||
<button
|
||||
class="absolute right-1 top-1 hidden rounded bg-red-500 px-1.5 text-xs text-white group-hover:block"
|
||||
@click="removeGalleryImage(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AssetUploader accept="image/*" label="Upload gallery image" @uploaded="handleGalleryImageUploaded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
:disabled="!canSubmit"
|
||||
class="rounded bg-primary-300 px-4 py-1.5 text-sm font-medium text-slate-900 transition-colors hover:bg-primary-400 disabled:opacity-50 dark:bg-primary-600 dark:text-white dark:hover:bg-primary-500"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
30
frontend/src/components/admin/AdminToolbar.vue
Normal file
30
frontend/src/components/admin/AdminToolbar.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script setup lang="ts">
|
||||
defineEmits<{
|
||||
"add-segment": [];
|
||||
logout: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-primary-100 bg-primary-50 px-6 py-2 dark:border-primary-700/30 dark:bg-slate-800">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between">
|
||||
<span class="rounded bg-primary-200 px-2 py-0.5 text-xs font-semibold text-primary-700 dark:bg-primary-700/30 dark:text-primary-300">
|
||||
Editing
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded bg-primary-300 px-3 py-1.5 text-sm font-medium text-slate-900 transition-colors hover:bg-primary-400 dark:bg-primary-600 dark:text-white dark:hover:bg-primary-500"
|
||||
@click="$emit('add-segment')"
|
||||
>
|
||||
+ Add Segment
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||
@click="$emit('logout')"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
68
frontend/src/components/admin/AssetUploader.vue
Normal file
68
frontend/src/components/admin/AssetUploader.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useAdmin } from "@/composables/useAdmin";
|
||||
import { useAssetUpload } from "@/composables/useAssetUpload";
|
||||
|
||||
const props = defineProps<{
|
||||
accept?: string;
|
||||
label?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: [filename: string];
|
||||
}>();
|
||||
|
||||
const { authHeaders } = useAdmin();
|
||||
const { uploading, error, upload } = useAssetUpload();
|
||||
const dragging = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
async function handleFile(file: File) {
|
||||
const filename = await upload(file, authHeaders.value);
|
||||
if (filename) {
|
||||
emit("uploaded", filename);
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
dragging.value = false;
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file) void handleFile(file);
|
||||
}
|
||||
|
||||
function onFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
target.value = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-colors"
|
||||
:class="dragging ? 'border-primary-300 bg-primary-50 dark:border-primary-500 dark:bg-primary-900/20' : 'border-slate-300 hover:border-primary-300 dark:border-slate-600 dark:hover:border-primary-500'"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
:accept="props.accept"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
|
||||
<div v-if="uploading" class="text-sm text-slate-500 dark:text-slate-400">Uploading...</div>
|
||||
<div v-else>
|
||||
<svg class="mx-auto mb-2 h-8 w-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{{ props.label ?? "Drop file here or click to browse" }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mt-2 text-sm text-red-500">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
188
frontend/src/components/admin/SegmentEditor.vue
Normal file
188
frontend/src/components/admin/SegmentEditor.vue
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { Segment } from "@/types/segment";
|
||||
import AssetUploader from "@/components/admin/AssetUploader.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
segment: Segment;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [updates: { title?: string; content?: string; metadata?: Record<string, unknown> }];
|
||||
cancel: [];
|
||||
delete: [];
|
||||
}>();
|
||||
|
||||
const title = ref(props.segment.title);
|
||||
const content = ref(props.segment.content);
|
||||
const metadata = ref<Record<string, unknown>>({ ...props.segment.metadata });
|
||||
const showDeleteConfirm = ref(false);
|
||||
|
||||
function handleSave() {
|
||||
const updates: { title?: string; content?: string; metadata?: Record<string, unknown> } = {};
|
||||
if (title.value !== props.segment.title) updates.title = title.value;
|
||||
if (content.value !== props.segment.content) updates.content = content.value;
|
||||
if (JSON.stringify(metadata.value) !== JSON.stringify(props.segment.metadata)) {
|
||||
updates.metadata = metadata.value;
|
||||
}
|
||||
emit("save", updates);
|
||||
}
|
||||
|
||||
function handleAssetUploaded(filename: string) {
|
||||
content.value = `/api/assets/${filename}`;
|
||||
}
|
||||
|
||||
function handleGalleryImageUploaded(filename: string) {
|
||||
const images = Array.isArray(metadata.value.images)
|
||||
? [...(metadata.value.images as string[])]
|
||||
: [];
|
||||
images.push(`/api/assets/${filename}`);
|
||||
metadata.value = { ...metadata.value, images };
|
||||
}
|
||||
|
||||
function removeGalleryImage(index: number) {
|
||||
const images = Array.isArray(metadata.value.images)
|
||||
? [...(metadata.value.images as string[])]
|
||||
: [];
|
||||
images.splice(index, 1);
|
||||
metadata.value = { ...metadata.value, images };
|
||||
}
|
||||
|
||||
const assetAcceptMap: Record<string, string> = {
|
||||
pdf: "application/pdf",
|
||||
video: "video/*",
|
||||
audio: "audio/*",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-4 rounded-lg border border-primary-100 bg-primary-50/50 p-4 dark:border-slate-600 dark:bg-slate-700/50">
|
||||
<!-- Title -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Title</label>
|
||||
<input
|
||||
v-model="title"
|
||||
type="text"
|
||||
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content: depends on segment type -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label>
|
||||
|
||||
<!-- Markdown: textarea -->
|
||||
<textarea
|
||||
v-if="segment.type === 'markdown'"
|
||||
v-model="content"
|
||||
rows="8"
|
||||
class="w-full rounded border border-slate-300 px-3 py-2 font-mono text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
|
||||
/>
|
||||
|
||||
<!-- Iframe / Link: URL input -->
|
||||
<input
|
||||
v-else-if="segment.type === 'iframe' || segment.type === 'link'"
|
||||
v-model="content"
|
||||
type="url"
|
||||
:placeholder="segment.type === 'link' ? 'https://external-site.com' : 'https://...'"
|
||||
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
|
||||
/>
|
||||
|
||||
<!-- PDF / Video / Audio: file upload + URL display -->
|
||||
<div v-else-if="segment.type === 'pdf' || segment.type === 'video' || segment.type === 'audio'">
|
||||
<div v-if="content" class="mb-2 flex items-center gap-2 rounded bg-white px-3 py-2 text-sm text-slate-600 dark:bg-slate-700 dark:text-slate-300">
|
||||
<span class="truncate">{{ content }}</span>
|
||||
<button
|
||||
class="shrink-0 text-red-400 hover:text-red-600"
|
||||
@click="content = ''"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<AssetUploader
|
||||
:accept="assetAcceptMap[segment.type]"
|
||||
:label="`Upload ${segment.type} file`"
|
||||
@uploaded="handleAssetUploaded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Gallery: multiple image upload -->
|
||||
<div v-else-if="segment.type === 'gallery'">
|
||||
<div v-if="Array.isArray(metadata.images) && (metadata.images as string[]).length > 0" class="mb-3 grid grid-cols-4 gap-2">
|
||||
<div
|
||||
v-for="(img, i) in (metadata.images as string[])"
|
||||
:key="i"
|
||||
class="group relative aspect-square overflow-hidden rounded bg-slate-100"
|
||||
>
|
||||
<img :src="img" class="h-full w-full object-cover" />
|
||||
<button
|
||||
class="absolute right-1 top-1 hidden rounded bg-red-500 px-1.5 text-xs text-white group-hover:block"
|
||||
@click="removeGalleryImage(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AssetUploader
|
||||
accept="image/*"
|
||||
label="Upload gallery image"
|
||||
@uploaded="handleGalleryImageUploaded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
class="rounded px-3 py-1.5 text-sm text-red-500 transition-colors hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/30"
|
||||
@click="showDeleteConfirm = true"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-primary-300 px-4 py-1.5 text-sm font-medium text-slate-900 transition-colors hover:bg-primary-400 dark:bg-primary-600 dark:text-white dark:hover:bg-primary-500"
|
||||
@click="handleSave"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showDeleteConfirm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
@click.self="showDeleteConfirm = false"
|
||||
>
|
||||
<div class="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
|
||||
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">Delete Segment</h3>
|
||||
<p class="mb-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
Are you sure you want to delete <strong>"{{ segment.title }}"</strong>? This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||
@click="showDeleteConfirm = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-red-500 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red-600"
|
||||
@click="showDeleteConfirm = false; $emit('delete')"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
79
frontend/src/components/admin/TokenPrompt.vue
Normal file
79
frontend/src/components/admin/TokenPrompt.vue
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useAdmin } from "@/composables/useAdmin";
|
||||
|
||||
const { isAdmin, verifying, login } = useAdmin();
|
||||
|
||||
const emit = defineEmits<{
|
||||
authenticated: [];
|
||||
}>();
|
||||
|
||||
const showInput = ref(false);
|
||||
const tokenInput = ref("");
|
||||
const loginError = ref(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
loginError.value = false;
|
||||
const success = await login(tokenInput.value);
|
||||
if (success) {
|
||||
showInput.value = false;
|
||||
tokenInput.value = "";
|
||||
emit("authenticated");
|
||||
} else {
|
||||
loginError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
showInput.value = false;
|
||||
tokenInput.value = "";
|
||||
loginError.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!isAdmin" class="flex items-center">
|
||||
<!-- Lock icon button -->
|
||||
<button
|
||||
v-if="!showInput"
|
||||
class="rounded p-1.5 text-slate-700 transition-colors hover:bg-primary-400/30"
|
||||
title="Admin login"
|
||||
@click="showInput = true"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Inline token input -->
|
||||
<form v-else class="flex items-center gap-2" @submit.prevent="handleSubmit">
|
||||
<input
|
||||
v-model="tokenInput"
|
||||
type="password"
|
||||
placeholder="Admin token"
|
||||
class="h-8 w-40 rounded border px-2 text-sm text-slate-900 placeholder-slate-400 focus:border-primary-400 focus:outline-none dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
|
||||
:class="loginError ? 'border-red-400' : 'border-slate-300 dark:border-slate-600'"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="verifying || !tokenInput"
|
||||
class="h-8 rounded bg-slate-800 px-3 text-sm font-medium text-white transition-colors hover:bg-slate-700 disabled:opacity-50 dark:bg-slate-600 dark:hover:bg-slate-500"
|
||||
>
|
||||
{{ verifying ? "..." : "Go" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 rounded px-2 text-sm text-slate-600 transition-colors hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
|
||||
@click="handleCancel"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
135
frontend/src/components/layout/AppShell.vue
Normal file
135
frontend/src/components/layout/AppShell.vue
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import type { Segment } from "@/types/segment";
|
||||
import { useAdmin } from "@/composables/useAdmin";
|
||||
import { useDarkMode } from "@/composables/useDarkMode";
|
||||
import SidebarNav from "@/components/layout/SidebarNav.vue";
|
||||
import MobileHeader from "@/components/layout/MobileHeader.vue";
|
||||
import TokenPrompt from "@/components/admin/TokenPrompt.vue";
|
||||
import AdminToolbar from "@/components/admin/AdminToolbar.vue";
|
||||
|
||||
defineProps<{
|
||||
segments: Segment[];
|
||||
siteTitle: string;
|
||||
activeId: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [id: string];
|
||||
"add-segment": [];
|
||||
logout: [];
|
||||
}>();
|
||||
|
||||
const { isAdmin } = useAdmin();
|
||||
const { isDark, toggle: toggleDark } = useDarkMode();
|
||||
|
||||
const showBackToTop = ref(false);
|
||||
|
||||
function handleScroll() {
|
||||
showBackToTop.value = window.scrollY > 300;
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function handleNavigate(id: string) {
|
||||
emit("navigate", id);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
<!-- Sticky header: title bar + nav -->
|
||||
<div class="sticky top-0 z-20">
|
||||
<!-- Title bar -->
|
||||
<header class="flex items-center justify-between bg-primary-300 px-6 py-4">
|
||||
<h1 class="text-xl font-bold text-slate-900">{{ siteTitle }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Dark mode toggle -->
|
||||
<button
|
||||
class="rounded p-1.5 text-slate-700 transition-colors hover:bg-primary-400/30"
|
||||
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="toggleDark"
|
||||
>
|
||||
<!-- Sun icon (shown in dark mode) -->
|
||||
<svg v-if="isDark" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<!-- Moon icon (shown in light mode) -->
|
||||
<svg v-else class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
</button>
|
||||
<TokenPrompt />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Desktop tab nav -->
|
||||
<div class="hidden md:block">
|
||||
<SidebarNav
|
||||
:segments="segments"
|
||||
:site-title="siteTitle"
|
||||
:active-id="activeId"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile header with hamburger -->
|
||||
<div class="md:hidden">
|
||||
<MobileHeader
|
||||
:site-title="siteTitle"
|
||||
:segments="segments"
|
||||
:active-id="activeId"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin toolbar -->
|
||||
<AdminToolbar
|
||||
v-if="isAdmin"
|
||||
@add-segment="$emit('add-segment')"
|
||||
@logout="$emit('logout')"
|
||||
/>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="mx-auto max-w-6xl px-6 py-8">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<!-- Back to top button -->
|
||||
<Transition name="fade-up">
|
||||
<button
|
||||
v-if="showBackToTop"
|
||||
class="fixed right-6 bottom-6 z-30 rounded-full bg-primary-300 p-3 shadow-lg transition-colors hover:bg-primary-400"
|
||||
title="Back to top"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<svg class="h-5 w-5 text-slate-900" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-up-enter-active,
|
||||
.fade-up-leave-active {
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
}
|
||||
.fade-up-enter-from,
|
||||
.fade-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
</style>
|
||||
129
frontend/src/components/layout/MobileHeader.vue
Normal file
129
frontend/src/components/layout/MobileHeader.vue
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { Segment } from "@/types/segment";
|
||||
|
||||
defineProps<{
|
||||
siteTitle: string;
|
||||
segments: Segment[];
|
||||
activeId: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [id: string];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value;
|
||||
}
|
||||
|
||||
function handleNav(id: string) {
|
||||
open.value = false;
|
||||
emit("navigate", id);
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Mobile nav bar -->
|
||||
<div class="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
|
||||
<span class="text-sm font-medium text-slate-500 dark:text-slate-400">Sections</span>
|
||||
<button
|
||||
class="text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
|
||||
aria-label="Open navigation"
|
||||
@click="toggle"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-40 bg-black/40"
|
||||
@click="handleBackdropClick"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- Dropdown panel -->
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed top-0 right-0 left-0 z-50"
|
||||
>
|
||||
<div class="bg-white shadow-lg dark:bg-slate-800">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-700">
|
||||
<span class="text-sm font-semibold text-slate-900 dark:text-slate-100">{{ siteTitle }}</span>
|
||||
<button
|
||||
class="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
|
||||
aria-label="Close navigation"
|
||||
@click="toggle"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="max-h-80 overflow-y-auto py-1">
|
||||
<li v-for="segment in segments" :key="segment.id">
|
||||
<!-- External link segments -->
|
||||
<a
|
||||
v-if="segment.type === 'link'"
|
||||
:href="segment.content"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex w-full items-center gap-1 px-4 py-2.5 text-left text-sm text-slate-600 transition-colors duration-150 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ segment.title }}
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<!-- Regular segment nav buttons -->
|
||||
<button
|
||||
v-else
|
||||
class="w-full px-4 py-2.5 text-left text-sm transition-colors duration-150"
|
||||
:class="
|
||||
activeId === segment.id
|
||||
? 'bg-primary-50 font-medium text-slate-900 dark:bg-primary-700/20 dark:text-slate-100'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100'
|
||||
"
|
||||
@click="handleNav(segment.id)"
|
||||
>
|
||||
{{ segment.title }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: transform 200ms ease, opacity 200ms ease;
|
||||
}
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
52
frontend/src/components/layout/SidebarNav.vue
Normal file
52
frontend/src/components/layout/SidebarNav.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
import type { Segment } from "@/types/segment";
|
||||
|
||||
defineProps<{
|
||||
segments: Segment[];
|
||||
siteTitle: string;
|
||||
activeId: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [id: string];
|
||||
}>();
|
||||
|
||||
function handleClick(id: string) {
|
||||
emit("navigate", id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="border-b border-slate-200 bg-white px-6 dark:border-slate-700 dark:bg-slate-800">
|
||||
<ul class="flex gap-1 overflow-x-auto">
|
||||
<li v-for="segment in segments" :key="segment.id">
|
||||
<!-- External link segments -->
|
||||
<a
|
||||
v-if="segment.type === 'link'"
|
||||
:href="segment.content"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 whitespace-nowrap border-b-2 border-transparent px-3 py-2.5 text-sm font-medium text-slate-500 transition-colors duration-150 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
|
||||
>
|
||||
{{ segment.title }}
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<!-- Regular segment nav buttons -->
|
||||
<button
|
||||
v-else
|
||||
class="whitespace-nowrap border-b-2 px-3 py-2.5 text-sm font-medium transition-colors duration-150"
|
||||
:class="
|
||||
activeId === segment.id
|
||||
? 'border-primary-400 text-slate-900 dark:text-slate-100'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100'
|
||||
"
|
||||
@click="handleClick(segment.id)"
|
||||
>
|
||||
{{ segment.title }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
17
frontend/src/components/segments/AudioSegment.vue
Normal file
17
frontend/src/components/segments/AudioSegment.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { Segment } from "@/types/segment";
|
||||
|
||||
defineProps<{
|
||||
segment: Segment;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<audio
|
||||
:src="segment.content"
|
||||
controls
|
||||
class="w-full"
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</template>
|
||||
28
frontend/src/components/segments/GallerySegment.vue
Normal file
28
frontend/src/components/segments/GallerySegment.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { Segment } from "@/types/segment";
|
||||
|
||||
const props = defineProps<{
|
||||
segment: Segment;
|
||||
}>();
|
||||
|
||||
const images = computed(() => {
|
||||
const meta = props.segment.metadata;
|
||||
if (Array.isArray(meta.images)) {
|
||||
return meta.images as string[];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
|
||||
<img
|
||||
v-for="(src, index) in images"
|
||||
:key="index"
|
||||
:src="src"
|
||||
:alt="`${segment.title} image ${index + 1}`"
|
||||
class="aspect-square w-full rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/segments/IframeSegment.vue
Normal file
17
frontend/src/components/segments/IframeSegment.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { Segment } from "@/types/segment";
|
||||
|
||||
defineProps<{
|
||||
segment: Segment;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<iframe
|
||||
:src="segment.content"
|
||||
:title="segment.title"
|
||||
class="h-[600px] w-full rounded border-none"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||
allow="fullscreen"
|
||||
/>
|
||||
</template>
|
||||
15
frontend/src/components/segments/MarkdownSegment.vue
Normal file
15
frontend/src/components/segments/MarkdownSegment.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { marked } from "marked";
|
||||
import type { Segment } from "@/types/segment";
|
||||
|
||||
const props = defineProps<{
|
||||
segment: Segment;
|
||||
}>();
|
||||
|
||||
const html = computed(() => marked.parse(props.segment.content) as string);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose max-w-none dark:prose-invert" v-html="html" />
|
||||
</template>
|
||||
15
frontend/src/components/segments/PdfSegment.vue
Normal file
15
frontend/src/components/segments/PdfSegment.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { Segment } from "@/types/segment";
|
||||
|
||||
defineProps<{
|
||||
segment: Segment;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<iframe
|
||||
:src="segment.content"
|
||||
class="h-[600px] w-full rounded border-none"
|
||||
:title="segment.title"
|
||||
/>
|
||||
</template>
|
||||
139
frontend/src/components/segments/SegmentList.vue
Normal file
139
frontend/src/components/segments/SegmentList.vue
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
import type { Segment } from "@/types/segment";
|
||||
import { useAdmin } from "@/composables/useAdmin";
|
||||
import SegmentRenderer from "@/components/segments/SegmentRenderer.vue";
|
||||
import SegmentEditor from "@/components/admin/SegmentEditor.vue";
|
||||
import draggable from "vuedraggable";
|
||||
|
||||
const props = defineProps<{
|
||||
segments: Segment[];
|
||||
}>();
|
||||
|
||||
// Filter out link segments from content rendering (they only appear in nav)
|
||||
const contentSegments = computed(() => props.segments.filter((s) => s.type !== "link"));
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [id: string, updates: { title?: string; content?: string; metadata?: Record<string, unknown> }];
|
||||
delete: [id: string];
|
||||
reorder: [ids: string[]];
|
||||
}>();
|
||||
|
||||
const { isAdmin } = useAdmin();
|
||||
const editingId = ref<string | null>(null);
|
||||
|
||||
// Local copy for draggable
|
||||
const localSegments = ref<Segment[]>([...props.segments]);
|
||||
|
||||
watch(
|
||||
() => props.segments,
|
||||
(val) => {
|
||||
localSegments.value = [...val];
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
function toggleEdit(id: string) {
|
||||
editingId.value = editingId.value === id ? null : id;
|
||||
}
|
||||
|
||||
function handleSave(
|
||||
id: string,
|
||||
updates: { title?: string; content?: string; metadata?: Record<string, unknown> }
|
||||
) {
|
||||
editingId.value = null;
|
||||
emit("save", id, updates);
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
editingId.value = null;
|
||||
emit("delete", id);
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
const ids = localSegments.value.map((s) => s.id);
|
||||
emit("reorder", ids);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6 p-6">
|
||||
<!-- Admin mode: draggable with edit controls -->
|
||||
<draggable
|
||||
v-if="isAdmin"
|
||||
v-model="localSegments"
|
||||
item-key="id"
|
||||
handle=".drag-handle"
|
||||
class="space-y-6"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element: segment }: { element: Segment }">
|
||||
<section
|
||||
:id="segment.id"
|
||||
class="rounded-lg bg-white p-6 shadow-md dark:bg-slate-800 dark:shadow-slate-950/30"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Drag handle: grip dots -->
|
||||
<button
|
||||
class="drag-handle mt-1 cursor-grab text-slate-300 transition-colors hover:text-primary-300 active:cursor-grabbing dark:text-slate-600 dark:hover:text-primary-400"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="9" cy="6" r="1.5" />
|
||||
<circle cx="15" cy="6" r="1.5" />
|
||||
<circle cx="9" cy="12" r="1.5" />
|
||||
<circle cx="15" cy="12" r="1.5" />
|
||||
<circle cx="9" cy="18" r="1.5" />
|
||||
<circle cx="15" cy="18" r="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100">{{ segment.title }}</h2>
|
||||
<!-- Edit pencil icon -->
|
||||
<button
|
||||
class="rounded p-1 text-slate-300 transition-colors hover:text-primary-300 dark:text-slate-600 dark:hover:text-primary-400"
|
||||
title="Edit segment"
|
||||
@click="toggleEdit(segment.id)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Link segments show URL instead of content -->
|
||||
<p v-if="segment.type === 'link'" class="text-sm text-slate-400">
|
||||
External link: <a :href="segment.content" target="_blank" rel="noopener noreferrer" class="text-primary-500 underline">{{ segment.content }}</a>
|
||||
</p>
|
||||
<SegmentRenderer v-else :segment="segment" />
|
||||
|
||||
<!-- Inline editor -->
|
||||
<SegmentEditor
|
||||
v-if="editingId === segment.id"
|
||||
:segment="segment"
|
||||
@save="(updates) => handleSave(segment.id, updates)"
|
||||
@cancel="editingId = null"
|
||||
@delete="handleDelete(segment.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<!-- Read-only mode: plain rendering (link segments excluded) -->
|
||||
<template v-else>
|
||||
<section
|
||||
v-for="segment in contentSegments"
|
||||
:key="segment.id"
|
||||
:id="segment.id"
|
||||
class="rounded-lg bg-white p-6 shadow-md dark:bg-slate-800 dark:shadow-slate-950/30"
|
||||
>
|
||||
<h2 class="mb-4 text-xl font-semibold text-slate-900 dark:text-slate-100">{{ segment.title }}</h2>
|
||||
<SegmentRenderer :segment="segment" />
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
22
frontend/src/components/segments/SegmentRenderer.vue
Normal file
22
frontend/src/components/segments/SegmentRenderer.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { Segment } from "@/types/segment";
|
||||
import MarkdownSegment from "@/components/segments/MarkdownSegment.vue";
|
||||
import PdfSegment from "@/components/segments/PdfSegment.vue";
|
||||
import VideoSegment from "@/components/segments/VideoSegment.vue";
|
||||
import AudioSegment from "@/components/segments/AudioSegment.vue";
|
||||
import IframeSegment from "@/components/segments/IframeSegment.vue";
|
||||
import GallerySegment from "@/components/segments/GallerySegment.vue";
|
||||
|
||||
defineProps<{
|
||||
segment: Segment;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MarkdownSegment v-if="segment.type === 'markdown'" :segment="segment" />
|
||||
<PdfSegment v-else-if="segment.type === 'pdf'" :segment="segment" />
|
||||
<VideoSegment v-else-if="segment.type === 'video'" :segment="segment" />
|
||||
<AudioSegment v-else-if="segment.type === 'audio'" :segment="segment" />
|
||||
<IframeSegment v-else-if="segment.type === 'iframe'" :segment="segment" />
|
||||
<GallerySegment v-else-if="segment.type === 'gallery'" :segment="segment" />
|
||||
</template>
|
||||
17
frontend/src/components/segments/VideoSegment.vue
Normal file
17
frontend/src/components/segments/VideoSegment.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { Segment } from "@/types/segment";
|
||||
|
||||
defineProps<{
|
||||
segment: Segment;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<video
|
||||
:src="segment.content"
|
||||
controls
|
||||
class="w-full rounded"
|
||||
>
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
</template>
|
||||
62
frontend/src/composables/useAdmin.ts
Normal file
62
frontend/src/composables/useAdmin.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { ref, computed } from "vue";
|
||||
import type { Ref, ComputedRef } from "vue";
|
||||
|
||||
const token: Ref<string | null> = ref(null);
|
||||
const isAdmin: Ref<boolean> = ref(false);
|
||||
const verifying: Ref<boolean> = ref(false);
|
||||
|
||||
async function verifyToken(t: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("/api/auth/verify", {
|
||||
headers: { Authorization: `Bearer ${t}` },
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check sessionStorage on module load
|
||||
const stored = sessionStorage.getItem("admin_token");
|
||||
if (stored) {
|
||||
verifying.value = true;
|
||||
verifyToken(stored).then((valid) => {
|
||||
if (valid) {
|
||||
token.value = stored;
|
||||
isAdmin.value = true;
|
||||
} else {
|
||||
sessionStorage.removeItem("admin_token");
|
||||
}
|
||||
verifying.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdmin() {
|
||||
async function login(t: string): Promise<boolean> {
|
||||
verifying.value = true;
|
||||
const valid = await verifyToken(t);
|
||||
if (valid) {
|
||||
token.value = t;
|
||||
isAdmin.value = true;
|
||||
sessionStorage.setItem("admin_token", t);
|
||||
}
|
||||
verifying.value = false;
|
||||
return valid;
|
||||
}
|
||||
|
||||
function logout(): void {
|
||||
token.value = null;
|
||||
isAdmin.value = false;
|
||||
sessionStorage.removeItem("admin_token");
|
||||
}
|
||||
|
||||
const authHeaders = computed((): Record<string, string> =>
|
||||
token.value ? { Authorization: `Bearer ${token.value}` } : {}
|
||||
);
|
||||
|
||||
const authQuery: ComputedRef<string> = computed(() =>
|
||||
token.value ? `?token=${encodeURIComponent(token.value)}` : ""
|
||||
);
|
||||
|
||||
return { token, isAdmin, verifying, login, logout, authHeaders, authQuery };
|
||||
}
|
||||
42
frontend/src/composables/useAssetUpload.ts
Normal file
42
frontend/src/composables/useAssetUpload.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { ref } from "vue";
|
||||
|
||||
export function useAssetUpload() {
|
||||
const uploading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function upload(
|
||||
file: File,
|
||||
authHeaders: Record<string, string>
|
||||
): Promise<string | null> {
|
||||
uploading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await fetch("/api/assets/", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ detail: "Upload failed" }));
|
||||
const detail = (body as { detail?: string }).detail ?? "Upload failed";
|
||||
error.value = detail;
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { filename: string };
|
||||
return data.filename;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Upload failed";
|
||||
return null;
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { uploading, error, upload };
|
||||
}
|
||||
30
frontend/src/composables/useDarkMode.ts
Normal file
30
frontend/src/composables/useDarkMode.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { ref, watch } from "vue";
|
||||
|
||||
const isDark = ref(false);
|
||||
|
||||
// Initialize from localStorage or system preference
|
||||
const stored = localStorage.getItem("dark_mode");
|
||||
if (stored !== null) {
|
||||
isDark.value = stored === "true";
|
||||
} else {
|
||||
isDark.value = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
// Apply class to <html>
|
||||
function applyClass() {
|
||||
document.documentElement.classList.toggle("dark", isDark.value);
|
||||
}
|
||||
applyClass();
|
||||
|
||||
watch(isDark, () => {
|
||||
applyClass();
|
||||
localStorage.setItem("dark_mode", String(isDark.value));
|
||||
});
|
||||
|
||||
export function useDarkMode() {
|
||||
function toggle() {
|
||||
isDark.value = !isDark.value;
|
||||
}
|
||||
|
||||
return { isDark, toggle };
|
||||
}
|
||||
38
frontend/src/composables/useSegments.ts
Normal file
38
frontend/src/composables/useSegments.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { ref, onMounted } from "vue";
|
||||
import type { Segment, SiteInfo } from "@/types/segment";
|
||||
|
||||
export function useSegments() {
|
||||
const segments = ref<Segment[]>([]);
|
||||
const siteTitle = ref("Handin");
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const [siteRes, segmentsRes] = await Promise.all([
|
||||
fetch("/api/site/"),
|
||||
fetch("/api/segments/"),
|
||||
]);
|
||||
if (!siteRes.ok) throw new Error(`Site fetch failed: ${siteRes.status}`);
|
||||
if (!segmentsRes.ok) throw new Error(`Segments fetch failed: ${segmentsRes.status}`);
|
||||
|
||||
const siteData = (await siteRes.json()) as SiteInfo;
|
||||
const segmentsData = (await segmentsRes.json()) as Segment[];
|
||||
|
||||
siteTitle.value = siteData.title;
|
||||
segments.value = segmentsData.sort((a, b) => a.sort_order - b.sort_order);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Unknown error";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
});
|
||||
|
||||
return { segments, siteTitle, loading, error, refresh };
|
||||
}
|
||||
5
frontend/src/main.ts
Normal file
5
frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
15
frontend/src/style.css
Normal file
15
frontend/src/style.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-primary-50: #fffbeb;
|
||||
--color-primary-100: #fff3c4;
|
||||
--color-primary-200: #fce588;
|
||||
--color-primary-300: #ffcd00;
|
||||
--color-primary-400: #e6b800;
|
||||
--color-primary-500: #cc9900;
|
||||
--color-primary-600: #997300;
|
||||
--color-primary-700: #664d00;
|
||||
}
|
||||
16
frontend/src/types/segment.ts
Normal file
16
frontend/src/types/segment.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery" | "link";
|
||||
|
||||
export interface Segment {
|
||||
id: string;
|
||||
type: SegmentType;
|
||||
sort_order: number;
|
||||
title: string;
|
||||
content: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SiteInfo {
|
||||
title: string;
|
||||
}
|
||||
5
frontend/src/types/vuedraggable.d.ts
vendored
Normal file
5
frontend/src/types/vuedraggable.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
declare module "vuedraggable" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent;
|
||||
export default component;
|
||||
}
|
||||
165
frontend/src/views/HomePage.vue
Normal file
165
frontend/src/views/HomePage.vue
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { useSegments } from "@/composables/useSegments";
|
||||
import { useAdmin } from "@/composables/useAdmin";
|
||||
import type { SegmentType } from "@/types/segment";
|
||||
import AppShell from "@/components/layout/AppShell.vue";
|
||||
import SegmentList from "@/components/segments/SegmentList.vue";
|
||||
import AddSegmentModal from "@/components/admin/AddSegmentModal.vue";
|
||||
|
||||
const { segments, siteTitle, loading, error, refresh } = useSegments();
|
||||
const { authHeaders, logout } = useAdmin();
|
||||
const activeId = ref<string | null>(null);
|
||||
const showAddModal = ref(false);
|
||||
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
function handleNavigate(id: string) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth" });
|
||||
activeId.value = id;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateSegment(data: {
|
||||
type: SegmentType;
|
||||
title: string;
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
const res = await fetch("/api/segments/", {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders.value, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
showAddModal.value = false;
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSegment(
|
||||
id: string,
|
||||
updates: { title?: string; content?: string; metadata?: Record<string, unknown> }
|
||||
) {
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
const res = await fetch(`/api/segments/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...authHeaders.value, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (res.ok) {
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSegment(id: string) {
|
||||
const res = await fetch(`/api/segments/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders.value,
|
||||
});
|
||||
if (res.ok) {
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReorder(ids: string[]) {
|
||||
const res = await fetch("/api/segments/reorder", {
|
||||
method: "PUT",
|
||||
headers: { ...authHeaders.value, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ segment_ids: ids }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
activeId.value = entry.target.id;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-20% 0px -60% 0px" }
|
||||
);
|
||||
|
||||
const mutObs = new MutationObserver(() => {
|
||||
for (const seg of segments.value) {
|
||||
const el = document.getElementById(seg.id);
|
||||
if (el) observer?.observe(el);
|
||||
}
|
||||
});
|
||||
mutObs.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect();
|
||||
mutObs.disconnect();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell
|
||||
:segments="segments"
|
||||
:site-title="siteTitle"
|
||||
:active-id="activeId"
|
||||
@navigate="handleNavigate"
|
||||
@add-segment="showAddModal = true"
|
||||
@logout="handleLogout"
|
||||
>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-32">
|
||||
<svg
|
||||
class="h-10 w-10 animate-spin text-primary-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="flex items-center justify-center py-32">
|
||||
<p class="text-lg text-red-500">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="segments.length === 0" class="flex flex-col items-center justify-center py-32 text-slate-400 dark:text-slate-600">
|
||||
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-lg">No content yet</p>
|
||||
</div>
|
||||
|
||||
<!-- Segment list -->
|
||||
<SegmentList
|
||||
v-else
|
||||
:segments="segments"
|
||||
@save="handleSaveSegment"
|
||||
@delete="handleDeleteSegment"
|
||||
@reorder="handleReorder"
|
||||
/>
|
||||
</AppShell>
|
||||
|
||||
<!-- Add segment modal -->
|
||||
<AddSegmentModal
|
||||
v-if="showAddModal"
|
||||
@create="handleCreateSegment"
|
||||
@cancel="showAddModal = false"
|
||||
/>
|
||||
</template>
|
||||
Loading…
Add table
Add a link
Reference in a new issue