Initial commit: fristy.net personal site
Build & Push / Lint & Build (push) Successful in 1m15s
Build & Push / Build & Push Image (push) Successful in 52s

This commit is contained in:
fristy committed 2026-09-10 04:59:12 +03:00
commit ad943fff8c
51 files changed
+6052

No files matched your search

+87
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+202
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}
+50
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}