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

+9
View File
@@ -0,0 +1,9 @@
node_modules
.next
out
.git
.gitignore
*.log
.env*
Dockerfile
.dockerignore
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.png binary
*.ico binary
+77
View File
@@ -0,0 +1,77 @@
name: Build & Push
on:
push:
branches: ["main"]
tags: ["v*"]
pull_request:
env:
REGISTRY: git.fristy.net
IMAGE_NAME: fristy/fristynet
jobs:
check:
name: Lint & Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store path
id: pnpm-store
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check formatting
run: pnpm format:check
- name: Lint
run: pnpm lint
- name: Build
run: pnpm build
image:
name: Build & Push Image
needs: check
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Resolve tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=sha,prefix=
- name: Build & push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+44
View File
@@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# pnpm store (CI workspace'e inebilir)
.pnpm-store/
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+7
View File
@@ -0,0 +1,7 @@
.next
out
build
node_modules
.pnpm-store
pnpm-lock.yaml
next-env.d.ts
+7
View File
@@ -0,0 +1,7 @@
{
"tabWidth": 4,
"useTabs": false,
"trailingComma": "all",
"printWidth": 100,
"endOfLine": "lf"
}
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vs-code-eslint",
"bradlc.vscode-tailwindcss"
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.detectIndentation": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
+25
View File
@@ -0,0 +1,25 @@
FROM node:lts-alpine AS deps
RUN npm install -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
FROM node:lts-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM node:lts-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 fristy
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+63
View File
@@ -0,0 +1,63 @@
# fristy.net
My personal corner of the internet — projects, live Discord status, and ways to reach me.
**Live:** https://fristy.net
## What's inside
- **Projects** — pulled automatically from my self-hosted Gitea and refreshed hourly. If a repo has a website, the card links there; otherwise it goes straight to the repo.
- **Discord** — live presence via Lanyard: status, game and activity artwork, Spotify, custom status. Refreshed every 15 seconds.
- **Contact & Social** — email, Discord server, GitHub, Gitea, Spotify and Steam, each with its own card.
- **Three languages** — Turkish, English and German. No path-style; your browser language is detected on the first visit, saved in a cookie, and switchable from the header anytime.
## Stack
[Next.js](https://nextjs.org/) (App Router) · [React](https://react.dev/) · [Tailwind CSS v4](https://tailwindcss.com/) · [pnpm](https://pnpm.io/) · [Docker](https://docker.com/)
## Run it locally
You need Node LTS and pnpm.
```bash
pnpm install
pnpm dev # http://localhost:3000
```
| Script | What it does |
| ------------------- | ------------------------------- |
| `pnpm dev` | Start the dev server |
| `pnpm build` | Production build |
| `pnpm start` | Serve the production build |
| `pnpm lint` | Run ESLint |
| `pnpm format` | Format everything with Prettier |
| `pnpm format:check` | Fail if anything is unformatted |
Code style is 4 spaces, enforced by Prettier (`.prettierrc`), EditorConfig and VS Code settings — `pnpm format` fixes everything.
## Docker
```bash
docker build -t fristynet .
docker run --rm -p 3000:3000 fristynet
```
No need to build it yourself though — CI publishes every `main` push:
```bash
docker pull git.fristy.net/fristy/fristynet:latest
```
## CI/CD
Pushes to `main` (and `v*` tags) run format check, lint and build first. If those pass, the Docker image is built and pushed to the Gitea registry:
- `git.fristy.net/fristy/fristynet:latest` on every `main` push
- `git.fristy.net/fristy/fristynet:<version>` on tags like `v1.2.0`
- `git.fristy.net/fristy/fristynet:<sha>` for every pushed commit
Pull requests only run the checks — nothing is pushed.
## License
Apache-2.0 — see [LICENSE](./LICENSE).
+22
View File
@@ -0,0 +1,22 @@
"use client";
export default function Error({
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="mx-auto flex w-full max-w-4xl flex-1 flex-col items-center justify-center gap-4 px-5 py-20 text-center">
<p className="font-mono text-sm text-blurple-soft">{"// 500"}</p>
<h2 className="text-2xl font-bold">Bir şeyler ters gitti / Something went wrong</h2>
<button
type="button"
onClick={reset}
className="rounded-full bg-blurple px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-blurple-soft hover:text-void"
>
Tekrar dene / Retry
</button>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
@import "tailwindcss";
@theme {
--color-void: #060913;
--color-panel: #0c1222;
--color-blurple: #5865f2;
--color-blurple-soft: #8891ff;
--color-mint: #57f287;
--font-sans: var(--font-cascadia), ui-monospace, SFMono-Regular, monospace;
--font-mono: var(--font-cascadia), ui-monospace, SFMono-Regular, monospace;
}
html {
scroll-behavior: smooth;
}
body {
background-color: var(--color-void);
color: #e8ecf6;
font-family: var(--font-sans);
background-image:
radial-gradient(60rem 30rem at 80% -10%, rgba(88, 101, 242, 0.16), transparent 60%),
radial-gradient(50rem 26rem at 10% 0%, rgba(87, 242, 135, 0.07), transparent 60%);
background-attachment: fixed;
}
::selection {
background: rgba(88, 101, 242, 0.45);
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: var(--color-void);
}
::-webkit-scrollbar-thumb {
background: #232c47;
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-blurple);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { Cascadia_Mono } from "next/font/google";
import "./globals.css";
import { getDictionary } from "@/lib/i18n/get-dictionary";
import { getLocale } from "@/lib/i18n/get-locale";
import { SITE_URL } from "@/lib/site";
const cascadiaCode = Cascadia_Mono({
variable: "--font-cascadia",
subsets: ["latin"],
});
export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale();
const dict = getDictionary(locale);
return {
metadataBase: new URL(SITE_URL),
title: {
default: dict.meta.title,
template: "%s · fristy.net",
},
description: dict.meta.description,
openGraph: {
title: dict.meta.title,
description: dict.meta.description,
url: "/",
type: "website",
},
};
}
export default async function RootLayout({ children }: LayoutProps<"/">) {
const locale = await getLocale();
return (
<html lang={locale} className={`${cascadiaCode.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
+15
View File
@@ -0,0 +1,15 @@
export default function Loading() {
return (
<div
className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 px-5 py-14"
aria-busy="true"
>
<div className="h-10 w-48 animate-pulse rounded-full bg-white/5" />
<div className="h-64 animate-pulse rounded-2xl bg-white/5" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="h-44 animate-pulse rounded-2xl bg-white/5" />
<div className="h-44 animate-pulse rounded-2xl bg-white/5" />
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { getDictionary } from "@/lib/i18n/get-dictionary";
import { getLocale } from "@/lib/i18n/get-locale";
export default async function NotFound() {
const locale = await getLocale();
const dict = getDictionary(locale);
return (
<div className="flex min-h-full flex-1 flex-col">
<SiteHeader dict={dict.nav} locale={locale} />
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col items-center justify-center px-5 py-20 text-center">
<p className="font-mono text-sm text-blurple-soft">{"// 404"}</p>
<h1 className="mt-2 bg-gradient-to-r from-white via-blurple-soft to-mint bg-clip-text text-8xl font-black tracking-tight text-transparent">
404
</h1>
<h2 className="mt-4 text-2xl font-bold">{dict.notFound.title}</h2>
<p className="mt-2 max-w-md text-zinc-400">{dict.notFound.subtitle}</p>
<Link
href="/"
className="mt-8 rounded-full bg-blurple px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-blurple-soft hover:text-void"
>
{dict.notFound.home}
</Link>
</main>
<SiteFooter dict={dict.footer} />
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { ContactSection } from "@/components/contact-section";
import { DiscordCard } from "@/components/discord-card";
import { ProjectsSection } from "@/components/projects-section";
import { SectionHeading } from "@/components/section-heading";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { getRepos } from "@/lib/gitea";
import { getDictionary } from "@/lib/i18n/get-dictionary";
import { getLocale } from "@/lib/i18n/get-locale";
import { DISCORD_ID, discordProfileUrl, getLanyard } from "@/lib/lanyard";
export default async function Home() {
const locale = await getLocale();
const dict = getDictionary(locale);
const [repos, presence] = await Promise.all([getRepos(), getLanyard()]);
return (
<div id="top" className="flex min-h-full flex-1 flex-col">
<SiteHeader dict={dict.nav} locale={locale} />
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-20 px-5 py-14">
<section className="pt-6 text-center sm:pt-10">
<p className="font-mono text-sm text-blurple-soft">{dict.hero.greeting}</p>
<h1 className="mt-2 bg-[linear-gradient(to_right,#7ee7d8,#7ccbff,#7f8cff,#b56dff,#f38ad8)] bg-clip-text text-6xl font-black tracking-tight text-transparent sm:text-7xl">
fristy
</h1>
<p className="mx-auto mt-5 max-w-xl text-zinc-400">{dict.hero.tagline}</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<a
href="#projects"
className="rounded-full bg-blurple px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-blurple-soft hover:text-void"
>
{dict.hero.ctaProjects}
</a>
<a
href="#contact"
className="rounded-full border border-white/15 px-6 py-3 text-sm text-zinc-300 transition-colors hover:border-blurple hover:text-white"
>
{dict.hero.ctaContact}
</a>
</div>
</section>
<section id="discord" className="scroll-mt-24">
<SectionHeading
kicker="lanyard"
title={dict.discord.title}
action={
<a
href={discordProfileUrl(DISCORD_ID)}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blurple-soft transition-colors hover:text-white"
>
{dict.discord.profile} →
</a>
}
/>
<DiscordCard initial={presence} dict={dict.discord} />
</section>
<ProjectsSection repos={repos} dict={dict.projects} locale={locale} />
<ContactSection dict={dict.contact} />
</main>
<SiteFooter dict={dict.footer} />
</div>
);
}
+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>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import prettierConfig from "eslint-config-prettier/flat";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
prettierConfig,
]);
export default eslintConfig;
+61
View File
@@ -0,0 +1,61 @@
const GITEA_API = "https://git.fristy.net/api/v1";
const GITEA_USER = "fristy";
const EXCLUDED_REPOS = new Set([".profile"]);
export interface GiteaRepo {
id: number;
name: string;
fullName: string;
description: string | null;
language: string | null;
htmlUrl: string;
website: string | null;
stars: number;
forks: number;
updatedAt: string;
}
interface GiteaApiRepo {
id: number;
name: string;
full_name: string;
description: string | null;
language: string | null;
html_url: string;
website: string;
stars_count: number;
forks_count: number;
updated_at: string;
}
export async function getRepos(): Promise<GiteaRepo[]> {
try {
const response = await fetch(
`${GITEA_API}/users/${GITEA_USER}/repos?limit=50&sort=updated&order=desc`,
{ next: { revalidate: 3600 } },
);
if (!response.ok) {
console.error(`cannot fetch gitea repos: ${response.status}`);
return [];
}
const data = (await response.json()) as GiteaApiRepo[];
return data
.filter((repo) => !EXCLUDED_REPOS.has(repo.name))
.map((repo) => ({
id: repo.id,
name: repo.name,
fullName: repo.full_name,
description: repo.description,
language: repo.language,
htmlUrl: repo.html_url,
website: repo.website || null,
stars: repo.stars_count,
forks: repo.forks_count,
updatedAt: repo.updated_at,
}));
} catch (error) {
console.error("cannot fetch gitea repos:", error);
return [];
}
}
+66
View File
@@ -0,0 +1,66 @@
{
"meta": {
"title": "fristy.net — fristy love you",
"description": "Fristys persönliche Ecke: Projekte, Live-Discord-Status und Kontakt."
},
"nav": {
"projects": "Projekte",
"discord": "Discord",
"contact": "Kontakt"
},
"hero": {
"greeting": "Hey, ich bin",
"tagline": "Ich programmiere als Hobby. Das ist meine persönliche Ecke: Projekte, Live-Discord-Status und Kontaktinfos.",
"ctaProjects": "Projekte Ansehen",
"ctaContact": "Kontakt"
},
"discord": {
"title": "Discord",
"live": "Live",
"online": "Online",
"idle": "Abwesend",
"dnd": "Bitte Nicht Stören",
"offline": "Offline",
"playing": "Spielt",
"listening": "Hört",
"customStatus": "Status",
"noActivity": "Gerade still... Hier läuft wohl nichts.",
"profile": "Profil Ansehen"
},
"projects": {
"title": "Projekte",
"subtitle": "Meine Open-Source-Projekte auf git.fristy.net. Die Liste aktualisiert sich automatisch.",
"updated": "Aktualisiert",
"stars": "Sterne",
"forks": "Forks",
"viewOnGitea": "Auf Gitea Öffnen",
"viewWebsite": "Webseite Öffnen",
"empty": "Noch keine öffentlichen Repos.",
"noDescription": "Keine Beschreibung."
},
"contact": {
"title": "Kontakt & Social",
"email": "E-Mail",
"discord": "Discord",
"github": "GitHub",
"gitea": "Gitea",
"spotify": "Spotify",
"steam": "Steam",
"copy": "Kopieren",
"copied": "Kopiert!"
},
"footer": {
"rights": "Alle Rechte vorbehalten",
"prefix": "Diese Seite ist",
"licenseLabel": "Apache 2.0",
"middle": "unter der",
"sourceLabel": "Open Source",
"suffix": "-Lizenz",
"licenseFirst": false
},
"notFound": {
"title": "Seite Nicht Gefunden",
"subtitle": "Hier gibt es keine Seite, aber du bist nicht verloren.",
"home": "Zurück Nach Hause"
}
}
+66
View File
@@ -0,0 +1,66 @@
{
"meta": {
"title": "fristy.net — fristy love you",
"description": "Fristy's personal corner: projects, live Discord status and contact."
},
"nav": {
"projects": "Projects",
"discord": "Discord",
"contact": "Contact"
},
"hero": {
"greeting": "Hey, I'm",
"tagline": "I write code as a hobby. This is my personal corner: my projects, live Discord status and contact info.",
"ctaProjects": "See Projects",
"ctaContact": "Contact"
},
"discord": {
"title": "Discord",
"live": "Live",
"online": "Online",
"idle": "Idle",
"dnd": "Do Not Disturb",
"offline": "Offline",
"playing": "Playing",
"listening": "Listening To",
"customStatus": "Status",
"noActivity": "Quiet right now... Nothing seems to be going on.",
"profile": "View Profile"
},
"projects": {
"title": "Projects",
"subtitle": "My open-source projects on git.fristy.net. The list updates automatically.",
"updated": "Updated",
"stars": "Stars",
"forks": "Forks",
"viewOnGitea": "Open on Gitea",
"viewWebsite": "Open Website",
"empty": "No public repos to show yet.",
"noDescription": "No description."
},
"contact": {
"title": "Contact & Social",
"email": "Email",
"discord": "Discord",
"github": "GitHub",
"gitea": "Gitea",
"spotify": "Spotify",
"steam": "Steam",
"copy": "Copy",
"copied": "Copied!"
},
"footer": {
"rights": "All rights reserved",
"prefix": "This site is",
"licenseLabel": "Apache 2.0",
"middle": "under the",
"sourceLabel": "open source",
"suffix": " license",
"licenseFirst": false
},
"notFound": {
"title": "Page Not Found",
"subtitle": "There's no page here, but you're not lost. Home is right there.",
"home": "Back Home"
}
}
+66
View File
@@ -0,0 +1,66 @@
{
"meta": {
"title": "fristy.net - fristy love you",
"description": "Fristy'nin kişisel köşesi: projeler, canlı Discord durumu ve iletişim."
},
"nav": {
"projects": "Projeler",
"discord": "Discord",
"contact": "İletişim"
},
"hero": {
"greeting": "Selam, ben",
"tagline": "Hobi olarak kod yazıyorum. Burası kişisel köşem: projelerim, anlık Discord durumum ve iletişim bilgilerim.",
"ctaProjects": "Projelere Bak",
"ctaContact": "İletişim"
},
"discord": {
"title": "Discord",
"live": "Canlı",
"online": "Çevrimiçi",
"idle": "Boşta",
"dnd": "Rahatsız Etmeyin",
"offline": "Çevrimdışı",
"playing": "Oynuyor",
"listening": "Dinliyor",
"customStatus": "Durum",
"noActivity": "Şu an sessizlik... Ortalıkta bir şey yok gibi.",
"profile": "Profile Git"
},
"projects": {
"title": "Projeler",
"subtitle": "Açık kaynak projelerim git.fristy.net üzerinde. Liste otomatik güncelleniyor.",
"updated": "Güncellendi",
"stars": "Yıldız",
"forks": "Çatal",
"viewOnGitea": "Gitea'da Aç",
"viewWebsite": "Web Sitesini Aç",
"empty": "Henüz gösterilecek public repo yok.",
"noDescription": "Açıklama yok."
},
"contact": {
"title": "İletişim ve Sosyal",
"email": "E-Posta",
"discord": "Discord",
"github": "GitHub",
"gitea": "Gitea",
"spotify": "Spotify",
"steam": "Steam",
"copy": "Kopyala",
"copied": "Kopyalandı!"
},
"footer": {
"rights": "Tüm Hakları Saklıdır",
"prefix": "Bu site",
"licenseLabel": "Apache 2.0",
"middle": "lisansı altında",
"sourceLabel": "açık kaynak",
"suffix": " kodludur",
"licenseFirst": true
},
"notFound": {
"title": "Sayfa Bulunamadı",
"subtitle": "Böyle bir sayfa yok, ama kaybolmadın. Ana sayfa hemen şurada.",
"home": "Ana Sayfaya Dön"
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { Locale } from "./locales";
import tr from "./dictionaries/tr.json";
import en from "./dictionaries/en.json";
import de from "./dictionaries/de.json";
const dictionaries = { tr, en, de } as const;
export type Dictionary = typeof tr;
export function getDictionary(locale: Locale): Dictionary {
return dictionaries[locale] ?? dictionaries.tr;
}
+11
View File
@@ -0,0 +1,11 @@
import { cookies } from "next/headers";
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale, type Locale } from "./locales";
export async function getLocale(): Promise<Locale> {
const store = await cookies();
const raw = store.get(LOCALE_COOKIE)?.value;
if (raw && isLocale(raw)) {
return raw;
}
return DEFAULT_LOCALE;
}
+48
View File
@@ -0,0 +1,48 @@
export const LOCALES = ["tr", "en", "de"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "tr";
export const LOCALE_COOKIE = "locale";
export const LOCALE_LABELS: Record<Locale, string> = {
tr: "TR",
en: "EN",
de: "DE",
};
export function isLocale(value: string): value is Locale {
return (LOCALES as readonly string[]).includes(value);
}
export function matchLocale(acceptLanguage: string | null): Locale {
if (!acceptLanguage) {
return DEFAULT_LOCALE;
}
const preferences = acceptLanguage
.split(",")
.map((part) => {
const [range, ...params] = part.trim().split(";");
let q = 1;
for (const param of params) {
const match = param.trim().match(/^q=([0-9.]+)$/);
if (match) {
q = Number(match[1]);
}
}
return { range: range.toLowerCase(), q };
})
.sort((a, b) => b.q - a.q);
for (const { range } of preferences) {
if (range === "*") {
return DEFAULT_LOCALE;
}
const base = range.split("-")[0];
if (isLocale(base)) {
return base;
}
}
return DEFAULT_LOCALE;
}
+16
View File
@@ -0,0 +1,16 @@
"use server";
import { cookies } from "next/headers";
import { LOCALE_COOKIE, isLocale, type Locale } from "./locales";
export async function setLocaleCookie(locale: Locale): Promise<void> {
if (!isLocale(locale)) {
return;
}
const store = await cookies();
store.set(LOCALE_COOKIE, locale, {
path: "/",
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
});
}
+108
View File
@@ -0,0 +1,108 @@
export const DISCORD_ID = "1014549805608992901";
const LANYARD_API = `https://api.lanyard.rest/v1/users/${DISCORD_ID}`;
export type DiscordStatus = "online" | "idle" | "dnd" | "offline";
export interface LanyardEmoji {
name: string;
id?: string;
animated?: boolean;
}
export interface LanyardActivityAssets {
large_image?: string;
large_text?: string;
small_image?: string;
small_text?: string;
}
export interface LanyardActivity {
id: string;
name: string;
type: number;
state?: string | null;
details?: string | null;
emoji?: LanyardEmoji | null;
timestamps?: { start?: number; end?: number } | null;
application_id?: string | null;
assets?: LanyardActivityAssets | null;
}
export function activityImageUrl(activity: LanyardActivity, small: boolean): string | null {
const key = small ? activity.assets?.small_image : activity.assets?.large_image;
if (!key) {
return null;
}
if (key.startsWith("mp:")) {
return `https://media.discordapp.net/external/${key.slice(3)}`;
}
if (key.startsWith("spotify:")) {
return `https://i.scdn.co/image/${key.slice("spotify:".length)}`;
}
if (activity.application_id) {
return `https://cdn.discordapp.com/app-assets/${activity.application_id}/${key}.png`;
}
return null;
}
export interface LanyardSpotify {
track_id: string;
song: string;
artist: string;
album: string;
album_art_url: string | null;
}
export interface LanyardData {
discord_user: {
id: string;
username: string;
global_name: string | null;
display_name: string | null;
discriminator: string;
avatar: string | null;
bot: boolean;
};
activities: LanyardActivity[];
discord_status: DiscordStatus;
active_on_discord_desktop: boolean;
active_on_discord_mobile: boolean;
active_on_discord_web: boolean;
listening_to_spotify: boolean;
spotify: LanyardSpotify | null;
}
interface LanyardResponse {
success: boolean;
data: LanyardData;
}
export async function getLanyard(): Promise<LanyardData | null> {
try {
const response = await fetch(LANYARD_API, { next: { revalidate: 10 } });
if (!response.ok) {
return null;
}
const json = (await response.json()) as LanyardResponse;
return json.success ? json.data : null;
} catch {
return null;
}
}
export function lanyardUrl(): string {
return LANYARD_API;
}
export function discordAvatarUrl(userId: string, avatarHash: string | null, size = 128): string {
if (!avatarHash) {
return `https://cdn.discordapp.com/embed/avatars/0.png?size=${size}`;
}
const extension = avatarHash.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/avatars/${userId}/${avatarHash}.${extension}?size=${size}`;
}
export function discordProfileUrl(userId: string): string {
return `https://discord.com/users/${userId}`;
}
+9
View File
@@ -0,0 +1,9 @@
export const SITE_URL = "https://fristy.net";
export const CONTACT_EMAIL = "iletisim@fristy.net";
export const GITEA_PROFILE_URL = "https://git.fristy.net/fristy";
export const SOURCE_URL = "https://git.fristy.net/fristy/fristynet";
export const GITHUB_URL = "https://github.com/fristyxd";
export const DISCORD_SERVER_URL = "https://discord.gg/fristy";
export const SPOTIFY_URL = "https://open.spotify.com/user/gayiw5j9i0rtvyra6gfzhpfwf";
export const STEAM_URL = "https://steamcommunity.com/id/fristylikeaillusion";
export const APACHE_LICENSE_URL = "https://www.apache.org/licenses/LICENSE-2.0";
+15
View File
@@ -0,0 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
remotePatterns: [
{ protocol: "https", hostname: "cdn.discordapp.com" },
{ protocol: "https", hostname: "media.discordapp.net" },
{ protocol: "https", hostname: "git.fristy.net" },
{ protocol: "https", hostname: "i.scdn.co" },
],
},
};
export default nextConfig;
+31
View File
@@ -0,0 +1,31 @@
{
"name": "fristynet",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.5.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"eslint": "^9.39.5",
"eslint-config-next": "16.3.4",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.6",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
},
"packageManager": "pnpm@11.2.2"
}
+4207
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
sharp: false
unrs-resolver: true
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { LOCALE_COOKIE, matchLocale } from "./lib/i18n/locales";
export function proxy(request: NextRequest) {
if (request.cookies.has(LOCALE_COOKIE)) {
return NextResponse.next();
}
const locale = matchLocale(request.headers.get("accept-language"));
const response = NextResponse.next();
response.cookies.set(LOCALE_COOKIE, locale, {
path: "/",
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
});
return response;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)).*)",
],
};
+1
View File
@@ -0,0 +1 @@
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Gitea</title><path d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 837 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Gmail</title><path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z"/></svg>

After

Width:  |  Height:  |  Size: 354 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Spotify</title><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>

After

Width:  |  Height:  |  Size: 712 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#ffffff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Steam</title><path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}