Add links page with QR codes
This commit is contained in:
@@ -75,5 +75,11 @@ export const pageMeta: Record<string, PageMeta> = {
|
|||||||
description: 'Terminal UI components showcase and documentation.',
|
description: 'Terminal UI components showcase and documentation.',
|
||||||
icon: 'mdi:puzzle',
|
icon: 'mdi:puzzle',
|
||||||
keywords: ['components', 'ui', 'terminal', 'tui']
|
keywords: ['components', 'ui', 'terminal', 'tui']
|
||||||
|
},
|
||||||
|
'/links': {
|
||||||
|
title: `${user.displayname} — Links`,
|
||||||
|
description: 'All my links with scannable QR codes.',
|
||||||
|
icon: 'mdi:link-variant',
|
||||||
|
keywords: ['links', 'qr', 'social', 'contact']
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
import { user, navigation, pageMeta } from "$lib/config";
|
||||||
|
import { themeColors } from "$lib/stores/theme";
|
||||||
|
import TuiHeader from "$lib/components/tui/TuiHeader.svelte";
|
||||||
|
import TuiFooter from "$lib/components/tui/TuiFooter.svelte";
|
||||||
|
import "$lib/assets/css/terminal-tui.css";
|
||||||
|
import "$lib/assets/css/tui-body.css";
|
||||||
|
|
||||||
|
interface LinkItem {
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blogLink = navigation.find((item) => item.name === "blog")?.path;
|
||||||
|
|
||||||
|
const links: LinkItem[] = [
|
||||||
|
...user.socials,
|
||||||
|
{ name: "Email", icon: "mdi:email", link: `mailto:${user.email}` },
|
||||||
|
...(blogLink ? [{ name: "Blog", icon: "mdi:rss", link: blogLink }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
let selected = $state<LinkItem | null>(null);
|
||||||
|
let QrCode = $state<any>(null);
|
||||||
|
let detailEl = $state<HTMLDivElement>();
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const module = await import("svelte-qrcode");
|
||||||
|
QrCode = module.default;
|
||||||
|
});
|
||||||
|
|
||||||
|
function select(item: LinkItem) {
|
||||||
|
selected = item;
|
||||||
|
queueMicrotask(() =>
|
||||||
|
detailEl?.scrollIntoView({ behavior: "smooth", block: "center" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyLink(link: string) {
|
||||||
|
return link
|
||||||
|
.replace(/^mailto:/, "")
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = pageMeta["/links"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{meta?.title ?? `${user.displayname} — Links`}</title>
|
||||||
|
<meta name="description" content={meta?.description ?? "All my links"} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="links-container">
|
||||||
|
<div
|
||||||
|
class="tui-terminal"
|
||||||
|
style="
|
||||||
|
--terminal-bg: {$themeColors.terminal};
|
||||||
|
--terminal-text: {$themeColors.text};
|
||||||
|
--terminal-muted: {$themeColors.textMuted};
|
||||||
|
--terminal-border: {$themeColors.border};
|
||||||
|
--terminal-prompt: {$themeColors.terminalPrompt};
|
||||||
|
--terminal-user: {$themeColors.terminalUser};
|
||||||
|
--terminal-path: {$themeColors.terminalPath};
|
||||||
|
--terminal-primary: {$themeColors.primary};
|
||||||
|
--terminal-secondary: {$themeColors.secondary};
|
||||||
|
--terminal-accent: {$themeColors.accent};
|
||||||
|
--terminal-bg-light: {$themeColors.backgroundLight};
|
||||||
|
"
|
||||||
|
role="region"
|
||||||
|
aria-label="Links"
|
||||||
|
>
|
||||||
|
<div class="tui-border-glow"></div>
|
||||||
|
|
||||||
|
<div class="tui-content">
|
||||||
|
<TuiHeader title="~/links" interactive={false} hasButtons={false} />
|
||||||
|
|
||||||
|
<div class="tui-body">
|
||||||
|
<div class="links-body">
|
||||||
|
<div class="links-list">
|
||||||
|
<div class="list-label">
|
||||||
|
// click a link to generate a QR code
|
||||||
|
</div>
|
||||||
|
{#each links as item (item.name)}
|
||||||
|
<button
|
||||||
|
class="link-row"
|
||||||
|
class:active={selected?.name === item.name}
|
||||||
|
onclick={() => select(item)}
|
||||||
|
>
|
||||||
|
<Icon icon={item.icon} width="20" />
|
||||||
|
<span class="link-name">{item.name}</span>
|
||||||
|
<span class="link-host">{prettyLink(item.link)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="links-detail" bind:this={detailEl}>
|
||||||
|
{#if selected}
|
||||||
|
<div class="qr-card">
|
||||||
|
{#if QrCode}
|
||||||
|
<QrCode
|
||||||
|
value={selected.link}
|
||||||
|
size="240"
|
||||||
|
background="#ffffff"
|
||||||
|
color="#11111b"
|
||||||
|
padding={4}
|
||||||
|
errorCorrection="M"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="qr-loading">Generating...</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="detail-name">
|
||||||
|
<Icon icon={selected.icon} width="18" />
|
||||||
|
<span>{selected.name}</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
class="detail-link"
|
||||||
|
href={selected.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<span>{prettyLink(selected.link)}</span>
|
||||||
|
<Icon icon="mdi:open-in-new" width="16" />
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div class="detail-empty">
|
||||||
|
<Icon icon="mdi:qrcode-scan" width="48" />
|
||||||
|
<span>Select a link to generate a QR code</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TuiFooter
|
||||||
|
isTyping={false}
|
||||||
|
linesCount={links.length}
|
||||||
|
skipAnimation={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.links-container {
|
||||||
|
padding: 0;
|
||||||
|
min-height: calc(100vh - var(--navbar-height, 60px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 45%;
|
||||||
|
max-width: 460px;
|
||||||
|
padding-right: 1.25rem;
|
||||||
|
border-right: 1px solid var(--terminal-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-label {
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--terminal-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--terminal-text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row:hover {
|
||||||
|
border-color: var(--terminal-primary);
|
||||||
|
background: var(--terminal-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row.active {
|
||||||
|
border-color: var(--terminal-accent);
|
||||||
|
background: var(--terminal-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-host {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-detail {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: min(280px, 80vw);
|
||||||
|
padding: 1rem;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-card :global(img) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-loading {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #11111b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border: 1px solid var(--terminal-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--terminal-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition:
|
||||||
|
background 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-link:hover {
|
||||||
|
background: var(--terminal-primary);
|
||||||
|
color: var(--terminal-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.links-body {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-list {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
padding-right: 0;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--terminal-border);
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row {
|
||||||
|
padding: 0.85rem 0.9rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-detail {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-link {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user