rename terminal theme to web, make it the default, drop the mobile home screen, and rebuild the web shell as a light/dark glassmorphism layout
This commit is contained in:
parent
32d622f80e
commit
d947ecfb1a
21 changed files with 1707 additions and 2978 deletions
62
README.md
62
README.md
|
|
@ -2,27 +2,35 @@
|
||||||
|
|
||||||
> The source code for my personal website, **[dvorakmortonxd.dev](https://dvorakmortonxd.dev)**.
|
> The source code for my personal website, **[dvorakmortonxd.dev](https://dvorakmortonxd.dev)**.
|
||||||
|
|
||||||
This is not a normal portfolio page. It ships **two fully interactive shells** and picks
|
This is not a normal portfolio page. It ships **two shells** and picks which one to show
|
||||||
which one to show based on the saved theme and the device, all from the same content:
|
from the saved theme, all from the same content:
|
||||||
|
|
||||||
- **`skeuo`** (default) — a KDE Plasma / Windows-style **desktop simulation**: draggable
|
- **`web`** (default) — a light, frosted-glass **website**: a sticky sidebar (section nav,
|
||||||
snap-to-grid icons, resizable/floating windows, a Start menu, taskbar and live clock.
|
light/dark switch, desktop switch) beside a column of glass cards — hero, quick tiles,
|
||||||
On phones it becomes an **iOS-style home screen** with a widget grid, dock, drag-to-reorder
|
live services, activity heatmap, projects, hardware, features and friends. Light by
|
||||||
and an iOS launch animation.
|
default with a persisted dark mode, and fully responsive, so phones and desktops get the
|
||||||
- **`terminal`** — a **Gruvbox TUI** version of the site: TUI panels, an animated ASCII
|
same shell.
|
||||||
train background, a clickable easter-egg logo, and a working in-browser terminal (Alacritty)
|
- **`skeuo`** — an alternate KDE Plasma / Windows-style **desktop simulation** (desktop
|
||||||
with commands like `help`, `ls`, `cat`, `neofetch`, `emerge`, `ssh` and more.
|
only): draggable snap-to-grid icons, resizable/floating windows, a Start menu, taskbar and
|
||||||
|
live clock. Reachable from the web theme's sidebar switch.
|
||||||
|
|
||||||
Both shells surface the same personal content: socials, self-hosted services with **live
|
Both shells surface the same personal content: socials, self-hosted services with **live
|
||||||
up/down health checks**, a hardware collection, coding projects, a **GitDXD contribution
|
up/down health checks**, a hardware collection, coding projects, a **GitDXD contribution
|
||||||
heatmap**, a gym SBD panel and links to friends' sites.
|
heatmap**, a gym SBD panel and links to friends' sites. The desktop shell additionally embeds
|
||||||
|
an animated ASCII train background, a clickable easter-egg logo and a working in-browser
|
||||||
|
terminal (Alacritty) with commands like `help`, `ls`, `cat`, `neofetch`, `emerge`, `ssh` and
|
||||||
|
more.
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Two themes / shells** — KDE-style desktop and Gruvbox terminal, toggled from either UI
|
- **Two themes / shells** — a default frosted-glass **web** shell and an alternate KDE-style
|
||||||
and persisted in `localStorage` (applied pre-paint to avoid a flash).
|
**desktop** shell, toggled from either UI and persisted in `localStorage` (applied pre-paint
|
||||||
- **Phone vs. desktop detection** — a user-agent sniff on `<html data-device>` decides whether
|
to avoid a flash). Phones always get the web shell.
|
||||||
the desktop or the mobile home screen is shown.
|
- **Light / dark mode** — the web shell has a light/dark switch (light by default, remembered
|
||||||
|
in `localStorage`, applied pre-paint). The desktop shell keeps its own Breeze Dark look.
|
||||||
|
- **Glassmorphic web layout** — a sticky sidebar with section navigation beside a column of
|
||||||
|
frosted-glass panels (subtle gradients + `backdrop-filter` blur), reorganised into
|
||||||
|
hero / quick-tile / section cards.
|
||||||
- **Draggable desktop icons** — free placement that snaps to a measured grid, one icon per
|
- **Draggable desktop icons** — free placement that snaps to a measured grid, one icon per
|
||||||
cell, with a follow-the-cursor drag ghost. Icons shrink on cramped viewports so they never
|
cell, with a follow-the-cursor drag ghost. Icons shrink on cramped viewports so they never
|
||||||
collide.
|
collide.
|
||||||
|
|
@ -58,13 +66,13 @@ heatmap**, a gym SBD panel and links to friends' sites.
|
||||||
|
|
||||||
- **Framework:** Next.js 16 (App Router, Turbopack)
|
- **Framework:** Next.js 16 (App Router, Turbopack)
|
||||||
- **Language:** TypeScript, React 19 (client components everywhere interactivity lives)
|
- **Language:** TypeScript, React 19 (client components everywhere interactivity lives)
|
||||||
- **Styling:** hand-written CSS — `src/app/page.css` (terminal theme), `src/app/skeuo.css`
|
- **Styling:** hand-written CSS — `src/app/web.css` (web theme: frosted glass + light/dark),
|
||||||
(desktop + mobile), `src/app/globals.css` (base/reset). Tailwind v4 is imported for the
|
`src/app/skeuo.css` (desktop theme), `src/app/globals.css` (base/reset). Tailwind v4 is
|
||||||
base layer but the UI is custom CSS.
|
imported for the base layer but the UI is custom CSS.
|
||||||
- **Fonts:** Monocraft (self-hosted in `public/`, used by the terminal theme; the desktop
|
- **Fonts:** the web theme uses the system sans stack; Monocraft (self-hosted in `public/`) is
|
||||||
theme uses the system sans stack)
|
used by the desktop theme's SMP menu and the in-desktop terminal.
|
||||||
- **Images:** `next/image`
|
- **Images:** `next/image`
|
||||||
- **Version:** `2.1.0` (see `package.json`)
|
- **Version:** `2.2.0` (see `package.json`)
|
||||||
|
|
||||||
## 🚀 Running Locally
|
## 🚀 Running Locally
|
||||||
|
|
||||||
|
|
@ -167,8 +175,8 @@ src/app/
|
||||||
page.tsx → renders <ThemeRoot/>, imports the two theme stylesheets
|
page.tsx → renders <ThemeRoot/>, imports the two theme stylesheets
|
||||||
layout.tsx → html shell + metadata, injects ThemeScript
|
layout.tsx → html shell + metadata, injects ThemeScript
|
||||||
globals.css → base/reset styles + Tailwind import
|
globals.css → base/reset styles + Tailwind import
|
||||||
page.css → terminal theme styles
|
web.css → web theme styles (frosted glass, light + dark)
|
||||||
skeuo.css → desktop + mobile home styles
|
skeuo.css → desktop theme styles (KDE/Plasma shell)
|
||||||
data.ts → socials, codingProjects, techProjects, friends, wallpapers
|
data.ts → socials, codingProjects, techProjects, friends, wallpapers
|
||||||
revival-inc/page.tsx → embeds the bundled game at /public/revival-inc/index.html
|
revival-inc/page.tsx → embeds the bundled game at /public/revival-inc/index.html
|
||||||
api/
|
api/
|
||||||
|
|
@ -176,21 +184,19 @@ src/app/
|
||||||
activities/route.ts → GitDXD activity feed proxy
|
activities/route.ts → GitDXD activity feed proxy
|
||||||
ping/route.ts → service health check (HTTP + Minecraft)
|
ping/route.ts → service health check (HTTP + Minecraft)
|
||||||
components/
|
components/
|
||||||
ThemeRoot.tsx → picks which shell(s) to mount
|
ThemeRoot.tsx → picks which shell to mount (web or desktop)
|
||||||
ThemeScript.tsx → pre-paint script: theme + device detection
|
ThemeScript.tsx → pre-paint script: theme + light/dark scheme
|
||||||
useTheme.ts → theme state via useSyncExternalStore
|
useTheme.ts → theme state via useSyncExternalStore
|
||||||
TerminalLayout.tsx → the Gruvbox terminal shell
|
useColorScheme.ts → light/dark state via useSyncExternalStore
|
||||||
|
WebLayout.tsx → default web shell: sidebar + glass content
|
||||||
WindowsDesktop.tsx → desktop shell: icons, windows, taskbar, Start menu
|
WindowsDesktop.tsx → desktop shell: icons, windows, taskbar, Start menu
|
||||||
MobileHome.tsx → phone shell: iOS home screen + app view
|
|
||||||
WinWindow.tsx → one draggable/resizable window
|
WinWindow.tsx → one draggable/resizable window
|
||||||
apps.tsx → APPS registry, window contents, in-app terminal
|
apps.tsx → APPS registry, window contents, in-app terminal
|
||||||
useDesktopIcons.ts → desktop icon grid + drag/drop logic
|
useDesktopIcons.ts → desktop icon grid + drag/drop logic
|
||||||
useDragOrder.ts → list reordering (mobile grid/dock)
|
|
||||||
ServiceButton.tsx → service link + live status dot
|
ServiceButton.tsx → service link + live status dot
|
||||||
ContributionHeatmap.tsx → heatmap + recent commits widget
|
ContributionHeatmap.tsx → heatmap + recent commits widget
|
||||||
Clock.tsx → live clock
|
Clock.tsx → live clock
|
||||||
JokerLogo.tsx → logo with click easter egg
|
JokerLogo.tsx → logo with click easter egg
|
||||||
SlBackground.tsx → animated ASCII train background
|
|
||||||
scripts/
|
scripts/
|
||||||
dev-server.mjs → custom dev server: live updates without the dev-only debug surface
|
dev-server.mjs → custom dev server: live updates without the dev-only debug surface
|
||||||
deploy/
|
deploy/
|
||||||
|
|
|
||||||
91
agents.md
91
agents.md
|
|
@ -34,15 +34,16 @@ When in doubt, prefer updating. A stale README **or** agents.md is treated as a
|
||||||
## What this project is
|
## What this project is
|
||||||
|
|
||||||
Personal portfolio site for **dvorakmortonxd.dev**. It's not a normal website —
|
Personal portfolio site for **dvorakmortonxd.dev**. It's not a normal website —
|
||||||
it ships **two fully interactive desktop/OS simulations** plus a terminal, and
|
it ships **two shells** and renders whichever the saved theme selects: a default
|
||||||
picks which one to show based on theme and device.
|
frosted-glass **web** site, and an alternate KDE/Plasma **desktop simulation**
|
||||||
|
(desktop-only). Phones always get the web shell.
|
||||||
|
|
||||||
- Framework: **Next.js 16** (App Router, Turbopack), **React 19**, TypeScript.
|
- Framework: **Next.js 16** (App Router, Turbopack), **React 19**, TypeScript.
|
||||||
- Styling: plain CSS files (`page.css`, `skeuo.css`), no CSS-in-JS, no Tailwind
|
- Styling: plain CSS files (`web.css`, `skeuo.css`), no CSS-in-JS, no Tailwind
|
||||||
utilities in the components (Tailwind is present via PostCSS but the look is
|
utilities in the components (Tailwind is present via PostCSS but the look is
|
||||||
hand-written CSS).
|
hand-written CSS).
|
||||||
- Path alias: `@/*` → `src/*`.
|
- Path alias: `@/*` → `src/*`.
|
||||||
- Version: `2.1.0` (see `package.json`).
|
- Version: `2.2.0` (see `package.json`).
|
||||||
|
|
||||||
### Scripts
|
### Scripts
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -73,12 +74,12 @@ token.** Do not print it, copy it into new files, or push it anywhere else.
|
||||||
|
|
||||||
```
|
```
|
||||||
src/app/
|
src/app/
|
||||||
page.tsx → renders <ThemeRoot/>, imports page.css + skeuo.css
|
page.tsx → renders <ThemeRoot/>, imports web.css + skeuo.css
|
||||||
layout.tsx → html shell, metadata, ThemeScript
|
layout.tsx → html shell, metadata, ThemeScript
|
||||||
globals.css → base/reset styles + Tailwind import
|
globals.css → base/reset styles + Tailwind import
|
||||||
data.ts → socials, codingProjects, techProjects, friends, wallpapers
|
data.ts → socials, codingProjects, techProjects, friends, wallpapers
|
||||||
page.css → base / terminal theme styles
|
web.css → web theme: frosted glass, light + dark tokens
|
||||||
skeuo.css → Windows/Plasma desktop + mobile home styles
|
skeuo.css → Windows/Plasma desktop styles (desktop theme only)
|
||||||
revival-inc/page.tsx → fullscreen iframe embedding /public/revival-inc/index.html
|
revival-inc/page.tsx → fullscreen iframe embedding /public/revival-inc/index.html
|
||||||
api/
|
api/
|
||||||
heatmap/route.ts → proxy for git.dvorakmortonxd.dev contribution heatmap
|
heatmap/route.ts → proxy for git.dvorakmortonxd.dev contribution heatmap
|
||||||
|
|
@ -86,38 +87,62 @@ src/app/
|
||||||
ping/route.ts → service health check (HTTP + minecraft:// via mcstatus.io); host allowlist
|
ping/route.ts → service health check (HTTP + minecraft:// via mcstatus.io); host allowlist
|
||||||
frame/route.ts → header-stripping proxy so an unframeable friend site loads in a window; host allowlist
|
frame/route.ts → header-stripping proxy so an unframeable friend site loads in a window; host allowlist
|
||||||
components/
|
components/
|
||||||
ThemeRoot.tsx → picks which shell to show (the router of the UI)
|
ThemeRoot.tsx → picks which shell to mount (the router of the UI)
|
||||||
TerminalLayout.tsx → "terminal" theme (Gruvbox)
|
WebLayout.tsx → "web" theme (default): sticky sidebar + glass content
|
||||||
WindowsDesktop.tsx → "skeuo" theme, desktop: icons, windows, taskbar
|
WindowsDesktop.tsx → "skeuo" theme, desktop: icons, windows, taskbar
|
||||||
MobileHome.tsx → "skeuo" theme, phone: app grid + dock
|
|
||||||
WinWindow.tsx → a single draggable/resizable window
|
WinWindow.tsx → a single draggable/resizable window
|
||||||
apps.tsx → APPS registry + the content of each app + in-app terminal
|
apps.tsx → APPS registry + the content of each app + in-app terminal
|
||||||
+ embedded-site windows + the Minecraft-style SMP menu
|
+ embedded-site windows + the Minecraft-style SMP menu
|
||||||
useDesktopIcons.ts → desktop icon grid + drag/drop (THE KEY FILE, see below)
|
useDesktopIcons.ts → desktop icon grid + drag/drop (THE KEY FILE, see below)
|
||||||
useDragOrder.ts → simple list reordering (mobile home screen only)
|
useTheme.ts → theme state (web | skeuo) via useSyncExternalStore
|
||||||
useTheme.ts → theme state via useSyncExternalStore
|
useColorScheme.ts → light/dark scheme state via useSyncExternalStore
|
||||||
ThemeScript.tsx → inline pre-hydration script: theme + device detection
|
ThemeScript.tsx → inline pre-hydration script: theme + scheme
|
||||||
ServiceButton.tsx → service link + live status dot (pings /api/ping)
|
ServiceButton.tsx → service link + live status dot (pings /api/ping)
|
||||||
ContributionHeatmap.tsx → heatmap + recent-commit widget (uses the two proxies)
|
ContributionHeatmap.tsx → heatmap + recent-commit widget (uses the two proxies)
|
||||||
Clock.tsx → live clock (useSyncExternalStore + interval)
|
Clock.tsx → live clock (useSyncExternalStore + interval)
|
||||||
JokerLogo.tsx → site logo with a click easter egg
|
JokerLogo.tsx → site logo with a click easter egg
|
||||||
SlBackground.tsx → animated ASCII train background (terminal theme)
|
|
||||||
scripts/
|
scripts/
|
||||||
dev-server.mjs → custom dev server: live updates minus the dev-only debug surface
|
dev-server.mjs → custom dev server: live updates minus the dev-only debug surface
|
||||||
```
|
```
|
||||||
|
|
||||||
### How UI selection works
|
### How UI selection works
|
||||||
1. `ThemeScript` runs before paint. It reads `localStorage.theme` and sets
|
1. `ThemeScript` runs before paint. It reads `localStorage.theme` and sets
|
||||||
`data-theme` (`skeuo` | `terminal`) and `data-device` (`phone` | `desktop`)
|
`data-theme` (`web` | `skeuo`) on `<html>`, defaulting to `web`; it also reads
|
||||||
on `<html>` from a user-agent sniff.
|
`localStorage.scheme` and sets `data-scheme` (`light` | `dark`), defaulting to
|
||||||
2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via
|
`light`. There is no device sniff anymore.
|
||||||
`useSyncExternalStore` and dispatches a `theme-change` event on toggle.
|
2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via `useSyncExternalStore`
|
||||||
3. `ThemeRoot` shows the terminal when `theme === "terminal"`; otherwise it
|
and dispatches a `theme-change` event on toggle. `useColorScheme()` does the
|
||||||
mounts **both** `WindowsDesktop` and `MobileHome` and lets CSS
|
same for `data-scheme` via a `scheme-change` event.
|
||||||
(`html[data-device="phone"]`) decide which is visible. The desktop stays
|
3. `ThemeRoot` mounts **one** shell at a time: `WebLayout` unless the theme is
|
||||||
mounted while hidden so the Welcome window isn't recreated.
|
`skeuo`, in which case it mounts `WindowsDesktop`. It is deliberately *not*
|
||||||
|
kept mounted while hidden — the desktop icon grid is measured from real
|
||||||
|
rendered boxes, so a `display:none` desktop would measure zero-sized cells and
|
||||||
|
break its grid when shown again.
|
||||||
|
|
||||||
Themes: `skeuo` = KDE Plasma / Windows desktop, `terminal` = Gruvbox terminal.
|
Themes: `web` = default frosted-glass website (sidebar + content, light/dark),
|
||||||
|
`skeuo` = KDE Plasma desktop simulation (desktop-only; the web sidebar's
|
||||||
|
"Desktop" control switches to it, and it has a "Web" control in its tray).
|
||||||
|
|
||||||
|
### Web theme + light/dark (v2.2.0 rework)
|
||||||
|
- `WebLayout.tsx` is the default shell. Layout is a **sticky sidebar** (brand,
|
||||||
|
section nav, light/dark switch, desktop switch, contact/clock) beside a single
|
||||||
|
content column of `.glass` panels (hero, quick tiles, services, activity,
|
||||||
|
projects, hardware, features, friends). The old stacked TUI panel layout and
|
||||||
|
the `SlBackground` ASCII train were removed with it.
|
||||||
|
- Glassmorphism lives in one utility class, `.glass`, in `web.css`: a subtle
|
||||||
|
`linear-gradient` over `--glass-hi`/`--glass-lo` with
|
||||||
|
`backdrop-filter: blur(20px) saturate(165%)`. The page blobs behind the panels
|
||||||
|
are `.web-bg` (a fixed radial-gradient layer at `z-index: 0`; `.web-root` sits
|
||||||
|
at `z-index: 1`).
|
||||||
|
- Colours are tokenised in `web.css` under `:root` (light) and
|
||||||
|
`html[data-theme="web"][data-scheme="dark"]` (dark). The shared token names
|
||||||
|
(`--fg`, `--gray`, `--accent`, `--green`, …) are reused so `ServiceButton` and
|
||||||
|
`ContributionHeatmap` need no changes. `skeuo.css` overrides the same names
|
||||||
|
while the desktop theme is active, so the two themes don't bleed into each
|
||||||
|
other.
|
||||||
|
- The desktop switch (`.web-ctl-desktop`) is hidden on phones: under the
|
||||||
|
`max-width: 960px` breakpoint and under `(hover: none) and (pointer: coarse)`.
|
||||||
|
There is no device sniff anymore — phones just get the responsive web shell.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -180,9 +205,8 @@ Guard (already in place, keep it that way):
|
||||||
`e.target` is always the button.
|
`e.target` is always the button.
|
||||||
|
|
||||||
### Related
|
### Related
|
||||||
`useDragOrder.ts` is a **different, simpler** hook used only by `MobileHome` for
|
Nothing else consumes the desktop grid. `apps.tsx` is the only other place that
|
||||||
reordering the phone app grid/dock (drag one item over another to swap). It is
|
knows about icon ids, via `APP_BY_ID`.
|
||||||
list reordering, not free placement. Don't confuse the two.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -216,11 +240,12 @@ list reordering, not free placement. Don't confuse the two.
|
||||||
|
|
||||||
- Components are client components (`"use client"` at the top) — they use
|
- Components are client components (`"use client"` at the top) — they use
|
||||||
pointer events and window APIs freely.
|
pointer events and window APIs freely.
|
||||||
- CSS classes are prefixed by area: `win-` (desktop), `mobile-` (phone),
|
- CSS classes are prefixed by area: `web-` (web theme), `glass` (the shared glass
|
||||||
`app-` / `kickoff` / `terminal` etc. Grep by prefix to find styles.
|
panel utility), `win-` (desktop), `app-` / `kickoff` etc. Grep by prefix to
|
||||||
- Several places deliberately use **imperative DOM work** (drag ghost,
|
find styles.
|
||||||
transform-origin for the mobile launch animation). This is intentional for
|
- Several places deliberately use **imperative DOM work** (the desktop drag
|
||||||
performance; prefer matching the existing pattern over "Reactifying" it.
|
ghost). This is intentional for performance; prefer matching the existing
|
||||||
|
pattern over "Reactifying" it.
|
||||||
- `setState` inside an effect is blocked by the ESLint config
|
- `setState` inside an effect is blocked by the ESLint config
|
||||||
(`react-hooks/set-state-in-effect`). The one intentional exception is the
|
(`react-hooks/set-state-in-effect`). The one intentional exception is the
|
||||||
grid init in `useDesktopIcons.ts`, which carries a scoped
|
grid init in `useDesktopIcons.ts`, which carries a scoped
|
||||||
|
|
@ -326,6 +351,10 @@ list reordering, not free placement. Don't confuse the two.
|
||||||
## Testing notes
|
## Testing notes
|
||||||
- There is **no test suite**. Verification is `tsc --noEmit`, `eslint`, and
|
- There is **no test suite**. Verification is `tsc --noEmit`, `eslint`, and
|
||||||
`next build`.
|
`next build`.
|
||||||
|
- The default shell is the web theme: `curl -s http://localhost:3000/` should
|
||||||
|
contain `data-theme="web"` / `data-scheme="light"` and the `.web-root` markup.
|
||||||
|
Theme + scheme are read pre-paint from `localStorage.theme` / `.scheme`, so a
|
||||||
|
quick check is to set those in devtools and reload.
|
||||||
- A dev server may already be running on port 3000 — on this host it is normally
|
- A dev server may already be running on port 3000 — on this host it is normally
|
||||||
the **system** `dvorakmortonxd-dev` service, so check
|
the **system** `dvorakmortonxd-dev` service, so check
|
||||||
`systemctl status dvorakmortonxd-dev` first. Two dev servers on this repo
|
`systemctl status dvorakmortonxd-dev` first. Two dev servers on this repo
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "dvorakmortonxd",
|
"name": "dvorakmortonxd",
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dvorakmortonxd",
|
"name": "dvorakmortonxd",
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^16.3.5",
|
"next": "^16.3.5",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dvorakmortonxd",
|
"name": "dvorakmortonxd",
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev-server.mjs",
|
"dev": "node scripts/dev-server.mjs",
|
||||||
|
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useSyncExternalStore,
|
|
||||||
type CSSProperties,
|
|
||||||
type MouseEvent,
|
|
||||||
} from "react";
|
|
||||||
import { APPS, APP_BY_ID } from "./apps";
|
|
||||||
import Clock from "./Clock";
|
|
||||||
import { useDragOrder } from "./useDragOrder";
|
|
||||||
import { MOBILE_WALLPAPER } from "../data";
|
|
||||||
|
|
||||||
const DOCK_IDS = ["projects", "services", "socials", "contributions"];
|
|
||||||
|
|
||||||
function subscribe(callback: () => void) {
|
|
||||||
const id = window.setInterval(callback, 1000);
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Digital iOS-style clock widget: big time, small day/date underneath. */
|
|
||||||
function HomeWidget() {
|
|
||||||
const now = useSyncExternalStore(
|
|
||||||
subscribe,
|
|
||||||
() => {
|
|
||||||
const d = new Date();
|
|
||||||
return [
|
|
||||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
|
||||||
d.toLocaleDateString([], { weekday: "long", day: "numeric", month: "short" }),
|
|
||||||
].join("\n");
|
|
||||||
},
|
|
||||||
() => "\n"
|
|
||||||
);
|
|
||||||
const [time, day] = now.split("\n");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mobile-widget">
|
|
||||||
<div className="mobile-widget-time" suppressHydrationWarning>
|
|
||||||
{time}
|
|
||||||
</div>
|
|
||||||
<div className="mobile-widget-date" suppressHydrationWarning>
|
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LaunchOrigin {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
scale: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MobileHome() {
|
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
|
||||||
const [origin, setOrigin] = useState<LaunchOrigin | null>(null);
|
|
||||||
const [closing, setClosing] = useState(false);
|
|
||||||
const openApp = openId ? APP_BY_ID[openId] : null;
|
|
||||||
const closeTimer = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const [gridApps, setGridApps] = useState(APPS);
|
|
||||||
const [dockApps, setDockApps] = useState(() => DOCK_IDS.map((id) => ({ id })));
|
|
||||||
const gridDrag = useDragOrder(setGridApps);
|
|
||||||
const dockDrag = useDragOrder(setDockApps);
|
|
||||||
|
|
||||||
const clearCloseTimer = () => {
|
|
||||||
if (closeTimer.current !== null) {
|
|
||||||
window.clearTimeout(closeTimer.current);
|
|
||||||
closeTimer.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fallback for when the close animation never reports completion (e.g.
|
|
||||||
// reduced-motion, throttled tabs or a missed animationend). Without this the
|
|
||||||
// full-screen app view can linger and swallow every tap on the home screen.
|
|
||||||
const closeApp = () => {
|
|
||||||
setClosing(true);
|
|
||||||
clearCloseTimer();
|
|
||||||
closeTimer.current = window.setTimeout(() => {
|
|
||||||
setOpenId(null);
|
|
||||||
setClosing(false);
|
|
||||||
closeTimer.current = null;
|
|
||||||
}, 600);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => () => clearCloseTimer(), []);
|
|
||||||
|
|
||||||
// Compute a transform-origin so the scaled-down app view sits exactly on top
|
|
||||||
// of the tapped icon, then let it grow to fill the screen (iOS style).
|
|
||||||
const launch = (appId: string, e: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
const icon = rect.width > 0 ? rect.width : rect.height;
|
|
||||||
// Guard against a zero/degenerate measured icon (e.g. hidden layout) so the
|
|
||||||
// scale tween never collapses to 0 and the open animation can always run.
|
|
||||||
const scale = icon > 0 ? Math.min(icon / window.innerWidth, 0.95) : 0.2;
|
|
||||||
const cx = rect.left + rect.width / 2;
|
|
||||||
const cy = rect.top + rect.height / 2;
|
|
||||||
const denom = 1 - scale || 1;
|
|
||||||
setOrigin({
|
|
||||||
x: (cx - (scale * window.innerWidth) / 2) / denom,
|
|
||||||
y: (cy - (scale * window.innerHeight) / 2) / denom,
|
|
||||||
scale,
|
|
||||||
});
|
|
||||||
setClosing(false);
|
|
||||||
setOpenId(appId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderIcon = (appId: string, inDock: boolean, drag: ReturnType<typeof useDragOrder>) => {
|
|
||||||
const app = APP_BY_ID[appId];
|
|
||||||
if (!app) return null;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={app.id}
|
|
||||||
type="button"
|
|
||||||
data-drag-id={app.id}
|
|
||||||
className={
|
|
||||||
"mobile-app" +
|
|
||||||
(inDock ? " in-dock" : "") +
|
|
||||||
(drag.dragId === app.id ? " dragging" : "")
|
|
||||||
}
|
|
||||||
onPointerDown={drag.onPointerDown(app.id)}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (drag.consumeDragged()) return;
|
|
||||||
launch(app.id, e);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="mobile-app-icon">
|
|
||||||
<span className="mobile-app-glyph">{app.icon}</span>
|
|
||||||
</span>
|
|
||||||
{!inDock && <span className="mobile-app-label">{app.title}</span>}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// The transform origin is exposed as a CSS variable (not an inline
|
|
||||||
// transform-origin) so only the opening keyframe uses the tapped icon as its
|
|
||||||
// pivot. The closing animation keeps a centred origin and always slides down.
|
|
||||||
const viewStyle = origin
|
|
||||||
? ({
|
|
||||||
"--app-origin": `${origin.x}px ${origin.y}px`,
|
|
||||||
"--app-scale": String(origin.scale),
|
|
||||||
} as CSSProperties)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mobile-root">
|
|
||||||
<div
|
|
||||||
className="mobile-home"
|
|
||||||
style={{ backgroundImage: `url('${MOBILE_WALLPAPER}')` }}
|
|
||||||
aria-hidden={openApp ? true : undefined}
|
|
||||||
>
|
|
||||||
<div className="mobile-statusbar">
|
|
||||||
<span className="mobile-status-time">
|
|
||||||
<Clock />
|
|
||||||
</span>
|
|
||||||
<span className="mobile-status-icons" aria-hidden="true">
|
|
||||||
<svg width="17" height="11" viewBox="0 0 17 11" fill="currentColor">
|
|
||||||
<rect x="0" y="7" width="3" height="4" rx="1" />
|
|
||||||
<rect x="4.5" y="5" width="3" height="6" rx="1" />
|
|
||||||
<rect x="9" y="2.5" width="3" height="8.5" rx="1" />
|
|
||||||
<rect x="13.5" y="0" width="3" height="11" rx="1" />
|
|
||||||
</svg>
|
|
||||||
<svg width="16" height="11" viewBox="0 0 16 11" fill="currentColor">
|
|
||||||
<path d="M8 2.2c2.3 0 4.4.9 6 2.4l-1.2 1.3A6.9 6.9 0 0 0 8 4.2a6.9 6.9 0 0 0-4.8 1.7L2 4.6A8.5 8.5 0 0 1 8 2.2Z" />
|
|
||||||
<path d="M8 5.6c1.5 0 2.8.6 3.8 1.6l-1.2 1.3A3.7 3.7 0 0 0 8 7.6c-1 0-1.9.4-2.6 1l-1.2-1.4A5.7 5.7 0 0 1 8 5.6Z" />
|
|
||||||
<path d="M8 9a1.4 1.4 0 1 1 0 2.8A1.4 1.4 0 0 1 8 9Z" />
|
|
||||||
</svg>
|
|
||||||
<svg width="25" height="12" viewBox="0 0 25 12" aria-hidden="true">
|
|
||||||
<rect
|
|
||||||
x="0.5"
|
|
||||||
y="0.5"
|
|
||||||
width="21"
|
|
||||||
height="11"
|
|
||||||
rx="3.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
opacity="0.5"
|
|
||||||
/>
|
|
||||||
<rect x="2" y="2" width="14" height="8" rx="2" fill="currentColor" />
|
|
||||||
<path d="M23 4v4a2 2 0 0 0 0-4Z" fill="currentColor" opacity="0.5" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mobile-grid">
|
|
||||||
<HomeWidget />
|
|
||||||
{gridApps.map((app) => renderIcon(app.id, false, gridDrag))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mobile-dock">
|
|
||||||
{dockApps.map((d) => renderIcon(d.id, true, dockDrag))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mobile-pagedots" aria-hidden="true">
|
|
||||||
<span className="active" />
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{openApp && (
|
|
||||||
<div
|
|
||||||
className={"mobile-app-view" + (closing ? " closing" : " opening")}
|
|
||||||
style={viewStyle}
|
|
||||||
onAnimationEnd={(e) => {
|
|
||||||
if (e.target !== e.currentTarget) return;
|
|
||||||
if (closing) {
|
|
||||||
clearCloseTimer();
|
|
||||||
setOpenId(null);
|
|
||||||
setClosing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<header className="mobile-appbar">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mobile-back"
|
|
||||||
onClick={closeApp}
|
|
||||||
aria-label="Back"
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
<span className="mobile-appbar-title">{openApp.title}</span>
|
|
||||||
</header>
|
|
||||||
<div className="mobile-app-body app-surface">{openApp.content}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
const TRAIN_ASCII = ` ==== ________ ___________
|
|
||||||
_D _| |_______/ \\__I_I_____===__|_________|
|
|
||||||
|(_)--- | H\\________/ | | =|___ ___| _________________ _________________ _________________ _________________ _________________ _________________
|
|
||||||
/ | | H | | | | ||_| |_|| _| \\_____| \\_____| \\_____| \\_____| \\_____| \\_____A
|
|
||||||
| | | H |__--------------------| [___] | =| | | | | | |
|
|
||||||
| ________|___H__/__|_____/[][]~\\_______| | -| | | | | | |
|
|
||||||
|/ | |-----------I_____I [][] [] D |=======|____|________________________|_______________________|_______________________|_______________________|_______________________|_______________________|_
|
|
||||||
__/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__|__________________________|_______________________|_______________________|_______________________|_______________________|_______________________|
|
|
||||||
|/-=|___|= || || || |_____/~\\___/ |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_|
|
|
||||||
\\_/ \\O=====O=====O=====O_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/`;
|
|
||||||
|
|
||||||
export default function SlBackground() {
|
|
||||||
const [pos, setPos] = useState(150);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let animationFrameId: number;
|
|
||||||
let lastTime = 0;
|
|
||||||
|
|
||||||
const animate = (timestamp: number) => {
|
|
||||||
if (!lastTime) lastTime = timestamp;
|
|
||||||
const delta = timestamp - lastTime;
|
|
||||||
|
|
||||||
// Update position every ~30ms to create a retro sliding terminal refresh feel
|
|
||||||
if (delta > 45) {
|
|
||||||
setPos((prev) => {
|
|
||||||
if (prev <= -150) return 150;
|
|
||||||
return prev - 1.5;
|
|
||||||
});
|
|
||||||
lastTime = timestamp;
|
|
||||||
}
|
|
||||||
animationFrameId = requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
animationFrameId = requestAnimationFrame(animate);
|
|
||||||
return () => cancelAnimationFrame(animationFrameId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<style>
|
|
||||||
{`
|
|
||||||
@keyframes rainbowShift {
|
|
||||||
0% { background-position: 0% 50%; }
|
|
||||||
50% { background-position: 100% 50%; }
|
|
||||||
100% { background-position: 0% 50%; }
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
<div
|
|
||||||
className="sl-background"
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
pointerEvents: "none",
|
|
||||||
zIndex: 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "flex-end",
|
|
||||||
paddingBottom: "5vh",
|
|
||||||
justifyContent: "center",
|
|
||||||
userSelect: "none",
|
|
||||||
backgroundImage: "linear-gradient(rgba(29, 32, 33, 0.94), rgba(29, 32, 33, 0.94)), url('/gruvbox.webp')",
|
|
||||||
backgroundSize: "cover",
|
|
||||||
backgroundPosition: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<pre
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
whiteSpace: "pre",
|
|
||||||
fontFamily: "var(--font-custom-mono), monospace",
|
|
||||||
fontSize: "1rem",
|
|
||||||
transform: "translateX(" + pos + "vw)",
|
|
||||||
willChange: "transform",
|
|
||||||
background: "linear-gradient(90deg, var(--red), var(--accent2), var(--accent), var(--green), var(--aqua), var(--blue), var(--purple))",
|
|
||||||
backgroundSize: "200% auto",
|
|
||||||
WebkitBackgroundClip: "text",
|
|
||||||
WebkitTextFillColor: "transparent",
|
|
||||||
animation: "rainbowShift 3s linear infinite",
|
|
||||||
opacity: 0.35,
|
|
||||||
filter: "drop-shadow(0 0 12px rgba(250, 189, 47, 0.5))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{TRAIN_ASCII}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,603 +0,0 @@
|
||||||
import Image from "next/image";
|
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import SlBackground from "./SlBackground";
|
|
||||||
import JokerLogo from "./JokerLogo";
|
|
||||||
import ServiceButton from "./ServiceButton";
|
|
||||||
import ContributionHeatmap from "./ContributionHeatmap";
|
|
||||||
import { useTheme } from "./useTheme";
|
|
||||||
import { socials, codingProjects, techProjects, friends } from "../data";
|
|
||||||
|
|
||||||
type PanelStyle = CSSProperties & { "--panel-color"?: string };
|
|
||||||
|
|
||||||
const panelStyle = (color: string, style: CSSProperties = {}): PanelStyle => ({
|
|
||||||
...style,
|
|
||||||
"--panel-color": color,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function TerminalLayout() {
|
|
||||||
const { toggle } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SlBackground />
|
|
||||||
<div className="container">
|
|
||||||
<header className="header">
|
|
||||||
<div className="header-center">
|
|
||||||
<JokerLogo />
|
|
||||||
<h1 className="site-name">dvorakmortonxd.dev</h1>
|
|
||||||
<button type="button" className="header-theme-btn" onClick={toggle}>
|
|
||||||
Desktop
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="hero">
|
|
||||||
<div className="hero-panel" style={panelStyle("var(--accent)")}>
|
|
||||||
<span
|
|
||||||
className="hero-panel-label"
|
|
||||||
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
README.MD
|
|
||||||
</span>
|
|
||||||
<h2 className="hero-title">$ whoami</h2>
|
|
||||||
<p className="hero-subtitle">
|
|
||||||
Welcome my personal website! Here I show off my projects and hardware I own,
|
|
||||||
aswell as my self-hosted services :)<span className="cursor-blink"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="social-section">
|
|
||||||
<div className="tui-panel" style={panelStyle("var(--accent)")}>
|
|
||||||
<span
|
|
||||||
className="tui-panel-label"
|
|
||||||
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
SOCIALS.JS
|
|
||||||
</span>
|
|
||||||
<div className="social-buttons">
|
|
||||||
{socials.map((s) => (
|
|
||||||
<a
|
|
||||||
key={s.name}
|
|
||||||
href={s.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="social-button"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={s.icon}
|
|
||||||
alt={s.name}
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className="social-icon"
|
|
||||||
/>
|
|
||||||
<span className="social-label">{s.name}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mail-contact">
|
|
||||||
mail if u ever wanna contact me:{" "}
|
|
||||||
<a href="mailto:dvorak@dvorakmortonxd.dev">dvorak@dvorakmortonxd.dev</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="gym-section">
|
|
||||||
<div className="tui-panel" style={panelStyle("var(--white)")}>
|
|
||||||
<span
|
|
||||||
className="tui-panel-label"
|
|
||||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
|
||||||
>
|
|
||||||
GYM_SBD_AT_73KG_BW.DB
|
|
||||||
</span>
|
|
||||||
<div className="gym-grid">
|
|
||||||
<div className="gym-item">
|
|
||||||
<Image src="/squat.svg" alt="Squat" width={48} height={48} className="gym-icon" />
|
|
||||||
<div className="gym-label">SQUAT</div>
|
|
||||||
<div className="gym-weight">125kg</div>
|
|
||||||
<div className="gym-date">5.1.2025</div>
|
|
||||||
</div>
|
|
||||||
<div className="gym-item">
|
|
||||||
<Image src="/bench.svg" alt="Bench" width={48} height={48} className="gym-icon" />
|
|
||||||
<div className="gym-label">BENCH</div>
|
|
||||||
<div className="gym-weight">111kg</div>
|
|
||||||
<div className="gym-date">29.5.2026</div>
|
|
||||||
</div>
|
|
||||||
<div className="gym-item">
|
|
||||||
<Image
|
|
||||||
src="/deadlift.svg"
|
|
||||||
alt="Deadlift"
|
|
||||||
width={48}
|
|
||||||
height={48}
|
|
||||||
className="gym-icon"
|
|
||||||
/>
|
|
||||||
<div className="gym-label">DEADLIFT</div>
|
|
||||||
<div className="gym-weight">165kg</div>
|
|
||||||
<div className="gym-date">17.12.2024</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="about-section">
|
|
||||||
<div className="tui-panel" style={panelStyle("var(--white)")}>
|
|
||||||
<span
|
|
||||||
className="tui-panel-label"
|
|
||||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
|
||||||
>
|
|
||||||
NOW.TXT
|
|
||||||
</span>
|
|
||||||
<div className="about-content">
|
|
||||||
<p>
|
|
||||||
<span style={{ color: "var(--green)" }}>martin@dvorakmortonxd</span>:
|
|
||||||
<span className="text-blue">~</span>$ cat now.txt
|
|
||||||
</p>
|
|
||||||
<div style={{ color: "#a3a3a3" }}>
|
|
||||||
<p>
|
|
||||||
I'm currently hosting an SMP (dm me on discord). Also I'm pretty
|
|
||||||
much cooking daily, because im bulking through out the non-summer seasons.
|
|
||||||
And me and my pal <a href="https://skopec.dev">Pepa</a> are working on an
|
|
||||||
education app so we can study more efficiently, It's called{" "}
|
|
||||||
<a href="https://git.dvorakmortonxd.dev/edu/maturita.c">maturita.c</a>!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="about-section">
|
|
||||||
<div className="tui-panel" style={panelStyle("var(--accent)")}>
|
|
||||||
<span
|
|
||||||
className="tui-panel-label"
|
|
||||||
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
README.MD
|
|
||||||
</span>
|
|
||||||
<div className="about-content">
|
|
||||||
<p>
|
|
||||||
<span className="text-green">martin@dvorakmortonxd</span>:
|
|
||||||
<span className="text-blue">~</span>$ cat about_me.md
|
|
||||||
</p>
|
|
||||||
<div style={{ color: "#a3a3a3" }}>
|
|
||||||
<p>
|
|
||||||
Hey, I'm Martin Dvořák — a 17(soon to be 18)-year-old IT student based
|
|
||||||
in the Czech Republic, specializing in network management and
|
|
||||||
administration at <a href="https://sssvt.cz">sssvt.cz</a>. I'm a
|
|
||||||
sophomore and I have wanted to go to this school since 7th grade (2022) so
|
|
||||||
I'm happy I got in in 2025, the school is pretty cool so if you are in
|
|
||||||
middle school and interested in IT, make sure to check it out. I'm a
|
|
||||||
homelabber and developer heavily focused on self-hosting{" "}
|
|
||||||
<i>literally everything</i>, writing code (for example games for gamejams),
|
|
||||||
tinkering with GNU/Linux or Musl/Linux systems and retro hardware. I own a
|
|
||||||
small collection of laptops ranging from 2009 to 2014 (and ofcourse my 2025
|
|
||||||
macbook), most of these found in ewaste. In my free time I mostly spend
|
|
||||||
time with my gf {"<"}3, I also spend a bit of time setting up my self
|
|
||||||
hosted stuff (like jellyfin or gitdxd), and also when I'm in my gaming
|
|
||||||
mood I play terraria (around 1300hr playtime btw) and minecraft modpacks
|
|
||||||
with mekanism and/or create. I also go to the gym around 3-5 times a week,
|
|
||||||
running a PPL split focused on strength/powerlifting and especially SBD. I
|
|
||||||
take a big focus on my health and physique.
|
|
||||||
<span className="cursor-blink"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="services-section">
|
|
||||||
<div className="tui-panel" style={panelStyle("var(--accent)")}>
|
|
||||||
<span
|
|
||||||
className="tui-panel-label"
|
|
||||||
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
SERVICES.HTML
|
|
||||||
</span>
|
|
||||||
<div className="services-buttons services-main">
|
|
||||||
<ServiceButton
|
|
||||||
url="https://proxmox.dvorakmortonxd.dev/"
|
|
||||||
className="proxmox"
|
|
||||||
icon="🖥️"
|
|
||||||
name="Proxmox"
|
|
||||||
desc="Server Management"
|
|
||||||
access="private"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<ServiceButton
|
|
||||||
url="https://ai.dvorakmortonxd.dev/"
|
|
||||||
className="ai"
|
|
||||||
icon="🦾"
|
|
||||||
name="Llama.cpp"
|
|
||||||
desc="Local AI Server"
|
|
||||||
access="on_request"
|
|
||||||
/>
|
|
||||||
<ServiceButton
|
|
||||||
url="https://jelly.dvorakmortonxd.dev/"
|
|
||||||
className="jellyfin"
|
|
||||||
icon="🎬"
|
|
||||||
name="Jellyfin"
|
|
||||||
desc="Local Media Server"
|
|
||||||
access="on_request"
|
|
||||||
/>
|
|
||||||
<ServiceButton
|
|
||||||
url="https://discord.gg/vyvhk7d8bX"
|
|
||||||
checkUrl="minecraft://mc.dvorakmortonxd.dev:25565"
|
|
||||||
className="pterodactyl"
|
|
||||||
icon="🎮"
|
|
||||||
name="SMP"
|
|
||||||
desc="My Survival Minecraft Server"
|
|
||||||
access="on_request"
|
|
||||||
/>
|
|
||||||
<ServiceButton
|
|
||||||
url="https://navidrome.dvorakmortonxd.dev/"
|
|
||||||
className="funkwhale"
|
|
||||||
icon="🎸"
|
|
||||||
name="Navidrome"
|
|
||||||
desc="Music Streaming"
|
|
||||||
access="private"
|
|
||||||
/>
|
|
||||||
<ServiceButton
|
|
||||||
url="https://git.dvorakmortonxd.dev"
|
|
||||||
className="gitdxd"
|
|
||||||
icon="🐙"
|
|
||||||
name="GitDXD"
|
|
||||||
desc="Best Git Hosting OAT"
|
|
||||||
access="public"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="gitdxd-section" style={{ marginTop: "20px" }}>
|
|
||||||
<div className="tui-panel" style={panelStyle("var(--white)")}>
|
|
||||||
<span
|
|
||||||
className="tui-panel-label"
|
|
||||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
|
||||||
>
|
|
||||||
GITDXD.GIT
|
|
||||||
</span>
|
|
||||||
<div style={{ padding: "24px 16px 16px" }}>
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
fontSize: "2.4rem",
|
|
||||||
fontWeight: "bold",
|
|
||||||
color: "var(--white)",
|
|
||||||
marginBottom: "16px",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
GitDXD
|
|
||||||
</h2>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "1.8rem",
|
|
||||||
color: "var(--white)",
|
|
||||||
fontWeight: "500",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
My Self-hosted Git Instance
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: "16px",
|
|
||||||
marginBottom: "16px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
overflow: "hidden",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.4)",
|
|
||||||
boxShadow: "0 0 30px rgba(180, 255, 125, 0.15)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/gitdxd.webp"
|
|
||||||
alt="GitDXD Preview"
|
|
||||||
width={1280}
|
|
||||||
height={720}
|
|
||||||
style={{ width: "100%", height: "auto", display: "block" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
color: "var(--gray)",
|
|
||||||
marginTop: "12px",
|
|
||||||
lineHeight: "1.6",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
My very own forgejo git instance, originally started as a private git
|
|
||||||
instance for me and my friends to boycott GitHub but I've made it
|
|
||||||
public now so. Join at{" "}
|
|
||||||
<span className="text-purple-500">
|
|
||||||
<a href="https://git.dvorakmortonxd.dev">git.dvorakmortonxd.dev</a>
|
|
||||||
</span>
|
|
||||||
.<span className="cursor-blink"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="commits-section" style={{ marginTop: "20px" }}>
|
|
||||||
<div className="tui-panel" style={panelStyle("var(--accent)")}>
|
|
||||||
<span
|
|
||||||
className="tui-panel-label"
|
|
||||||
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
CONTRIBUTIONS@GITDXD.GIT
|
|
||||||
</span>
|
|
||||||
<div className="commits-content" style={{ padding: "24px 12px 16px" }}>
|
|
||||||
<ContributionHeatmap />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="revival-section" style={{ marginTop: "20px" }}>
|
|
||||||
<div className="tui-panel" style={panelStyle("var(--white)")}>
|
|
||||||
<span
|
|
||||||
className="tui-panel-label"
|
|
||||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
|
||||||
>
|
|
||||||
REVIVAL.INC
|
|
||||||
</span>
|
|
||||||
<div style={{ padding: "24px 16px 16px" }}>
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
fontSize: "2.4rem",
|
|
||||||
fontWeight: "bold",
|
|
||||||
color: "var(--white)",
|
|
||||||
marginBottom: "32px",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Revival inc.
|
|
||||||
</h2>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: "12px",
|
|
||||||
marginBottom: "12px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
overflow: "hidden",
|
|
||||||
border: "1px solid rgba(0, 0, 0, 0.4)",
|
|
||||||
boxShadow: "0 0 30px rgba(255, 255, 255, 0.35)",
|
|
||||||
width: "80%",
|
|
||||||
margin: "12px auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/revivalinc.webp"
|
|
||||||
alt="revival inc thumbnail"
|
|
||||||
width={768}
|
|
||||||
height={576}
|
|
||||||
style={{ width: "100%", height: "auto", display: "block" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "1.8rem",
|
|
||||||
color: "var(--white)",
|
|
||||||
fontWeight: "500",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
What is it?
|
|
||||||
</p>
|
|
||||||
<div style={{ color: "var(--gray)", marginTop: "12px", lineHeight: "1.6" }}>
|
|
||||||
<p>
|
|
||||||
It's a game I made for the game jam "Mini Jame Gam #55", the
|
|
||||||
theme was Necromancy and the special item was Milk, the game also includes
|
|
||||||
a tutorial so if you wanna try it do it. Check it out at{" "}
|
|
||||||
<span className="text-purple-500">
|
|
||||||
<a href="https://martin-dv.itch.io/revival-inc">itch.io</a>
|
|
||||||
</span>{" "}
|
|
||||||
or play{" "}
|
|
||||||
<span className="text-purple-500">
|
|
||||||
<a href="https://dvorakmortonxd.dev/revival-inc">here</a>
|
|
||||||
</span>
|
|
||||||
.<span className="cursor-blink"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rustywolf-section" style={{ marginTop: "20px" }}>
|
|
||||||
<div className="tui-panel" style={panelStyle("var(--white)")}>
|
|
||||||
<span
|
|
||||||
className="tui-panel-label"
|
|
||||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
|
||||||
>
|
|
||||||
RUSTYWOLF.RS
|
|
||||||
</span>
|
|
||||||
<div style={{ padding: "24px 16px 16px" }}>
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
fontSize: "2.4rem",
|
|
||||||
fontWeight: "bold",
|
|
||||||
color: "var(--white)",
|
|
||||||
marginBottom: "32px",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Introducing RustyWolf
|
|
||||||
</h2>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: "12px",
|
|
||||||
marginBottom: "12px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
overflow: "hidden",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.4)",
|
|
||||||
boxShadow: "0 0 30px rgba(255, 255, 255, 0.2)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/RustyWolfPreview.webp"
|
|
||||||
alt="RustyWolf Browser Preview"
|
|
||||||
width={1280}
|
|
||||||
height={720}
|
|
||||||
style={{ width: "100%", height: "auto", display: "block" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "1.8rem",
|
|
||||||
color: "var(--white)",
|
|
||||||
fontWeight: "500",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
What makes it so cool???
|
|
||||||
</p>
|
|
||||||
<div style={{ color: "var(--gray)", marginTop: "12px", lineHeight: "1.6" }}>
|
|
||||||
<p>
|
|
||||||
A lightweight browser made in Rust with WebKitGTK. It's the successor
|
|
||||||
to my previous project Venturesome, aiming to provide a fast and minimalist
|
|
||||||
browsing experience. It's a perfect browser for old machines that
|
|
||||||
struggle with modern browsers. Check it out at{" "}
|
|
||||||
<span style={{ color: "var(--accent2)" }}>
|
|
||||||
<a href="https://git.dvorakmortonxd.dev/dvorakmortonxd/RustyWolf">
|
|
||||||
GitDXD
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
.<span className="cursor-blink"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<section className="projects">
|
|
||||||
<div className="subsection-wrapper">
|
|
||||||
<div className="subsection-panel" style={panelStyle("var(--white)")}>
|
|
||||||
<span
|
|
||||||
className="subsection-panel-label"
|
|
||||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
|
||||||
>
|
|
||||||
TECH_LIST.JSON
|
|
||||||
</span>
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
fontSize: "2rem",
|
|
||||||
fontWeight: "bold",
|
|
||||||
textAlign: "center",
|
|
||||||
margin: "4px 0 20px",
|
|
||||||
color: "var(--white)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
All my hardware equipment :)
|
|
||||||
</h2>
|
|
||||||
<div className="project-grid">
|
|
||||||
{techProjects.map((project) => (
|
|
||||||
<div key={project.title} className="project-card">
|
|
||||||
<div
|
|
||||||
className="project-image"
|
|
||||||
style={{ backgroundImage: `url(${project.image})` }}
|
|
||||||
/>
|
|
||||||
<h4 className="project-title">{project.title}</h4>
|
|
||||||
<p className="project-desc">{project.desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="subsection-wrapper">
|
|
||||||
<div className="subsection-panel coding-panel" style={panelStyle("var(--accent)")}>
|
|
||||||
<span
|
|
||||||
className="subsection-panel-label"
|
|
||||||
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
PROJECTS.DOCX
|
|
||||||
</span>
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
fontSize: "2rem",
|
|
||||||
fontWeight: "bold",
|
|
||||||
textAlign: "center",
|
|
||||||
margin: "4px 0 20px",
|
|
||||||
color: "var(--accent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
All the stuff I've worked on :))
|
|
||||||
</h2>
|
|
||||||
<div className="project-grid">
|
|
||||||
{codingProjects.map((project, idx) => (
|
|
||||||
<a
|
|
||||||
key={idx}
|
|
||||||
href={project.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="project-card"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="project-image"
|
|
||||||
style={{ backgroundImage: `url(${project.img})` }}
|
|
||||||
/>
|
|
||||||
<h4 className="project-title">{project.title}</h4>
|
|
||||||
<p className="project-desc">{project.desc}</p>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="subsection-wrapper">
|
|
||||||
<div
|
|
||||||
className="subsection-panel friends-panel"
|
|
||||||
style={panelStyle("var(--white)", { backgroundColor: "rgba(255, 255, 255, 0.02)" })}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="subsection-panel-label"
|
|
||||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
|
||||||
>
|
|
||||||
FRIENDS.TSX
|
|
||||||
</span>
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
fontSize: "2rem",
|
|
||||||
fontWeight: "bold",
|
|
||||||
textAlign: "center",
|
|
||||||
margin: "4px 0 20px",
|
|
||||||
color: "var(--white)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Friend's websites and projects
|
|
||||||
</h2>
|
|
||||||
<div className="services-buttons" style={{ gap: "1rem" }}>
|
|
||||||
{friends.map((friend, idx) => (
|
|
||||||
<ServiceButton
|
|
||||||
key={idx}
|
|
||||||
url={friend.realUrl || friend.url}
|
|
||||||
className="friend"
|
|
||||||
icon={
|
|
||||||
<Image
|
|
||||||
src={friend.img}
|
|
||||||
alt={friend.name}
|
|
||||||
width={68}
|
|
||||||
height={68}
|
|
||||||
className="friend-icon"
|
|
||||||
style={{
|
|
||||||
borderRadius: "50%",
|
|
||||||
filter:
|
|
||||||
"brightness(0) invert(65%) hue-rotate(70deg) saturate(100%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
name={friend.name}
|
|
||||||
desc={friend.desc}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer className="footer">
|
|
||||||
<span className="footer-label">v2.1.0</span>
|
|
||||||
© 2025-2026 Martin Dvořák. All rights reserved (not really I dont care, just follow
|
|
||||||
the GPL3.0 License)
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTheme } from "./useTheme";
|
import { useTheme } from "./useTheme";
|
||||||
import TerminalLayout from "./TerminalLayout";
|
|
||||||
import WindowsDesktop from "./WindowsDesktop";
|
import WindowsDesktop from "./WindowsDesktop";
|
||||||
import MobileHome from "./MobileHome";
|
import WebLayout from "./WebLayout";
|
||||||
|
|
||||||
export default function ThemeRoot() {
|
export default function ThemeRoot() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const terminal = theme === "terminal";
|
|
||||||
|
|
||||||
// The desktop is kept mounted (just hidden) while the Gruvbox/terminal theme
|
// The desktop shell is mounted lazily, only while it is selected. Its icon
|
||||||
// is active. That way the Welcome window is only created on a fresh page
|
// grid is measured from real rendered boxes, so hiding it with `display:none`
|
||||||
// load and isn't reopened by toggling themes.
|
// would measure zero-sized cells and break the grid when it comes back.
|
||||||
return (
|
// Phones never see the desktop shell at all — they always get the web theme.
|
||||||
<>
|
if (theme === "skeuo") return <WindowsDesktop />;
|
||||||
<div
|
return <WebLayout />;
|
||||||
className="skeuo-desktop-root"
|
|
||||||
style={terminal ? { display: "none" } : undefined}
|
|
||||||
>
|
|
||||||
<WindowsDesktop />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="skeuo-mobile-root"
|
|
||||||
style={terminal ? { display: "none" } : undefined}
|
|
||||||
>
|
|
||||||
<MobileHome />
|
|
||||||
</div>
|
|
||||||
{terminal && <TerminalLayout />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { useServerInsertedHTML } from "next/navigation";
|
import { useServerInsertedHTML } from "next/navigation";
|
||||||
|
|
||||||
// Runs before paint so the saved theme and the phone/desktop split are applied
|
// Runs before paint so the saved theme (web/skeuo) and the light/dark scheme
|
||||||
// without a flash. It is injected with useServerInsertedHTML so the <script>
|
// are applied without a flash. It is injected with useServerInsertedHTML so the
|
||||||
// lives in the SSR HTML stream instead of inside the React tree — React 19
|
// <script> lives in the SSR HTML stream instead of inside the React tree —
|
||||||
// logs an error for script tags rendered by components on the client.
|
// React 19 logs an error for script tags rendered by components on the client.
|
||||||
const INIT_SCRIPT = `(function(){try{var t=localStorage.getItem('theme');if(t==='terminal'||t==='skeuo'){document.documentElement.setAttribute('data-theme',t);}var ua=navigator.userAgent||'';var phone=/(iPhone|iPod|Android.+Mobile|Windows Phone|IEMobile|BlackBerry|Opera Mini|Mobile)/i.test(ua);document.documentElement.setAttribute('data-device',phone?'phone':'desktop');}catch(e){}})();`;
|
// Anything unrecognised falls back to the defaults: web + light.
|
||||||
|
const INIT_SCRIPT = `(function(){try{var t=localStorage.getItem('theme');document.documentElement.setAttribute('data-theme',t==='skeuo'?'skeuo':'web');var s=localStorage.getItem('scheme');document.documentElement.setAttribute('data-scheme',s==='dark'?'dark':'light');}catch(e){}})();`;
|
||||||
|
|
||||||
export default function ThemeScript() {
|
export default function ThemeScript() {
|
||||||
// useServerInsertedHTML can fire several times during a single render pass;
|
// useServerInsertedHTML can fire several times during a single render pass;
|
||||||
|
|
|
||||||
386
src/app/components/WebLayout.tsx
Normal file
386
src/app/components/WebLayout.tsx
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import JokerLogo from "./JokerLogo";
|
||||||
|
import ServiceButton from "./ServiceButton";
|
||||||
|
import ContributionHeatmap from "./ContributionHeatmap";
|
||||||
|
import Clock from "./Clock";
|
||||||
|
import { useTheme } from "./useTheme";
|
||||||
|
import { useColorScheme } from "./useColorScheme";
|
||||||
|
import { socials, codingProjects, techProjects, friends } from "../data";
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ id: "overview", label: "Overview" },
|
||||||
|
{ id: "services", label: "Services" },
|
||||||
|
{ id: "activity", label: "Activity" },
|
||||||
|
{ id: "projects", label: "Projects" },
|
||||||
|
{ id: "hardware", label: "Hardware" },
|
||||||
|
{ id: "friends", label: "Friends" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
id,
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section id={id} className={"web-section glass" + (className ? ` ${className}` : "")}>
|
||||||
|
<header className="web-section-head">
|
||||||
|
<span className="web-eyebrow">{eyebrow}</span>
|
||||||
|
<h2 className="web-section-title">{title}</h2>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Socials() {
|
||||||
|
return (
|
||||||
|
<div className="social-buttons">
|
||||||
|
{socials.map((s) => (
|
||||||
|
<a
|
||||||
|
key={s.name}
|
||||||
|
href={s.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="social-button"
|
||||||
|
>
|
||||||
|
<Image src={s.icon} alt={s.name} width={16} height={16} className="social-icon" />
|
||||||
|
<span className="social-label">{s.name}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Services() {
|
||||||
|
return (
|
||||||
|
<div className="services-buttons services-main">
|
||||||
|
<ServiceButton
|
||||||
|
url="https://proxmox.dvorakmortonxd.dev/"
|
||||||
|
className="proxmox"
|
||||||
|
icon="🖥️"
|
||||||
|
name="Proxmox"
|
||||||
|
desc="Server Management"
|
||||||
|
access="private"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<ServiceButton
|
||||||
|
url="https://ai.dvorakmortonxd.dev/"
|
||||||
|
className="ai"
|
||||||
|
icon="🦾"
|
||||||
|
name="Llama.cpp"
|
||||||
|
desc="Local AI Server"
|
||||||
|
access="on_request"
|
||||||
|
/>
|
||||||
|
<ServiceButton
|
||||||
|
url="https://jelly.dvorakmortonxd.dev/"
|
||||||
|
className="jellyfin"
|
||||||
|
icon="🎬"
|
||||||
|
name="Jellyfin"
|
||||||
|
desc="Local Media Server"
|
||||||
|
access="on_request"
|
||||||
|
/>
|
||||||
|
<ServiceButton
|
||||||
|
url="https://discord.gg/vyvhk7d8bX"
|
||||||
|
checkUrl="minecraft://mc.dvorakmortonxd.dev:25565"
|
||||||
|
className="pterodactyl"
|
||||||
|
icon="🎮"
|
||||||
|
name="SMP"
|
||||||
|
desc="My Survival Minecraft Server"
|
||||||
|
access="on_request"
|
||||||
|
/>
|
||||||
|
<ServiceButton
|
||||||
|
url="https://navidrome.dvorakmortonxd.dev/"
|
||||||
|
className="funkwhale"
|
||||||
|
icon="🎸"
|
||||||
|
name="Navidrome"
|
||||||
|
desc="Music Streaming"
|
||||||
|
access="private"
|
||||||
|
/>
|
||||||
|
<ServiceButton
|
||||||
|
url="https://git.dvorakmortonxd.dev"
|
||||||
|
className="gitdxd"
|
||||||
|
icon="🐙"
|
||||||
|
name="GitDXD"
|
||||||
|
desc="Best Git Hosting OAT"
|
||||||
|
access="public"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Gym() {
|
||||||
|
const lifts = [
|
||||||
|
{ icon: "/squat.svg", label: "SQUAT", weight: "125kg", date: "5.1.2025" },
|
||||||
|
{ icon: "/bench.svg", label: "BENCH", weight: "111kg", date: "29.5.2026" },
|
||||||
|
{ icon: "/deadlift.svg", label: "DEADLIFT", weight: "165kg", date: "17.12.2024" },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="gym-grid">
|
||||||
|
{lifts.map((l) => (
|
||||||
|
<div key={l.label} className="gym-item">
|
||||||
|
<Image src={l.icon} alt={l.label} width={48} height={48} className="gym-icon" />
|
||||||
|
<div className="gym-label">{l.label}</div>
|
||||||
|
<div className="gym-weight">{l.weight}</div>
|
||||||
|
<div className="gym-date">{l.date}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatureShot({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="web-shot">
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
style={{ width: "100%", height: "auto", display: "block" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WebLayout() {
|
||||||
|
const { toggle } = useTheme();
|
||||||
|
const { scheme, toggle: toggleScheme } = useColorScheme();
|
||||||
|
const dark = scheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="web-bg" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div className="web-root">
|
||||||
|
<aside className="web-sidebar glass">
|
||||||
|
<div className="web-brand">
|
||||||
|
<JokerLogo />
|
||||||
|
<div className="web-brand-text">
|
||||||
|
<span className="web-brand-name">dvorakmortonxd.dev</span>
|
||||||
|
<span className="web-brand-tag">I use gentoo btw</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="web-nav" aria-label="Sections">
|
||||||
|
{NAV.map((n) => (
|
||||||
|
<a key={n.id} href={`#${n.id}`}>
|
||||||
|
{n.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="web-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="web-ctl"
|
||||||
|
onClick={toggleScheme}
|
||||||
|
aria-pressed={dark}
|
||||||
|
title="Toggle light / dark mode"
|
||||||
|
>
|
||||||
|
<span className="web-ctl-icon" aria-hidden="true">
|
||||||
|
{dark ? "☾" : "☀"}
|
||||||
|
</span>
|
||||||
|
<span className="web-ctl-label">{dark ? "Dark" : "Light"}</span>
|
||||||
|
<span className={"web-switch" + (dark ? " on" : "")} aria-hidden="true">
|
||||||
|
<span className="web-switch-knob" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="web-ctl web-ctl-desktop"
|
||||||
|
onClick={toggle}
|
||||||
|
title="Switch to the desktop theme"
|
||||||
|
>
|
||||||
|
<span className="web-ctl-icon" aria-hidden="true">
|
||||||
|
❖
|
||||||
|
</span>
|
||||||
|
<span className="web-ctl-label">Desktop</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="web-side-foot">
|
||||||
|
<Clock className="web-clock" />
|
||||||
|
<a href="mailto:dvorak@dvorakmortonxd.dev">dvorak@dvorakmortonxd.dev</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="web-main">
|
||||||
|
<header id="overview" className="web-hero glass">
|
||||||
|
<span className="web-eyebrow">README.MD</span>
|
||||||
|
<h1 className="web-title">$ whoami</h1>
|
||||||
|
<p className="web-lead">
|
||||||
|
Welcome to my personal website! Here I show off my projects and the hardware I
|
||||||
|
own, as well as my self-hosted services.
|
||||||
|
<span className="cursor-blink" />
|
||||||
|
</p>
|
||||||
|
<div className="web-hero-actions">
|
||||||
|
<a className="web-pill" href="#projects">
|
||||||
|
View projects
|
||||||
|
</a>
|
||||||
|
<a className="web-pill ghost" href="#services">
|
||||||
|
Self-hosted services
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="web-tiles">
|
||||||
|
<article id="now" className="web-tile glass">
|
||||||
|
<h3 className="web-tile-title">now.txt</h3>
|
||||||
|
<p className="web-tile-text">
|
||||||
|
I'm currently hosting an SMP (dm me on discord). Also I'm pretty much
|
||||||
|
cooking daily, because im bulking through out the non-summer seasons. And me
|
||||||
|
and my pal <a href="https://skopec.dev">Pepa</a> are working on an education
|
||||||
|
app so we can study more efficiently, It's called{" "}
|
||||||
|
<a href="https://git.dvorakmortonxd.dev/edu/maturita.c">maturita.c</a>!
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="web-tile glass">
|
||||||
|
<h3 className="web-tile-title">gym_sbd.db</h3>
|
||||||
|
<Gym />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="web-tile glass">
|
||||||
|
<h3 className="web-tile-title">socials.js</h3>
|
||||||
|
<Socials />
|
||||||
|
<div className="mail-contact">
|
||||||
|
mail if u ever wanna contact me:{" "}
|
||||||
|
<a href="mailto:dvorak@dvorakmortonxd.dev">dvorak@dvorakmortonxd.dev</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section id="services" eyebrow="SELF-HOSTED" title="Services">
|
||||||
|
<Services />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section id="activity" eyebrow="GITDXD.GIT" title="Contribution activity">
|
||||||
|
<ContributionHeatmap />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section id="projects" eyebrow="PROJECTS.DOCX" title="Things I've worked on">
|
||||||
|
<div className="project-grid">
|
||||||
|
{codingProjects.map((project, idx) => (
|
||||||
|
<a
|
||||||
|
key={idx}
|
||||||
|
href={project.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="project-card"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="project-image"
|
||||||
|
style={{ backgroundImage: `url(${project.img})` }}
|
||||||
|
/>
|
||||||
|
<h4 className="project-title">{project.title}</h4>
|
||||||
|
<p className="project-desc">{project.desc}</p>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section id="hardware" eyebrow="TECH_LIST.JSON" title="Hardware I own">
|
||||||
|
<div className="project-grid">
|
||||||
|
{techProjects.map((project) => (
|
||||||
|
<div key={project.title} className="project-card">
|
||||||
|
<div
|
||||||
|
className="project-image"
|
||||||
|
style={{ backgroundImage: `url(${project.image})` }}
|
||||||
|
/>
|
||||||
|
<h4 className="project-title">{project.title}</h4>
|
||||||
|
<p className="project-desc">{project.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<div className="web-split">
|
||||||
|
<Section eyebrow="REVIVAL.INC" title="Revival Inc." className="web-feature">
|
||||||
|
<FeatureShot
|
||||||
|
src="/revivalinc.webp"
|
||||||
|
alt="Revival Inc. thumbnail"
|
||||||
|
width={768}
|
||||||
|
height={576}
|
||||||
|
/>
|
||||||
|
<p className="web-feature-text">
|
||||||
|
A game I made for the game jam "Mini Jame Gam #55", the theme was
|
||||||
|
Necromancy and the special item was Milk, the game also includes a tutorial so
|
||||||
|
if you wanna try it do it. Check it out at{" "}
|
||||||
|
<a href="https://martin-dv.itch.io/revival-inc">itch.io</a> or play{" "}
|
||||||
|
<a href="https://dvorakmortonxd.dev/revival-inc">here</a>.
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section eyebrow="RUSTYWOLF.RS" title="Introducing RustyWolf" className="web-feature">
|
||||||
|
<FeatureShot
|
||||||
|
src="/RustyWolfPreview.webp"
|
||||||
|
alt="RustyWolf Browser Preview"
|
||||||
|
width={1280}
|
||||||
|
height={720}
|
||||||
|
/>
|
||||||
|
<p className="web-feature-text">
|
||||||
|
A lightweight browser made in Rust with WebKitGTK. It's the successor to my
|
||||||
|
previous project Venturesome, aiming to provide a fast and minimalist browsing
|
||||||
|
experience — perfect for old machines that struggle with modern browsers.
|
||||||
|
Check it out on{" "}
|
||||||
|
<a href="https://git.dvorakmortonxd.dev/dvorakmortonxd/RustyWolf">GitDXD</a>.
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section id="friends" eyebrow="FRIENDS.TSX" title="Friend's websites and projects">
|
||||||
|
<div className="services-buttons">
|
||||||
|
{friends.map((friend, idx) => (
|
||||||
|
<ServiceButton
|
||||||
|
key={idx}
|
||||||
|
url={friend.realUrl || friend.url}
|
||||||
|
className="friend"
|
||||||
|
icon={
|
||||||
|
<Image
|
||||||
|
src={friend.img}
|
||||||
|
alt={friend.name}
|
||||||
|
width={68}
|
||||||
|
height={68}
|
||||||
|
className="friend-icon"
|
||||||
|
style={{ borderRadius: "50%" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
name={friend.name}
|
||||||
|
desc={friend.desc}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<footer className="web-footer glass">
|
||||||
|
<span className="web-footer-label">v2.2.0</span>
|
||||||
|
© 2025-2026 Martin Dvořák. All rights reserved (not really I dont care, just
|
||||||
|
follow the GPL3.0 License)
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -89,7 +89,7 @@ const ICON_KEYS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function WindowsDesktop() {
|
export default function WindowsDesktop() {
|
||||||
const { theme, toggle } = useTheme();
|
const { toggle } = useTheme();
|
||||||
|
|
||||||
const [wins, setWins] = useState<WinState[]>(() => [
|
const [wins, setWins] = useState<WinState[]>(() => [
|
||||||
{
|
{
|
||||||
|
|
@ -577,10 +577,10 @@ export default function WindowsDesktop() {
|
||||||
type="button"
|
type="button"
|
||||||
className="win-theme-toggle"
|
className="win-theme-toggle"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
title={`Switch to ${theme === "skeuo" ? "gruvbox" : "Desktop"} theme`}
|
title="Switch to the web theme"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">{theme === "skeuo" ? "▚" : "❖"}</span>
|
<span aria-hidden="true">▚</span>
|
||||||
<span>{theme === "skeuo" ? "Gruvbox" : "Desktop"}</span>
|
<span>Web</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Clock className="win-clock" />
|
<Clock className="win-clock" />
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
techProjects,
|
techProjects,
|
||||||
friends,
|
friends,
|
||||||
WALLPAPER_CREDIT,
|
WALLPAPER_CREDIT,
|
||||||
MOBILE_WALLPAPER_CREDIT,
|
|
||||||
} from "../data";
|
} from "../data";
|
||||||
import ServiceButton from "./ServiceButton";
|
import ServiceButton from "./ServiceButton";
|
||||||
import ContributionHeatmap from "./ContributionHeatmap";
|
import ContributionHeatmap from "./ContributionHeatmap";
|
||||||
|
|
@ -46,14 +45,14 @@ function WelcomeApp() {
|
||||||
<li>Drag windows around by their title bar.</li>
|
<li>Drag windows around by their title bar.</li>
|
||||||
<li>Use the buttons at the top-right to minimize, maximize or close.</li>
|
<li>Use the buttons at the top-right to minimize, maximize or close.</li>
|
||||||
<li>Open apps from the Gentoo launcher, bottom-left.</li>
|
<li>Open apps from the Gentoo launcher, bottom-left.</li>
|
||||||
<li>Switch back to the Gruvbox theme from the panel tray, bottom-right.</li>
|
<li>Switch back to the web theme from the panel tray, bottom-right.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="app-preference">
|
<p className="app-preference">
|
||||||
If you'd prefer a normal website{" "}
|
If you'd prefer a normal website{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="app-inline-link"
|
className="app-inline-link"
|
||||||
onClick={() => setTheme("terminal")}
|
onClick={() => setTheme("web")}
|
||||||
>
|
>
|
||||||
click here
|
click here
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -63,13 +62,9 @@ function WelcomeApp() {
|
||||||
Wallpaper from{" "}
|
Wallpaper from{" "}
|
||||||
<a href={WALLPAPER_CREDIT} target="_blank" rel="noopener noreferrer">
|
<a href={WALLPAPER_CREDIT} target="_blank" rel="noopener noreferrer">
|
||||||
wallpapercave.com/kde-plasma-wallpapers
|
wallpapercave.com/kde-plasma-wallpapers
|
||||||
</a>{" "}
|
</a>
|
||||||
(desktop) and{" "}
|
. Gentoo logo by Matteo 'Peach' Pescarin (CC BY-SA 2.5), via Wikimedia
|
||||||
<a href={MOBILE_WALLPAPER_CREDIT} target="_blank" rel="noopener noreferrer">
|
Commons.
|
||||||
wallpapercave.com/landscape-4k-mobile-wallpapers
|
|
||||||
</a>{" "}
|
|
||||||
(mobile). Gentoo logo by Matteo 'Peach' Pescarin (CC BY-SA 2.5), via
|
|
||||||
Wikimedia Commons.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
51
src/app/components/useColorScheme.ts
Normal file
51
src/app/components/useColorScheme.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Light/dark appearance for the web theme. Persisted in `localStorage.scheme`
|
||||||
|
* and applied pre-paint as `<html data-scheme>` so there is no flash. Light is
|
||||||
|
* the default.
|
||||||
|
*/
|
||||||
|
export type ColorScheme = "light" | "dark";
|
||||||
|
|
||||||
|
const EVENT = "scheme-change";
|
||||||
|
export const DEFAULT_SCHEME: ColorScheme = "light";
|
||||||
|
|
||||||
|
function subscribe(callback: () => void) {
|
||||||
|
window.addEventListener(EVENT, callback);
|
||||||
|
window.addEventListener("storage", callback);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(EVENT, callback);
|
||||||
|
window.removeEventListener("storage", callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(): ColorScheme {
|
||||||
|
return document.documentElement.getAttribute("data-scheme") === "dark"
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerSnapshot(): ColorScheme {
|
||||||
|
return DEFAULT_SCHEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setColorScheme(scheme: ColorScheme) {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
document.documentElement.setAttribute("data-scheme", scheme);
|
||||||
|
try {
|
||||||
|
localStorage.setItem("scheme", scheme);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new Event(EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColorScheme() {
|
||||||
|
const scheme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
|
return {
|
||||||
|
scheme,
|
||||||
|
toggle: () => setColorScheme(scheme === "dark" ? "light" : "dark"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type Dispatch,
|
|
||||||
type PointerEvent as ReactPointerEvent,
|
|
||||||
type SetStateAction,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pointer-based reordering for a list of items rendered with
|
|
||||||
* `data-drag-id={item.id}`. Dragging an item over another swaps them. Works
|
|
||||||
* with both mouse and touch.
|
|
||||||
*
|
|
||||||
* The click that follows a drag is swallowed via `consumeDragged()` so tapping
|
|
||||||
* still launches the icon while dragging only rearranges it.
|
|
||||||
*/
|
|
||||||
export function useDragOrder<T extends { id: string }>(
|
|
||||||
setItems: Dispatch<SetStateAction<T[]>>
|
|
||||||
) {
|
|
||||||
const [dragId, setDragId] = useState<string | null>(null);
|
|
||||||
const suppressRef = useRef(false);
|
|
||||||
|
|
||||||
const onPointerDown = (id: string) => (e: ReactPointerEvent) => {
|
|
||||||
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
||||||
const startX = e.clientX;
|
|
||||||
const startY = e.clientY;
|
|
||||||
let active = false;
|
|
||||||
|
|
||||||
const move = (ev: PointerEvent) => {
|
|
||||||
if (!active) {
|
|
||||||
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < 8) return;
|
|
||||||
active = true;
|
|
||||||
suppressRef.current = true;
|
|
||||||
setDragId(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const el = document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null;
|
|
||||||
const target = el?.closest<HTMLElement>("[data-drag-id]");
|
|
||||||
const overId = target?.dataset.dragId;
|
|
||||||
if (!overId || overId === id) return;
|
|
||||||
|
|
||||||
setItems((prev) => {
|
|
||||||
const from = prev.findIndex((x) => x.id === id);
|
|
||||||
const to = prev.findIndex((x) => x.id === overId);
|
|
||||||
if (from < 0 || to < 0 || from === to) return prev;
|
|
||||||
const next = prev.slice();
|
|
||||||
const [moved] = next.splice(from, 1);
|
|
||||||
next.splice(to, 0, moved);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const up = () => {
|
|
||||||
window.removeEventListener("pointermove", move);
|
|
||||||
window.removeEventListener("pointerup", up);
|
|
||||||
window.removeEventListener("pointercancel", up);
|
|
||||||
setDragId(null);
|
|
||||||
if (active) {
|
|
||||||
active = false;
|
|
||||||
window.setTimeout(() => {
|
|
||||||
suppressRef.current = false;
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("pointermove", move);
|
|
||||||
window.addEventListener("pointerup", up);
|
|
||||||
window.addEventListener("pointercancel", up);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Returns true (once) when a drag just happened, so the click is ignored. */
|
|
||||||
const consumeDragged = () => {
|
|
||||||
if (suppressRef.current) {
|
|
||||||
suppressRef.current = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
return { onPointerDown, dragId, consumeDragged };
|
|
||||||
}
|
|
||||||
|
|
@ -2,9 +2,16 @@
|
||||||
|
|
||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
export type Theme = "skeuo" | "terminal";
|
/**
|
||||||
|
* The two shells the site can render:
|
||||||
|
* - `web` — the default frosted-glass website (sidebar + content).
|
||||||
|
* - `skeuo` — the alternate KDE Plasma desktop simulation.
|
||||||
|
* `web` is the default and the only shell phones ever see.
|
||||||
|
*/
|
||||||
|
export type Theme = "web" | "skeuo";
|
||||||
|
|
||||||
const EVENT = "theme-change";
|
const EVENT = "theme-change";
|
||||||
|
export const DEFAULT_THEME: Theme = "web";
|
||||||
|
|
||||||
function subscribe(callback: () => void) {
|
function subscribe(callback: () => void) {
|
||||||
window.addEventListener(EVENT, callback);
|
window.addEventListener(EVENT, callback);
|
||||||
|
|
@ -15,14 +22,16 @@ function subscribe(callback: () => void) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anything that isn't explicitly "skeuo" is the web theme, so a missing/legacy
|
||||||
|
// value (e.g. the old "terminal") falls back to the default.
|
||||||
function getSnapshot(): Theme {
|
function getSnapshot(): Theme {
|
||||||
return document.documentElement.getAttribute("data-theme") === "terminal"
|
return document.documentElement.getAttribute("data-theme") === "skeuo"
|
||||||
? "terminal"
|
? "skeuo"
|
||||||
: "skeuo";
|
: "web";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getServerSnapshot(): Theme {
|
function getServerSnapshot(): Theme {
|
||||||
return "skeuo";
|
return DEFAULT_THEME;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setTheme(theme: Theme) {
|
export function setTheme(theme: Theme) {
|
||||||
|
|
@ -40,6 +49,6 @@ export function useTheme() {
|
||||||
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
return {
|
return {
|
||||||
theme,
|
theme,
|
||||||
toggle: () => setTheme(theme === "skeuo" ? "terminal" : "skeuo"),
|
toggle: () => setTheme(theme === "web" ? "skeuo" : "web"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,3 @@ export const friends: Friend[] = [
|
||||||
|
|
||||||
export const WALLPAPER = "/wallpaper.jpg";
|
export const WALLPAPER = "/wallpaper.jpg";
|
||||||
export const WALLPAPER_CREDIT = "https://wallpapercave.com/kde-plasma-wallpapers";
|
export const WALLPAPER_CREDIT = "https://wallpapercave.com/kde-plasma-wallpapers";
|
||||||
export const MOBILE_WALLPAPER = "/phonewallpaper.jpg";
|
|
||||||
export const MOBILE_WALLPAPER_CREDIT =
|
|
||||||
"https://wallpapercave.com/landscape-4k-mobile-wallpapers";
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export default function RootLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" data-theme="skeuo" suppressHydrationWarning>
|
<html lang="en" data-theme="web" data-scheme="light" suppressHydrationWarning>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<ThemeScript />
|
<ThemeScript />
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
1256
src/app/page.css
1256
src/app/page.css
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import './page.css';
|
import './web.css';
|
||||||
import './skeuo.css';
|
import './skeuo.css';
|
||||||
import ThemeRoot from './components/ThemeRoot';
|
import ThemeRoot from './components/ThemeRoot';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
SKEUOMORPHIC THEME
|
SKEUOMORPHIC THEME
|
||||||
------------------------------------------------------------
|
------------------------------------------------------------
|
||||||
Desktop -> KDE Plasma (Breeze)
|
Desktop -> KDE Plasma (Breeze)
|
||||||
Mobile -> iOS-style home screen
|
Active when <html data-theme="skeuo">. This is the alternate
|
||||||
Active when <html data-theme="skeuo">. The terminal theme is
|
shell; the default is the web theme (web.css). It is desktop
|
||||||
the alternate, reachable from the tray / launcher / app.
|
only — phones always get the web theme.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
html[data-theme="skeuo"] {
|
html[data-theme="skeuo"] {
|
||||||
|
|
@ -27,18 +27,6 @@ html[data-theme="skeuo"] body {
|
||||||
font-family: var(--font-custom-mono);
|
font-family: var(--font-custom-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-desktop-root { display: block; }
|
|
||||||
.skeuo-mobile-root { display: none; }
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.skeuo-desktop-root { display: none; }
|
|
||||||
.skeuo-mobile-root { display: block; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Real phones always get the mobile UI, even in landscape. */
|
|
||||||
html[data-device="phone"] .skeuo-desktop-root { display: none; }
|
|
||||||
html[data-device="phone"] .skeuo-mobile-root { display: block; }
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
DESKTOP (Plasma)
|
DESKTOP (Plasma)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
@ -536,7 +524,7 @@ a.win-icon {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
APP CONTENT SURFACE (shared by desktop windows + mobile apps)
|
APP CONTENT SURFACE (shared by the desktop windows)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.app-surface {
|
.app-surface {
|
||||||
--white: #0e3355;
|
--white: #0e3355;
|
||||||
|
|
@ -1630,537 +1618,6 @@ a.win-icon {
|
||||||
background: rgba(61, 174, 233, 0.3);
|
background: rgba(61, 174, 233, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
MOBILE -> iOS-style home screen
|
|
||||||
============================================================ */
|
|
||||||
.mobile-root {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-home {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
height: 100dvh;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: #101319;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
color: #ffffff;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Roboto,
|
|
||||||
"Helvetica Neue", Arial, sans-serif;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
}
|
|
||||||
.mobile-home::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
rgba(0, 0, 0, 0.4),
|
|
||||||
rgba(0, 0, 0, 0.12) 28%,
|
|
||||||
rgba(0, 0, 0, 0.25) 62%,
|
|
||||||
rgba(0, 0, 0, 0.6)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-statusbar {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 34px;
|
|
||||||
z-index: 55;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 18px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
.mobile-status-time {
|
|
||||||
font-size: 0.94rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0;
|
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
.mobile-status-icons {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.mobile-status-icons svg {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 2x1 iOS-style clock widget */
|
|
||||||
.mobile-widget {
|
|
||||||
grid-column: span 2;
|
|
||||||
grid-row: span 1;
|
|
||||||
border-radius: 22px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
gap: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
color: #ffffff;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
|
||||||
background-color: rgba(28, 30, 38, 0.42);
|
|
||||||
background-image: linear-gradient(
|
|
||||||
160deg,
|
|
||||||
rgba(255, 255, 255, 0.22),
|
|
||||||
rgba(255, 255, 255, 0.06) 52%,
|
|
||||||
rgba(0, 0, 0, 0.18)
|
|
||||||
);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
|
||||||
0 6px 18px rgba(0, 0, 0, 0.32);
|
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
|
||||||
backdrop-filter: blur(20px) saturate(150%);
|
|
||||||
}
|
|
||||||
.mobile-widget-time {
|
|
||||||
font-size: 1.65rem;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.05;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
|
||||||
.mobile-widget-date {
|
|
||||||
font-size: 0.66rem;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.1;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-grid {
|
|
||||||
position: absolute;
|
|
||||||
top: 46px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 112px;
|
|
||||||
z-index: 10;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 6px 14px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
grid-auto-rows: 86px;
|
|
||||||
gap: 14px 6px;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
.mobile-app {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
color: inherit;
|
|
||||||
min-width: 0;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
.mobile-app.dragging {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
.mobile-app-icon {
|
|
||||||
position: relative;
|
|
||||||
width: 58px;
|
|
||||||
height: 58px;
|
|
||||||
border-radius: 21px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 27px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
|
||||||
background-color: rgba(22, 24, 30, 0.42);
|
|
||||||
background-image: linear-gradient(
|
|
||||||
160deg,
|
|
||||||
rgba(255, 255, 255, 0.38),
|
|
||||||
rgba(255, 255, 255, 0.08) 44%,
|
|
||||||
rgba(0, 0, 0, 0.08) 72%,
|
|
||||||
rgba(0, 0, 0, 0.3)
|
|
||||||
);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.5),
|
|
||||||
inset 0 -9px 16px rgba(0, 0, 0, 0.2),
|
|
||||||
0 4px 12px rgba(0, 0, 0, 0.42);
|
|
||||||
-webkit-backdrop-filter: blur(16px) saturate(140%);
|
|
||||||
backdrop-filter: blur(16px) saturate(140%);
|
|
||||||
}
|
|
||||||
.mobile-app-glyph {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
filter: saturate(0.35) contrast(1.02);
|
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
|
||||||
.mobile-app-icon::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
rgba(255, 255, 255, 0.3),
|
|
||||||
rgba(255, 255, 255, 0) 48%
|
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.mobile-app-icon::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
rgba(255, 255, 255, 0.3),
|
|
||||||
rgba(255, 255, 255, 0) 48%
|
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.mobile-app-label {
|
|
||||||
max-width: 100%;
|
|
||||||
font-size: 0.66rem;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1.15;
|
|
||||||
text-align: center;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-dock {
|
|
||||||
position: absolute;
|
|
||||||
left: 12px;
|
|
||||||
right: 12px;
|
|
||||||
bottom: 30px;
|
|
||||||
z-index: 15;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-evenly;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 11px 12px;
|
|
||||||
border-radius: 30px;
|
|
||||||
background: rgba(255, 255, 255, 0.16);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.28),
|
|
||||||
0 10px 30px rgba(0, 0, 0, 0.35);
|
|
||||||
-webkit-backdrop-filter: blur(22px) saturate(150%);
|
|
||||||
backdrop-filter: blur(22px) saturate(150%);
|
|
||||||
}
|
|
||||||
.mobile-app.in-dock .mobile-app-icon {
|
|
||||||
width: 54px;
|
|
||||||
height: 54px;
|
|
||||||
border-radius: 19px;
|
|
||||||
font-size: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-pagedots {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 14px;
|
|
||||||
z-index: 15;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
.mobile-pagedots span {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.35);
|
|
||||||
}
|
|
||||||
.mobile-pagedots span.active {
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-app-view {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
height: 100dvh;
|
|
||||||
z-index: 40;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: rgba(10, 13, 19, 0.66);
|
|
||||||
color: #eef1f6;
|
|
||||||
font-family: Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
-webkit-backdrop-filter: blur(30px) saturate(160%);
|
|
||||||
backdrop-filter: blur(30px) saturate(160%);
|
|
||||||
will-change: transform, opacity, border-radius;
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
.mobile-app-view.opening {
|
|
||||||
animation: mobileAppOpen 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
||||||
/* Grow out of the tapped icon. */
|
|
||||||
transform-origin: var(--app-origin, 50% 50%);
|
|
||||||
}
|
|
||||||
.mobile-app-view.closing {
|
|
||||||
animation: mobileAppClose 0.36s cubic-bezier(0.4, 0, 0.7, 0.2) forwards;
|
|
||||||
pointer-events: none;
|
|
||||||
/* Always shrink straight down from the centre, never toward the icon. */
|
|
||||||
transform-origin: 50% 50%;
|
|
||||||
}
|
|
||||||
.mobile-app-view .mobile-appbar,
|
|
||||||
.mobile-app-view .mobile-app-body {
|
|
||||||
animation: mobileAppContentIn 0.3s ease-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes mobileAppOpen {
|
|
||||||
from {
|
|
||||||
transform: scale(var(--app-scale, 0.2));
|
|
||||||
border-radius: 27px;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: scale(1);
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes mobileAppClose {
|
|
||||||
from {
|
|
||||||
translate: 0 0;
|
|
||||||
scale: 1;
|
|
||||||
border-radius: 0;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
/* One full viewport height guarantees it clears the bottom edge entirely. */
|
|
||||||
translate: 0 100vh;
|
|
||||||
scale: var(--app-scale, 0.2);
|
|
||||||
border-radius: 27px;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes mobileAppContentIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.mobile-appbar {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 12px 10px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
|
||||||
backdrop-filter: blur(20px) saturate(150%);
|
|
||||||
}
|
|
||||||
.mobile-back {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: transparent;
|
|
||||||
color: #eef1f6;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.mobile-back:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
.mobile-appbar-title {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.mobile-app-body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.mobile-app-body.app-surface {
|
|
||||||
--white: #eef1f6;
|
|
||||||
--gray: #a7b0bd;
|
|
||||||
--accent: #7cc4ff;
|
|
||||||
--accent2: #a99bff;
|
|
||||||
--accent3: #7cc4ff;
|
|
||||||
--green: #5bd67a;
|
|
||||||
--aqua: #4fd6c9;
|
|
||||||
--blue: #68b9ff;
|
|
||||||
--purple: #b79bff;
|
|
||||||
--red: #ff7a7a;
|
|
||||||
--darkpink: #ff86c8;
|
|
||||||
--fg: #eef1f6;
|
|
||||||
color: #eef1f6;
|
|
||||||
}
|
|
||||||
.mobile-app-body .project-card,
|
|
||||||
.mobile-app-body .service-button,
|
|
||||||
.mobile-app-body .social-button,
|
|
||||||
.mobile-app-body .gym-item {
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Dark glassmorphism content (mobile apps only) ---- */
|
|
||||||
.mobile-app-body .app-cmd {
|
|
||||||
color: #aeb7c4;
|
|
||||||
}
|
|
||||||
.mobile-app-body .app-user {
|
|
||||||
color: #5bd67a;
|
|
||||||
}
|
|
||||||
.mobile-app-body .app-path {
|
|
||||||
color: #7cc4ff;
|
|
||||||
}
|
|
||||||
.mobile-app-body .app-text,
|
|
||||||
.mobile-app-body .app-list {
|
|
||||||
color: #c7cfda;
|
|
||||||
}
|
|
||||||
.mobile-app-body .about-content,
|
|
||||||
.mobile-app-body .about-content > div {
|
|
||||||
color: #c7cfda !important;
|
|
||||||
}
|
|
||||||
.mobile-app-body a {
|
|
||||||
color: #7cc4ff;
|
|
||||||
}
|
|
||||||
.mobile-app-body .mail-contact {
|
|
||||||
color: #c7cfda;
|
|
||||||
}
|
|
||||||
.mobile-app-body .mail-contact a {
|
|
||||||
color: #7cc4ff;
|
|
||||||
}
|
|
||||||
.mobile-app-body .app-h1,
|
|
||||||
.mobile-app-body .app-h2,
|
|
||||||
.mobile-app-body .app-welcome-head h2 {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.mobile-app-body .app-muted {
|
|
||||||
color: #98a2b0;
|
|
||||||
}
|
|
||||||
.mobile-app-body .app-welcome-head img {
|
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
|
||||||
}
|
|
||||||
.mobile-app-body .app-shot {
|
|
||||||
border-color: rgba(255, 255, 255, 0.16);
|
|
||||||
}
|
|
||||||
.mobile-app-body .app-linkrow a {
|
|
||||||
color: #eef1f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glass cards */
|
|
||||||
.mobile-app-body .social-button,
|
|
||||||
.mobile-app-body .service-button,
|
|
||||||
.mobile-app-body .project-card,
|
|
||||||
.mobile-app-body .gym-item,
|
|
||||||
.mobile-app-body .app-linkrow a {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
background: rgba(255, 255, 255, 0.07);
|
|
||||||
color: #eef1f6;
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.16),
|
|
||||||
0 10px 24px rgba(0, 0, 0, 0.35);
|
|
||||||
-webkit-backdrop-filter: blur(16px) saturate(150%);
|
|
||||||
backdrop-filter: blur(16px) saturate(150%);
|
|
||||||
}
|
|
||||||
.mobile-app-body .social-button:hover,
|
|
||||||
.mobile-app-body .service-button:hover,
|
|
||||||
.mobile-app-body .project-card:hover,
|
|
||||||
.mobile-app-body .gym-item:hover,
|
|
||||||
.mobile-app-body .app-linkrow a:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.13);
|
|
||||||
border-color: rgba(124, 196, 255, 0.6);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.22),
|
|
||||||
0 0 0 1px rgba(124, 196, 255, 0.35),
|
|
||||||
0 12px 28px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-app-body .social-icon {
|
|
||||||
opacity: 0.95;
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
.mobile-app-body .service-name {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.mobile-app-body .service-desc {
|
|
||||||
color: #a7b0bd;
|
|
||||||
}
|
|
||||||
.mobile-app-body .service-button:hover .service-desc {
|
|
||||||
color: #dfe6ef;
|
|
||||||
}
|
|
||||||
.mobile-app-body .friend-icon {
|
|
||||||
filter: invert(1) brightness(1.5);
|
|
||||||
}
|
|
||||||
.mobile-app-body .project-image {
|
|
||||||
border-color: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
.mobile-app-body .project-title {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.mobile-app-body .project-card:hover .project-title {
|
|
||||||
color: #7cc4ff;
|
|
||||||
}
|
|
||||||
.mobile-app-body .project-desc {
|
|
||||||
color: #a7b0bd;
|
|
||||||
}
|
|
||||||
.mobile-app-body .project-card:hover .project-desc {
|
|
||||||
color: #dfe6ef;
|
|
||||||
}
|
|
||||||
.mobile-app-body .gym-label {
|
|
||||||
color: #a7b0bd;
|
|
||||||
}
|
|
||||||
.mobile-app-body .gym-weight {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.mobile-app-body .gym-date {
|
|
||||||
color: #8b95a3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glass heatmap */
|
|
||||||
.mobile-app-body .heatmap-container {
|
|
||||||
border-color: rgba(124, 196, 255, 0.5);
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
.mobile-app-body .heatmap-cell,
|
|
||||||
.mobile-app-body .heatmap-legend-cell {
|
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
.mobile-app-body .heatmap-cell[data-level="1"],
|
|
||||||
.mobile-app-body .heatmap-legend-cell[data-level="1"] {
|
|
||||||
background-color: rgba(124, 196, 255, 0.3);
|
|
||||||
}
|
|
||||||
.mobile-app-body .heatmap-cell[data-level="2"],
|
|
||||||
.mobile-app-body .heatmap-legend-cell[data-level="2"] {
|
|
||||||
background-color: rgba(124, 196, 255, 0.55);
|
|
||||||
}
|
|
||||||
.mobile-app-body .heatmap-cell[data-level="3"],
|
|
||||||
.mobile-app-body .heatmap-legend-cell[data-level="3"] {
|
|
||||||
background-color: rgba(124, 196, 255, 0.8);
|
|
||||||
}
|
|
||||||
.mobile-app-body .heatmap-cell[data-level="4"],
|
|
||||||
.mobile-app-body .heatmap-legend-cell[data-level="4"] {
|
|
||||||
background-color: #7cc4ff;
|
|
||||||
}
|
|
||||||
.mobile-app-body .heatmap-cell:hover {
|
|
||||||
box-shadow: 0 0 8px rgba(124, 196, 255, 0.9);
|
|
||||||
}
|
|
||||||
.mobile-app-body .contribution-command,
|
|
||||||
.mobile-app-body .contribution-entry,
|
|
||||||
.mobile-app-body .contribution-total-title,
|
|
||||||
.mobile-app-body .contribution-message {
|
|
||||||
color: #eef1f6;
|
|
||||||
}
|
|
||||||
.mobile-app-body .contribution-repo {
|
|
||||||
color: #a7b0bd;
|
|
||||||
}
|
|
||||||
.mobile-app-body .contribution-date {
|
|
||||||
color: #5bd67a;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.app-pad {
|
.app-pad {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
|
@ -2183,45 +1640,13 @@ a.win-icon {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 380px) {
|
|
||||||
.mobile-grid {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Phones in landscape: compact the home so the app grid (and its labels) fit. */
|
|
||||||
@media (orientation: landscape) and (max-height: 620px) {
|
|
||||||
.mobile-statusbar { height: 28px; font-size: 0.72rem; padding: 0 16px; }
|
|
||||||
.mobile-status-time { font-size: 0.84rem; }
|
|
||||||
.mobile-grid {
|
|
||||||
top: 30px;
|
|
||||||
bottom: 76px;
|
|
||||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
||||||
grid-auto-rows: 60px;
|
|
||||||
gap: 8px 4px;
|
|
||||||
padding: 4px 16px;
|
|
||||||
}
|
|
||||||
.mobile-app { gap: 2px; }
|
|
||||||
.mobile-app-icon { width: 40px; height: 40px; border-radius: 14px; font-size: 19px; }
|
|
||||||
.mobile-app-label { font-size: 0.55rem; }
|
|
||||||
.mobile-widget { border-radius: 14px; padding: 5px 8px; }
|
|
||||||
.mobile-widget-time { font-size: 1rem; }
|
|
||||||
.mobile-widget-date { font-size: 0.5rem; }
|
|
||||||
.mobile-dock { bottom: 10px; padding: 6px 10px; border-radius: 22px; }
|
|
||||||
.mobile-app.in-dock .mobile-app-icon { width: 38px; height: 38px; border-radius: 13px; font-size: 18px; }
|
|
||||||
.mobile-pagedots { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Respect reduced-motion preferences without breaking animationend callbacks
|
/* Respect reduced-motion preferences without breaking animationend callbacks
|
||||||
(a near-zero duration still fires them so closing windows/apps unmount). */
|
(a near-zero duration still fires them so closing windows unmount). */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.win-window,
|
.win-window,
|
||||||
.win-window.closing,
|
.win-window.closing,
|
||||||
.kickoff,
|
.kickoff {
|
||||||
.mobile-app-view.opening,
|
|
||||||
.mobile-app-view.closing,
|
|
||||||
.mobile-app-view .mobile-appbar,
|
|
||||||
.mobile-app-view .mobile-app-body {
|
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
.win-icon {
|
.win-icon {
|
||||||
|
|
|
||||||
1126
src/app/web.css
Normal file
1126
src/app/web.css
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue