initial release

This commit is contained in:
Justin Visser 2026-03-03 16:16:36 +01:00
parent e156de22c3
commit b3ac11a85e
37 changed files with 3649 additions and 669 deletions

View file

@ -1,7 +1,9 @@
<script setup lang="ts">
import HomePage from "@/views/HomePage.vue";
import AppShell from "@/components/layout/AppShell.vue";
</script>
<template>
<HomePage />
<AppShell>
<RouterView />
</AppShell>
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View file

@ -2,29 +2,27 @@
import { ref, computed } from "vue";
import type { SegmentType } from "@/types/segment";
import AssetUploader from "@/components/admin/AssetUploader.vue";
import RichTextEditor from "@/components/admin/RichTextEditor.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 segmentTypes: { value: SegmentType; label: string; description: string; icon: string }[] = [
{ value: "markdown", label: "Text", description: "Write formatted text, headings, and lists", icon: '<svg xmlns="http://www.w3.org/2000/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 10h16M4 14h10M4 18h6" /></svg>' },
{ value: "gallery", label: "Images", description: "Display a photo gallery", icon: '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" ry="2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="8.5" cy="8.5" r="1.5" stroke-width="2" /><polyline points="21 15 16 10 5 21" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>' },
{ value: "pdf", label: "PDF", description: "Show a PDF document", icon: '<svg xmlns="http://www.w3.org/2000/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="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>' },
{ value: "video", label: "Video", description: "Embed a video file", icon: '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>' },
{ value: "audio", label: "Audio", description: "Embed an audio file", icon: '<svg xmlns="http://www.w3.org/2000/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="M15.536 8.464a5 5 0 010 7.072M12 6v12m0 0a3 3 0 003-3V9a3 3 0 00-3 3m0 0a3 3 0 01-3 3V9a3 3 0 013-3" /></svg>' },
{ value: "iframe", label: "Embed", description: "Embed an external website or tool", icon: '<svg xmlns="http://www.w3.org/2000/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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>' },
];
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;
@ -67,82 +65,83 @@ const assetAcceptMap: Record<string, string> = {
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>
<div class="mx-4 w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">Add a new section</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">
<!-- Type selector: icon cards -->
<div class="mb-5">
<div class="grid grid-cols-3 gap-2 sm:grid-cols-6">
<button
v-for="st in segmentTypes"
:key="st.value"
class="rounded-full px-3 py-1 text-sm font-medium transition-colors"
type="button"
class="flex flex-col items-center gap-1.5 rounded-lg border-2 py-3 px-2 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'
? 'border-primary-300 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
"
:title="st.description"
@click="selectedType = st.value; content = ''; galleryImages = []"
>
{{ st.label }}
<span class="text-gray-500 dark:text-gray-400" v-html="st.icon" />
<span class="text-xs font-medium text-gray-900 dark:text-gray-100">{{ st.label }}</span>
</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>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Section title <span class="font-normal text-gray-400">(optional)</span>
</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"
placeholder="Give this section a title"
class="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-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>
<div class="mb-5">
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-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"
/>
<!-- Markdown: rich text editor -->
<RichTextEditor v-if="selectedType === 'markdown'" v-model="content" />
<!-- 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"
/>
<!-- Iframe: URL input -->
<div v-else-if="selectedType === 'iframe'">
<p class="mb-2 text-xs text-gray-400">Paste the URL of the website or tool you want to embed.</p>
<input
v-model="content"
type="url"
placeholder="https://..."
class="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<!-- 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">
<div v-if="content" class="mb-2 flex items-center gap-2 rounded bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:bg-gray-700 dark:text-gray-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[selectedType]"
:label="`Upload ${selectedType} file`"
:label="`Click to upload your ${selectedType} file, or drag and drop`"
@uploaded="handleAssetUploaded"
/>
</div>
<!-- Gallery -->
<div v-else-if="selectedType === 'gallery'">
<p class="mb-2 text-xs text-gray-400">Upload one or more photos. You can add more later.</p>
<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"
class="group relative aspect-square overflow-hidden rounded bg-gray-100 dark:bg-gray-700"
>
<img :src="img" class="h-full w-full object-cover" />
<button
@ -153,24 +152,24 @@ const assetAcceptMap: Record<string, string> = {
</button>
</div>
</div>
<AssetUploader accept="image/*" label="Upload gallery image" @uploaded="handleGalleryImageUploaded" />
<AssetUploader accept="image/*" :multiple="true" label="Click to upload photos, or drag and drop" @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"
class="rounded px-3 py-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-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"
class="rounded bg-primary-300 px-4 py-1.5 text-sm font-medium text-gray-900 transition-colors hover:bg-primary-400 disabled:opacity-50"
@click="handleSubmit"
>
Create
Add section
</button>
</div>
</div>

View file

@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref } from "vue";
import { ref, onMounted, watch } from "vue";
import { useAdmin } from "@/composables/useAdmin";
import { useAssetUpload } from "@/composables/useAssetUpload";
const props = defineProps<{
accept?: string;
label?: string;
multiple?: boolean;
}>();
const emit = defineEmits<{
@ -13,28 +14,61 @@ const emit = defineEmits<{
}>();
const { authHeaders } = useAdmin();
const { uploading, error, upload } = useAssetUpload();
const { 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);
// Track batch upload state independently of the per-request composable
const busy = ref(false);
const progressLabel = ref("");
const uploadError = ref<string | null>(null);
// Ensure the `multiple` HTML attribute is set directly on the DOM node,
// since some GTK/GNOME file pickers only respect the attribute, not the
// DOM property that Vue's :multiple binding sets.
function applyMultiple() {
if (!fileInput.value) return;
if (props.multiple) {
fileInput.value.setAttribute("multiple", "");
} else {
fileInput.value.removeAttribute("multiple");
}
}
onMounted(applyMultiple);
watch(() => props.multiple, applyMultiple);
async function handleFiles(files: File[]) {
if (files.length === 0) return;
busy.value = true;
uploadError.value = null;
try {
for (let i = 0; i < files.length; i++) {
progressLabel.value = files.length > 1
? `Uploading ${i + 1} of ${files.length}`
: "Uploading…";
const filename = await upload(files[i], authHeaders.value);
if (filename) {
emit("uploaded", filename);
}
}
} finally {
busy.value = false;
progressLabel.value = "";
}
}
function onDrop(e: DragEvent) {
dragging.value = false;
const file = e.dataTransfer?.files[0];
if (file) void handleFile(file);
const files = Array.from(e.dataTransfer?.files ?? []);
void handleFiles(files);
}
function onFileSelect(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) void handleFile(file);
const files = Array.from(target.files ?? []);
target.value = "";
void handleFiles(files);
}
</script>
@ -47,15 +81,19 @@ function onFileSelect(e: Event) {
@drop.prevent="onDrop"
@click="fileInput?.click()"
>
<!-- Hidden file input: multiple attribute set imperatively via onMounted/watch -->
<input
ref="fileInput"
type="file"
class="hidden"
:accept="props.accept"
@click.stop
@change="onFileSelect"
/>
<div v-if="uploading" class="text-sm text-slate-500 dark:text-slate-400">Uploading...</div>
<div v-if="busy" class="text-sm text-slate-500 dark:text-slate-400">
{{ progressLabel }}
</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" />
@ -63,6 +101,6 @@ function onFileSelect(e: Event) {
<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>
<p v-if="uploadError" class="mt-2 text-sm text-red-500">{{ uploadError }}</p>
</div>
</template>

View file

@ -0,0 +1,243 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from "vue";
import { useEditor, EditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
const props = defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
const headingDropdownOpen = ref(false);
const headingLevels = [
{ level: 0, label: "Paragraph" },
{ level: 1, label: "Heading 1" },
{ level: 2, label: "Heading 2" },
{ level: 3, label: "Heading 3" },
] as const;
const editor = useEditor({
extensions: [
StarterKit,
Markdown.configure({ html: false, transformPastedText: true }),
],
content: props.modelValue,
onUpdate() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emit("update:modelValue", (editor.value?.storage as any).markdown?.getMarkdown() ?? "");
},
});
function getActiveHeadingLabel() {
if (!editor.value) return "Paragraph";
for (const h of headingLevels) {
if (h.level > 0 && editor.value.isActive("heading", { level: h.level })) return h.label;
}
return "Paragraph";
}
function applyHeading(level: 0 | 1 | 2 | 3) {
headingDropdownOpen.value = false;
if (!editor.value) return;
if (level === 0) {
editor.value.chain().focus().setParagraph().run();
} else {
editor.value.chain().focus().toggleHeading({ level }).run();
}
}
watch(
() => props.modelValue,
(val) => {
if (!editor.value) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const current = (editor.value.storage as any).markdown?.getMarkdown() ?? "";
if (val !== current) {
editor.value.commands.setContent(val);
}
},
);
onBeforeUnmount(() => {
editor.value?.destroy();
});
</script>
<template>
<div class="rounded border border-gray-300 dark:border-gray-600">
<!-- Toolbar -->
<div class="flex flex-wrap items-center gap-0.5 border-b border-gray-300 bg-gray-50 p-1.5 dark:border-gray-600 dark:bg-gray-700/50">
<!-- Heading dropdown -->
<div class="relative">
<button
type="button"
class="flex items-center gap-1 rounded px-2 py-1.5 text-xs text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('heading') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Heading level"
@click="headingDropdownOpen = !headingDropdownOpen"
>
<span class="font-medium">{{ getActiveHeadingLabel() }}</span>
<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="M19 9l-7 7-7-7" />
</svg>
</button>
<div
v-if="headingDropdownOpen"
class="absolute left-0 top-full z-10 mt-0.5 min-w-[120px] rounded border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-600 dark:bg-gray-800"
>
<button
v-for="h in headingLevels"
:key="h.level"
type="button"
class="block w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
:class="[
h.level === 0 ? 'text-gray-700 dark:text-gray-300' : '',
h.level === 1 ? 'text-base font-bold text-gray-900 dark:text-gray-100' : '',
h.level === 2 ? 'font-semibold text-gray-900 dark:text-gray-100' : '',
h.level === 3 ? 'font-medium text-gray-800 dark:text-gray-200' : '',
]"
@click="applyHeading(h.level as 0 | 1 | 2 | 3)"
>{{ h.label }}</button>
</div>
</div>
<div class="mx-1 h-5 w-px bg-gray-300 dark:bg-gray-600" />
<!-- Bold -->
<button
type="button"
class="rounded p-1.5 text-sm font-bold text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('bold') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Bold (Ctrl+B)"
@click="editor?.chain().focus().toggleBold().run()"
>B</button>
<!-- Italic -->
<button
type="button"
class="rounded p-1.5 text-sm italic text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('italic') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Italic (Ctrl+I)"
@click="editor?.chain().focus().toggleItalic().run()"
>I</button>
<!-- Strikethrough -->
<button
type="button"
class="rounded p-1.5 text-sm text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('strike') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Strikethrough"
@click="editor?.chain().focus().toggleStrike().run()"
><span class="line-through">S</span></button>
<!-- Inline code -->
<button
type="button"
class="rounded p-1.5 text-xs font-mono text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('code') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Inline code"
@click="editor?.chain().focus().toggleCode().run()"
>&lt;/&gt;</button>
<div class="mx-1 h-5 w-px bg-gray-300 dark:bg-gray-600" />
<!-- Bullet list -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('bulletList') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Bullet list"
@click="editor?.chain().focus().toggleBulletList().run()"
>
<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="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
</svg>
</button>
<!-- Ordered list -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('orderedList') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Ordered list"
@click="editor?.chain().focus().toggleOrderedList().run()"
>
<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="M7 6h13M7 12h13M7 18h13M3 6h.01M3 12h.01M3 18h.01" />
</svg>
</button>
<!-- Blockquote -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('blockquote') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Blockquote"
@click="editor?.chain().focus().toggleBlockquote().run()"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.804.167 3.226 1.648 3.226 3.489a3.5 3.5 0 01-3.5 3.5c-1.073 0-2.099-.49-2.748-1.179zm10 0C13.553 16.227 13 15 13 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.804.167 3.226 1.648 3.226 3.489a3.5 3.5 0 01-3.5 3.5c-1.073 0-2.099-.49-2.748-1.179z" />
</svg>
</button>
<!-- Code block -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
:class="editor?.isActive('codeBlock') ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : ''"
title="Code block"
@click="editor?.chain().focus().toggleCodeBlock().run()"
>
<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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</button>
<!-- Horizontal rule -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-600"
title="Horizontal rule"
@click="editor?.chain().focus().setHorizontalRule().run()"
>
<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="M5 12h14" />
</svg>
</button>
<div class="mx-1 h-5 w-px bg-gray-300 dark:bg-gray-600" />
<!-- Undo -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 disabled:opacity-30 dark:text-gray-300 dark:hover:bg-gray-600"
title="Undo (Ctrl+Z)"
:disabled="!editor?.can().undo()"
@click="editor?.chain().focus().undo().run()"
>
<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="M3 10h10a8 8 0 018 8v2M3 10l6 6M3 10l6-6" />
</svg>
</button>
<!-- Redo -->
<button
type="button"
class="rounded p-1.5 text-gray-700 transition-colors hover:bg-gray-200 disabled:opacity-30 dark:text-gray-300 dark:hover:bg-gray-600"
title="Redo (Ctrl+Y)"
:disabled="!editor?.can().redo()"
@click="editor?.chain().focus().redo().run()"
>
<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="M21 10H11a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6" />
</svg>
</button>
</div>
<!-- Close heading dropdown on outside click -->
<div v-if="headingDropdownOpen" class="fixed inset-0 z-0" @click="headingDropdownOpen = false" />
<!-- Editor content -->
<EditorContent
:editor="editor"
class="min-h-[200px] px-3 py-2 text-sm text-gray-900 dark:text-gray-100 [&_.ProseMirror]:min-h-[180px] [&_.ProseMirror]:outline-none [&_.ProseMirror]:prose [&_.ProseMirror]:dark:prose-invert [&_.ProseMirror]:prose-sm [&_.ProseMirror]:max-w-none"
/>
</div>
</template>

View file

@ -2,6 +2,7 @@
import { ref } from "vue";
import type { Segment } from "@/types/segment";
import AssetUploader from "@/components/admin/AssetUploader.vue";
import RichTextEditor from "@/components/admin/RichTextEditor.vue";
const props = defineProps<{
segment: Segment;
@ -17,11 +18,21 @@ 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);
const teamMembers = ref<{ name: string; student_number: string }[]>(
Array.isArray(props.segment.metadata.members)
? [...(props.segment.metadata.members as { name: string; student_number: string }[])]
: [],
);
const newMemberName = ref("");
const newMemberNumber = ref("");
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 (props.segment.type === "team") {
metadata.value = { ...metadata.value, members: teamMembers.value };
}
if (JSON.stringify(metadata.value) !== JSON.stringify(props.segment.metadata)) {
updates.metadata = metadata.value;
}
@ -71,20 +82,15 @@ const assetAcceptMap: Record<string, string> = {
<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"
/>
<!-- Markdown: rich text editor -->
<RichTextEditor v-if="segment.type === 'markdown'" v-model="content" />
<!-- Iframe / Link: URL input -->
<!-- Iframe: URL input -->
<input
v-else-if="segment.type === 'iframe' || segment.type === 'link'"
v-else-if="segment.type === 'iframe'"
v-model="content"
type="url"
:placeholder="segment.type === 'link' ? 'https://external-site.com' : 'https://...'"
placeholder="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"
/>
@ -125,10 +131,47 @@ const assetAcceptMap: Record<string, string> = {
</div>
<AssetUploader
accept="image/*"
label="Upload gallery image"
:multiple="true"
label="Click to upload photos, or drag and drop"
@uploaded="handleGalleryImageUploaded"
/>
</div>
<!-- Team: member list -->
<div v-else-if="segment.type === 'team'">
<div v-if="teamMembers.length > 0" class="mb-3 space-y-1">
<div
v-for="(member, i) in teamMembers"
:key="i"
class="flex items-center justify-between rounded bg-white px-3 py-1.5 text-sm dark:bg-slate-700"
>
<span class="text-slate-900 dark:text-slate-100">{{ member.name }}</span>
<span class="font-mono text-slate-500 dark:text-slate-400">{{ member.student_number }}</span>
<button class="ml-2 shrink-0 text-red-400 hover:text-red-600" @click="teamMembers = teamMembers.filter((_, j) => j !== i)">&times;</button>
</div>
</div>
<div class="flex gap-2">
<input
v-model="newMemberName"
type="text"
placeholder="Name"
class="min-w-0 flex-1 rounded border border-slate-300 px-3 py-1.5 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"
/>
<input
v-model="newMemberNumber"
type="text"
placeholder="Student number"
class="w-36 rounded border border-slate-300 px-3 py-1.5 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"
/>
<button
:disabled="!newMemberName.trim() || !newMemberNumber.trim()"
class="rounded bg-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-300 disabled:opacity-50 dark:bg-slate-600 dark:text-slate-200 dark:hover:bg-slate-500"
@click="teamMembers = [...teamMembers, { name: newMemberName.trim(), student_number: newMemberNumber.trim() }]; newMemberName = ''; newMemberNumber = ''"
>
Add
</button>
</div>
</div>
</div>
<!-- Actions -->

View file

@ -1,135 +1,175 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import type { Segment } from "@/types/segment";
import { ref, nextTick, onMounted, watch, watchEffect } from "vue";
import { useRouter, useRoute } from "vue-router";
import Sidebar from "@/components/layout/Sidebar.vue";
import { usePages } from "@/composables/usePages";
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";
import type { SiteInfo } from "@/types/segment";
import logoUrl from "@/assets/logo.png";
defineProps<{
segments: Segment[];
siteTitle: string;
activeId: string | null;
}>();
const router = useRouter();
const route = useRoute();
const { pages, fetchPages } = usePages();
const { isAdmin, authHeaders } = useAdmin();
const emit = defineEmits<{
navigate: [id: string];
"add-segment": [];
logout: [];
}>();
const mobileNavOpen = ref(false);
const siteTitle = ref("Untitled Site");
const editingSiteTitle = ref(false);
const siteTitleInput = ref("");
const { isAdmin } = useAdmin();
const { isDark, toggle: toggleDark } = useDarkMode();
const showBackToTop = ref(false);
function handleScroll() {
showBackToTop.value = window.scrollY > 300;
async function loadSiteTitle() {
try {
const res = await fetch("/api/site/");
if (res.ok) {
const data = (await res.json()) as SiteInfo;
siteTitle.value = data.title;
}
} catch { /* Silently fail */ }
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
function startEditTitle() {
if (!isAdmin.value) return;
siteTitleInput.value = siteTitle.value;
editingSiteTitle.value = true;
nextTick(() => { document.getElementById("header-title-input")?.focus(); });
}
function handleNavigate(id: string) {
emit("navigate", id);
async function saveSiteTitle() {
const trimmed = siteTitleInput.value.trim();
if (trimmed && trimmed !== siteTitle.value) {
try {
const res = await fetch("/api/site/", {
method: "PUT",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ title: trimmed }),
});
if (res.ok) siteTitle.value = trimmed;
} catch { /* Silently fail */ }
}
editingSiteTitle.value = false;
}
onMounted(() => {
window.addEventListener("scroll", handleScroll, { passive: true });
// Dynamic browser tab title
watchEffect(() => {
const slug = route.params.slug as string | undefined;
const page = slug ? pages.value.find((p) => p.slug === slug) : undefined;
document.title = page ? `${page.name} | ${siteTitle.value}` : siteTitle.value;
});
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
// Redirect "/" to first page once pages are loaded
watch(pages, (loaded) => {
if (loaded.length > 0 && (route.path === "/" || route.path === "")) {
void router.replace({ name: "page", params: { slug: loaded[0].slug } });
}
});
onMounted(async () => {
await Promise.all([loadSiteTitle(), fetchPages()]);
if (pages.value.length > 0 && (route.path === "/" || route.path === "")) {
void router.replace({ name: "page", params: { slug: pages.value[0].slug } });
}
});
</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">
<div class="flex h-screen flex-col overflow-hidden bg-white dark:bg-gray-900">
<!-- Full-width yellow header -->
<header class="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-3 border-b border-primary-400 bg-primary-300 px-4">
<!-- Hamburger (mobile only) -->
<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"
class="shrink-0 rounded p-1.5 text-gray-700 transition-colors hover:bg-primary-400 md:hidden"
aria-label="Open navigation"
@click="mobileNavOpen = true"
>
<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 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>
</Transition>
<!-- Logo -->
<img :src="logoUrl" alt="Logo" class="h-8 w-auto shrink-0" />
<!-- Site title -->
<form v-if="editingSiteTitle" @submit.prevent="saveSiteTitle">
<input
id="header-title-input"
v-model="siteTitleInput"
type="text"
class="rounded border border-primary-500 bg-primary-200/60 px-2 py-0.5 text-base font-bold text-gray-900 focus:outline-none"
@blur="saveSiteTitle"
@keydown.escape="editingSiteTitle = false"
/>
</form>
<div
v-else
class="group flex min-w-0 items-center gap-1"
:class="isAdmin ? 'cursor-pointer' : ''"
:title="isAdmin ? 'Click to rename' : undefined"
@click="startEditTitle"
>
<span class="truncate text-base font-bold text-gray-900">{{ siteTitle }}</span>
<svg
v-if="isAdmin"
class="h-3 w-3 shrink-0 text-gray-600 opacity-0 transition-opacity group-hover:opacity-100"
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>
</div>
</header>
<!-- Body: sidebar + scrollable content -->
<div class="flex min-h-0 flex-1">
<!-- Mobile sidebar overlay -->
<Transition name="sidebar-fade">
<div
v-if="mobileNavOpen"
class="fixed inset-0 z-40 bg-black/40 md:hidden"
@click="mobileNavOpen = false"
/>
</Transition>
<!-- Mobile sidebar drawer -->
<Transition name="sidebar-slide">
<div
v-show="mobileNavOpen"
class="fixed inset-y-0 left-0 z-50 md:hidden"
>
<Sidebar @close="mobileNavOpen = false" />
</div>
</Transition>
<!-- Desktop sidebar (always visible) -->
<div class="hidden md:flex md:shrink-0">
<Sidebar @close="() => {}" />
</div>
<!-- Scrollable content -->
<main class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-4xl px-6 py-10">
<slot />
</div>
</main>
</div>
</div>
</template>
<style scoped>
.fade-up-enter-active,
.fade-up-leave-active {
transition: opacity 200ms ease, transform 200ms ease;
.sidebar-fade-enter-active,
.sidebar-fade-leave-active {
transition: opacity 200ms ease;
}
.fade-up-enter-from,
.fade-up-leave-to {
.sidebar-fade-enter-from,
.sidebar-fade-leave-to {
opacity: 0;
transform: translateY(8px);
}
.sidebar-slide-enter-active,
.sidebar-slide-leave-active {
transition: transform 250ms ease;
}
.sidebar-slide-enter-from,
.sidebar-slide-leave-to {
transform: translateX(-100%);
}
</style>

View file

@ -1,129 +0,0 @@
<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,411 @@
<script setup lang="ts">
import { ref, watch, nextTick } from "vue";
import { useRouter, useRoute } from "vue-router";
import draggable from "vuedraggable";
import type { Page } from "@/types/segment";
import { useAdmin } from "@/composables/useAdmin";
import { usePages } from "@/composables/usePages";
import { useDarkMode } from "@/composables/useDarkMode";
const emit = defineEmits<{
close: [];
}>();
const router = useRouter();
const route = useRoute();
const { isAdmin, verifying, login, logout } = useAdmin();
const { pages, createPage, updatePage, deletePage, reorderPages, toggleHidden } = usePages();
const { isDark, toggle: toggleDark } = useDarkMode();
// --- Page list drag ---
const localPages = ref<Page[]>(pages.value.filter((p) => !p.is_system));
watch(pages, (val) => { localPages.value = val.filter((p) => !p.is_system); });
function onPageDragEnd() {
void reorderPages(localPages.value.map((p) => p.id));
}
// --- Page rename ---
const renamingPageId = ref<string | null>(null);
const renameInput = ref("");
function startRename(page: Page) {
renamingPageId.value = page.id;
renameInput.value = page.name;
nextTick(() => { document.getElementById(`rename-input-${page.id}`)?.focus(); });
}
async function saveRename(page: Page) {
const trimmed = renameInput.value.trim();
if (trimmed && trimmed !== page.name) {
const newSlug = trimmed.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
await updatePage(page.id, { name: trimmed, slug: newSlug });
if (route.params.slug === page.slug) {
await router.replace({ name: "page", params: { slug: newSlug } });
}
}
renamingPageId.value = null;
}
// --- Delete page ---
async function handleDelete(page: Page) {
const result = await deletePage(page.id);
if (result.ok && route.params.slug === page.slug) {
const remaining = pages.value.filter((p) => p.id !== page.id);
if (remaining.length > 0) {
await router.push({ name: "page", params: { slug: remaining[0].slug } });
} else {
await router.push("/");
}
}
}
// --- Add page ---
const showAddPage = ref(false);
const newPageName = ref("");
const addPageError = ref<string | null>(null);
const addPageLoading = ref(false);
function openAddPage() {
showAddPage.value = true;
newPageName.value = "";
addPageError.value = null;
nextTick(() => { document.getElementById("new-page-name")?.focus(); });
}
async function submitAddPage() {
const name = newPageName.value.trim();
if (!name) return;
addPageLoading.value = true;
addPageError.value = null;
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const page = await createPage(name, slug);
addPageLoading.value = false;
if (page) {
showAddPage.value = false;
await router.push({ name: "page", params: { slug: page.slug } });
} else {
addPageError.value = "Could not create page. The name might already be taken.";
}
}
// --- Admin login (inline) ---
const showLoginInput = ref(false);
const loginTokenInput = ref("");
const loginError = ref(false);
async function handleLogin() {
loginError.value = false;
const success = await login(loginTokenInput.value);
if (success) {
showLoginInput.value = false;
loginTokenInput.value = "";
} else {
loginError.value = true;
}
}
</script>
<template>
<nav class="flex h-full w-60 flex-col border-r border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-700">
<!-- Pages list (scrollable) -->
<div class="flex-1 overflow-y-auto py-2">
<!-- Pinned Home page (always at top, no actions) -->
<template v-if="pages.find(p => p.slug === 'home')">
<div class="px-2 py-0.5">
<router-link
:to="{ name: 'page', params: { slug: 'home' } }"
class="flex items-center rounded-md px-2 py-2 text-sm transition-colors"
:class="
$route.params.slug === 'home'
? 'border-l-2 border-primary-400 bg-primary-50 font-medium text-gray-900 dark:border-primary-400 dark:bg-gray-600 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white'
"
@click="$emit('close')"
>
<span v-if="isAdmin" class="w-3.5 shrink-0" />
<span class="flex-1 truncate">{{ pages.find(p => p.slug === 'home')?.name }}</span>
</router-link>
</div>
</template>
<!-- Admin draggable user pages (non-system) -->
<draggable
v-if="isAdmin"
v-model="localPages"
item-key="id"
handle=".page-drag-handle"
:animation="150"
@end="onPageDragEnd"
>
<template #item="{ element: page }: { element: Page }">
<div class="group relative px-2 py-0.5">
<!-- Rename input -->
<form v-if="renamingPageId === page.id" @submit.prevent="saveRename(page)">
<input
:id="`rename-input-${page.id}`"
v-model="renameInput"
type="text"
class="w-full rounded border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
@blur="saveRename(page)"
@keydown.escape="renamingPageId = null"
/>
</form>
<!-- Normal page row -->
<router-link
v-else
:to="{ name: 'page', params: { slug: page.slug } }"
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm transition-colors"
:class="[
$route.params.slug === page.slug
? 'border-l-2 border-primary-400 bg-primary-50 font-medium text-gray-900 dark:border-primary-400 dark:bg-gray-600 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white',
page.is_hidden ? 'opacity-50' : '',
]"
@click="$emit('close')"
>
<!-- Drag handle -->
<span class="page-drag-handle shrink-0 cursor-grab text-gray-400 active:cursor-grabbing dark:text-gray-600">
<svg class="h-3.5 w-3.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>
</span>
<span class="flex-1 truncate">{{ page.name }}</span>
<span class="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100">
<!-- Hide/show toggle -->
<button
class="rounded p-0.5 transition-colors"
:class="page.is_hidden ? 'text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' : 'text-gray-400 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300'"
:title="page.is_hidden ? 'Show page' : 'Hide page'"
@click.prevent="toggleHidden(page.id, !page.is_hidden)"
>
<svg v-if="page.is_hidden" 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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
<svg v-else 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
<!-- Rename -->
<button
class="rounded p-0.5 text-gray-400 hover:text-gray-700 dark:text-gray-600 dark:hover:text-primary-400"
title="Rename page"
@click.prevent="startRename(page)"
>
<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="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>
<!-- Delete -->
<button
class="rounded p-0.5 text-gray-400 hover:text-red-500 dark:text-gray-600 dark:hover:text-red-400"
title="Delete page"
@click.prevent="handleDelete(page)"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</span>
</router-link>
</div>
</template>
</draggable>
<!-- System pages shown after draggable list (admin), excluding home -->
<div v-if="isAdmin" v-for="page in pages.filter(p => p.is_system && p.slug !== 'home')" :key="page.id" class="group px-2 py-0.5">
<router-link
:to="{ name: 'page', params: { slug: page.slug } }"
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm transition-colors"
:class="[
$route.params.slug === page.slug
? 'border-l-2 border-primary-400 bg-primary-50 font-medium text-gray-900 dark:border-primary-400 dark:bg-gray-600 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white',
page.is_hidden ? 'opacity-50' : '',
]"
@click="$emit('close')"
>
<span class="w-3.5 shrink-0" />
<span class="flex-1 truncate">{{ page.name }}</span>
<span class="flex shrink-0 items-center opacity-0 group-hover:opacity-100">
<button
class="rounded p-0.5 transition-colors"
:class="page.is_hidden ? 'text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300' : 'text-gray-400 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300'"
:title="page.is_hidden ? 'Show page' : 'Hide page'"
@click.prevent="toggleHidden(page.id, !page.is_hidden)"
>
<svg v-if="page.is_hidden" 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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
<svg v-else 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</span>
</router-link>
</div>
<!-- Read-only pages list (non-admin) -->
<template v-else-if="!isAdmin">
<!-- Home always shown first -->
<template v-if="!pages.find(p => p.slug === 'home')">
<!-- home already rendered above via pinned template -->
</template>
<!-- Other non-system, non-hidden pages -->
<div v-for="page in pages.filter(p => !p.is_hidden && !p.is_system)" :key="page.id" class="px-2 py-0.5">
<router-link
:to="{ name: 'page', params: { slug: page.slug } }"
class="flex items-center rounded-md px-3 py-2 text-sm transition-colors"
:class="
$route.params.slug === page.slug
? 'border-l-2 border-primary-400 bg-primary-50 font-medium text-gray-900 dark:border-primary-400 dark:bg-gray-600 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white'
"
@click="$emit('close')"
>
{{ page.name }}
</router-link>
</div>
<!-- System pages (e.g. team) that are not hidden, excluding home -->
<div v-for="page in pages.filter(p => !p.is_hidden && p.is_system && p.slug !== 'home')" :key="`sys-${page.id}`" class="px-2 py-0.5">
<router-link
:to="{ name: 'page', params: { slug: page.slug } }"
class="flex items-center rounded-md px-3 py-2 text-sm transition-colors"
:class="
$route.params.slug === page.slug
? 'border-l-2 border-primary-400 bg-primary-50 font-medium text-gray-900 dark:border-primary-400 dark:bg-gray-600 dark:text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white'
"
@click="$emit('close')"
>
{{ page.name }}
</router-link>
</div>
</template>
</div>
<!-- Add page (admin only) -->
<div v-if="isAdmin" class="border-t border-gray-300 p-3 dark:border-gray-600">
<div v-if="showAddPage">
<form @submit.prevent="submitAddPage">
<input
id="new-page-name"
v-model="newPageName"
type="text"
placeholder="Page name"
class="mb-1.5 w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
@keydown.escape="showAddPage = false"
/>
<p v-if="addPageError" class="mb-1.5 text-xs text-red-700 dark:text-red-400">{{ addPageError }}</p>
<div class="flex gap-2">
<button
type="submit"
:disabled="!newPageName.trim() || addPageLoading"
class="flex-1 rounded bg-primary-200 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-primary-300 disabled:opacity-50"
>
{{ addPageLoading ? "Creating..." : "Create page" }}
</button>
<button
type="button"
class="rounded px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
@click="showAddPage = false"
>
Cancel
</button>
</div>
</form>
</div>
<button
v-else
class="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-gray-300 px-3 py-2 text-sm text-gray-500 transition-colors hover:border-primary-400 hover:bg-primary-50 hover:text-primary-700 dark:border-gray-600 dark:text-gray-400 dark:hover:border-primary-400 dark:hover:text-primary-400"
@click="openAddPage"
>
<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="M12 4v16m8-8H4" />
</svg>
Add new page
</button>
</div>
<!-- Footer: dark mode + auth -->
<div class="border-t border-gray-300 px-3 py-2 dark:border-gray-600">
<div class="flex items-center justify-between">
<!-- Dark mode toggle (icon only) -->
<button
class="rounded-md p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-gray-200"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleDark"
>
<svg v-if="isDark" 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="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>
<svg v-else 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="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>
<!-- Logged-in admin: badge + logout -->
<div v-if="isAdmin" class="flex items-center gap-1.5">
<span class="rounded bg-gray-900 px-1.5 py-0.5 text-xs font-semibold text-primary-300 dark:bg-primary-400 dark:text-gray-900">Admin</span>
<button
class="rounded-md p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-red-500 dark:text-gray-500 dark:hover:bg-gray-600 dark:hover:text-red-400"
title="Log out"
@click="logout()"
>
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
<!-- Not logged in: lock icon or inline login form -->
<div v-else>
<button
v-if="!showLoginInput"
class="rounded-md p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
title="Admin login"
@click="showLoginInput = true"
>
<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="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>
</div>
</div>
<!-- Inline login form (expands below the icon row) -->
<form v-if="!isAdmin && showLoginInput" class="mt-2 space-y-1.5" @submit.prevent="handleLogin">
<input
v-model="loginTokenInput"
type="password"
placeholder="Admin token"
autofocus
class="w-full rounded border px-2 py-1.5 text-sm text-gray-900 placeholder-gray-400 focus:outline-none dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
:class="loginError ? 'border-red-400 bg-red-50' : 'border-gray-300 dark:border-gray-600'"
/>
<p v-if="loginError" class="text-xs text-red-700 dark:text-red-400">Incorrect token.</p>
<div class="flex gap-2">
<button
type="submit"
:disabled="verifying || !loginTokenInput"
class="flex-1 rounded bg-primary-200 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-primary-300 disabled:opacity-50 dark:bg-primary-200 dark:text-gray-800 dark:hover:bg-primary-300"
>
{{ verifying ? "Checking..." : "Log in" }}
</button>
<button
type="button"
class="rounded px-2 py-1.5 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
@click="showLoginInput = false; loginTokenInput = ''; loginError = false"
>
&times;
</button>
</div>
</form>
</div>
</nav>
</template>

View file

@ -1,52 +0,0 @@
<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

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from "vue";
import { ref, computed } from "vue";
import type { Segment } from "@/types/segment";
const props = defineProps<{
@ -8,21 +8,67 @@ const props = defineProps<{
const images = computed(() => {
const meta = props.segment.metadata;
if (Array.isArray(meta.images)) {
return meta.images as string[];
}
if (Array.isArray(meta.images)) return meta.images as string[];
return [];
});
const current = ref(0);
function prev() {
current.value = (current.value - 1 + images.value.length) % images.value.length;
}
function next() {
current.value = (current.value + 1) % images.value.length;
}
</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 v-if="images.length > 0" class="select-none">
<!-- Fixed-height image box with arrows overlaid inside it -->
<div class="relative flex h-[70vh] items-center justify-center overflow-hidden rounded bg-white dark:bg-gray-900">
<img
:src="images[current]"
:alt="`${segment.title} image ${current + 1}`"
class="h-full w-full object-contain"
/>
<!-- Arrows (only when multiple images) -->
<template v-if="images.length > 1">
<button
type="button"
class="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/40 p-1.5 text-white transition-colors hover:bg-black/65"
aria-label="Previous image"
@click="prev"
>
<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="M15 19l-7-7 7-7" />
</svg>
</button>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/40 p-1.5 text-white transition-colors hover:bg-black/65"
aria-label="Next image"
@click="next"
>
<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="M9 5l7 7-7 7" />
</svg>
</button>
</template>
</div>
<!-- Dot indicators below (only when multiple images) -->
<div v-if="images.length > 1" class="mt-3 flex justify-center gap-1.5">
<button
v-for="(_, i) in images"
:key="i"
type="button"
class="h-2 rounded-full transition-all"
:class="i === current ? 'w-4 bg-gray-600 dark:bg-gray-300' : 'w-2 bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500'"
:aria-label="`Go to image ${i + 1}`"
@click="current = i"
/>
</div>
</div>
</template>

View file

@ -9,7 +9,7 @@ defineProps<{
<template>
<iframe
:src="segment.content"
class="h-[600px] w-full rounded border-none"
class="h-[85vh] w-full rounded border-none"
:title="segment.title"
/>
</template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { ref, watch } from "vue";
import type { Segment } from "@/types/segment";
import { useAdmin } from "@/composables/useAdmin";
import SegmentRenderer from "@/components/segments/SegmentRenderer.vue";
@ -10,9 +10,6 @@ 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];
@ -22,14 +19,11 @@ const emit = defineEmits<{
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];
},
(val) => { localSegments.value = [...val]; },
{ deep: true }
);
@ -51,89 +45,100 @@ function handleDelete(id: string) {
}
function onDragEnd() {
const ids = localSegments.value.map((s) => s.id);
emit("reorder", ids);
emit("reorder", localSegments.value.map((s) => s.id));
}
</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>
<!-- Admin: draggable flowing sections -->
<draggable
v-if="isAdmin"
v-model="localSegments"
item-key="id"
handle=".drag-handle"
:animation="150"
:force-fallback="false"
ghost-class="drag-ghost"
chosen-class="drag-chosen"
class="segment-list"
@end="onDragEnd"
>
<template #item="{ element: segment }: { element: Segment }">
<section :id="segment.id" class="py-8 first:pt-0">
<div class="flex items-start gap-3">
<!-- Drag handle -->
<button
class="drag-handle mt-1 shrink-0 cursor-grab text-gray-300 transition-colors hover:text-primary-300 active:cursor-grabbing dark:text-gray-700 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 class="min-w-0 flex-1">
<div class="mb-3 flex items-center justify-between gap-2">
<h2 v-if="segment.title" class="text-xl font-semibold text-gray-900 dark:text-gray-100">{{ segment.title }}</h2>
<span v-else class="flex-1" />
<button
class="shrink-0 rounded p-1.5 text-gray-300 transition-colors hover:bg-gray-100 hover:text-primary-400 dark:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-primary-400"
:title="editingId === segment.id ? 'Close editor' : 'Edit section'"
@click="toggleEdit(segment.id)"
>
<svg v-if="editingId !== segment.id" 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>
<svg v-else 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</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" />
<!-- Inline editor replaces content -->
<SegmentEditor
v-if="editingId === segment.id"
:segment="segment"
@save="(updates) => handleSave(segment.id, updates)"
@cancel="editingId = null"
@delete="handleDelete(segment.id)"
/>
<SegmentRenderer v-else :segment="segment" />
</div>
</div>
</section>
</template>
</draggable>
<!-- Read-only: flowing sections -->
<div v-else class="segment-list">
<section
v-for="segment in segments"
:key="segment.id"
:id="segment.id"
class="py-8 first:pt-0"
>
<h2 v-if="segment.title" class="mb-3 text-xl font-semibold text-gray-900 dark:text-gray-100">{{ segment.title }}</h2>
<SegmentRenderer :segment="segment" />
</section>
</div>
</template>
<style scoped>
.segment-list > * + * {
border-top: 2px solid transparent;
border-image: linear-gradient(to right, transparent, #d1d5db 20%, #d1d5db 80%, transparent) 1;
}
:global(.dark) .segment-list > * + * {
border-image: linear-gradient(to right, transparent, #374151 20%, #374151 80%, transparent) 1;
}
.drag-ghost {
opacity: 0.4;
}
.drag-chosen {
box-shadow: 0 4px 16px rgb(0 0 0 / 0.1);
}
</style>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Segment } from "@/types/segment";
const props = defineProps<{
segment: Segment;
}>();
interface TeamMember {
name: string;
student_number: string;
}
const members = computed<TeamMember[]>(() => {
const raw = props.segment.metadata.members;
return Array.isArray(raw) ? (raw as TeamMember[]) : [];
});
</script>
<template>
<ul class="divide-y divide-slate-200 dark:divide-slate-700">
<li
v-for="(member, i) in members"
:key="i"
class="flex items-center justify-between py-2 text-sm"
>
<span class="font-medium text-slate-900 dark:text-slate-100">{{ member.name }}</span>
<span class="font-mono text-slate-500 dark:text-slate-400">{{ member.student_number }}</span>
</li>
</ul>
</template>

View file

@ -0,0 +1,110 @@
import { ref } from "vue";
import type { Page } from "@/types/segment";
import { useAdmin } from "@/composables/useAdmin";
const pages = ref<Page[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
export function usePages() {
const { authHeaders } = useAdmin();
async function fetchPages(): Promise<void> {
loading.value = true;
error.value = null;
try {
const res = await fetch("/api/pages/");
if (!res.ok) throw new Error(`Failed to fetch pages: ${res.status}`);
pages.value = (await res.json()) as Page[];
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
} finally {
loading.value = false;
}
}
async function createPage(name: string, slug: string): Promise<Page | null> {
try {
const res = await fetch("/api/pages/", {
method: "POST",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ name, slug }),
});
if (!res.ok) {
const data = (await res.json()) as { detail?: string };
throw new Error(data.detail ?? `Create failed: ${res.status}`);
}
const page = (await res.json()) as Page;
await fetchPages();
return page;
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
return null;
}
}
async function updatePage(id: string, data: { name?: string; slug?: string }): Promise<Page | null> {
try {
const res = await fetch(`/api/pages/${id}`, {
method: "PATCH",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const body = (await res.json()) as { detail?: string };
throw new Error(body.detail ?? `Update failed: ${res.status}`);
}
const page = (await res.json()) as Page;
await fetchPages();
return page;
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
return null;
}
}
async function deletePage(id: string): Promise<{ ok: boolean; error?: string }> {
try {
const res = await fetch(`/api/pages/${id}`, {
method: "DELETE",
headers: authHeaders.value,
});
if (!res.ok) {
const body = (await res.json()) as { detail?: string };
return { ok: false, error: body.detail ?? `Delete failed: ${res.status}` };
}
await fetchPages();
return { ok: true };
} catch (e) {
const msg = e instanceof Error ? e.message : "Unknown error";
return { ok: false, error: msg };
}
}
async function toggleHidden(id: string, hidden: boolean): Promise<void> {
try {
await fetch(`/api/pages/${id}`, {
method: "PATCH",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ is_hidden: hidden }),
});
await fetchPages();
} catch { /* Silently fail */ }
}
async function reorderPages(ids: string[]): Promise<void> {
try {
const res = await fetch("/api/pages/reorder", {
method: "PUT",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ page_ids: ids }),
});
if (!res.ok) throw new Error(`Reorder failed: ${res.status}`);
pages.value = (await res.json()) as Page[];
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
}
}
return { pages, loading, error, fetchPages, createPage, updatePage, deletePage, reorderPages, toggleHidden };
}

View file

@ -1,9 +1,8 @@
import { ref, onMounted } from "vue";
import type { Segment, SiteInfo } from "@/types/segment";
import { ref } from "vue";
import type { Segment } from "@/types/segment";
export function useSegments() {
export function useSegments(pageId: string) {
const segments = ref<Segment[]>([]);
const siteTitle = ref("Handin");
const loading = ref(true);
const error = ref<string | null>(null);
@ -11,18 +10,10 @@ export function useSegments() {
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);
const res = await fetch(`/api/segments/?page_id=${encodeURIComponent(pageId)}`);
if (!res.ok) throw new Error(`Segments fetch failed: ${res.status}`);
const data = (await res.json()) as Segment[];
segments.value = data.sort((a, b) => a.sort_order - b.sort_order);
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
} finally {
@ -30,9 +21,5 @@ export function useSegments() {
}
}
onMounted(() => {
void refresh();
});
return { segments, siteTitle, loading, error, refresh };
return { segments, loading, error, refresh };
}

View file

@ -0,0 +1,57 @@
import { ref } from "vue";
import type { TeamMember } from "@/types/segment";
import { useAdmin } from "@/composables/useAdmin";
export function useTeamMembers() {
const { authHeaders } = useAdmin();
const members = ref<TeamMember[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchMembers(): Promise<void> {
loading.value = true;
error.value = null;
try {
const res = await fetch("/api/team/");
if (!res.ok) throw new Error(`Failed to fetch team: ${res.status}`);
members.value = (await res.json()) as TeamMember[];
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
} finally {
loading.value = false;
}
}
async function addMember(name: string, student_number: string): Promise<TeamMember | null> {
try {
const res = await fetch("/api/team/", {
method: "POST",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ name, student_number }),
});
if (!res.ok) throw new Error(`Add failed: ${res.status}`);
const member = (await res.json()) as TeamMember;
await fetchMembers();
return member;
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
return null;
}
}
async function deleteMember(id: string): Promise<boolean> {
try {
const res = await fetch(`/api/team/${id}`, {
method: "DELETE",
headers: authHeaders.value,
});
if (!res.ok) return false;
await fetchMembers();
return true;
} catch {
return false;
}
}
return { members, loading, error, fetchMembers, addMember, deleteMember };
}

View file

@ -1,5 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import "./style.css";
createApp(App).mount("#app");
createApp(App).use(router).mount("#app");

View file

@ -0,0 +1,22 @@
import { createRouter, createWebHistory } from "vue-router";
import PageView from "@/views/PageView.vue";
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
redirect: () => {
// Will be redirected to first page after pages load
return "/";
},
},
{
path: "/:slug",
name: "page",
component: PageView,
},
],
});
export default router;

View file

@ -4,6 +4,7 @@
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* Brand yellow — unchanged */
--color-primary-50: #fffbeb;
--color-primary-100: #fff3c4;
--color-primary-200: #fce588;
@ -12,4 +13,17 @@
--color-primary-500: #cc9900;
--color-primary-600: #997300;
--color-primary-700: #664d00;
/* Soft dark gray palette — used throughout dark mode */
--color-gray-950: #0d0d0d;
--color-gray-900: #171717;
--color-gray-800: #1e1e1e;
--color-gray-700: #2a2a2a;
--color-gray-600: #3d3d3d;
--color-gray-500: #6b6b6b;
--color-gray-400: #9e9e9e;
--color-gray-300: #c2c2c2;
--color-gray-200: #e0e0e0;
--color-gray-100: #f0f0f0;
--color-gray-50: #f8f8f8;
}

View file

@ -1,4 +1,4 @@
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery" | "link";
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery" | "team";
export interface Segment {
id: string;
@ -7,6 +7,7 @@ export interface Segment {
title: string;
content: string;
metadata: Record<string, unknown>;
page_id: string | null;
created_at: string;
updated_at: string;
}
@ -14,3 +15,23 @@ export interface Segment {
export interface SiteInfo {
title: string;
}
export interface Page {
id: string;
name: string;
slug: string;
sort_order: number;
is_system: boolean;
is_hidden: boolean;
created_at: string;
updated_at: string;
}
export interface TeamMember {
id: string;
name: string;
student_number: string;
sort_order: number;
created_at: string;
updated_at: string;
}

View file

@ -1,165 +0,0 @@
<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>

View file

@ -0,0 +1,182 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { useRoute } from "vue-router";
import { usePages } from "@/composables/usePages";
import { useAdmin } from "@/composables/useAdmin";
import type { Page, Segment, SegmentType } from "@/types/segment";
import SegmentList from "@/components/segments/SegmentList.vue";
import AddSegmentModal from "@/components/admin/AddSegmentModal.vue";
import TeamPage from "@/views/TeamPage.vue";
const route = useRoute();
const { pages } = usePages();
const { authHeaders, isAdmin } = useAdmin();
const currentPage = ref<Page | null>(null);
const segments = ref<Segment[]>([]);
const loading = ref(false);
const segmentError = ref<string | null>(null);
const showAddModal = ref(false);
const pageNotFound = ref(false);
async function fetchSegments(pageId: string) {
loading.value = true;
segmentError.value = null;
try {
const res = await fetch(`/api/segments/?page_id=${encodeURIComponent(pageId)}`);
if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
const data = (await res.json()) as Segment[];
segments.value = data.sort((a, b) => a.sort_order - b.sort_order);
} catch (e) {
segmentError.value = e instanceof Error ? e.message : "Unknown error";
} finally {
loading.value = false;
}
}
function resolvePage(slug: string) {
if (!pages.value.length) return;
pageNotFound.value = false;
const found = pages.value.find((p) => p.slug === slug);
if (found) {
currentPage.value = found;
void fetchSegments(found.id);
} else {
currentPage.value = null;
pageNotFound.value = true;
}
}
watch(() => route.params.slug as string, (slug) => { resolvePage(slug); }, { immediate: true });
watch(pages, () => { resolvePage(route.params.slug as string); });
async function handleCreateSegment(data: {
type: SegmentType;
title: string;
content: string;
metadata?: Record<string, unknown>;
}) {
if (!currentPage.value) return;
const res = await fetch("/api/segments/", {
method: "POST",
headers: { ...authHeaders.value, "Content-Type": "application/json" },
body: JSON.stringify({ ...data, page_id: currentPage.value.id }),
});
if (res.ok) {
showAddModal.value = false;
await fetchSegments(currentPage.value.id);
}
}
async function handleSaveSegment(
id: string,
updates: { title?: string; content?: string; metadata?: Record<string, unknown> }
) {
if (!currentPage.value || 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 fetchSegments(currentPage.value.id);
}
async function handleDeleteSegment(id: string) {
if (!currentPage.value) return;
const res = await fetch(`/api/segments/${id}`, {
method: "DELETE",
headers: authHeaders.value,
});
if (res.ok) await fetchSegments(currentPage.value.id);
}
async function handleReorder(ids: string[]) {
if (!currentPage.value) return;
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 fetchSegments(currentPage.value.id);
}
</script>
<template>
<!-- Not found -->
<div v-if="pageNotFound" class="flex flex-col items-center justify-center py-32 text-gray-400">
<p class="text-lg">Page not found.</p>
</div>
<!-- Waiting for pages to load -->
<div v-else-if="!currentPage && !pageNotFound" class="flex items-center justify-center py-32">
<svg class="h-8 w-8 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>
<!-- Page content -->
<template v-else-if="currentPage">
<!-- Team system page -->
<TeamPage v-if="currentPage.slug === 'team'" />
<!-- Regular page -->
<template v-else>
<!-- Loading segments -->
<div v-if="loading" class="flex items-center justify-center py-32">
<svg class="h-8 w-8 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 -->
<div v-else-if="segmentError" class="flex items-center justify-center py-32">
<p class="text-lg text-red-500">{{ segmentError }}</p>
</div>
<!-- Empty page -->
<div
v-else-if="!segments.length"
class="flex flex-col items-center justify-center py-32 text-gray-400 dark:text-gray-600"
>
<svg class="mb-4 h-14 w-14" 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="mb-1 text-base font-medium">This page is empty</p>
<p v-if="isAdmin" class="text-sm">Use the <strong>+ Add section</strong> button to add your first section.</p>
</div>
<!-- Segment list -->
<SegmentList
v-else
:segments="segments"
@save="handleSaveSegment"
@delete="handleDeleteSegment"
@reorder="handleReorder"
/>
</template>
</template>
<!-- Floating "Add section" FAB (admin only) -->
<Teleport to="body">
<button
v-if="isAdmin && currentPage && !currentPage.is_system"
class="fixed right-6 bottom-6 z-30 flex items-center gap-2 rounded-full bg-primary-300 px-5 py-3 font-medium text-gray-900 shadow-lg transition-all hover:bg-primary-400 hover:shadow-xl active:scale-95"
title="Add a new section to this page"
@click="showAddModal = 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 4v16m8-8H4" />
</svg>
Add section
</button>
</Teleport>
<!-- Add segment modal -->
<AddSegmentModal
v-if="showAddModal"
@create="handleCreateSegment"
@cancel="showAddModal = false"
/>
</template>

View file

@ -0,0 +1,116 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useTeamMembers } from "@/composables/useTeamMembers";
import { useAdmin } from "@/composables/useAdmin";
const { members, loading, fetchMembers, addMember, deleteMember } = useTeamMembers();
const { isAdmin } = useAdmin();
const newName = ref("");
const newNumber = ref("");
const adding = ref(false);
const addError = ref<string | null>(null);
onMounted(() => { void fetchMembers(); });
async function handleAdd() {
const name = newName.value.trim();
const number = newNumber.value.trim();
if (!name || !number) return;
adding.value = true;
addError.value = null;
const result = await addMember(name, number);
adding.value = false;
if (result) {
newName.value = "";
newNumber.value = "";
} else {
addError.value = "Could not add member.";
}
}
</script>
<template>
<div>
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-100">Team Members</h1>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-16">
<svg class="h-8 w-8 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>
<template v-else>
<!-- Empty state (non-admin) -->
<div
v-if="!members.length && !isAdmin"
class="flex flex-col items-center justify-center py-24 text-gray-400 dark:text-gray-600"
>
<svg class="mb-4 h-12 w-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p class="text-base font-medium">No team members yet.</p>
</div>
<!-- Member table -->
<div v-else-if="members.length" class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800">
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">Name</th>
<th class="px-4 py-3 text-left font-mono font-medium text-gray-600 dark:text-gray-300">Student number</th>
<th v-if="isAdmin" class="w-12 px-4 py-3" />
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
<tr
v-for="member in members"
:key="member.id"
class="bg-white transition-colors hover:bg-gray-50 dark:bg-gray-900 dark:hover:bg-gray-800/60"
>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">{{ member.name }}</td>
<td class="px-4 py-3 font-mono text-gray-500 dark:text-gray-400">{{ member.student_number }}</td>
<td v-if="isAdmin" class="px-4 py-3 text-right">
<button
class="rounded p-1 text-gray-300 transition-colors hover:bg-red-50 hover:text-red-500 dark:text-gray-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Remove member"
@click="deleteMember(member.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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Add member form (admin) -->
<form v-if="isAdmin" class="mt-4 flex gap-2" @submit.prevent="handleAdd">
<input
v-model="newName"
type="text"
placeholder="Full name"
class="min-w-0 flex-1 rounded border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
/>
<input
v-model="newNumber"
type="text"
placeholder="Student number"
class="w-40 rounded border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
/>
<button
type="submit"
:disabled="!newName.trim() || !newNumber.trim() || adding"
class="rounded bg-primary-200 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-primary-300 disabled:opacity-50"
>
{{ adding ? "Adding…" : "Add" }}
</button>
</form>
<p v-if="addError" class="mt-1.5 text-xs text-red-600 dark:text-red-400">{{ addError }}</p>
</template>
</div>
</template>