feat: implement portal action for modal rendering and add SvelteKit page state

- Add portal action to render modals at document.body, escaping stacking context
- Switch Discord modal from bindable prop to SvelteKit page state management
- Add cursor-pointer utility to interactive elements for better UX
This commit is contained in:
2026-01-13 19:12:02 -06:00
parent f881e03055
commit b6d377a143
6 changed files with 187 additions and 124 deletions
+20
View File
@@ -0,0 +1,20 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
interface PageState {
discordModal?: {
open: boolean;
username: string;
};
}
// interface Platform {}
}
}
export {};
+40
View File
@@ -0,0 +1,40 @@
/**
* Svelte action that moves an element to a target container (default: document.body).
* This allows elements to escape their parent's stacking context, which is essential
* for modals that need to appear above all other content regardless of z-index.
*
* @example
* ```svelte
* <div use:portal class="fixed inset-0 z-50">
* <!-- Modal content renders at document.body -->
* </div>
* ```
*
* @example
* ```svelte
* <!-- Portal to a specific container -->
* <div use:portal={"#modal-container"}>
* ...
* </div>
* ```
*/
export function portal(
node: HTMLElement,
target: HTMLElement | string = document.body,
): { destroy: () => void } | void {
const targetEl =
typeof target === "string" ? document.querySelector(target) : target;
if (!targetEl) {
console.warn(`Portal target "${target}" not found`);
return;
}
targetEl.appendChild(node);
return {
destroy() {
node.remove();
},
};
}
+108 -110
View File
@@ -2,19 +2,20 @@
import { fade, scale } from "svelte/transition"; import { fade, scale } from "svelte/transition";
import IconCopy from "~icons/material-symbols/content-copy-rounded"; import IconCopy from "~icons/material-symbols/content-copy-rounded";
import IconCheck from "~icons/material-symbols/check-rounded"; import IconCheck from "~icons/material-symbols/check-rounded";
import { portal } from "$lib/actions/portal";
interface Props { interface Props {
open: boolean;
username: string; username: string;
avatarUrl?: string; avatarUrl?: string;
bannerUrl?: string; bannerUrl?: string;
onclose: () => void;
} }
let { let {
open = $bindable(false),
username, username,
avatarUrl = "https://cdn.discordapp.com/avatars/184118083143598081/798e497f55abdcadbd8440e5eed551a0.png?size=4096", avatarUrl = "https://cdn.discordapp.com/avatars/184118083143598081/798e497f55abdcadbd8440e5eed551a0.png?size=4096",
bannerUrl = "https://cdn.discordapp.com/banners/184118083143598081/174425460b67261a124d873b016e038f.png?size=4096", bannerUrl = "https://cdn.discordapp.com/banners/184118083143598081/174425460b67261a124d873b016e038f.png?size=4096",
onclose,
}: Props = $props(); }: Props = $props();
let copySuccess = $state(false); let copySuccess = $state(false);
@@ -23,12 +24,12 @@
function handleBackdropClick(e: MouseEvent) { function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
open = false; onclose();
} }
} }
function handleClose() { function handleClose() {
open = false; onclose();
} }
async function copyUsername() { async function copyUsername() {
@@ -44,124 +45,121 @@
} }
</script> </script>
{#if open} <div
use:portal
class="fixed inset-0 z-[60] flex items-start justify-center bg-black/30 backdrop-blur-[3px] 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 <div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/30 backdrop-blur-[2px] p-4 pt-[15vh]" 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"
onclick={handleBackdropClick} role="dialog"
onkeydown={(e) => e.key === "Escape" && handleClose()} aria-modal="true"
role="presentation" aria-labelledby="discord-profile-title"
tabindex="-1" transition:scale={{ duration: 200, start: 0.95 }}
transition:fade={{ duration: 200 }}
> >
<!-- SCALE: Adjust the scale() value to resize entire modal proportionally --> <!-- Banner -->
<div {#if bannerUrl && !bannerFailed}
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" <img
role="dialog" src={bannerUrl}
aria-modal="true" alt=""
aria-labelledby="discord-profile-title" class="h-28 w-full object-cover"
transition:scale={{ duration: 200, start: 0.95 }} onerror={() => (bannerFailed = true)}
> />
<!-- Banner --> {:else}
{#if bannerUrl && !bannerFailed} <div
<img class="h-28 bg-linear-to-br from-zinc-300 to-zinc-400 dark:from-zinc-700 dark:to-zinc-800"
src={bannerUrl} ></div>
alt="" {/if}
class="h-28 w-full object-cover"
onerror={() => (bannerFailed = true)} <!-- Content area -->
/> <div class="px-5 pb-5">
{:else} <!-- 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 <div
class="h-28 bg-linear-to-br from-zinc-300 to-zinc-400 dark:from-zinc-700 dark:to-zinc-800" class="absolute inset-0 -m-1 size-[104px] rounded-full bg-zinc-100 dark:bg-zinc-900"
></div> ></div>
{/if}
<!-- Content area --> <!-- Avatar circle -->
<div class="px-5 pb-5"> <!-- SIZE: size-24 = 96px -->
<!-- Avatar with stroke effect --> {#if avatarUrl && !avatarFailed}
<div class="relative -mt-14 mb-3 w-fit"> <img
<!-- Stroke ring (larger circle behind avatar) --> src={avatarUrl}
<!-- SIZE: avatar (96px) + stroke (4px * 2) = 104px --> alt="Profile avatar"
<!-- POSITION: -m-1 centers the stroke ring behind the avatar --> class="relative size-24 rounded-full object-cover"
onerror={() => (avatarFailed = true)}
/>
{:else}
<div <div
class="absolute inset-0 -m-1 size-[104px] rounded-full bg-zinc-100 dark:bg-zinc-900" 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> ></div>
{/if}
<!-- Avatar circle --> <!-- Online indicator -->
<!-- SIZE: size-24 = 96px --> <!-- POSITION: bottom/right values place center on avatar circumference -->
{#if avatarUrl && !avatarFailed} <!-- For 96px avatar at 315° (bottom-right): ~4px from edge -->
<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 bottom-0.5 right-0.5 size-5 rounded-full bg-green-500 border-[3px] border-zinc-100 dark:border-zinc-900"
></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 <div
class="p-3 rounded-lg bg-zinc-200/50 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700" class="absolute bottom-0.5 right-0.5 size-5 rounded-full bg-green-500 border-[3px] border-zinc-100 dark:border-zinc-900"
></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"
> >
<h3 Xevion
class="text-xs font-semibold uppercase text-zinc-500 dark:text-zinc-500 mb-1" </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
> >
About Me <button
</h3> onclick={copyUsername}
<p class="text-sm text-zinc-700 dark:text-zinc-300"> class="p-0.5 rounded hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
Live with dignity.<br /> title={copySuccess ? "Copied!" : "Copy username"}
<a >
href="https://xevion.dev" {#if copySuccess}
class="text-blue-600 dark:text-blue-400 hover:underline" <IconCheck class="size-3.5 text-green-600 dark:text-green-500" />
target="_blank" {:else}
rel="noopener noreferrer">https://xevion.dev</a <IconCopy class="size-3.5 text-zinc-400 dark:text-zinc-500" />
> {/if}
</p> </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>
</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> </div>
{/if} </div>
+1 -1
View File
@@ -10,7 +10,7 @@
aria-label={themeStore.isDark aria-label={themeStore.isDark
? "Switch to light mode" ? "Switch to light mode"
: "Switch to dark mode"} : "Switch to dark mode"}
class="relative size-9 rounded-md border border-zinc-300 dark:border-zinc-700 bg-zinc-100 dark:bg-zinc-900/50 hover:bg-zinc-200 dark:hover:bg-zinc-800/70 transition-all duration-200" class="relative size-9 rounded-md border border-zinc-300 dark:border-zinc-700 bg-zinc-100 dark:bg-zinc-900/50 hover:bg-zinc-200 dark:hover:bg-zinc-800/70 transition-all duration-200 cursor-pointer"
> >
<div class="absolute inset-0 flex items-center justify-center"> <div class="absolute inset-0 flex items-center justify-center">
<IconSun <IconSun
+16 -11
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { pushState } from "$app/navigation";
import { page } from "$app/state";
import ProjectCard from "$lib/components/ProjectCard.svelte"; import ProjectCard from "$lib/components/ProjectCard.svelte";
import DiscordProfileModal from "$lib/components/DiscordProfileModal.svelte"; import DiscordProfileModal from "$lib/components/DiscordProfileModal.svelte";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
@@ -26,8 +28,9 @@
socialLinksWithIcons.filter((link: { visible: boolean }) => link.visible), socialLinksWithIcons.filter((link: { visible: boolean }) => link.visible),
); );
let discordModalOpen = $state(false); function openDiscordModal(username: string) {
let discordUsername = $state(""); pushState("", { discordModal: { open: true, username } });
}
</script> </script>
<main class="page-main overflow-x-hidden font-schibsted"> <main class="page-main overflow-x-hidden font-schibsted">
@@ -61,7 +64,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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500" 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 cursor-pointer"
> >
<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 -->
@@ -76,11 +79,8 @@
<!-- Discord - button that opens profile modal --> <!-- 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500" 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 cursor-pointer"
onclick={() => { onclick={() => openDiscordModal(link.value)}
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 -->
@@ -95,7 +95,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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500" 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 cursor-pointer"
> >
<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,7 +111,7 @@
<!-- PGP Key - links to dedicated page --> <!-- PGP Key - links to dedicated page -->
<a <a
href="/pgp" 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500" 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 cursor-pointer"
> >
<MaterialSymbolsVpnKey <MaterialSymbolsVpnKey
class="size-4.5 text-zinc-600 dark:text-zinc-300" class="size-4.5 text-zinc-600 dark:text-zinc-300"
@@ -135,4 +135,9 @@
</div> </div>
</main> </main>
<DiscordProfileModal bind:open={discordModalOpen} username={discordUsername} /> {#if page.state.discordModal?.open}
<DiscordProfileModal
username={page.state.discordModal.username}
onclose={() => history.back()}
/>
{/if}
+2 -2
View File
@@ -128,7 +128,7 @@
<div class="flex flex-col sm:flex-row gap-2 sm:gap-3"> <div class="flex flex-col sm:flex-row gap-2 sm:gap-3">
<button <button
onclick={copyToClipboard} onclick={copyToClipboard}
class="flex items-center justify-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 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 shadow-sm" class="flex items-center justify-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 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 shadow-sm cursor-pointer"
> >
<IconCopy class="size-4 sm:size-5" /> <IconCopy class="size-4 sm:size-5" />
<span class="text-sm sm:text-base font-medium" <span class="text-sm sm:text-base font-medium"
@@ -137,7 +137,7 @@
</button> </button>
<button <button
onclick={downloadKey} onclick={downloadKey}
class="flex items-center justify-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 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" class="flex items-center justify-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 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 cursor-pointer"
> >
<IconDownload class="size-4 sm:size-5" /> <IconDownload class="size-4 sm:size-5" />
<span class="text-sm sm:text-base font-medium">Download</span> <span class="text-sm sm:text-base font-medium">Download</span>