first commit

This commit is contained in:
Justin Visser 2026-03-02 20:09:27 +01:00
commit b47f825d54
83 changed files with 10016 additions and 0 deletions

7
frontend/src/App.vue Normal file
View file

@ -0,0 +1,7 @@
<script setup lang="ts">
import HomePage from "@/views/HomePage.vue";
</script>
<template>
<HomePage />
</template>

View 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 = ''">&times;</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)"
>
&times;
</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>

View 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>

View 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>

View 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 = ''"
>
&times;
</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)"
>
&times;
</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>

View 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"
>
&times;
</button>
</form>
</div>
</template>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 };
}

View 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 };
}

View 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 };
}

View 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
View 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
View 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;
}

View 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
View file

@ -0,0 +1,5 @@
declare module "vuedraggable" {
import type { DefineComponent } from "vue";
const component: DefineComponent;
export default component;
}

View 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>