initial release
This commit is contained in:
parent
e156de22c3
commit
b3ac11a85e
37 changed files with 3649 additions and 669 deletions
|
|
@ -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>
|
||||
|
|
|
|||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
|
|
@ -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 = ''">×</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
243
frontend/src/components/admin/RichTextEditor.vue
Normal file
243
frontend/src/components/admin/RichTextEditor.vue
Normal 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()"
|
||||
></></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>
|
||||
|
|
@ -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)">×</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 -->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
411
frontend/src/components/layout/Sidebar.vue
Normal file
411
frontend/src/components/layout/Sidebar.vue
Normal 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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
31
frontend/src/components/segments/TeamSegment.vue
Normal file
31
frontend/src/components/segments/TeamSegment.vue
Normal 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>
|
||||
110
frontend/src/composables/usePages.ts
Normal file
110
frontend/src/composables/usePages.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
57
frontend/src/composables/useTeamMembers.ts
Normal file
57
frontend/src/composables/useTeamMembers.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
22
frontend/src/router/index.ts
Normal file
22
frontend/src/router/index.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
182
frontend/src/views/PageView.vue
Normal file
182
frontend/src/views/PageView.vue
Normal 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>
|
||||
116
frontend/src/views/TeamPage.vue
Normal file
116
frontend/src/views/TeamPage.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue