Initial commit: fristy.net personal site
No files matched your search
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
.env*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.ico binary
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
.next
|
||||
out
|
||||
build
|
||||
node_modules
|
||||
.pnpm-store
|
||||
pnpm-lock.yaml
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vs-code-eslint",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
After Width: | Height: | Size: 54 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
sharp: false
|
||||
unrs-resolver: true
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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)).*)",
|
||||
],
|
||||
};
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"]
|
||||
}
|
||||