mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 04:26:43 -06:00
feat: replace PGP modal with Discord profile modal
- Remove PGP key modal component in favor of dedicated /pgp page - Add Discord profile modal with avatar, banner, and copy username - Convert PGP key link to navigate to dedicated page instead of modal - Add focus-visible styles for keyboard navigation accessibility
This commit is contained in:
@@ -0,0 +1,166 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade, scale } from "svelte/transition";
|
||||||
|
import IconCopy from "~icons/material-symbols/content-copy-rounded";
|
||||||
|
import IconCheck from "~icons/material-symbols/check-rounded";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
username: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
bannerUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
username,
|
||||||
|
avatarUrl = "https://cdn.discordapp.com/avatars/184118083143598081/798e497f55abdcadbd8440e5eed551a0.png?size=4096",
|
||||||
|
bannerUrl = "https://cdn.discordapp.com/banners/184118083143598081/174425460b67261a124d873b016e038f.png?size=4096",
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let copySuccess = $state(false);
|
||||||
|
let avatarFailed = $state(false);
|
||||||
|
let bannerFailed = $state(false);
|
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyUsername() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(username);
|
||||||
|
copySuccess = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copySuccess = false;
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy username:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-start justify-center bg-black/30 backdrop-blur-[2px] p-4 pt-[15vh]"
|
||||||
|
onclick={handleBackdropClick}
|
||||||
|
onkeydown={(e) => e.key === "Escape" && handleClose()}
|
||||||
|
role="presentation"
|
||||||
|
tabindex="-1"
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
>
|
||||||
|
<!-- SCALE: Adjust the scale() value to resize entire modal proportionally -->
|
||||||
|
<div
|
||||||
|
class="relative w-full max-w-md rounded-xl bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 shadow-lg overflow-hidden scale-110 origin-top"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="discord-profile-title"
|
||||||
|
transition:scale={{ duration: 200, start: 0.95 }}
|
||||||
|
>
|
||||||
|
<!-- Banner -->
|
||||||
|
{#if bannerUrl && !bannerFailed}
|
||||||
|
<img
|
||||||
|
src={bannerUrl}
|
||||||
|
alt=""
|
||||||
|
class="h-28 w-full object-cover"
|
||||||
|
onerror={() => (bannerFailed = true)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="h-28 bg-linear-to-br from-zinc-300 to-zinc-400 dark:from-zinc-700 dark:to-zinc-800"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Content area -->
|
||||||
|
<div class="px-5 pb-5">
|
||||||
|
<!-- Avatar with stroke effect -->
|
||||||
|
<div class="relative -mt-14 mb-3 w-fit">
|
||||||
|
<!-- Stroke ring (larger circle behind avatar) -->
|
||||||
|
<!-- SIZE: avatar (96px) + stroke (4px * 2) = 104px -->
|
||||||
|
<!-- POSITION: -m-1 centers the stroke ring behind the avatar -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 -m-1 rounded-full bg-zinc-100 dark:bg-zinc-900"
|
||||||
|
style="width: 104px; height: 104px;"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Avatar circle -->
|
||||||
|
<!-- SIZE: size-24 = 96px -->
|
||||||
|
{#if avatarUrl && !avatarFailed}
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="Profile avatar"
|
||||||
|
class="relative size-24 rounded-full object-cover"
|
||||||
|
onerror={() => (avatarFailed = true)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="relative size-24 rounded-full bg-linear-to-br from-zinc-400 to-zinc-500 dark:from-zinc-500 dark:to-zinc-600"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Online indicator -->
|
||||||
|
<!-- POSITION: bottom/right values place center on avatar circumference -->
|
||||||
|
<!-- For 96px avatar at 315° (bottom-right): ~4px from edge -->
|
||||||
|
<div
|
||||||
|
class="absolute size-5 rounded-full bg-green-500 border-[3px] border-zinc-100 dark:border-zinc-900"
|
||||||
|
style="bottom: 2px; right: 2px;"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile info -->
|
||||||
|
<!-- SPACING: mb-4 controls gap before About Me section -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2
|
||||||
|
id="discord-profile-title"
|
||||||
|
class="text-xl font-bold text-zinc-900 dark:text-zinc-100"
|
||||||
|
>
|
||||||
|
Xevion
|
||||||
|
</h2>
|
||||||
|
<!-- USERNAME ROW: gap-1.5 controls spacing between elements -->
|
||||||
|
<div class="flex items-center gap-1.5 text-sm">
|
||||||
|
<span class="font-mono text-xs px-1.5 py-0.5 rounded border border-zinc-300 dark:border-zinc-700 bg-zinc-200/50 dark:bg-zinc-800/50 text-zinc-600 dark:text-zinc-400">{username}</span>
|
||||||
|
<button
|
||||||
|
onclick={copyUsername}
|
||||||
|
class="p-0.5 rounded hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
||||||
|
title={copySuccess ? "Copied!" : "Copy username"}
|
||||||
|
>
|
||||||
|
{#if copySuccess}
|
||||||
|
<IconCheck
|
||||||
|
class="size-3.5 text-green-600 dark:text-green-500"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<IconCopy class="size-3.5 text-zinc-400 dark:text-zinc-500" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<span class="text-zinc-400 dark:text-zinc-500">·</span>
|
||||||
|
<span class="text-zinc-500 dark:text-zinc-400">any/they</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- About Me section -->
|
||||||
|
<div
|
||||||
|
class="p-3 rounded-lg bg-zinc-200/50 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="text-xs font-semibold uppercase text-zinc-500 dark:text-zinc-500 mb-1"
|
||||||
|
>
|
||||||
|
About Me
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-zinc-700 dark:text-zinc-300">
|
||||||
|
Live with dignity.<br />
|
||||||
|
<a
|
||||||
|
href="https://xevion.dev"
|
||||||
|
class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">https://xevion.dev</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { PGP_KEY_METADATA } from "$lib/pgp/key-info";
|
|
||||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-svelte";
|
|
||||||
import "overlayscrollbars/overlayscrollbars.css";
|
|
||||||
import IconDownload from "~icons/material-symbols/download-rounded";
|
|
||||||
import IconCopy from "~icons/material-symbols/content-copy-rounded";
|
|
||||||
import { fade, scale } from "svelte/transition";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
open: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { open = $bindable(false) }: Props = $props();
|
|
||||||
|
|
||||||
let copySuccess = $state(false);
|
|
||||||
let keyContent = $state<string>("");
|
|
||||||
let loading = $state(false);
|
|
||||||
|
|
||||||
// Fetch key content when modal opens
|
|
||||||
$effect(() => {
|
|
||||||
if (open && !keyContent && !loading) {
|
|
||||||
loading = true;
|
|
||||||
fetch("/publickey.asc")
|
|
||||||
.then((res) => res.text())
|
|
||||||
.then((text) => {
|
|
||||||
keyContent = text;
|
|
||||||
loading = false;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Failed to fetch PGP key:", err);
|
|
||||||
loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleBackdropClick(e: MouseEvent) {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyToClipboard() {
|
|
||||||
if (!keyContent) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(keyContent);
|
|
||||||
copySuccess = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
copySuccess = false;
|
|
||||||
}, 2000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadKey() {
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = "/publickey.asc";
|
|
||||||
a.download = "publickey.asc";
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if open}
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-[2px] p-4"
|
|
||||||
onclick={handleBackdropClick}
|
|
||||||
onkeydown={(e) => e.key === "Escape" && handleClose()}
|
|
||||||
role="presentation"
|
|
||||||
tabindex="-1"
|
|
||||||
transition:fade={{ duration: 200 }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="relative w-full max-w-2xl rounded-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-6 shadow-xl"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="pgp-modal-title"
|
|
||||||
transition:scale={{ duration: 200, start: 0.95 }}
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between mb-4">
|
|
||||||
<h2
|
|
||||||
id="pgp-modal-title"
|
|
||||||
class="text-xl font-semibold text-zinc-900 dark:text-white"
|
|
||||||
>
|
|
||||||
PGP Public Key
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onclick={handleClose}
|
|
||||||
class="text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 transition-colors"
|
|
||||||
aria-label="Close modal"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fingerprint -->
|
|
||||||
<div
|
|
||||||
class="mb-4 p-3 bg-zinc-100 dark:bg-zinc-800 rounded border border-zinc-200 dark:border-zinc-700"
|
|
||||||
>
|
|
||||||
<div class="text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1">
|
|
||||||
Fingerprint
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="font-mono text-sm text-zinc-900 dark:text-zinc-100 break-all"
|
|
||||||
>
|
|
||||||
{PGP_KEY_METADATA.fingerprint}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Key Content -->
|
|
||||||
<div
|
|
||||||
class="mb-4 border border-zinc-200 dark:border-zinc-700 rounded overflow-hidden"
|
|
||||||
>
|
|
||||||
{#if loading}
|
|
||||||
<div class="p-4 text-center text-zinc-600 dark:text-zinc-400">
|
|
||||||
Loading key...
|
|
||||||
</div>
|
|
||||||
{:else if keyContent}
|
|
||||||
<OverlayScrollbarsComponent
|
|
||||||
options={{
|
|
||||||
scrollbars: { autoHide: "leave", autoHideDelay: 800 },
|
|
||||||
}}
|
|
||||||
defer
|
|
||||||
style="max-height: 400px"
|
|
||||||
>
|
|
||||||
<pre
|
|
||||||
class="p-4 text-xs font-mono text-zinc-800 dark:text-zinc-200 bg-zinc-50 dark:bg-zinc-900/50 overflow-x-auto">{keyContent}</pre>
|
|
||||||
</OverlayScrollbarsComponent>
|
|
||||||
{:else}
|
|
||||||
<div class="p-4 text-center text-zinc-600 dark:text-zinc-400">
|
|
||||||
Failed to load key
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="flex gap-3 justify-end">
|
|
||||||
<button
|
|
||||||
onclick={downloadKey}
|
|
||||||
class="flex items-center gap-2 px-4 py-2 rounded-sm bg-zinc-100 dark:bg-zinc-800 text-zinc-800 dark:text-zinc-100 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
|
||||||
>
|
|
||||||
<IconDownload class="size-4" />
|
|
||||||
<span class="text-sm font-medium">Download</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={copyToClipboard}
|
|
||||||
class="flex items-center gap-2 px-4 py-2 rounded-sm bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
|
|
||||||
>
|
|
||||||
<IconCopy class="size-4" />
|
|
||||||
<span class="text-sm font-medium"
|
|
||||||
>{copySuccess ? "Copied!" : "Copy to Clipboard"}</span
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -10,7 +10,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn("overflow-x-auto rounded-lg border border-admin-border bg-admin-surface", className)}
|
class={cn(
|
||||||
|
"overflow-x-auto rounded-lg border border-admin-border bg-admin-surface",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { IconifyJSON } from "@iconify/types";
|
import type { IconifyJSON } from "@iconify/types";
|
||||||
import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
|
import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
|
||||||
import { getLogger } from "@logtape/logtape";
|
import { getLogger } from "@logtape/logtape";
|
||||||
import type { IconCollection, IconIdentifier, IconRenderOptions } from "$lib/types/icons";
|
import type {
|
||||||
|
IconCollection,
|
||||||
|
IconIdentifier,
|
||||||
|
IconRenderOptions,
|
||||||
|
} from "$lib/types/icons";
|
||||||
|
|
||||||
const logger = getLogger(["server", "icons"]);
|
const logger = getLogger(["server", "icons"]);
|
||||||
|
|
||||||
@@ -39,7 +43,9 @@ function parseIdentifier(
|
|||||||
/**
|
/**
|
||||||
* Load icon collection from disk via dynamic import (internal - no caching logic)
|
* Load icon collection from disk via dynamic import (internal - no caching logic)
|
||||||
*/
|
*/
|
||||||
async function loadCollectionFromDisk(collection: string): Promise<IconifyJSON | null> {
|
async function loadCollectionFromDisk(
|
||||||
|
collection: string,
|
||||||
|
): Promise<IconifyJSON | null> {
|
||||||
try {
|
try {
|
||||||
// Dynamic import - Bun resolves the package path automatically
|
// Dynamic import - Bun resolves the package path automatically
|
||||||
const module = await import(`@iconify/json/json/${collection}.json`);
|
const module = await import(`@iconify/json/json/${collection}.json`);
|
||||||
@@ -137,7 +143,9 @@ function renderIconData(
|
|||||||
/**
|
/**
|
||||||
* Render the default fallback icon (internal helper)
|
* Render the default fallback icon (internal helper)
|
||||||
*/
|
*/
|
||||||
async function renderFallbackIcon(options: IconRenderOptions): Promise<string | null> {
|
async function renderFallbackIcon(
|
||||||
|
options: IconRenderOptions,
|
||||||
|
): Promise<string | null> {
|
||||||
const parsed = parseIdentifier(DEFAULT_FALLBACK_ICON);
|
const parsed = parseIdentifier(DEFAULT_FALLBACK_ICON);
|
||||||
if (!parsed) return null;
|
if (!parsed) return null;
|
||||||
|
|
||||||
@@ -169,7 +177,10 @@ export async function renderIconsBatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse and group by collection
|
// Parse and group by collection
|
||||||
const byCollection = new Map<string, { identifier: string; name: string }[]>();
|
const byCollection = new Map<
|
||||||
|
string,
|
||||||
|
{ identifier: string; name: string }[]
|
||||||
|
>();
|
||||||
const invalidIdentifiers: string[] = [];
|
const invalidIdentifiers: string[] = [];
|
||||||
|
|
||||||
for (const identifier of identifiers) {
|
for (const identifier of identifiers) {
|
||||||
@@ -185,7 +196,9 @@ export async function renderIconsBatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (invalidIdentifiers.length > 0) {
|
if (invalidIdentifiers.length > 0) {
|
||||||
logger.warn("Invalid icon identifiers in batch", { identifiers: invalidIdentifiers });
|
logger.warn("Invalid icon identifiers in batch", {
|
||||||
|
identifiers: invalidIdentifiers,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all needed collections in parallel
|
// Load all needed collections in parallel
|
||||||
@@ -256,9 +269,12 @@ export async function renderIconsBatch(
|
|||||||
/**
|
/**
|
||||||
* Get single icon data (for API endpoint use only)
|
* Get single icon data (for API endpoint use only)
|
||||||
*/
|
*/
|
||||||
export async function getIconForApi(
|
export async function getIconForApi(identifier: string): Promise<{
|
||||||
identifier: string,
|
identifier: string;
|
||||||
): Promise<{ identifier: string; collection: string; name: string; svg: string } | null> {
|
collection: string;
|
||||||
|
name: string;
|
||||||
|
svg: string;
|
||||||
|
} | null> {
|
||||||
const parsed = parseIdentifier(identifier);
|
const parsed = parseIdentifier(identifier);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
logger.warn(`Invalid icon identifier: ${identifier}`);
|
logger.warn(`Invalid icon identifier: ${identifier}`);
|
||||||
@@ -318,7 +334,8 @@ export async function searchIcons(
|
|||||||
query: string,
|
query: string,
|
||||||
limit: number = 50,
|
limit: number = 50,
|
||||||
): Promise<{ identifier: string; collection: string; name: string }[]> {
|
): Promise<{ identifier: string; collection: string; name: string }[]> {
|
||||||
const results: { identifier: string; collection: string; name: string }[] = [];
|
const results: { identifier: string; collection: string; name: string }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
// Parse query for collection prefix (e.g., "lucide:home" or "lucide:")
|
// Parse query for collection prefix (e.g., "lucide:home" or "lucide:")
|
||||||
const colonIndex = query.indexOf(":");
|
const colonIndex = query.indexOf(":");
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const load: PageServerLoad = async ({ fetch, parent }) => {
|
|||||||
...project,
|
...project,
|
||||||
tags: project.tags.map((tag) => ({
|
tags: project.tags.map((tag) => ({
|
||||||
...tag,
|
...tag,
|
||||||
iconSvg: tag.icon ? smallIcons.get(tag.icon) ?? "" : "",
|
iconSvg: tag.icon ? (smallIcons.get(tag.icon) ?? "") : "",
|
||||||
})),
|
})),
|
||||||
clockIconSvg: smallIcons.get(CLOCK_ICON) ?? "",
|
clockIconSvg: smallIcons.get(CLOCK_ICON) ?? "",
|
||||||
}));
|
}));
|
||||||
|
|||||||
+17
-20
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AppWrapper from "$lib/components/AppWrapper.svelte";
|
import AppWrapper from "$lib/components/AppWrapper.svelte";
|
||||||
import ProjectCard from "$lib/components/ProjectCard.svelte";
|
import ProjectCard from "$lib/components/ProjectCard.svelte";
|
||||||
import PgpKeyModal from "$lib/components/PgpKeyModal.svelte";
|
import DiscordProfileModal from "$lib/components/DiscordProfileModal.svelte";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key";
|
import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key";
|
||||||
|
|
||||||
@@ -27,13 +27,8 @@
|
|||||||
socialLinksWithIcons.filter((link: { visible: boolean }) => link.visible),
|
socialLinksWithIcons.filter((link: { visible: boolean }) => link.visible),
|
||||||
);
|
);
|
||||||
|
|
||||||
let pgpModalOpen = $state(false);
|
let discordModalOpen = $state(false);
|
||||||
|
let discordUsername = $state("");
|
||||||
// Handle Discord click (copy to clipboard)
|
|
||||||
function handleDiscordClick(username: string) {
|
|
||||||
navigator.clipboard.writeText(username);
|
|
||||||
// TODO: Add toast notification
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AppWrapper class="overflow-x-hidden font-schibsted">
|
<AppWrapper class="overflow-x-hidden font-schibsted">
|
||||||
@@ -67,7 +62,7 @@
|
|||||||
<!-- Simple link platforms -->
|
<!-- Simple link platforms -->
|
||||||
<a
|
<a
|
||||||
href={link.value}
|
href={link.value}
|
||||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500"
|
||||||
>
|
>
|
||||||
<span class="size-4 text-zinc-600 dark:text-zinc-300">
|
<span class="size-4 text-zinc-600 dark:text-zinc-300">
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
@@ -79,11 +74,14 @@
|
|||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
{:else if link.platform === "discord"}
|
{:else if link.platform === "discord"}
|
||||||
<!-- Discord - button that copies username -->
|
<!-- Discord - button that opens profile modal -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500"
|
||||||
onclick={() => handleDiscordClick(link.value)}
|
onclick={() => {
|
||||||
|
discordUsername = link.value;
|
||||||
|
discordModalOpen = true;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span class="size-4 text-zinc-600 dark:text-zinc-300">
|
<span class="size-4 text-zinc-600 dark:text-zinc-300">
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
@@ -98,7 +96,7 @@
|
|||||||
<!-- Email - mailto link -->
|
<!-- Email - mailto link -->
|
||||||
<a
|
<a
|
||||||
href="mailto:{link.value}"
|
href="mailto:{link.value}"
|
||||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500"
|
||||||
>
|
>
|
||||||
<span class="size-4.5 text-zinc-600 dark:text-zinc-300">
|
<span class="size-4.5 text-zinc-600 dark:text-zinc-300">
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
@@ -111,11 +109,10 @@
|
|||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<!-- PGP Key - kept separate from settings system -->
|
<!-- PGP Key - links to dedicated page -->
|
||||||
<button
|
<a
|
||||||
type="button"
|
href="/pgp"
|
||||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500"
|
||||||
onclick={() => (pgpModalOpen = true)}
|
|
||||||
>
|
>
|
||||||
<MaterialSymbolsVpnKey
|
<MaterialSymbolsVpnKey
|
||||||
class="size-4.5 text-zinc-600 dark:text-zinc-300"
|
class="size-4.5 text-zinc-600 dark:text-zinc-300"
|
||||||
@@ -124,7 +121,7 @@
|
|||||||
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
||||||
>PGP Key</span
|
>PGP Key</span
|
||||||
>
|
>
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,4 +136,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</AppWrapper>
|
</AppWrapper>
|
||||||
|
|
||||||
<PgpKeyModal bind:open={pgpModalOpen} />
|
<DiscordProfileModal bind:open={discordModalOpen} username={discordUsername} />
|
||||||
|
|||||||
@@ -297,7 +297,9 @@
|
|||||||
{#if editIcon}
|
{#if editIcon}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="text-admin-text">
|
<div class="text-admin-text">
|
||||||
<span class="text-xs text-admin-text-muted">{editIcon}</span>
|
<span class="text-xs text-admin-text-muted"
|
||||||
|
>{editIcon}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -354,7 +356,9 @@
|
|||||||
{#if tag.icon}
|
{#if tag.icon}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="text-admin-text">
|
<div class="text-admin-text">
|
||||||
<span class="text-xs text-admin-text-muted">{tag.icon}</span>
|
<span class="text-xs text-admin-text-muted"
|
||||||
|
>{tag.icon}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
Reference in New Issue
Block a user