b7093b703a
- Palette: gray/zinc/slate/neutral → stein, red → fire, amber → erde, sky → himmel, green → wald - Footer: replaced raw rgb with stein tokens - Radius: unified to rounded-xs across cards, buttons, inputs, pagination - Buttons: .btn-primary/.btn-secondary now font-medium; added secondary - Card hover: -translate-y-0.5 + shadow + wald-300 border - Header active link: wald border-bottom + wald-700 text - Mobile nav: bigger touch targets + wald active accent - Pagination: prev/next text labels + wald-500 active state with separated shape/color classes to avoid Tailwind conflicts - Lead paragraph: scoped .markdown > p:first-child for top-level MarkdownBlock only via [data-block-type="markdown"] - Section dividers: subtle wald-200 gradient between content rows - Global focus-visible ring (wald-500) - Inline hex → palette tokens (Badge amber, Tag custom-color contrast) - Font weights snapped to design system (300/400/500/600/700) - transition-all → transition-colors where only color changes - Removed em-dashes from user-visible templates Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
211 lines
6.6 KiB
Svelte
211 lines
6.6 KiB
Svelte
<script lang="ts">
|
|
import Icon from "@iconify/svelte";
|
|
import "$lib/iconify-offline";
|
|
import { env } from "$env/dynamic/public";
|
|
import { useTranslate, T } from "$lib/translations";
|
|
|
|
let {
|
|
url,
|
|
title,
|
|
readingTimeMinutes = null,
|
|
commentPageId = null,
|
|
commentEnvironment,
|
|
commentTargetId = "comments-section",
|
|
class: cls = "",
|
|
}: {
|
|
/** Absolute URL of the page being shared. */
|
|
url: string;
|
|
/** Page headline — used by the native share-sheet. */
|
|
title: string;
|
|
/** Estimated reading time. `null` hides the badge. */
|
|
readingTimeMinutes?: number | null;
|
|
/** Page id for the comment plugin. `null` hides the count badge. */
|
|
commentPageId?: string | null;
|
|
/** Optional space override for the comment count fetch. */
|
|
commentEnvironment?: string;
|
|
/** DOM id of the comment accordion (used by the count-badge click). */
|
|
commentTargetId?: string;
|
|
class?: string;
|
|
} = $props();
|
|
|
|
const t = useTranslate();
|
|
|
|
// ── Comment count fetch (only when commentPageId is set) ────────────────
|
|
let commentCount = $state<number | null>(null);
|
|
const cmsBase = (
|
|
env.PUBLIC_CMS_URL ||
|
|
import.meta.env.PUBLIC_CMS_URL ||
|
|
"http://localhost:3000"
|
|
).replace(/\/$/, "");
|
|
|
|
$effect(() => {
|
|
if (!commentPageId) {
|
|
commentCount = null;
|
|
return;
|
|
}
|
|
const fetchUrl = `${cmsBase}/api/comments/${encodeURIComponent(commentPageId)}/count${
|
|
commentEnvironment
|
|
? `?_environment=${encodeURIComponent(commentEnvironment)}`
|
|
: ""
|
|
}`;
|
|
let cancelled = false;
|
|
fetch(fetchUrl)
|
|
.then((r) => r.json())
|
|
.then((d) => {
|
|
if (cancelled) return;
|
|
commentCount = typeof d?.count === "number" ? d.count : 0;
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) commentCount = 0;
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
});
|
|
|
|
function onCommentClick(e: MouseEvent) {
|
|
const el = document.getElementById(
|
|
commentTargetId,
|
|
) as HTMLDetailsElement | null;
|
|
if (!el) return;
|
|
e.preventDefault();
|
|
el.open = true;
|
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
|
|
let copied = $state(false);
|
|
let shared = $state(false);
|
|
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let shareTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// Share button only renders when the browser actually has a native share-
|
|
// sheet — otherwise it would just be a duplicate of "Link kopieren".
|
|
// SSR has no `navigator`, so this stays false until client mount.
|
|
let canShare = $state(false);
|
|
$effect(() => {
|
|
canShare =
|
|
typeof navigator !== "undefined" &&
|
|
typeof (navigator as Navigator & { share?: unknown }).share ===
|
|
"function";
|
|
});
|
|
|
|
function flashCopied() {
|
|
copied = true;
|
|
if (copyTimer) clearTimeout(copyTimer);
|
|
copyTimer = setTimeout(() => (copied = false), 1500);
|
|
}
|
|
|
|
function flashShared() {
|
|
shared = true;
|
|
if (shareTimer) clearTimeout(shareTimer);
|
|
shareTimer = setTimeout(() => (shared = false), 1500);
|
|
}
|
|
|
|
async function copyLink() {
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
flashCopied();
|
|
} catch {
|
|
// Fallback: temporary <input> + execCommand for older browsers.
|
|
const el = document.createElement("input");
|
|
el.value = url;
|
|
document.body.appendChild(el);
|
|
el.select();
|
|
try {
|
|
document.execCommand("copy");
|
|
flashCopied();
|
|
} catch {
|
|
// give up — clipboard not available
|
|
} finally {
|
|
el.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function share() {
|
|
const nav = navigator as Navigator & {
|
|
share?: (data: ShareData) => Promise<void>;
|
|
};
|
|
if (typeof nav.share !== "function") return;
|
|
try {
|
|
await nav.share({ title, url });
|
|
flashShared();
|
|
} catch {
|
|
// user cancelled — no fallback, copy-link button covers that case
|
|
}
|
|
}
|
|
|
|
function print() {
|
|
window.print();
|
|
}
|
|
</script>
|
|
|
|
<!--
|
|
Post-actions toolbar. Each item is a rounded-full pill with a subtle
|
|
border so they read as visually equal "badges". Order:
|
|
[info badges] | [action buttons]
|
|
i.e. info-style items (count, reading time) stand left/right, the
|
|
three icon-only actions cluster in the middle.
|
|
-->
|
|
<div class="inline-flex flex-wrap items-center gap-2 {cls}">
|
|
{#if commentPageId}
|
|
<a
|
|
href="#{commentTargetId}"
|
|
onclick={onCommentClick}
|
|
aria-label={t(T.comments_jump_to)}
|
|
title={t(T.comments_jump_to)}
|
|
class="no-underline! inline-flex items-center gap-1 rounded-full border border-stein-200 bg-white px-2.5 py-1 text-xs font-medium text-stein-700! hover:bg-stein-50 hover:border-stein-300 transition-colors"
|
|
>
|
|
<Icon icon="mdi:comment-outline" class="size-3.5" />
|
|
<span class="tabular-nums">{commentCount ?? "…"}</span>
|
|
</a>
|
|
{/if}
|
|
|
|
<div class="inline-flex items-center rounded-full border border-stein-200 bg-white overflow-hidden divide-x divide-stein-200 print:hidden">
|
|
{#if canShare}
|
|
<button
|
|
type="button"
|
|
onclick={share}
|
|
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
|
|
aria-label={t(T.post_action_share)}
|
|
title={t(T.post_action_share)}
|
|
>
|
|
<Icon
|
|
icon={shared ? "mdi:check" : "mdi:share-variant-outline"}
|
|
class="size-3.5"
|
|
/>
|
|
</button>
|
|
{/if}
|
|
<button
|
|
type="button"
|
|
onclick={copyLink}
|
|
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
|
|
aria-label={copied ? t(T.post_action_copied) : t(T.post_action_copy)}
|
|
title={copied ? t(T.post_action_copied) : t(T.post_action_copy)}
|
|
>
|
|
<Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={print}
|
|
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
|
|
aria-label={t(T.post_action_print)}
|
|
title={t(T.post_action_print)}
|
|
>
|
|
<Icon icon="mdi:printer-outline" class="size-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{#if readingTimeMinutes != null && readingTimeMinutes > 0}
|
|
<span
|
|
class="inline-flex items-center gap-1 rounded-full border border-stein-200 bg-white px-2.5 py-1 text-xs text-stein-500"
|
|
title={t(T.post_action_reading_time, { minutes: readingTimeMinutes })}
|
|
>
|
|
<Icon icon="mdi:clock-outline" class="size-3.5" />
|
|
<span class="tabular-nums"
|
|
>{t(T.post_action_reading_time, { minutes: readingTimeMinutes })}</span
|
|
>
|
|
</span>
|
|
{/if}
|
|
</div>
|