Added source segment

This commit is contained in:
Justin Visser 2026-03-26 15:35:19 +01:00
parent f13861c580
commit 1b1784f4ee
8 changed files with 311 additions and 6 deletions

View file

@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import type { SegmentType } from "@/types/segment";
import type { SegmentType, Source } from "@/types/segment";
import AssetUploader from "@/components/admin/AssetUploader.vue";
import RichTextEditor from "@/components/admin/RichTextEditor.vue";
import { formatApaCitation } from "@/utils/formatApa";
const emit = defineEmits<{
create: [data: { type: SegmentType; title: string; content: string; metadata?: Record<string, unknown> }];
@ -16,14 +17,30 @@ const segmentTypes: { value: SegmentType; label: string; description: string; ic
{ 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>' },
{ value: "sources", label: "Sources", description: "Add an APA reference list", 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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>' },
];
const selectedType = ref<SegmentType>("markdown");
const title = ref("");
const content = ref("");
const galleryImages = ref<string[]>([]);
const sources = ref<Source[]>([]);
const sourceForm = ref({ authors: "", year: "", title: "", source: "", url: "" });
function addSource() {
const f = sourceForm.value;
if (!f.authors.trim() && !f.title.trim()) return;
sources.value = [...sources.value, { id: crypto.randomUUID(), ...f }];
sourceForm.value = { authors: "", year: "", title: "", source: "", url: "" };
}
function removeSource(id: string) {
sources.value = sources.value.filter((s) => s.id !== id);
}
const canSubmit = computed(() => {
if (selectedType.value === "gallery") return galleryImages.value.length > 0;
if (selectedType.value === "sources") return sources.value.length > 0;
if (selectedType.value === "markdown") return true;
return content.value.trim().length > 0;
});
@ -49,6 +66,9 @@ function handleSubmit() {
if (selectedType.value === "gallery") {
data.metadata = { images: galleryImages.value };
}
if (selectedType.value === "sources") {
data.metadata = { sources: sources.value };
}
emit("create", data);
}
@ -70,7 +90,7 @@ const assetAcceptMap: Record<string, string> = {
<!-- Type selector: icon cards -->
<div class="mb-5">
<div class="grid grid-cols-3 gap-2 sm:grid-cols-6">
<div class="grid grid-cols-3 gap-2 sm:grid-cols-7">
<button
v-for="st in segmentTypes"
:key="st.value"
@ -82,7 +102,7 @@ const assetAcceptMap: Record<string, string> = {
: '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 = []"
@click="selectedType = st.value; content = ''; galleryImages = []; sources = []"
>
<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>
@ -154,6 +174,74 @@ const assetAcceptMap: Record<string, string> = {
</div>
<AssetUploader accept="image/*" :multiple="true" label="Click to upload photos, or drag and drop" @uploaded="handleGalleryImageUploaded" />
</div>
<!-- Sources -->
<div v-else-if="selectedType === 'sources'">
<!-- Added sources list -->
<div v-if="sources.length > 0" class="mb-3 space-y-2">
<div
v-for="source in sources"
:key="source.id"
class="group flex items-start gap-2 rounded bg-gray-50 px-3 py-2 text-sm dark:bg-gray-700"
>
<p
class="min-w-0 flex-1 pl-6 -indent-6 text-gray-700 dark:text-gray-200"
v-html="formatApaCitation(source)"
/>
<button
class="shrink-0 text-red-400 hover:text-red-600"
@click="removeSource(source.id)"
>
&times;
</button>
</div>
</div>
<!-- Source input form -->
<div class="space-y-2">
<div class="flex gap-2">
<input
v-model="sourceForm.authors"
type="text"
placeholder="Authors (e.g. Liu, Z., Zhang, W., & Yang, P.)"
class="min-w-0 flex-1 rounded border border-gray-300 px-3 py-1.5 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"
/>
<input
v-model="sourceForm.year"
type="text"
placeholder="Year"
class="w-20 rounded border border-gray-300 px-3 py-1.5 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>
<input
v-model="sourceForm.title"
type="text"
placeholder="Title"
class="w-full rounded border border-gray-300 px-3 py-1.5 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 class="flex gap-2">
<input
v-model="sourceForm.source"
type="text"
placeholder="Journal, publisher, or site name"
class="min-w-0 flex-1 rounded border border-gray-300 px-3 py-1.5 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"
/>
<input
v-model="sourceForm.url"
type="text"
placeholder="URL or DOI link (optional)"
class="min-w-0 flex-1 rounded border border-gray-300 px-3 py-1.5 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>
<button
:disabled="!sourceForm.authors.trim() && !sourceForm.title.trim()"
class="rounded bg-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-300 disabled:opacity-50 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500"
@click="addSource"
>
Add source
</button>
</div>
</div>
</div>
<!-- Actions -->

View file

@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref } from "vue";
import type { Segment } from "@/types/segment";
import type { Segment, Source } from "@/types/segment";
import AssetUploader from "@/components/admin/AssetUploader.vue";
import RichTextEditor from "@/components/admin/RichTextEditor.vue";
import { formatApaCitation } from "@/utils/formatApa";
const props = defineProps<{
segment: Segment;
@ -26,6 +27,48 @@ const teamMembers = ref<{ name: string; student_number: string }[]>(
const newMemberName = ref("");
const newMemberNumber = ref("");
const sources = ref<Source[]>(
Array.isArray(props.segment.metadata.sources)
? [...(props.segment.metadata.sources as Source[])]
: [],
);
const editingSourceId = ref<string | null>(null);
const sourceForm = ref({ authors: "", year: "", title: "", source: "", url: "" });
function resetSourceForm() {
sourceForm.value = { authors: "", year: "", title: "", source: "", url: "" };
editingSourceId.value = null;
}
function addOrUpdateSource() {
const f = sourceForm.value;
if (!f.authors.trim() && !f.title.trim()) return;
if (editingSourceId.value) {
sources.value = sources.value.map((s) =>
s.id === editingSourceId.value ? { ...s, ...f } : s,
);
} else {
sources.value = [...sources.value, { id: crypto.randomUUID(), ...f }];
}
resetSourceForm();
}
function editSource(source: Source) {
editingSourceId.value = source.id;
sourceForm.value = {
authors: source.authors,
year: source.year,
title: source.title,
source: source.source,
url: source.url,
};
}
function removeSource(id: string) {
sources.value = sources.value.filter((s) => s.id !== id);
if (editingSourceId.value === id) resetSourceForm();
}
function normaliseUrl(url: string): string {
const trimmed = url.trim();
if (trimmed && !/^https?:\/\//i.test(trimmed)) return `https://${trimmed}`;
@ -41,6 +84,9 @@ function handleSave() {
if (props.segment.type === "team") {
metadata.value = { ...metadata.value, members: teamMembers.value };
}
if (props.segment.type === "sources") {
metadata.value = { ...metadata.value, sources: sources.value };
}
if (JSON.stringify(metadata.value) !== JSON.stringify(props.segment.metadata)) {
updates.metadata = metadata.value;
}
@ -184,6 +230,95 @@ const assetAcceptMap: Record<string, string> = {
</button>
</div>
</div>
<!-- Sources: reference list -->
<div v-else-if="segment.type === 'sources'">
<!-- Existing sources -->
<div v-if="sources.length > 0" class="mb-4 space-y-2">
<div
v-for="source in sources"
:key="source.id"
class="group flex items-start gap-2 rounded bg-white px-3 py-2 text-sm dark:bg-slate-700"
>
<p
class="min-w-0 flex-1 pl-6 -indent-6 text-slate-700 dark:text-slate-200"
v-html="formatApaCitation(source)"
/>
<div class="flex shrink-0 gap-1 opacity-0 group-hover:opacity-100">
<button
class="rounded p-1 text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
title="Edit"
@click="editSource(source)"
>
<svg class="h-3.5 w-3.5" 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>
<button
class="rounded p-1 text-red-400 hover:text-red-600"
title="Remove"
@click="removeSource(source.id)"
>
&times;
</button>
</div>
</div>
</div>
<!-- Add/edit source form -->
<div class="space-y-2">
<div class="flex gap-2">
<input
v-model="sourceForm.authors"
type="text"
placeholder="Authors (e.g. Liu, Z., Zhang, W., & Yang, P.)"
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="sourceForm.year"
type="text"
placeholder="Year"
class="w-20 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"
/>
</div>
<input
v-model="sourceForm.title"
type="text"
placeholder="Title"
class="w-full 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"
/>
<div class="flex gap-2">
<input
v-model="sourceForm.source"
type="text"
placeholder="Journal, publisher, or site 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="sourceForm.url"
type="text"
placeholder="URL or DOI link (optional)"
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"
/>
</div>
<div class="flex gap-2">
<button
:disabled="!sourceForm.authors.trim() && !sourceForm.title.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="addOrUpdateSource"
>
{{ editingSourceId ? "Update" : "Add" }}
</button>
<button
v-if="editingSourceId"
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
@click="resetSourceForm"
>
Cancel
</button>
</div>
</div>
</div>
</div>
<!-- Actions -->

View file

@ -6,6 +6,7 @@ import VideoSegment from "@/components/segments/VideoSegment.vue";
import AudioSegment from "@/components/segments/AudioSegment.vue";
import IframeSegment from "@/components/segments/IframeSegment.vue";
import GallerySegment from "@/components/segments/GallerySegment.vue";
import SourcesSegment from "@/components/segments/SourcesSegment.vue";
defineProps<{
segment: Segment;
@ -19,4 +20,5 @@ defineProps<{
<AudioSegment v-else-if="segment.type === 'audio'" :segment="segment" />
<IframeSegment v-else-if="segment.type === 'iframe'" :segment="segment" />
<GallerySegment v-else-if="segment.type === 'gallery'" :segment="segment" />
<SourcesSegment v-else-if="segment.type === 'sources'" :segment="segment" />
</template>

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Segment, Source } from "@/types/segment";
import { formatApaCitation } from "@/utils/formatApa";
const props = defineProps<{
segment: Segment;
}>();
const sortedSources = computed(() => {
const sources = Array.isArray(props.segment.metadata.sources)
? ([...props.segment.metadata.sources] as Source[])
: [];
return sources.sort((a, b) =>
a.authors.localeCompare(b.authors, undefined, { sensitivity: "base" }),
);
});
</script>
<template>
<div v-if="sortedSources.length > 0" class="space-y-2">
<p
v-for="source in sortedSources"
:key="source.id"
class="pl-8 -indent-8 text-sm text-slate-800 dark:text-slate-200"
v-html="formatApaCitation(source)"
/>
</div>
<p v-else class="text-sm italic text-slate-400 dark:text-slate-500">No sources added yet.</p>
</template>

View file

@ -1,4 +1,13 @@
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery" | "team";
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery" | "team" | "sources";
export interface Source {
id: string;
authors: string;
year: string;
title: string;
source: string;
url: string;
}
export interface Segment {
id: string;

View file

@ -0,0 +1,37 @@
import type { Source } from "@/types/segment";
function escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function formatUrl(url: string): string {
const escaped = escapeHtml(url);
return `<a href="${escaped}" target="_blank" rel="noopener noreferrer" class="text-primary-600 underline break-all dark:text-primary-400">${escaped}</a>`;
}
export function formatApaCitation(source: Source): string {
const parts: string[] = [];
if (source.authors) {
const authors = escapeHtml(source.authors);
parts.push(authors.endsWith(".") ? authors : `${authors}.`);
}
parts.push(`(${escapeHtml(source.year || "n.d.")}).`);
if (source.title) {
const title = escapeHtml(source.title);
parts.push(title.endsWith(".") ? `<em>${title}</em>` : `<em>${title}</em>.`);
}
if (source.source) {
const src = escapeHtml(source.source);
parts.push(src.endsWith(".") ? `<em>${src}</em>` : `<em>${src}</em>.`);
}
if (source.url) {
parts.push(formatUrl(source.url));
}
return parts.join(" ").replace(/\.\./g, ".");
}