feat: implement new feature for enhanced user experience

This commit is contained in:
2026-05-17 14:19:45 +00:00
parent db968ab318
commit f332c707b7
214 changed files with 424 additions and 2562 deletions
+37
View File
@@ -0,0 +1,37 @@
[
{
"_id": { "$oid": "700000000000000000000001" },
"active": true,
"contentId": "6821c0a10000000000000001",
"author": "Maximilian Müller",
"message": "Ein großartiges Demo-Projekt! Die Umsetzung mit Svelte 5 sieht wirklich extrem aufgeräumt und performant aus. Freue mich schon, darauf aufzubauen."
},
{
"_id": { "$oid": "700000000000000000000002" },
"active": true,
"contentId": "6821c0a10000000000000001",
"author": "Sarah Jenkins",
"message": "Awesome work on the SSR cache mechanism. It clarifies so many edge cases regarding dependent collections."
},
{
"_id": { "$oid": "700000000000000000000003" },
"active": true,
"contentId": "6821c0a10000000000000001",
"author": "Thomas W.",
"message": "Gibt es zufällig auch Beispiele für den kombinierten Einsatz von aggregate mit einem nested array? Ansonsten aber Top-Starter!"
},
{
"_id": { "$oid": "700000000000000000000004" },
"active": true,
"contentId": "6821c0a10000000000000003",
"author": "Lisa Schmidt",
"message": "Das 'Über uns' sieht sehr clean aus. Ich denke, das Grid-Layout passt hier perfekt."
},
{
"_id": { "$oid": "700000000000000000000005" },
"active": true,
"contentId": "6821c0a10000000000000003",
"author": "David C.",
"message": "Looking forward to seeing more blocks added to the generic block renderer in the future."
}
]
+33 -1
View File
@@ -131,6 +131,7 @@
}
let loading = $state(true)
let notFound = $state(false)
let comments = $state<any[]>([])
// Header scroll detection
let scrolled = $state(false)
@@ -194,7 +195,7 @@
1,
undefined,
undefined,
undefined,
{ aggregate: "comments:contentId:count" },
CONTENT_MEDIA_LOOKUP
)
@@ -205,6 +206,17 @@
lang: contentEntry.lang,
path: contentEntry.path,
}
try {
comments = await getCachedEntries(
"comments",
{ active: true, contentId: contentEntry.id as string },
"sort"
)
} catch (e) {
console.error("Failed to load comments", e)
comments = []
}
} else {
notFound = true
}
@@ -392,6 +404,26 @@
{:else if contentEntry?.blocks}
<div class="page-enter">
<BlockRenderer blocks={contentEntry.blocks} />
{#if (contentEntry as any)?._aggregate?.commentsCount !== undefined}
<div class="max-w-6xl mx-auto px-6 py-8 border-t border-gray-100 my-12">
<h3 class="text-xl font-bold mb-6">
Kommentare ({(contentEntry as any)._aggregate.commentsCount})
</h3>
{#if comments && comments.length > 0}
<div class="space-y-6">
{#each comments as comment}
<div class="bg-gray-50 p-6 rounded-lg">
<div class="font-bold text-gray-900 mb-2">{comment.author}</div>
<p class="text-gray-700 whitespace-pre-wrap">{comment.message}</p>
</div>
{/each}
</div>
{:else}
<p class="text-gray-500 italic">Noch keine Kommentare vorhanden.</p>
{/if}
</div>
{/if}
</div>
{/if}
</main>
+1 -1
View File
@@ -24,7 +24,7 @@
{#if hasImage}
<div class="absolute inset-0 z-0">
<MedialibImage
id={block.heroImage?.image || resolvedHeroImage?.id || resolvedHeroImage?._id || ""}
id={block.heroImage?.image || resolvedHeroImage?.id || ""}
entry={resolvedHeroImage}
noPlaceholder
style="width:100%;height:100%;object-fit:cover;"
+1 -1
View File
@@ -59,7 +59,7 @@
<div class="rounded-2xl overflow-hidden shadow-xl shadow-brand-900/10">
{#if block.image || resolvedImage}
<MedialibImage
id={block.image || resolvedImage?.id || resolvedImage?._id || ""}
id={block.image || resolvedImage?.id || ""}
entry={resolvedImage}
noPlaceholder
style="width:100%;height:auto;aspect-ratio:4/3;object-fit:cover;"
+70 -2
View File
@@ -16,6 +16,7 @@
import contentData from "../../mocking/content.json"
import medialibData from "../../mocking/medialib.json"
import navigationData from "../../mocking/navigation.json"
import commentsData from "../../mocking/comments.json"
type EJsonObjectId = {
$oid: string
@@ -25,6 +26,7 @@ const mockRegistry: Record<string, Record<string, unknown>[]> = {
content: normalizeMockCollection(contentData as Record<string, unknown>[]),
medialib: normalizeMockCollection(medialibData as Record<string, unknown>[]),
navigation: normalizeMockCollection(navigationData as Record<string, unknown>[]),
comments: normalizeMockCollection(commentsData as Record<string, unknown>[]),
}
function isEJsonObjectId(value: unknown): value is EJsonObjectId {
@@ -88,7 +90,11 @@ export function mockApiRequest(endpoint: string, options?: ApiOptions, _body?: u
// --- Single-item retrieval ---
if (itemId) {
const item = sourceData.find((e) => e.id === itemId || e._id === itemId)
const resultItem = item ? applyLookups(cloneEntry(item), options) : null
let resultItem = item ? cloneEntry(item) : null
if (resultItem) {
resultItem = applyAggregate(resultItem, options)
resultItem = applyLookups(resultItem, options)
}
return {
data: resultItem,
count: resultItem ? 1 : 0,
@@ -119,7 +125,12 @@ export function mockApiRequest(endpoint: string, options?: ApiOptions, _body?: u
results = results.slice(0, options.limit)
}
results = results.map((entry) => applyLookups(cloneEntry(entry), options))
results = results.map((entry) => {
let e = cloneEntry(entry)
e = applyAggregate(e, options)
e = applyLookups(e, options)
return e
})
// Projection
if (options?.projection) {
@@ -230,6 +241,63 @@ function cloneEntry<T>(entry: T): T {
return JSON.parse(JSON.stringify(entry)) as T
}
function applyAggregate(entry: Record<string, unknown>, options?: ApiOptions): Record<string, unknown> {
const rawAggregate = options?.params?.aggregate
if (!rawAggregate) return entry
const aggregates = Array.isArray(rawAggregate)
? rawAggregate
: typeof rawAggregate === "string"
? rawAggregate.split(",")
: []
if (!entry._aggregate) {
entry._aggregate = {}
}
for (const spec of aggregates) {
try {
// Check for json object first
if (spec.startsWith("{")) {
// Not supported in this simple mock
continue
}
// "comments:contentId:count"
const parts = spec.split(":")
if (parts.length < 3) continue
const targetCollection = parts[0]
const foreignField = parts[1]
const operator = parts[2]
const lookupSource = mockRegistry[targetCollection]
if (!lookupSource) continue
const localId = entry._id
? typeof (entry._id as any).$oid === "string"
? (entry._id as any).$oid
: entry._id.toString()
: null
if (operator === "count") {
const count = lookupSource.filter((item) => {
const itemForeignId = item[foreignField]
? typeof (item[foreignField] as any).$oid === "string"
? (item[foreignField] as any).$oid
: (item[foreignField] as any).toString()
: null
return itemForeignId === localId && item.active !== false // basic mock rule
}).length
;(entry._aggregate as any)[`${targetCollection}Count`] = count
}
} catch (e) {
console.warn("Mock aggregate error", e)
}
}
return entry
}
function applyLookups(entry: Record<string, unknown>, options?: ApiOptions): Record<string, unknown> {
const lookupSpecs = parseLookupSpecs(options)
if (!lookupSpecs.length) return entry
+2 -2
View File
@@ -36,8 +36,8 @@
let imgEl = $state<HTMLImageElement | null>(null)
let currentFilter = $state<string>("l-webp")
const effectiveId = $derived(entry?.id || entry?._id || id || "")
const fileSrc = $derived(resolveFileSrc(entry?.file?.src, entry?.id || entry?._id || effectiveId))
const effectiveId = $derived(entry?.id || id || "")
const fileSrc = $derived(resolveFileSrc(entry?.file?.src, entry?.id || effectiveId))
const placeholderSrc = $derived(
resolveApiAssetUrl("/assets/img/placeholder-image.svg") || "/assets/img/placeholder-image.svg"
)