SMP: Minecraft-style join window with live status; allow beta tunnel origin for dev HMR
This commit is contained in:
parent
8fa1f50e8a
commit
d540a6a873
7 changed files with 318 additions and 6 deletions
|
|
@ -37,6 +37,9 @@ heatmap**, a gym SBD panel and links to friends' sites.
|
|||
Jellyfin, Navidrome, GitDXD) from the desktop shows the real site inside the window (an
|
||||
iframe). The service must allow framing; SMP (Discord) keeps a link card because Discord
|
||||
sends `X-Frame-Options: DENY`.
|
||||
- **Minecraft-style SMP window** — the SMP shortcut opens a join menu modelled on
|
||||
Minecraft's multiplayer screen, with live status, player count, MOTD and version pulled
|
||||
from mcstatus.io through the site's own ping API.
|
||||
- **Live GitDXD stats** — contribution heatmap + latest commit activity proxied server-side
|
||||
from the self-hosted Forgejo instance.
|
||||
- **Extras** — a playable `Revival Inc.` game embedded at `/revival-inc`, `llms.txt`,
|
||||
|
|
@ -92,7 +95,9 @@ Handy checks before shipping: `npx tsc --noEmit`, `npx eslint <files>`, `npm run
|
|||
### Testing from a phone on your LAN
|
||||
|
||||
`next.config.ts` sets `allowedDevOrigins` so the dev server can be reached from other devices
|
||||
on the local network (otherwise Next.js 403s the dev asset requests).
|
||||
on the local network and from the `beta.dvorakmortonxd.dev` tunnel. Without it Next.js
|
||||
rejects the dev asset requests *and* the HMR WebSocket, which leaves the page un-hydrated
|
||||
(stacked icons, dead buttons).
|
||||
|
||||
## 📡 API Routes
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,16 @@ const nextConfig: NextConfig = {
|
|||
// Allow testing the dev server from other devices on the LAN (e.g. a phone).
|
||||
// Without this Next.js blocks cross-origin requests to /_next/* dev assets
|
||||
// with a 403, which stops the JS from loading and makes the page inert.
|
||||
allowedDevOrigins: ["192.168.10.124", "192.168.*.*", "10.*.*.*"],
|
||||
// The HMR WebSocket is checked against this list too: if the tunnel/beta host
|
||||
// isn't listed, the socket is rejected and the dev client never hydrates
|
||||
// (SSR-only page: stacked icons, dead buttons).
|
||||
allowedDevOrigins: [
|
||||
"192.168.10.124",
|
||||
"192.168.*.*",
|
||||
"10.*.*.*",
|
||||
"beta.dvorakmortonxd.dev",
|
||||
"*.dvorakmortonxd.dev",
|
||||
],
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/i,
|
||||
|
|
|
|||
11
opencode.md
11
opencode.md
|
|
@ -83,7 +83,7 @@ src/app/
|
|||
MobileHome.tsx → "skeuo" theme, phone: app grid + dock
|
||||
WinWindow.tsx → a single draggable/resizable window
|
||||
apps.tsx → APPS registry + the content of each app + in-app terminal
|
||||
+ embedded-site windows
|
||||
+ embedded-site windows + the Minecraft-style SMP menu
|
||||
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 via useSyncExternalStore
|
||||
|
|
@ -211,6 +211,15 @@ list reordering, not free placement. Don't confuse the two.
|
|||
`eslint-disable-next-line` with a comment. Don't add more without a reason.
|
||||
- Watch for `pointercancel`: any custom pointer gesture over an `<img>`, link,
|
||||
or text selection needs `draggable=false` / `user-select: none`.
|
||||
- Exposing the **dev** server over a tunnel needs the tunnel host in
|
||||
`allowedDevOrigins` (`next.config.ts`). Without it Next dev rejects the
|
||||
cross-origin HMR WebSocket, so the client never hydrates — the page is SSR
|
||||
only: icons all stacked at `(0,0)`, buttons dead. Changing `next.config.ts`
|
||||
needs a dev-server restart. For a public URL prefer a production build
|
||||
(`npm run build && npm run start`): no HMR socket, so nothing to reject.
|
||||
- The SMP service shortcut opens `SMP_APP` (in `apps.tsx`, registered in
|
||||
`APP_BY_ID` only, like `ALACRITTY_APP`) — a Minecraft-style join menu that
|
||||
reads live data from `/api/ping?url=minecraft://…`.
|
||||
- Service shortcuts show the real site in an iframe via
|
||||
`createWebsiteApp({ embed: true })` (the shortcut's `embed` flag). It only
|
||||
works for sites that don't send `X-Frame-Options` / a `frame-ancestors` CSP.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,16 @@ export async function GET(request: Request) {
|
|||
cache: 'no-store',
|
||||
});
|
||||
const data = await res.json();
|
||||
return NextResponse.json({ status: data.online ? 'up' : 'down' });
|
||||
// Extra fields power the Minecraft-style SMP join window; ServiceButton
|
||||
// only reads `status`, so this stays backward compatible.
|
||||
return NextResponse.json({
|
||||
status: data.online ? 'up' : 'down',
|
||||
players: data.players?.online ?? null,
|
||||
maxPlayers: data.players?.max ?? null,
|
||||
version: data.version?.name_clean ?? null,
|
||||
motd: data.motd?.clean ?? null,
|
||||
icon: data.icon ?? null,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ status: 'down' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const SERVICE_SHORTCUTS: Shortcut[] = [
|
|||
{ id: "svc-proxmox", icon: "🖥️", label: "Proxmox", appId: "alacritty", color: "#1257a0", desc: "Server Management" },
|
||||
{ id: "svc-ai", icon: "🦾", label: "Llama.cpp", url: "https://ai.dvorakmortonxd.dev/", color: "#157347", desc: "Local AI Server", embed: true },
|
||||
{ id: "svc-jellyfin", icon: "🎬", label: "Jellyfin", url: "https://jelly.dvorakmortonxd.dev/", color: "#6a4fb3", desc: "Local Media Server", embed: true },
|
||||
{ id: "svc-smp", icon: "🎮", label: "SMP", url: "https://discord.gg/vyvhk7d8bX", color: "#c0392b", desc: "My Survival Minecraft Server" },
|
||||
{ id: "svc-smp", icon: "🎮", label: "SMP", appId: "smp", color: "#c0392b", desc: "My Survival Minecraft Server" },
|
||||
{ id: "svc-navidrome", icon: "🎸", label: "Navidrome", url: "https://navidrome.dvorakmortonxd.dev/", color: "#b3005f", desc: "Music Streaming", embed: true },
|
||||
{ id: "svc-gitdxd", icon: "🐙", label: "GitDXD", url: "https://git.dvorakmortonxd.dev", color: "#6a4fb3", desc: "Best Git Hosting OAT", embed: true },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -343,6 +343,129 @@ function GymApp() {
|
|||
);
|
||||
}
|
||||
|
||||
function SmpApp() {
|
||||
const address = "mc.dvorakmortonxd.dev";
|
||||
const [info, setInfo] = useState<{
|
||||
status: "pending" | "up" | "down";
|
||||
players: number | null;
|
||||
maxPlayers: number | null;
|
||||
version: string | null;
|
||||
motd: string | null;
|
||||
icon: string | null;
|
||||
}>({
|
||||
status: "pending",
|
||||
players: null,
|
||||
maxPlayers: null,
|
||||
version: null,
|
||||
motd: null,
|
||||
icon: null,
|
||||
});
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetch(`/api/ping?url=${encodeURIComponent(`minecraft://${address}:25565`)}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
setInfo({
|
||||
status: d.status === "up" ? "up" : "down",
|
||||
players: d.players ?? null,
|
||||
maxPlayers: d.maxPlayers ?? null,
|
||||
version: d.version ?? null,
|
||||
motd: d.motd ?? null,
|
||||
icon: d.icon ?? null,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setInfo((prev) => ({ ...prev, status: "down" }));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const online = info.status === "up";
|
||||
const pending = info.status === "pending";
|
||||
|
||||
const copyAddress = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch {
|
||||
/* clipboard unavailable - the address is shown below anyway */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-pad smp">
|
||||
<div className="smp-header">
|
||||
<h2 className="smp-title">Play Multiplayer</h2>
|
||||
<p className="smp-subtitle">dvorakmortonxd SMP</p>
|
||||
</div>
|
||||
|
||||
<div className={"smp-server" + (online ? "" : " smp-server-offline")}>
|
||||
<div className="smp-server-icon">
|
||||
{info.icon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={info.icon} alt="" />
|
||||
) : (
|
||||
<span className="smp-server-icon-fallback">⛏️</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="smp-server-info">
|
||||
<div className="smp-server-name">dvorakmortonxd SMP</div>
|
||||
<div className="smp-server-motd">
|
||||
{pending
|
||||
? "Pinging server..."
|
||||
: online
|
||||
? info.motd || "A survival Minecraft server"
|
||||
: "Can't connect to the server."}
|
||||
</div>
|
||||
<div className="smp-server-addr">
|
||||
{address}
|
||||
{online && info.version ? ` · ${info.version}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="smp-server-meta">
|
||||
<div className="smp-server-players">
|
||||
{online ? `${info.players ?? 0}/${info.maxPlayers ?? "?"}` : "—"}
|
||||
</div>
|
||||
<div
|
||||
className={"smp-ping" + (online ? " smp-ping-on" : "")}
|
||||
aria-label={online ? "online" : "offline"}
|
||||
>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<span key={i} style={{ height: `${4 + i * 3}px` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="smp-actions">
|
||||
<button type="button" className="smp-btn smp-btn-green" onClick={copyAddress}>
|
||||
{copied ? "Address Copied!" : "Join Server"}
|
||||
</button>
|
||||
<a
|
||||
className="smp-btn"
|
||||
href="https://discord.gg/vyvhk7d8bX"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="smp-hint">
|
||||
{copied
|
||||
? "Paste it in Minecraft → Multiplayer → Add Server."
|
||||
: "Join Server copies the address. Add it in Minecraft → Multiplayer → Add Server."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RevivalApp() {
|
||||
return (
|
||||
<div className="app-pad app-center">
|
||||
|
|
@ -622,6 +745,17 @@ export const ALACRITTY_APP: AppDef = {
|
|||
content: <Terminal />,
|
||||
};
|
||||
|
||||
/** Opened from the SMP service shortcut; styled like the MC multiplayer menu. */
|
||||
export const SMP_APP: AppDef = {
|
||||
id: "smp",
|
||||
title: "SMP",
|
||||
icon: "🎮",
|
||||
color: "#c0392b",
|
||||
width: 560,
|
||||
height: 430,
|
||||
content: <SmpApp />,
|
||||
};
|
||||
|
||||
function WebsiteApp({
|
||||
title,
|
||||
url,
|
||||
|
|
@ -724,5 +858,5 @@ export const APPS: AppDef[] = [
|
|||
];
|
||||
|
||||
export const APP_BY_ID: Record<string, AppDef> = Object.fromEntries(
|
||||
[...APPS, ALACRITTY_APP].map((a) => [a.id, a])
|
||||
[...APPS, ALACRITTY_APP, SMP_APP].map((a) => [a.id, a])
|
||||
);
|
||||
|
|
|
|||
|
|
@ -619,6 +619,152 @@ a.win-icon {
|
|||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
MINECRAFT SMP JOIN MENU (opened from the SMP service shortcut)
|
||||
============================================================ */
|
||||
.smp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
font-family: "Monocraft", "JetBrains Mono", monospace;
|
||||
color: #e6e6e6;
|
||||
background-color: #313131;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.045) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.045) 1px, transparent 1px);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
.smp-header {
|
||||
text-align: center;
|
||||
}
|
||||
.smp-title {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
color: #ffffff;
|
||||
text-shadow: 2px 2px 0 #1d1d1d;
|
||||
}
|
||||
.smp-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: #a8a8a8;
|
||||
}
|
||||
.smp-server {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 2px solid #000000;
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.smp-server-offline {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.smp-server-icon {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: #111111;
|
||||
border: 2px solid #000000;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.smp-server-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.smp-server-icon-fallback {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.smp-server-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.smp-server-name {
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
text-shadow: 2px 2px 0 #1d1d1d;
|
||||
}
|
||||
.smp-server-motd {
|
||||
margin-top: 3px;
|
||||
font-size: 0.76rem;
|
||||
color: #a8a8a8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.smp-server-addr {
|
||||
margin-top: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: #7a7a7a;
|
||||
}
|
||||
.smp-server-meta {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
.smp-server-players {
|
||||
font-size: 0.85rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
.smp-ping {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
height: 16px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.smp-ping span {
|
||||
width: 3px;
|
||||
background: #4a4a4a;
|
||||
}
|
||||
.smp-ping-on span {
|
||||
background: #4ade80;
|
||||
}
|
||||
.smp-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.smp .smp-btn {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 14px;
|
||||
font-family: inherit;
|
||||
font-size: 0.82rem;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.55);
|
||||
background: #6c6c6c;
|
||||
border: 2px solid;
|
||||
border-color: #a6a6a6 #2e2e2e #2e2e2e #a6a6a6;
|
||||
cursor: pointer;
|
||||
}
|
||||
.smp .smp-btn:hover {
|
||||
background: #7d7d7d;
|
||||
}
|
||||
.smp .smp-btn:active {
|
||||
border-color: #2e2e2e #a6a6a6 #a6a6a6 #2e2e2e;
|
||||
}
|
||||
.smp .smp-btn-green {
|
||||
background: #3f8f3f;
|
||||
border-color: #74d174 #1f4f1f #1f4f1f #74d174;
|
||||
}
|
||||
.smp .smp-btn-green:hover {
|
||||
background: #4aa34a;
|
||||
}
|
||||
.smp-hint {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
.app-surface .project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue