diff --git a/apps/admin-ui/src/app/globals.css b/apps/admin-ui/src/app/globals.css index f9a8eef..9db7afa 100644 --- a/apps/admin-ui/src/app/globals.css +++ b/apps/admin-ui/src/app/globals.css @@ -1652,3 +1652,72 @@ tr.row-selected { padding: 2.25rem 2.5rem 2.5rem; } } + +/* Install one-pager */ +.install-lang { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-bottom: 0.5rem; +} +.install-lang button { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.12); + color: #a1a1aa; + border-radius: 8px; + padding: 0.35rem 0.7rem; + font-size: 0.8rem; + cursor: pointer; +} +.install-lang button.active { + color: #f4f4f5; + border-color: rgba(147, 197, 253, 0.45); + background: rgba(147, 197, 253, 0.08); +} +.install-lead { + font-size: 1.05rem; + color: #d4d4d8; + line-height: 1.6; +} +.install-step-label { + margin: 1.1rem 0 0.35rem; + font-size: 0.85rem; + color: #e4e4e7; + font-weight: 600; +} +.install-code { + margin: 0; + padding: 0.9rem 1rem; + background: rgba(0, 0, 0, 0.45); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + overflow-x: auto; + font-size: 0.8rem; + line-height: 1.5; + color: #e4e4e7; + white-space: pre-wrap; +} +.install-tip { + margin-top: 1.25rem; + padding: 0.9rem 1rem; + border-radius: 10px; + border: 1px solid rgba(250, 204, 21, 0.25); + background: rgba(250, 204, 21, 0.06); +} +.install-tip strong { + color: #fde68a; + font-size: 0.85rem; +} +.install-tip p { + margin: 0.35rem 0 0; +} +.install-ol { + margin: 0.5rem 0 0.75rem; + padding-left: 1.25rem; +} +.install-ol li { + color: #a1a1aa; + line-height: 1.65; + font-size: 0.95rem; + margin-bottom: 0.4rem; +} diff --git a/apps/admin-ui/src/app/install/layout.tsx b/apps/admin-ui/src/app/install/layout.tsx new file mode 100644 index 0000000..35598a1 --- /dev/null +++ b/apps/admin-ui/src/app/install/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Install — Vonas Media Cluster", + description: + "Self-host Vonas Media Cluster: private media software for your own library, with apps that connect to your server.", + robots: { index: true, follow: true }, +}; + +export default function InstallLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/admin-ui/src/app/install/page.tsx b/apps/admin-ui/src/app/install/page.tsx new file mode 100644 index 0000000..b737014 --- /dev/null +++ b/apps/admin-ui/src/app/install/page.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; + +type Lang = "en" | "nl"; + +const COPY = { + en: { + kicker: "Vonas Media Cluster", + title: "Self-host your media library", + lead: + "VMC is private media software for your own movies and TV shows — like Emby or Plex, with apps for Android TV, phones, and more. Host it on your NAS or a small server; the Store apps connect to your instance.", + forWhom: "Who is this for?", + forWhomBody: + "Anyone who wants a personal media server at home (or on a VPS) and apps that talk to that server. You keep the files; we ship the software.", + reqTitle: "Requirements", + req: [ + "A Linux host, NAS, or VPS with Docker and Docker Compose", + "Storage for your media library", + "A domain or LAN hostname for HTTPS (recommended)", + "Outbound internet for metadata (TMDB) if you want posters and info", + ], + stepsTitle: "Install with Docker Compose", + stepsIntro: "On the host that will run Master (API + admin UI):", + afterTitle: "After it is running", + after: [ + "Open the admin UI (port 3001 by default) and sign in with ADMIN_EMAIL / ADMIN_PASSWORD from your .env.", + "Add media nodes (Unraid, Synology, …) so libraries can be scanned.", + "Create viewer accounts, then open the VMC app and choose “Connect to your server”.", + "Point the app at your Master URL (for example https://media.example.com) and sign in.", + ], + appsTitle: "Apps", + appsBody: + "Install Vonas Media Cluster from the app stores when available, or sideload the APK for Android TV. In the app, pick your own server URL — you are not locked to vonas.nl.", + privacyNote: "Privacy policy", + offlineTitle: "Install guide temporarily unavailable", + offlineBody: "This page is currently turned off by the server administrator.", + langNl: "Nederlands", + langEn: "English", + cloneLabel: "Get the compose stack", + envLabel: "Configure environment", + upLabel: "Start services", + tipTitle: "Tip", + tipBody: + "Change SESSION_SECRET, ADMIN_PASSWORD, and database passwords before exposing the stack to the internet. Set PUBLIC_URL to the HTTPS URL clients will use.", + }, + nl: { + kicker: "Vonas Media Cluster", + title: "Host je eigen mediabibliotheek", + lead: + "VMC is privé-mediasoftware voor je eigen films en series — vergelijkbaar met Emby of Plex, met apps voor Android TV, telefoons en meer. Draai het op je NAS of een kleine server; de Store-apps verbinden met jouw instance.", + forWhom: "Voor wie?", + forWhomBody: + "Iedereen die thuis (of op een VPS) een persoonlijke mediaserver wil, met apps die met die server praten. Jij houdt de bestanden; wij leveren de software.", + reqTitle: "Vereisten", + req: [ + "Een Linux-host, NAS of VPS met Docker en Docker Compose", + "Opslag voor je mediabibliotheek", + "Een domein of LAN-hostnaam voor HTTPS (aanbevolen)", + "Uitgaand internet voor metadata (TMDB) als je posters en info wilt", + ], + stepsTitle: "Installeren met Docker Compose", + stepsIntro: "Op de host die Master (API + admin-UI) draait:", + afterTitle: "Daarna", + after: [ + "Open de admin-UI (standaard poort 3001) en log in met ADMIN_EMAIL / ADMIN_PASSWORD uit je .env.", + "Voeg media-nodes toe (Unraid, Synology, …) zodat bibliotheken gescand kunnen worden.", + "Maak kijker-accounts aan, open daarna de VMC-app en kies “Connect to your server”.", + "Vul je Master-URL in (bijv. https://media.voorbeeld.nl) en log in.", + ], + appsTitle: "Apps", + appsBody: + "Installeer Vonas Media Cluster via de appstores wanneer beschikbaar, of sideload de APK voor Android TV. In de app kies je je eigen server-URL — je zit niet vast aan vonas.nl.", + privacyNote: "Privacybeleid", + offlineTitle: "Installatiegids tijdelijk niet beschikbaar", + offlineBody: "Deze pagina staat momenteel uit bij de serverbeheerder.", + langNl: "Nederlands", + langEn: "English", + cloneLabel: "Compose-stack ophalen", + envLabel: "Omgeving configureren", + upLabel: "Services starten", + tipTitle: "Tip", + tipBody: + "Wijzig SESSION_SECRET, ADMIN_PASSWORD en databasewachtwoorden voordat je de stack op internet zet. Zet PUBLIC_URL op de HTTPS-URL die clients gebruiken.", + }, +} as const; + +const CLONE = `git clone https://git.vonas.nl/jos/stremio.git +cd stremio/deploy/docker`; + +const ENV = `cp ../master/.env.example ../master/.env +# edit ../master/.env — especially SESSION_SECRET, ADMIN_*, PUBLIC_URL`; + +const UP = `docker compose up -d --build`; + +export default function InstallPage() { + const [lang, setLang] = useState("en"); + const [enabled, setEnabled] = useState(null); + const t = useMemo(() => COPY[lang], [lang]); + + useEffect(() => { + const q = new URLSearchParams(window.location.search).get("lang"); + if (q === "nl" || q === "en") setLang(q); + }, []); + + useEffect(() => { + let cancelled = false; + fetch("/api/v1/public/site") + .then(async (res) => { + if (!res.ok) return { installPageEnabled: true }; + return res.json(); + }) + .then((data) => { + if (!cancelled) setEnabled(data.installPageEnabled !== false); + }) + .catch(() => { + if (!cancelled) setEnabled(true); + }); + return () => { + cancelled = true; + }; + }, []); + + function switchLang(next: Lang) { + setLang(next); + const url = next === "en" ? "/install" : `/install?lang=${next}`; + window.history.replaceState(null, "", url); + } + + if (enabled === null) { + return ( +
+
+

Vonas Media Cluster

+

…

+
+
+ ); + } + + if (!enabled) { + return ( +
+
+
+ + +
+

{t.kicker}

+

{t.offlineTitle}

+

{t.offlineBody}

+
+
+ ); + } + + return ( +
+
+
+ + +
+ +

{t.kicker}

+

{t.title}

+

{t.lead}

+ +

{t.forWhom}

+

{t.forWhomBody}

+ +

{t.reqTitle}

+
    + {t.req.map((item) => ( +
  • {item}
  • + ))} +
+ +

{t.stepsTitle}

+

{t.stepsIntro}

+ +

{t.cloneLabel}

+
{CLONE}
+ +

{t.envLabel}

+
{ENV}
+ +

{t.upLabel}

+
{UP}
+ +
+ {t.tipTitle} +

{t.tipBody}

+
+ +

{t.afterTitle}

+
    + {t.after.map((item) => ( +
  1. {item}
  2. + ))} +
+ +

{t.appsTitle}

+

{t.appsBody}

+ +
+ {t.privacyNote} +
+
+
+ ); +} diff --git a/apps/admin-ui/src/app/settings/page.tsx b/apps/admin-ui/src/app/settings/page.tsx index 472f03c..d3dce29 100644 --- a/apps/admin-ui/src/app/settings/page.tsx +++ b/apps/admin-ui/src/app/settings/page.tsx @@ -118,6 +118,9 @@ export default function SettingsPage() { const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [installPageEnabled, setInstallPageEnabled] = useState(true); + const [installPageBusy, setInstallPageBusy] = useState(false); + const [installPageMsg, setInstallPageMsg] = useState(null); const [pwBusy, setPwBusy] = useState(false); const [pwMsg, setPwMsg] = useState(null); const [pwErr, setPwErr] = useState(null); @@ -210,8 +213,38 @@ export default function SettingsPage() { .then((r) => r.json()) .then((d) => setAdminEmail(d.user?.email ?? "")) .catch(() => undefined); + fetch("/api/v1/admin/site/install-page", { credentials: "include" }) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (d && typeof d.installPageEnabled === "boolean") { + setInstallPageEnabled(d.installPageEnabled); + } + }) + .catch(() => undefined); }, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay]); + async function saveInstallPageEnabled(enabled: boolean) { + setInstallPageBusy(true); + setInstallPageMsg(null); + try { + const res = await fetch("/api/v1/admin/site/install-page", { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setInstallPageMsg(data.error?.message ?? "Opslaan mislukt"); + return; + } + setInstallPageEnabled(data.installPageEnabled !== false); + setInstallPageMsg(enabled ? "Install-pagina staat aan." : "Install-pagina staat uit."); + } finally { + setInstallPageBusy(false); + } + } + useEffect(() => { if (!nodeId) return; const existing = configs.find((c) => c.nodeId === nodeId); @@ -479,6 +512,33 @@ export default function SettingsPage() { {pwBusy ? "Bezig…" : "Wachtwoord wijzigen"} + +
+

Publieke install-pagina

+

+ One-pager op{" "} + + /install + {" "} + (EN/NL) voor self-host instructies. Handig voor App Store-review. +

+
+
+ {installPageMsg && ( +

+ {installPageMsg} +

+ )} + +
)} diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index 63fbd81..4c6f693 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -10,6 +10,7 @@ import { registerNodeRoutes } from "./nodes/routes"; import { registerStremioRoutes } from "./stremio/routes"; import { registerAdminRoutes } from "./admin/routes"; import { registerInstallRoutes } from "./install/routes"; +import { registerSiteRoutes } from "./site/routes"; import { registerDownloadRoutes } from "./downloads/routes"; import { registerSettingsRoutes } from "./settings/routes"; import { registerViewerRoutes } from "./viewer/routes"; @@ -77,6 +78,7 @@ async function main() { await registerStremioRoutes(app, config); await registerAdminRoutes(app, config); await registerInstallRoutes(app, config); + registerSiteRoutes(app); const downloads = registerDownloadRoutes(app, config); registerViewerRoutes(app, config, downloads); const googlePlay = registerGooglePlayRoutes(app, config); diff --git a/apps/master-api/src/site/routes.ts b/apps/master-api/src/site/routes.ts new file mode 100644 index 0000000..0ff926a --- /dev/null +++ b/apps/master-api/src/site/routes.ts @@ -0,0 +1,71 @@ +import type { FastifyInstance } from "fastify"; +import { prisma } from "../database/client"; +import { requireAdmin } from "../auth/routes"; +import { AppError } from "../security/errors"; + +const SITE_SETTINGS_ID = "public_site"; + +type PublicSiteConfig = { + installPageEnabled: boolean; +}; + +function parseConfig(raw: string | null | undefined): Partial { + if (!raw) return {}; + try { + return JSON.parse(raw) as Partial; + } catch { + return {}; + } +} + +async function readSiteConfig(): Promise { + const row = await prisma.integrationSetting.findUnique({ where: { id: SITE_SETTINGS_ID } }); + if (!row) { + return { installPageEnabled: true }; + } + const parsed = parseConfig(row.configJson); + return { + installPageEnabled: parsed.installPageEnabled ?? row.enabled ?? true, + }; +} + +async function writeSiteConfig(cfg: PublicSiteConfig) { + await prisma.integrationSetting.upsert({ + where: { id: SITE_SETTINGS_ID }, + create: { + id: SITE_SETTINGS_ID, + enabled: cfg.installPageEnabled, + configJson: JSON.stringify(cfg), + }, + update: { + enabled: cfg.installPageEnabled, + configJson: JSON.stringify(cfg), + }, + }); +} + +export function registerSiteRoutes(app: FastifyInstance) { + app.get("/api/v1/public/site", async () => { + const cfg = await readSiteConfig(); + return { + installPageEnabled: cfg.installPageEnabled, + productName: "Vonas Media Cluster", + shortName: "VMC", + docsRepo: "https://git.vonas.nl/jos/stremio.git", + }; + }); + + app.get("/api/v1/admin/site/install-page", { preHandler: requireAdmin }, async () => { + return readSiteConfig(); + }); + + app.put("/api/v1/admin/site/install-page", { preHandler: requireAdmin }, async (request) => { + const body = (request.body || {}) as { enabled?: boolean }; + if (typeof body.enabled !== "boolean") { + throw new AppError("INVALID_REQUEST", "enabled (boolean) verplicht", 400); + } + const cfg = { installPageEnabled: body.enabled }; + await writeSiteConfig(cfg); + return cfg; + }); +}