Initial commit: fristy.net personal site
This commit is contained in:
51 files changed
+6052
No files matched your search
@@ -0,0 +1,87 @@
|
||||
import Image from "next/image";
|
||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||||
import {
|
||||
CONTACT_EMAIL,
|
||||
DISCORD_SERVER_URL,
|
||||
GITHUB_URL,
|
||||
GITEA_PROFILE_URL,
|
||||
SPOTIFY_URL,
|
||||
STEAM_URL,
|
||||
} from "@/lib/site";
|
||||
import { CopyEmail } from "./copy-email";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
export function ContactSection({ dict }: { dict: Dictionary["contact"] }) {
|
||||
const entries = [
|
||||
{ label: dict.github, sub: "@fristyxd", href: GITHUB_URL, icon: "/icons/github.svg" },
|
||||
{
|
||||
label: dict.discord,
|
||||
sub: "discord.gg/fristy",
|
||||
href: DISCORD_SERVER_URL,
|
||||
icon: "/icons/discord.svg",
|
||||
},
|
||||
{
|
||||
label: dict.gitea,
|
||||
sub: "git.fristy.net",
|
||||
href: GITEA_PROFILE_URL,
|
||||
icon: "/icons/gitea.svg",
|
||||
},
|
||||
{
|
||||
label: dict.spotify,
|
||||
sub: "open.spotify.com",
|
||||
href: SPOTIFY_URL,
|
||||
icon: "/icons/spotify.svg",
|
||||
},
|
||||
{
|
||||
label: dict.steam,
|
||||
sub: "fristylikeaillusion",
|
||||
href: STEAM_URL,
|
||||
icon: "/icons/steam.svg",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="contact" className="scroll-mt-24">
|
||||
<SectionHeading kicker={CONTACT_EMAIL} title={dict.title} />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-4 rounded-2xl border border-white/10 bg-panel p-4">
|
||||
<Image src="/icons/gmail.svg" alt="" width={28} height={28} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">{dict.email}</p>
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="block truncate font-mono text-sm text-blurple-soft transition-colors hover:text-white"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</div>
|
||||
<CopyEmail
|
||||
email={CONTACT_EMAIL}
|
||||
copyLabel={dict.copy}
|
||||
copiedLabel={dict.copied}
|
||||
/>
|
||||
</div>
|
||||
{entries.map((entry) => (
|
||||
<a
|
||||
key={entry.label}
|
||||
href={entry.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-4 rounded-2xl border border-white/10 bg-panel p-4 transition-colors hover:border-blurple/60"
|
||||
>
|
||||
<Image src={entry.icon} alt="" width={28} height={28} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">{entry.label}</p>
|
||||
<p className="truncate font-mono text-sm text-zinc-500 transition-colors group-hover:text-zinc-300">
|
||||
{entry.sub}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-zinc-600 transition-colors group-hover:text-white">
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function CopyEmail({
|
||||
email,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
}: {
|
||||
email: string;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(email);
|
||||
} catch {
|
||||
const area = document.createElement("textarea");
|
||||
area.value = email;
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(area);
|
||||
}
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="rounded-full border border-white/15 px-5 py-3 text-sm text-zinc-300 transition-colors hover:border-blurple hover:text-white"
|
||||
>
|
||||
{copied ? copiedLabel : copyLabel}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||||
import {
|
||||
activityImageUrl,
|
||||
discordAvatarUrl,
|
||||
lanyardUrl,
|
||||
type DiscordStatus,
|
||||
type LanyardActivity,
|
||||
type LanyardData,
|
||||
} from "@/lib/lanyard";
|
||||
|
||||
const STATUS_DOT: Record<DiscordStatus, string> = {
|
||||
online: "bg-emerald-400",
|
||||
idle: "bg-amber-400",
|
||||
dnd: "bg-rose-500",
|
||||
offline: "bg-zinc-500",
|
||||
};
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
|
||||
function formatElapsed(start: number): string {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000));
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return "<1m";
|
||||
}
|
||||
|
||||
function ActivityRow({ activity }: { activity: LanyardActivity }) {
|
||||
const large = activityImageUrl(activity, false);
|
||||
const small = activityImageUrl(activity, true);
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-3">
|
||||
{large ? (
|
||||
<div className="relative shrink-0">
|
||||
<Image
|
||||
src={large}
|
||||
alt={activity.assets?.large_text ?? activity.name}
|
||||
width={56}
|
||||
height={56}
|
||||
className="rounded-xl"
|
||||
/>
|
||||
{small ? (
|
||||
<Image
|
||||
src={small}
|
||||
alt={activity.assets?.small_text ?? ""}
|
||||
width={24}
|
||||
height={24}
|
||||
className="absolute -bottom-1.5 -right-1.5 rounded-full border-2 border-panel"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{activity.name}</p>
|
||||
{activity.details ? (
|
||||
<p className="truncate text-sm text-zinc-300">{activity.details}</p>
|
||||
) : null}
|
||||
{activity.state ? (
|
||||
<p className="truncate text-sm text-zinc-500">{activity.state}</p>
|
||||
) : null}
|
||||
{activity.timestamps?.start ? (
|
||||
<p className="mt-0.5 font-mono text-xs text-zinc-500">
|
||||
{formatElapsed(activity.timestamps.start)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiscordCard({
|
||||
initial,
|
||||
dict,
|
||||
}: {
|
||||
initial: LanyardData | null;
|
||||
dict: Dictionary["discord"];
|
||||
}) {
|
||||
const [data, setData] = useState<LanyardData | null>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
async function poll(): Promise<void> {
|
||||
const response = await fetch(lanyardUrl(), { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const json = (await response.json()) as { success: boolean; data: LanyardData };
|
||||
if (json.success) {
|
||||
setData(json.data);
|
||||
}
|
||||
}
|
||||
function tick(): void {
|
||||
void poll().catch(() => undefined);
|
||||
}
|
||||
const timer = window.setInterval(tick, POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-panel p-6 text-zinc-400">
|
||||
{dict.offline}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const user = data.discord_user;
|
||||
const displayName = user.display_name ?? user.global_name ?? user.username;
|
||||
const customStatus = data.activities.find((a) => a.type === 4);
|
||||
const games = data.activities.filter((a) => a.type === 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-panel p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative shrink-0">
|
||||
<Image
|
||||
src={discordAvatarUrl(user.id, user.avatar, 128)}
|
||||
alt={displayName}
|
||||
width={72}
|
||||
height={72}
|
||||
className="rounded-2xl"
|
||||
priority
|
||||
/>
|
||||
<span
|
||||
title={dict[data.discord_status]}
|
||||
className={`absolute -bottom-1 -right-1 h-5 w-5 rounded-full border-4 border-panel ${STATUS_DOT[data.discord_status]}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xl font-bold">{displayName}</p>
|
||||
<p className="font-mono text-sm text-zinc-500">@{user.username}</p>
|
||||
<p className="mt-1 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-2.5 py-0.5 text-xs text-zinc-300">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${STATUS_DOT[data.discord_status]}`}
|
||||
/>
|
||||
{dict[data.discord_status]}
|
||||
<span className="text-zinc-600">·</span>
|
||||
<span className="text-mint">{dict.live}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customStatus?.state ? (
|
||||
<p className="mt-4 rounded-xl border border-white/5 bg-white/[0.03] p-3 text-sm text-zinc-300">
|
||||
<span className="mr-2 font-mono text-xs text-zinc-500">
|
||||
{dict.customStatus}
|
||||
</span>
|
||||
{customStatus.emoji?.name ? (
|
||||
<span className="mr-1">{customStatus.emoji.name}</span>
|
||||
) : null}
|
||||
{customStatus.state}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{games.length > 0 ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||||
{dict.playing}
|
||||
</p>
|
||||
{games.map((game) => (
|
||||
<ActivityRow key={game.id} activity={game} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.listening_to_spotify && data.spotify ? (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-3">
|
||||
{data.spotify.album_art_url ? (
|
||||
<Image
|
||||
src={data.spotify.album_art_url}
|
||||
alt={data.spotify.album}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-xs uppercase tracking-wider text-mint">
|
||||
{dict.listening} Spotify
|
||||
</p>
|
||||
<p className="truncate text-sm font-semibold">{data.spotify.song}</p>
|
||||
<p className="truncate text-sm text-zinc-400">
|
||||
{data.spotify.artist} — {data.spotify.album}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{games.length === 0 && !data.listening_to_spotify && !customStatus?.state ? (
|
||||
<p className="mt-4 text-sm text-zinc-500">{dict.noActivity}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LOCALES, LOCALE_LABELS, type Locale } from "@/lib/i18n/locales";
|
||||
import { setLocaleCookie } from "@/lib/i18n/set-locale-cookie";
|
||||
|
||||
export function LanguageSwitcher({ current }: { current: Locale }) {
|
||||
const router = useRouter();
|
||||
|
||||
async function switchTo(locale: Locale) {
|
||||
if (locale === current) {
|
||||
return;
|
||||
}
|
||||
await setLocaleCookie(locale);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="language"
|
||||
className="flex items-center gap-1 rounded-full border border-white/10 bg-white/5 p-1"
|
||||
>
|
||||
{LOCALES.map((locale) => (
|
||||
<button
|
||||
key={locale}
|
||||
type="button"
|
||||
onClick={() => switchTo(locale)}
|
||||
aria-pressed={locale === current}
|
||||
className={`rounded-full px-2.5 py-1 font-mono text-xs transition-colors ${
|
||||
locale === current
|
||||
? "bg-blurple text-white"
|
||||
: "text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{LOCALE_LABELS[locale]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||||
import type { Locale } from "@/lib/i18n/locales";
|
||||
import type { GiteaRepo } from "@/lib/gitea";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
const LANGUAGE_COLORS: Record<string, string> = {
|
||||
"C#": "#178600",
|
||||
TypeScript: "#3178c6",
|
||||
JavaScript: "#f1e05a",
|
||||
Python: "#3572a5",
|
||||
Rust: "#dea584",
|
||||
Go: "#00add8",
|
||||
};
|
||||
|
||||
export function ProjectsSection({
|
||||
repos,
|
||||
dict,
|
||||
locale,
|
||||
}: {
|
||||
repos: GiteaRepo[];
|
||||
dict: Dictionary["projects"];
|
||||
locale: Locale;
|
||||
}) {
|
||||
const dateFormat = new Intl.DateTimeFormat(locale, { dateStyle: "medium" });
|
||||
|
||||
return (
|
||||
<section id="projects" className="scroll-mt-24">
|
||||
<SectionHeading kicker="git.fristy.net" title={dict.title} subtitle={dict.subtitle} />
|
||||
{repos.length === 0 ? (
|
||||
<p className="rounded-2xl border border-white/10 bg-panel p-6 text-zinc-400">
|
||||
{dict.empty}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{repos.map((repo) => (
|
||||
<div
|
||||
key={repo.id}
|
||||
className="group rounded-2xl border border-white/10 bg-panel p-5 transition-colors hover:border-blurple/60"
|
||||
>
|
||||
<p className="font-mono text-sm text-blurple-soft">{repo.fullName}</p>
|
||||
<h3 className="mt-1 text-lg font-bold">
|
||||
<a
|
||||
href={repo.website ?? repo.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition-colors group-hover:text-white"
|
||||
>
|
||||
{repo.name}
|
||||
</a>
|
||||
</h3>
|
||||
<p className="mt-2 line-clamp-3 text-sm text-zinc-400">
|
||||
{repo.description ?? dict.noDescription}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-zinc-500">
|
||||
{repo.language ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
LANGUAGE_COLORS[repo.language] ?? "#5865f2",
|
||||
}}
|
||||
/>
|
||||
{repo.language}
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
★ {repo.stars} {dict.stars}
|
||||
</span>
|
||||
<span>
|
||||
⑂ {repo.forks} {dict.forks}
|
||||
</span>
|
||||
<span>
|
||||
{dict.updated}: {dateFormat.format(new Date(repo.updatedAt))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-4 text-xs font-medium">
|
||||
<span>
|
||||
{repo.website ? (
|
||||
<a
|
||||
href={repo.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blurple-soft transition-colors hover:text-white"
|
||||
>
|
||||
{dict.viewWebsite} ↗
|
||||
</a>
|
||||
) : null}
|
||||
</span>
|
||||
<a
|
||||
href={repo.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-zinc-500 transition-colors hover:text-white"
|
||||
>
|
||||
{dict.viewOnGitea} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SectionHeading({
|
||||
kicker,
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
}: {
|
||||
kicker: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<p className="font-mono text-sm text-blurple-soft">
|
||||
{"// "}
|
||||
{kicker}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-end justify-between gap-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight">{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
{subtitle ? <p className="mt-2 max-w-xl text-zinc-400">{subtitle}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||||
import { APACHE_LICENSE_URL, SITE_URL, SOURCE_URL } from "@/lib/site";
|
||||
|
||||
export function SiteFooter({ dict }: { dict: Dictionary["footer"] }) {
|
||||
const year = new Date().getFullYear();
|
||||
const licenseLink = (
|
||||
<a
|
||||
href={APACHE_LICENSE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blurple-soft transition-colors hover:text-white"
|
||||
>
|
||||
{dict.licenseLabel}
|
||||
</a>
|
||||
);
|
||||
const sourceLink = (
|
||||
<a
|
||||
href={SOURCE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blurple-soft transition-colors hover:text-white"
|
||||
>
|
||||
{dict.sourceLabel}
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<footer className="border-t border-white/5 py-8 text-center text-sm text-zinc-500">
|
||||
<p>
|
||||
© {year}{" "}
|
||||
<a href={SITE_URL} className="transition-colors hover:text-white">
|
||||
fristy.net
|
||||
</a>{" "}
|
||||
· {dict.rights}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
{dict.prefix}{" "}
|
||||
{dict.licenseFirst ? (
|
||||
<>
|
||||
{licenseLink} {dict.middle} {sourceLink}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sourceLink} {dict.middle} {licenseLink}
|
||||
</>
|
||||
)}
|
||||
{dict.suffix}.
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||||
import type { Locale } from "@/lib/i18n/locales";
|
||||
import { LanguageSwitcher } from "./language-switcher";
|
||||
|
||||
export function SiteHeader({ dict, locale }: { dict: Dictionary["nav"]; locale: Locale }) {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-white/5 bg-void/80 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 w-full max-w-4xl items-center justify-between px-5">
|
||||
<a href="#top" className="font-mono text-lg font-bold tracking-tight">
|
||||
fristy<span className="text-blurple">.net</span>
|
||||
</a>
|
||||
<nav className="hidden items-center gap-6 text-sm text-zinc-400 sm:flex">
|
||||
<a href="#discord" className="transition-colors hover:text-white">
|
||||
{dict.discord}
|
||||
</a>
|
||||
<a href="#projects" className="transition-colors hover:text-white">
|
||||
{dict.projects}
|
||||
</a>
|
||||
<a href="#contact" className="transition-colors hover:text-white">
|
||||
{dict.contact}
|
||||
</a>
|
||||
</nav>
|
||||
<LanguageSwitcher current={locale} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user