Modernize frontend and centralize branding

This commit is contained in:
wfalkowski committed 2026-10-18 20:35:12 +00:00
1 parent bc4542d0fb
commit 2d61d80550
55 files changed
+1895 -14440

No files matched your search

+3 -2
View File
@@ -1,2 +1,3 @@
REACT_APP_API_URL=http://localhost:8080 VITE_API_URL=http://localhost:8080
REACT_APP_WS_URL=ws://localhost:8081 VITE_WS_URL=ws://localhost:8081
VITE_SITE_URL=https://anchorhost.eu
+1
View File
@@ -1,5 +1,6 @@
node_modules/ node_modules/
build/ build/
dist/
.env .env
.env.* .env.*
!.env.example !.env.example
+3 -3
View File
@@ -1,12 +1,12 @@
FROM node:20-alpine AS build FROM node:24-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci || npm install RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+8 -4
View File
@@ -4,9 +4,13 @@ React single-page application for the AnchorHost.eu hosting platform. The app so
## Development ## Development
1. Install Node.js and npm. 1. Install Node.js 24 LTS or newer and npm.
2. Run `npm install`. 2. Run `npm ci`.
3. Copy `.env.example` to `.env` and set the API/WebSocket URLs as needed. 3. Copy `.env.example` to `.env` and set the API/WebSocket URLs as needed.
4. Run `npm start`. 4. Run `npm run dev` (or `npm start`).
`npm run build` creates the production bundle. The included Dockerfile serves it with nginx and supports client-side routes. `npm run build` creates the production bundle in `dist/`. The included Dockerfile serves it with nginx and supports client-side routes. Vite expects JSX files to use `.jsx`; keep `.js` for plain JavaScript modules.
## Rebrand the deployment
Edit the root `branding.json` to set the product name, domain, logo path, public copy, contact addresses, documentation and source links, and footer credit. Replace the logo file at the configured path under `public/`. The app imports this file for its UI, API defaults, document metadata, and web app manifest. Keep `.env` API and WebSocket overrides for deployments whose service endpoints differ from the public brand domain.
+36 -35
View File
@@ -4,25 +4,26 @@ This skill is the working guide for understanding, changing, and extending the A
## What this project is ## What this project is
AnchorHost.eu is a React 18 single-page client for a hosting platform. It combines a public marketing site and documentation with a customer control panel for game servers, VPS instances, databases, websites, account management, support tickets, plugin discovery, and a privileged administration panel. The client talks to an HTTP API and a persistent WebSocket service. Most operations are user initiated, but dashboard data and operational status are kept live through WebSocket events. AnchorHost.eu is a React 19 single-page client for a hosting platform. It combines a public marketing site and documentation with a customer control panel for game servers, VPS instances, databases, websites, account management, support tickets, plugin discovery, and a privileged administration panel. The client talks to an HTTP API and a persistent WebSocket service. Most operations are user initiated, but dashboard data and operational status are kept live through WebSocket events.
The repository README states that the source was recovered from production source maps. `recovered-production/` keeps a compiled production bundle and maps as a reference artifact; normal development should modify `src/`, not compiled output. This is a frontend-only repository. Backend route implementation, validation, permission enforcement, schemas beyond what the client sends/reads, and server-side side effects cannot be established from this project alone. The repository README states that the source was recovered from production source maps. `recovered-production/` keeps a compiled production bundle and maps as a reference artifact; normal development should modify `src/`, not compiled output. This is a frontend-only repository. Backend route implementation, validation, permission enforcement, schemas beyond what the client sends/reads, and server-side side effects cannot be established from this project alone.
## Runtime and local development ## Runtime and local development
- React 18.3, Create React App / `react-scripts` 5, React Router 6, Tailwind CSS 3, PostCSS, and Autoprefixer. - React 19.3, Vite 8.3, React Router 7, Tailwind CSS 4.3, and the Vite React/Tailwind plugins. Versions are pinned in `package.json`; `package-lock.json` is the reproducible dependency tree.
- `npm start` starts CRA; `npm run build` produces the static bundle; `npm test` is not declared as a project script even though CRA's test runner exists. Do not eject CRA without a clear need. - `npm run dev` (or `npm start`) starts Vite; `npm run build` creates `dist/`; `npm run preview` serves the production build locally. There is no test script currently configured.
- `src/index.js` mounts the app and imports global CSS. `public/index.html` is the HTML shell. - `src/index.jsx` mounts the app and imports global CSS. Root `index.html` is the Vite HTML shell; static files under `public/` are served at the site root.
- JSX modules use `.jsx` extensions so Vite's parser treats them consistently; plain JavaScript utilities retain `.js`.
- `Dockerfile` builds/serves the static app with nginx; `nginx.conf` must preserve SPA route fallback so deep links such as `/dashboard` and `/plugins/:pluginId` resolve to `index.html`. - `Dockerfile` builds/serves the static app with nginx; `nginx.conf` must preserve SPA route fallback so deep links such as `/dashboard` and `/plugins/:pluginId` resolve to `index.html`.
- Runtime environment values are compiled into the frontend. `.env.example` shows `REACT_APP_API_URL=http://localhost:8080` and `REACT_APP_WS_URL=ws://localhost:8081`. - Runtime environment values are compiled into the frontend. `.env.example` shows `VITE_API_URL=http://localhost:8080` and `VITE_WS_URL=ws://localhost:8081`; `VITE_SITE_URL` can override the canonical public URL.
- `src/config/api.js` exports `API_BASE_URL`, `WS_BASE_URL`, and `SITE_BASE_URL`. Defaults are `https://anchorhost.eu` and `wss://anchorhost.eu/ws`; URL normalization strips one trailing slash. `SITE_BASE_URL` currently defaults to `API_BASE_URL`. Other code has a few direct origins/relative paths; review those before assuming changing the base URL relocates every request. - `src/config/api.js` exports `API_BASE_URL`, `WS_BASE_URL`, and `SITE_BASE_URL`. Defaults are `https://anchorhost.eu` and `wss://anchorhost.eu/ws`; URL normalization strips one trailing slash. `SITE_BASE_URL` currently defaults to `API_BASE_URL`. Other code has a few direct origins/relative paths; review those before assuming changing the base URL relocates every request.
- Never put secrets in `REACT_APP_*`; these values are public in the generated bundle. - Never put secrets in `VITE_*`; these values are public in the generated bundle.
## Repository map ## Repository map
```text ```text
src/ src/
App.js App shell, routes, auth and global notices/login/register App.jsx App shell, routes, auth and global notices/login/register
config/api.js HTTP, WebSocket, and site base URLs config/api.js HTTP, WebSocket, and site base URLs
index.css Global Tailwind layers, base styles, control/accessibility rules index.css Global Tailwind layers, base styles, control/accessibility rules
theme/colors.js Canonical named palette theme/colors.js Canonical named palette
@@ -37,11 +38,11 @@ public/assets/ Brand and marketing imagery; asset documentatio
recovered-production/ Compiled production snapshot and source maps; reference only recovered-production/ Compiled production snapshot and source maps; reference only
``` ```
The organization is feature-oriented but not fully uniform: large page components (especially `App.js`, `HomePage.js`, and `DashboardPage.js`) contain substantial orchestration and UI, while server/admin/support subfeatures have their own files. Follow existing imports and state ownership before extracting code into a new abstraction. The organization is feature-oriented but not fully uniform: large page components (especially `App.jsx`, `HomePage.jsx`, and `DashboardPage.jsx`) contain substantial orchestration and UI, while server/admin/support subfeatures have their own files. Follow existing imports and state ownership before extracting code into a new abstraction.
## Routes and application state ## Routes and application state
`src/App.js` owns the `BrowserRouter` route table and provides `AuthContext` (authentication flag, user, login/register/logout actions, and loading state). Routes: `src/App.jsx` owns the `BrowserRouter` route table and provides `AuthContext` (authentication flag, user, login/register/logout actions, and loading state). Routes:
| Path | View | Behavior | | Path | View | Behavior |
| --- | --- | --- | | --- | --- | --- |
@@ -57,7 +58,7 @@ The organization is feature-oriented but not fully uniform: large page component
| `/docs` | redirect | Replaces URL with `/documentation`. | | `/docs` | redirect | Replaces URL with `/documentation`. |
| `*` | redirect | Replaces unknown path with `/`. | | `*` | redirect | Replaces unknown path with `/`. |
Authentication is frontend-managed: `anchorhost_user` stores serialized user information and `anchorhost_token` stores a bearer token in `localStorage`. Restored state is parsed by `App.js`; malformed/missing pairs are cleared. This is not a secure cookie session. When changing auth persistence, update all consumers (many feature files read `anchorhost_token` directly), logout behavior, route guards, and WebSocket URL authentication together. OAuth state values are held temporarily in `sessionStorage` under `google_oauth_state` and `apple_oauth_state`. Cookie consent is stored as `cookie_consent=true`. Authentication is frontend-managed: `anchorhost_user` stores serialized user information and `anchorhost_token` stores a bearer token in `localStorage`. Restored state is parsed by `App.jsx`; malformed/missing pairs are cleared. This is not a secure cookie session. When changing auth persistence, update all consumers (many feature files read `anchorhost_token` directly), logout behavior, route guards, and WebSocket URL authentication together. OAuth state values are held temporarily in `sessionStorage` under `google_oauth_state` and `apple_oauth_state`. Cookie consent is stored as `cookie_consent=true`.
Login, registration, and two-factor challenge/verification primarily travel over the shared WebSocket (see protocol section). Google and Apple login bootstrap through HTTP and then redirect the browser to the returned `auth_url`. Password reset starts through HTTP. Email verification is a separate HTTP action. Login, registration, and two-factor challenge/verification primarily travel over the shared WebSocket (see protocol section). Google and Apple login bootstrap through HTTP and then redirect the browser to the returned `auth_url`. Password reset starts through HTTP. Email verification is a separate HTTP action.
@@ -84,11 +85,11 @@ Maintain the product principles of purpose, agency, familiarity, flexibility, si
| `border` / `brightBlack` | `#2F2F2F` | Strong outline and hard-shadow ink. | | `border` / `brightBlack` | `#2F2F2F` | Strong outline and hard-shadow ink. |
| `focus` | `#454545` | Focus-adjacent neutral boundary. | | `focus` | `#454545` | Focus-adjacent neutral boundary. |
| `foreground` | `#D0D0D0` | Body copy. | | `foreground` | `#D0D0D0` | Body copy. |
| `muted` | `#757575` | Secondary labels; do not use for essential small text. | | `muted` | `#898989` | Secondary copy and labels, with enough contrast for small text on the dark surfaces. |
| `dim` | `#505050` | Decorative or disabled content only. | | `dim` | `#505050` | Decorative or disabled content only. |
| `brightWhite` | `#F5F5F5` | Headings and key values. | | `brightWhite` | `#F5F5F5` | Headings and key values. |
| `primary` / `brightBlue` | `#6C99BB` | Primary actions and keyboard focus. | | `primary` / `brightBlue` | `#6C99BB` | Primary actions and keyboard focus. |
| `blue` | `#547C99` | Secondary accent. | | `accent_secondary` / `blue` | `#6C99BB` | Accent and link color, checked for readable contrast on panel surfaces. |
| `yellow` / `brightYellow` | `#D3A04D` | Warning state and selective emphasis. | | `yellow` / `brightYellow` | `#D3A04D` | Warning state and selective emphasis. |
| semantic green / red / cyan | `#7B9246` / `#A53C23` / `#5EA8A2` | Success, danger, and supporting status roles where required by existing UI. Keep labels/icons with color so meaning never depends on color alone. | | semantic green / red / cyan | `#7B9246` / `#A53C23` / `#5EA8A2` | Success, danger, and supporting status roles where required by existing UI. Keep labels/icons with color so meaning never depends on color alone. |
@@ -103,17 +104,17 @@ Dashboard semantic Tailwind tokens are `dash.surface`, `dash.surfaceElevated`, `
- Inputs are forced to at least 16px on narrow viewports to avoid mobile browser zoom. Touch target helpers provide 44px minimums. Horizontal overflow is clipped at the page level; code blocks and tables gain their own horizontal scrolling on mobile. - Inputs are forced to at least 16px on narrow viewports to avoid mobile browser zoom. Touch target helpers provide 44px minimums. Horizontal overflow is clipped at the page level; code blocks and tables gain their own horizontal scrolling on mobile.
- Global visible focus uses a 2px primary outline. Some dashboard controls opt out with `focus-skip-global` because they draw their own focus ring; preserve a visible keyboard focus indicator when editing those controls. - Global visible focus uses a 2px primary outline. Some dashboard controls opt out with `focus-skip-global` because they draw their own focus ring; preserve a visible keyboard focus indicator when editing those controls.
- Number steppers are globally hidden, range sliders use blue thumbs, and scrollbars are thin blue-gray. - Number steppers are globally hidden, range sliders use blue thumbs, and scrollbars are thin blue-gray.
- `HomePage.js` uses semantic `site-*` classes in `index.css`; this is the public landing-page system. Keep its header, service rows, estimator, FAQ, and footer aligned when adjusting the homepage. - `HomePage.jsx` uses semantic `site-*` classes in `index.css`; this is the public landing-page system. Keep its header, service rows, estimator, FAQ, and footer aligned when adjusting the homepage.
- `dashboard-app` and `admin-app` root classes scope cross-panel normalization in `index.css`. Dashboard primitives live in `components/dashboardPrimitives.js`; update them before hand-styling repeated panels. - `dashboard-app` and `admin-app` root classes scope cross-panel normalization in `index.css`. Dashboard primitives live in `components/dashboardPrimitives.jsx`; update them before hand-styling repeated panels.
- The homepage hero uses a manually controlled photo gallery backed by the local assets in `public/assets/`; keep arrow and thumbnail controls keyboard accessible and do not add autoplay by default. The homepage service selector and account country selector use the shared `Dropdown` primitive. - The homepage hero uses a manually controlled photo gallery backed by the local assets in `public/assets/`; keep arrow and thumbnail controls keyboard accessible and do not add autoplay by default. The homepage service selector and account country selector use the shared `Dropdown` primitive.
- `components/dashboardPrimitives.js` exports the shared `Dropdown` and `InlineDropdown`. The dropdown is a portal listbox, supports arrow/Home/End/Enter/Escape and prefix type-ahead, clamps itself to the viewport, and installs outside-click/scroll/resize listeners only while open. Keep popover styling in the global `.ah-dropdown-*` rules because portals render under `document.body`, outside `.app-container`. - `components/dashboardPrimitives.jsx` exports the shared `Dropdown` and `InlineDropdown`. The dropdown is a portal listbox, supports arrow/Home/End/Enter/Escape and prefix type-ahead, clamps itself to the viewport, and installs outside-click/scroll/resize listeners only while open. Keep popover styling in the global `.ah-dropdown-*` rules because portals render under `document.body`, outside `.app-container`.
- Visible brand copy uses `AnchorHost.eu`. Preserve lowercase technical identifiers and hostnames such as `anchorhost_token`, `anchorhost.eu`, and downloaded filenames; these are integration contracts, not display copy. - Visible brand copy uses `AnchorHost.eu`. Preserve lowercase technical identifiers and hostnames such as `anchorhost_token`, `anchorhost.eu`, and downloaded filenames; these are integration contracts, not display copy.
- Keep the layout useful at phone/tablet widths; the dashboard is a real management UI and should not depend on hover-only affordances. - Keep the layout useful at phone/tablet widths; the dashboard is a real management UI and should not depend on hover-only affordances.
### Interaction and content principles ### Interaction and content principles
- Display loading, empty, error, connected/disconnected, and completion states explicitly. Async work should not leave a button appearing inert. - Display loading, empty, error, connected/disconnected, and completion states explicitly. Async work should not leave a button appearing inert.
- Use the shared alert/modal patterns in `App.js`, `DashboardPage.js`, and feature components where practical. Confirm operations that delete accounts, servers, backups, or files; some existing flows use `window.confirm` and this pattern is intentional safety feedback. - Use the shared alert/modal patterns in `App.jsx`, `DashboardPage.jsx`, and feature components where practical. Confirm operations that delete accounts, servers, backups, or files; some existing flows use `window.confirm` and this pattern is intentional safety feedback.
- Keep server operations scoped by `serverId`, and ticket operations scoped by ticket ID. WebSocket messages are asynchronous; update the UI on the corresponding server acknowledgment/event rather than pretending a request succeeded immediately. - Keep server operations scoped by `serverId`, and ticket operations scoped by ticket ID. WebSocket messages are asynchronous; update the UI on the corresponding server acknowledgment/event rather than pretending a request succeeded immediately.
- Maintain existing capitalization and plain operational language. Preserve product terms (credits, VPS, SFTP, Modrinth) rather than replacing them with generic labels. - Maintain existing capitalization and plain operational language. Preserve product terms (credits, VPS, SFTP, Modrinth) rather than replacing them with generic labels.
- Respect reduced screen space and safe areas; global styles already account for dynamic viewport and mobile safe-area insets in some fixed UI. - Respect reduced screen space and safe areas; global styles already account for dynamic viewport and mobile safe-area insets in some fixed UI.
@@ -134,7 +135,7 @@ All `/api/...` routes below are observed in `src/`. HTTP methods and bodies are
| POST | `/api/verify-email` | No | JSON `{ token }`; token is read by `VerifyEmail` from URL query parameters. | | POST | `/api/verify-email` | No | JSON `{ token }`; token is read by `VerifyEmail` from URL query parameters. |
| GET | `/api/public/homepage-showcase` | No | Home page expects success and `links` array. | | GET | `/api/public/homepage-showcase` | No | Home page expects success and `links` array. |
| GET | `/api/public/stats` | No | Home page expects success and `user_count`. | | GET | `/api/public/stats` | No | Home page expects success and `user_count`. |
| GET | `/api/pricing` | No | Same route also loaded by `App.js`; keep in sync with public price display. | | GET | `/api/pricing` | No | Same route also loaded by `App.jsx`; keep in sync with public price display. |
| GET | `/api/announcements?limit=20` | No | Customer announcements panel; expects `{ success, announcements }`. | | GET | `/api/announcements?limit=20` | No | Customer announcements panel; expects `{ success, announcements }`. |
| GET | `/api/announcements?limit=50` | No | Admin announcement list loader; public read call. | | GET | `/api/announcements?limit=50` | No | Admin announcement list loader; public read call. |
| GET | `/api/exchange-rates` | No | Currency hook, store, and server creation form; expects `{ success, rates }`. | | GET | `/api/exchange-rates` | No | Currency hook, store, and server creation form; expects `{ success, rates }`. |
@@ -150,7 +151,7 @@ All `/api/...` routes below are observed in `src/`. HTTP methods and bodies are
| PUT | `/api/user/password` | Bearer | JSON includes `currentPassword` and `newPassword`. | | PUT | `/api/user/password` | Bearer | JSON includes `currentPassword` and `newPassword`. |
| DELETE | `/api/user/account` | Bearer | JSON `{ confirmation: confirmationText }`; on success the client clears auth storage/logs out. | | DELETE | `/api/user/account` | Bearer | JSON `{ confirmation: confirmationText }`; on success the client clears auth storage/logs out. |
| GET | `/api/user/export-data` | Bearer | Parses JSON; on `success`, serializes `data.data` as a downloadable JSON file. | | GET | `/api/user/export-data` | Bearer | Parses JSON; on `success`, serializes `data.data` as a downloadable JSON file. |
| POST | `/api/user/resend-verification` | Bearer | JSON request (inspect `DashboardPage.js` for current body; request is initiated from verification reminder). | | POST | `/api/user/resend-verification` | Bearer | JSON request (inspect `DashboardPage.jsx` for current body; request is initiated from verification reminder). |
### Servers, provisioning and checkout ### Servers, provisioning and checkout
@@ -241,7 +242,7 @@ The following message types are sent by the client. Fields shown are the fields
- `read_file { serverId, filePath }`; `write_file { serverId, filePath, content }`; `create_directory { serverId, dirPath }`; `delete_path { serverId, path }`. - `read_file { serverId, filePath }`; `write_file { serverId, filePath, content }`; `create_directory { serverId, dirPath }`; `delete_path { serverId, path }`.
- `move_path { serverId, srcPath, destPath }`; `copy_path { serverId, paths, ... }`; clipboard paste may send a list of paths. - `move_path { serverId, srcPath, destPath }`; `copy_path { serverId, paths, ... }`; clipboard paste may send a list of paths.
- `download_file { serverId, filePath }`. - `download_file { serverId, filePath }`.
- Chunk upload: `start_chunked_upload { serverId, filePath, ... }`, then `upload_file_chunk { serverId, filePath, uploadId, chunkIndex, totalChunks, chunkData, ... }`. The UI chunks at 512 KiB and resumes using in-memory upload tracking after reconnect. Keep encoding and acknowledgment flow aligned with `FileManager.js`. - Chunk upload: `start_chunked_upload { serverId, filePath, ... }`, then `upload_file_chunk { serverId, filePath, uploadId, chunkIndex, totalChunks, chunkData, ... }`. The UI chunks at 512 KiB and resumes using in-memory upload tracking after reconnect. Keep encoding and acknowledgment flow aligned with `FileManager.jsx`.
- Archive operations: `pack_zip { serverId, dirPath, zipPath }`, `unpack_zip { serverId, zipPath, ... }`. - Archive operations: `pack_zip { serverId, dirPath, zipPath }`, `unpack_zip { serverId, zipPath, ... }`.
**Console and terminal** **Console and terminal**
@@ -258,7 +259,7 @@ The following message types are sent by the client. Fields shown are the fields
**Support tickets** **Support tickets**
- `get_tickets { ...filters/pagination... }`, `get_ticket { ticketId }`, `create_ticket { ...form fields... }`, `send_ticket_message { ...ticket and message fields... }`, `update_ticket_status { ...ticket and status fields... }`. - `get_tickets { ...filters/pagination... }`, `get_ticket { ticketId }`, `create_ticket { ...form fields... }`, `send_ticket_message { ...ticket and message fields... }`, `update_ticket_status { ...ticket and status fields... }`.
- Admin actions include `claim` and `close` (ticket context is sent by the caller); inspect `TicketSystem.js` before changing the exact keys. - Admin actions include `claim` and `close` (ticket context is sent by the caller); inspect `TicketSystem.jsx` before changing the exact keys.
### Server-to-client event families ### Server-to-client event families
@@ -272,29 +273,29 @@ The UI explicitly handles these message types; the backend's full payloads are n
- Console/terminal: `game_console_output`, `game_console_result`, `vps_terminal_output`, `node_terminal_output`. - Console/terminal: `game_console_output`, `game_console_result`, `vps_terminal_output`, `node_terminal_output`.
- Backups: `backups_list`, `backup_status_updated`, `backup_deleted`, plus informational restoration status. - Backups: `backups_list`, `backup_status_updated`, `backup_deleted`, plus informational restoration status.
- Tickets: `tickets_list`, `ticket_created`, `ticket_details`, `ticket_message_sent`, `ticket_status_updated`, `ticket_stats`, `admin_notification_new_ticket`, `ticket_new_message`. - Tickets: `tickets_list`, `ticket_created`, `ticket_details`, `ticket_message_sent`, `ticket_status_updated`, `ticket_stats`, `admin_notification_new_ticket`, `ticket_new_message`.
- Domain management events are routed through the domain manager callback in `DashboardPage.js` and `DashboardComponents.js`; search both files for `domainMessageTypes` and corresponding handlers when changing that protocol. - Domain management events are routed through the domain manager callback in `DashboardPage.jsx` and `DashboardComponents.jsx`; search both files for `domainMessageTypes` and corresponding handlers when changing that protocol.
Always inspect the receiving switch/handler as well as the sending call before modifying a WebSocket contract. Preserve correlation fields such as `serverId`, file path, ticket ID, upload ID, or node name so events do not update the wrong active view. Keep high-volume terminal/file events scoped to their relevant UI, and avoid logging secrets or full file contents. Always inspect the receiving switch/handler as well as the sending call before modifying a WebSocket contract. Preserve correlation fields such as `serverId`, file path, ticket ID, upload ID, or node name so events do not update the wrong active view. Keep high-volume terminal/file events scoped to their relevant UI, and avoid logging secrets or full file contents.
## Feature ownership and extension map ## Feature ownership and extension map
- `App.js`: global routes, auth state, login/register UI and WebSocket, OAuth entry points, cookie consent, shared alert/location/pricing configuration. New routes belong in its route table; avoid duplicating auth state in page components. - `App.jsx`: global routes, auth state, login/register UI and WebSocket, OAuth entry points, cookie consent, shared alert/location/pricing configuration. New routes belong in its route table; avoid duplicating auth state in page components.
- `HomePage.js`: public brand/product landing page, homepage showcase and public stats. Marketing content and CTA route behavior live here. - `HomePage.jsx`: public brand/product landing page, homepage showcase and public stats. Marketing content and CTA route behavior live here.
- `DashboardPage.js`: authenticated customer workspace and its dashboard WebSocket dispatcher. It coordinates server selection, account panels, server actions, 2FA, credits, domain feature hooks, and server detail view composition. Keep dispatcher and child consumers coordinated. - `DashboardPage.jsx`: authenticated customer workspace and its dashboard WebSocket dispatcher. It coordinates server selection, account panels, server actions, 2FA, credits, domain feature hooks, and server detail view composition. Keep dispatcher and child consumers coordinated.
- `AdminPage.js` / `AdminSection.js`: admin route shell and admin settings, pricing, announcements, users, servers, cluster nodes, stats, and support actions. - `AdminPage.jsx` / `AdminSection.jsx`: admin route shell and admin settings, pricing, announcements, users, servers, cluster nodes, stats, and support actions.
- `ServerCreationForm.js`: provisioning configuration, capacity check, exchange rates, and demo eligibility. Creation itself is sent over WebSocket. - `ServerCreationForm.jsx`: provisioning configuration, capacity check, exchange rates, and demo eligibility. Creation itself is sent over WebSocket.
- `ServerDetailsModal.js`: selected server's metrics/logs/settings/version/ports, SFTP, sync/reinstall HTTP actions, and child tabs. - `ServerDetailsModal.jsx`: selected server's metrics/logs/settings/version/ports, SFTP, sync/reinstall HTTP actions, and child tabs.
- `FileManager.js`: remote file browsing/editing, safe destructive controls, upload/download, chunk resume, and zip actions. Its callbacks are fed by the dashboard socket; preserve this integration. - `FileManager.jsx`: remote file browsing/editing, safe destructive controls, upload/download, chunk resume, and zip actions. Its callbacks are fed by the dashboard socket; preserve this integration.
- `BackupSection.js`, `GameConsole.js`, `VPSTerminal.js`: narrowly scoped server tools driven by WebSocket. - `BackupSection.jsx`, `GameConsole.jsx`, `VPSTerminal.jsx`: narrowly scoped server tools driven by WebSocket.
- `StoreSection.js`: credits packages/currency and Stripe session start. - `StoreSection.js`: credits packages/currency and Stripe session start.
- `usePanelCurrency.js`: shared conversion rates/currency state. - `usePanelCurrency.js`: shared conversion rates/currency state.
- `PluginStore.js` and `PluginStoreEmbedded.js`: Modrinth search and two contexts; `PluginDetails.js` loads project versions and installs to a selected server. - `PluginStore.jsx` and `PluginStoreEmbedded.jsx`: Modrinth search and two contexts; `PluginDetails.jsx` loads project versions and installs to a selected server.
- `TicketSystem.js`: support-ticket UI and live socket protocol. - `TicketSystem.jsx`: support-ticket UI and live socket protocol.
- `AnnouncementsSection.js`: customer announcement list. - `AnnouncementsSection.js`: customer announcement list.
- `DocumentationPage.js`: long-form in-app documentation. Treat described defaults/credentials as user-facing claims that must match backend behavior. - `DocumentationPage.jsx`: long-form in-app documentation. Treat described defaults/credentials as user-facing claims that must match backend behavior.
- `LegalDocumentLayout.js`, `TermsOfService.js`, `PrivacyPolicy.js`: shared legal shell and static legal content. - `LegalDocumentLayout.jsx`, `TermsOfService.jsx`, `PrivacyPolicy.jsx`: shared legal shell and static legal content.
- `VerifyEmail.js`: HTTP verification callback view. - `VerifyEmail.jsx`: HTTP verification callback view.
- `DashboardComponents.js`, `dashboardPrimitives.js`, `components/ui/*`: shared dashboard panels, forms, navigation primitives, buttons/cards; extend before duplicating patterns. - `DashboardComponents.jsx`, `dashboardPrimitives.jsx`, `components/ui/*`: shared dashboard panels, forms, navigation primitives, buttons/cards; extend before duplicating patterns.
## Change workflow and quality rules ## Change workflow and quality rules
+25
View File
@@ -0,0 +1,25 @@
{
"name": "AnchorHost.eu",
"domain": "anchorhost.eu",
"siteUrl": "https://anchorhost.eu",
"logoPath": "/assets/transparent_anchorhost.png",
"description": "AnchorHost.eu game server hosting and management.",
"tagline": "Hosting, with room to work",
"heroTitle": "A control panel for the things you host.",
"heroDescription": "Run game servers, virtual machines, websites, and databases from one place. Keep the tools close and the busywork out of the way.",
"footerTagline": "Hosting services managed from one place.",
"supportEmail": "contact@anchorhost.eu",
"salesEmail": "sales@anchorhost.eu",
"supportEmailAddress": "support@anchorhost.eu",
"legalEmail": "legal@anchorhost.eu",
"privacyEmail": "privacy@anchorhost.eu",
"legalProviderName": "Wiktor Falkowski",
"legalProviderStatus": "unregistered business activity under Polish law",
"documentationUrl": "https://docs.anchorhost.eu",
"sourceCodeUrl": "https://git.anchorhost.eu/anchorhost",
"footerCredit": {
"name": "Wiktor Falkowski",
"url": "https://kvj.ovh"
},
"footerDescription": "High-Performance Cloud Solutions"
}
+2 -2
View File
@@ -6,9 +6,9 @@ hover = #282828
border = #2F2F2F border = #2F2F2F
focus = #454545 focus = #454545
text = #D0D0D0 text = #D0D0D0
muted = #757575 muted = #898989
dim = #505050 dim = #505050
emphasis = #F5F5F5 emphasis = #F5F5F5
accent = #6C99BB accent = #6C99BB
accent_secondary = #547C99 accent_secondary = #6C99BB
warning = #D3A04D warning = #D3A04D
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#151515" />
<meta name="description" content="%BRAND_DESCRIPTION%" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<title>%BRAND_NAME%</title>
</head>
<body>
<noscript>You need to enable JavaScript to run %BRAND_NAME%.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
+1 -1
View File
@@ -4,5 +4,5 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location / { try_files $uri $uri/ /index.html; } location / { try_files $uri $uri/ /index.html; }
location /static/ { expires 1y; add_header Cache-Control "public, immutable"; } location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; }
} }
+1323 -13971
View File
File diff suppressed because it is too large. Load diff
+28 -20
View File
@@ -2,25 +2,33 @@
"name": "anchorhost-frontend", "name": "anchorhost-frontend",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"dependencies": { "engines": {
"@testing-library/jest-dom": "^6.6.4", "node": ">=20.19.0"
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"framer-motion": "^11.15.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
"react-router-dom": "^6.28.1",
"react-scripts": "5.0.1",
"tailwindcss": "^3.4.17",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwind-merge": "^2.6.0",
"web-vitals": "^2.1.4"
}, },
"scripts": {"start": "react-scripts start", "build": "react-scripts build", "eject": "react-scripts eject"}, "scripts": {
"browserslist": {"production": [">0.2%", "not dead", "not op_mini all"], "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"]} "start": "vite --host 0.0.0.0",
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"axios": "1.20.0",
"clsx": "2.1.1",
"framer-motion": "14.3.0",
"lucide-react": "1.59.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-icons": "5.7.0",
"react-router-dom": "7.18.4",
"tailwind-merge": "3.7.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.8",
"@vitejs/plugin-react": "6.1.2",
"tailwindcss": "4.3.3",
"vite": "8.3.4"
}
} }
-1
View File
@@ -1 +0,0 @@
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };
+2 -17
View File
@@ -1,18 +1,3 @@
# Download Pricing Background Image # Hero Gallery Photo
Please download the pricing background image from: `pricing-bg.jpg` is currently used as the hero gallery's network equipment photo. It is by [Manuel Luikenga on Unsplash](https://unsplash.com/photos/ethernet-cables-connected-to-the-back-of-a-network-device-y4GHs9GEFdM) and is free to use under the Unsplash License.
https://cdn.pixabay.com/photo/2016/03/05/20/00/advertising-1238595_960_720.jpg
Save it as `pricing-bg.jpg` in this directory (`frontend/public/assets/`).
You can use:
```bash
cd frontend/public/assets
curl -o pricing-bg.jpg "https://cdn.pixabay.com/photo/2016/03/05/20/00/advertising-1238595_960_720.jpg"
```
Or:
```bash
cd frontend/public/assets
wget -O pricing-bg.jpg "https://cdn.pixabay.com/photo/2016/03/05/20/00/advertising-1238595_960_720.jpg"
```
+2 -2
View File
@@ -6,10 +6,10 @@ Recovered from https://www.anchorhost.eu/ on 2026-06-09.
|------|------|--------| |------|------|--------|
| `transparent_anchorhost.png` | 82 KB | `/assets/transparent_anchorhost.png` | | `transparent_anchorhost.png` | 82 KB | `/assets/transparent_anchorhost.png` |
| `hero-bg.jpg` | 41 KB | `/assets/hero-bg.jpg` | | `hero-bg.jpg` | 41 KB | `/assets/hero-bg.jpg` |
| `pricing-bg.jpg` | 129 KB | `/assets/pricing-bg.jpg` | | `pricing-bg.jpg` | Hero gallery: [Ethernet cables connected to a network device](https://unsplash.com/photos/ethernet-cables-connected-to-the-back-of-a-network-device-y4GHs9GEFdM) by Manuel Luikenga (Unsplash License) |
| `code.jpg` | 6.6 MB | `/assets/code.jpg` | | `code.jpg` | 6.6 MB | `/assets/code.jpg` |
| `dark-bg.png` | 1.8 MB | `/assets/dark-bg.png` | | `dark-bg.png` | 1.8 MB | `/assets/dark-bg.png` |
| `server-2.jpg` | 6.6 MB | `/assets/server-2.jpg` | | `server-2.jpg` | Hero gallery: [Two server racks filled with electronic components and wires](https://unsplash.com/photos/two-server-racks-filled-with-electronic-components-and-wires-k27hkqXuveo) by Eric Stoynov (Unsplash License) |
Root-level icons (in `public/`): Root-level icons (in `public/`):
Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 MiB

After

Width:  |  Height:  |  Size: 902 KiB

-14
View File
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0b0f16" />
<meta name="description" content="AnchorHost.eu game server hosting and management." />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>AnchorHost.eu</title>
</head>
<body><noscript>You need to enable JavaScript to run AnchorHost.eu.</noscript><div id="root"></div></body>
</html>
+14 -13
View File
@@ -1,16 +1,16 @@
import React, { useState, useEffect, useCallback, Suspense } from 'react'; import React, { useState, useEffect, useCallback, Suspense, lazy } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import DashboardPage from './pages/DashboardPage.js' const DashboardPage = lazy(() => import('./pages/DashboardPage.jsx'));
import HomePage from './pages/HomePage.js' const HomePage = lazy(() => import('./pages/HomePage.jsx'));
import AdminPage from './pages/AdminPage.js' const AdminPage = lazy(() => import('./pages/AdminPage.jsx'));
import VerifyEmail from './pages/VerifyEmail.js' const VerifyEmail = lazy(() => import('./pages/VerifyEmail.jsx'));
import TermsOfService from './pages/TermsOfService.js' const TermsOfService = lazy(() => import('./pages/TermsOfService.jsx'));
import PrivacyPolicy from './pages/PrivacyPolicy.js' const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy.jsx'));
import PluginStore from './features/plugins/PluginStore.js' const PluginStore = lazy(() => import('./features/plugins/PluginStore.jsx'));
import PluginDetails from './features/plugins/PluginDetails.js' const PluginDetails = lazy(() => import('./features/plugins/PluginDetails.jsx'));
import DocumentationPage from './pages/DocumentationPage.js' const DocumentationPage = lazy(() => import('./pages/DocumentationPage.jsx'));
import useWebSocket from './hooks/useWebSocket.js'; import useWebSocket from './hooks/useWebSocket.js';
import { colors } from './theme/colors'; import { colors } from './theme/colors';
import { Dropdown } from './components/dashboardPrimitives'; import { Dropdown } from './components/dashboardPrimitives';
@@ -21,6 +21,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { API_BASE_URL, WS_BASE_URL as WS_URL } from './config/api'; import { API_BASE_URL, WS_BASE_URL as WS_URL } from './config/api';
import branding from './branding';
// Auth context for user management // Auth context for user management
const AuthContext = React.createContext({ const AuthContext = React.createContext({
@@ -698,7 +699,7 @@ function LoginModal({ onClose, onLogin, onSwitchToRegister, colors, authConfig }
<div className="p-10"> <div className="p-10">
<div className="flex flex-col items-center text-center mb-10"> <div className="flex flex-col items-center text-center mb-10">
<div className="p-4 rounded-3xl mb-6 shadow-xl border" style={{ backgroundColor: `${colors.blue}15`, borderColor: `${colors.blue}30` }}> <div className="p-4 rounded-3xl mb-6 shadow-xl border" style={{ backgroundColor: `${colors.blue}15`, borderColor: `${colors.blue}30` }}>
<img src="/assets/transparent_anchorhost.png" alt="AnchorHost.eu" className="h-10 w-auto" /> <img src={branding.logoPath} alt={branding.name} className="h-10 w-auto" />
</div> </div>
<h3 className="text-3xl font-black tracking-tighter" style={{ color: colors.brightWhite }}> <h3 className="text-3xl font-black tracking-tighter" style={{ color: colors.brightWhite }}>
{is2faStep ? 'Verify Identity' : 'Welcome Back'} {is2faStep ? 'Verify Identity' : 'Welcome Back'}
@@ -828,7 +829,7 @@ function LoginModal({ onClose, onLogin, onSwitchToRegister, colors, authConfig }
<div className="mt-8 text-center"> <div className="mt-8 text-center">
<p className="text-xs font-bold" style={{ color: colors.foreground, opacity: 0.5 }}> <p className="text-xs font-bold" style={{ color: colors.foreground, opacity: 0.5 }}>
New to AnchorHost.eu?{' '} New to {branding.name}?{' '}
<button onClick={onSwitchToRegister} className="text-primary hover:underline ml-1">Create Account</button> <button onClick={onSwitchToRegister} className="text-primary hover:underline ml-1">Create Account</button>
</p> </p>
</div> </div>
@@ -1085,7 +1086,7 @@ function RegisterModal({
<Shield className="w-10 h-10" style={{ color: colors.brightGreen }} /> <Shield className="w-10 h-10" style={{ color: colors.brightGreen }} />
</div> </div>
<h3 className="text-3xl font-black tracking-tighter" style={{ color: colors.brightWhite }}>Create Account</h3> <h3 className="text-3xl font-black tracking-tighter" style={{ color: colors.brightWhite }}>Create Account</h3>
<p className="text-sm font-bold uppercase tracking-widest opacity-70 mt-2" style={{ color: colors.foreground }}>Join AnchorHost.eu</p> <p className="text-sm font-bold uppercase tracking-widest opacity-70 mt-2" style={{ color: colors.foreground }}>Join {branding.name}</p>
{error && ( {error && (
<p className="text-sm font-semibold mt-4" style={{ color: colors.brightRed }}>{error}</p> <p className="text-sm font-semibold mt-4" style={{ color: colors.brightRed }}>{error}</p>
)} )}
+7
View File
@@ -0,0 +1,7 @@
/**
* Shared public identity for the app. To rebrand a deployment, update the
* root branding.json and replace the logo at the configured public path.
*/
import branding from '../branding.json';
export default branding;
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useId, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useRef, useId, useCallback, useMemo } from 'react';
import branding from '../branding';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
CheckCircle, Search, CheckCircle, Search,
@@ -270,11 +271,12 @@ export const UnifiedTooltip = ({
? 'max-w-xs text-left leading-relaxed whitespace-normal break-words' ? 'max-w-xs text-left leading-relaxed whitespace-normal break-words'
: 'whitespace-nowrap'; : 'whitespace-nowrap';
const tooltipContent = isVisible ? ( const tooltipContent = (
<div <div
id={tooltipId} id={tooltipId}
role="tooltip" role="tooltip"
className={`ah-tooltip fixed z-[12000] px-3.5 py-2.5 text-sm pointer-events-none ${wrapClass}`} className={`ah-tooltip fixed z-[12000] px-3.5 py-2.5 text-sm pointer-events-none ${wrapClass}`}
hidden={!isVisible}
style={{ style={{
top: getTopPosition(), top: getTopPosition(),
left: `${position.left}px`, left: `${position.left}px`,
@@ -283,7 +285,14 @@ export const UnifiedTooltip = ({
> >
{content} {content}
</div> </div>
) : null; );
const describedChildren = React.Children.map(children, (child) => {
if (!React.isValidElement(child) || child.type === React.Fragment) return child;
const describedBy = [child.props['aria-describedby'], tooltipId]
.filter(Boolean)
.join(' ');
return React.cloneElement(child, { 'aria-describedby': describedBy || undefined });
});
return ( return (
<> <>
@@ -295,7 +304,7 @@ export const UnifiedTooltip = ({
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
> >
{children} {describedChildren}
</div> </div>
{tooltipContent && createPortal(tooltipContent, document.body)} {tooltipContent && createPortal(tooltipContent, document.body)}
</> </>
@@ -1115,7 +1124,7 @@ export const DomainManager = ({ userId, username, servers, sendMessage, showAler
</button> </button>
</div> </div>
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<SettingsPanel title="Domain Details" description="Choose a unique subdomain. It will end with .anchorhost.eu"> <SettingsPanel title="Domain Details" description={`Choose a unique subdomain. It will end with .${branding.domain}`}>
<div className="space-y-4"> <div className="space-y-4">
<div className="relative"> <div className="relative">
<input <input
@@ -1127,7 +1136,7 @@ export const DomainManager = ({ userId, username, servers, sendMessage, showAler
style={{ color: colors.brightWhite }} style={{ color: colors.brightWhite }}
/> />
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-black opacity-30 uppercase tracking-widest pointer-events-none"> <div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-black opacity-30 uppercase tracking-widest pointer-events-none">
.anchorhost.eu .{branding.domain}
</div> </div>
</div> </div>
</div> </div>
File renamed without changes.
@@ -74,7 +74,7 @@ export function StatTile({ icon: Icon, label, value, accentColor = colors.blue,
); );
} }
const actionBase = `inline-flex items-center justify-center gap-2 rounded-sm text-sm font-semibold transition-colors duration-150 motion-reduce:transition-none ${focusRing} min-h-[44px] sm:min-h-[40px] px-5 py-3 sm:py-2.5 shadow-[3px_3px_0_#2F2F2F] disabled:opacity-40 disabled:pointer-events-none active:translate-x-[3px] active:translate-y-[3px] active:shadow-none`; const actionBase = `inline-flex items-center justify-center gap-2 rounded-sm text-sm font-semibold transition-colors duration-150 motion-reduce:transition-none ${focusRing} min-h-[44px] sm:min-h-[40px] px-5 py-3 sm:py-2.5 disabled:opacity-40 disabled:pointer-events-none`;
/** /**
* Inset panel for settings rows (matches dash border / surface). * Inset panel for settings rows (matches dash border / surface).
File renamed without changes.
File renamed without changes.
+6 -4
View File
@@ -1,17 +1,19 @@
/** /**
* API / WebSocket base URLs for local dev and production. * API / WebSocket base URLs for local dev and production.
* Set REACT_APP_API_URL and REACT_APP_WS_URL in .env (see .env.example). * Set VITE_API_URL, VITE_WS_URL, and VITE_SITE_URL in .env (see .env.example).
*/ */
import branding from '../branding';
const trimTrailingSlash = (url) => (url || '').replace(/\/$/, ''); const trimTrailingSlash = (url) => (url || '').replace(/\/$/, '');
export const API_BASE_URL = trimTrailingSlash( export const API_BASE_URL = trimTrailingSlash(
process.env.REACT_APP_API_URL || 'https://anchorhost.eu' import.meta.env.VITE_API_URL || branding.siteUrl
); );
export const WS_BASE_URL = trimTrailingSlash( export const WS_BASE_URL = trimTrailingSlash(
process.env.REACT_APP_WS_URL || 'wss://anchorhost.eu/ws' import.meta.env.VITE_WS_URL || `wss://${branding.domain}/ws`
); );
export const SITE_BASE_URL = trimTrailingSlash( export const SITE_BASE_URL = trimTrailingSlash(
process.env.REACT_APP_SITE_URL || API_BASE_URL import.meta.env.VITE_SITE_URL || branding.siteUrl
); );
@@ -13,6 +13,7 @@ import ServerDetailsModal from '../servers/ServerDetailsModal';
import NodeTerminalModal from './NodeTerminalModal'; import NodeTerminalModal from './NodeTerminalModal';
import TicketSystem from '../support/TicketSystem'; import TicketSystem from '../support/TicketSystem';
import { colors } from '../../theme/colors'; import { colors } from '../../theme/colors';
import branding from '../../branding';
import { API_BASE_URL } from '../../config/api'; import { API_BASE_URL } from '../../config/api';
import { import {
StatTile, StatTile,
@@ -1121,7 +1122,7 @@ const AdminSection = ({ user, activeTab: parentActiveTab, showAlert }) => {
> >
<div className="mb-8"> <div className="mb-8">
<p className="text-sm text-dash-textMuted leading-relaxed max-w-2xl"> <p className="text-sm text-dash-textMuted leading-relaxed max-w-2xl">
Select three customer sites to feature on the public homepage. Use full URLs (e.g., https://site.anchorhost.eu). Leaving a URL empty will hide that slot. Select three customer sites to feature on the public homepage. Use full URLs (e.g., https://site.{branding.domain}). Leaving a URL empty will hide that slot.
</p> </p>
</div> </div>
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import branding from '../../branding';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
Terminal as TerminalIcon, Terminal as TerminalIcon,
@@ -58,7 +59,7 @@ const NodeTerminalModal = ({ node, onClose }) => {
const connectTerminal = useCallback(() => { const connectTerminal = useCallback(() => {
if (!node || !sendMessage) return; if (!node || !sendMessage) return;
setIsLoading(true); setIsLoading(true);
setTerminalOutput(prev => prev + `\r\n\x1b[36m[AnchorHost.eu]\x1b[0m Requesting shell session on node \x1b[1m${node.name}\x1b[0m...\r\n`); setTerminalOutput(prev => prev + `\r\n\x1b[36m[${branding.name}]\x1b[0m Requesting shell session on node \x1b[1m${node.name}\x1b[0m...\r\n`);
sendMessage({ sendMessage({
type: 'node_terminal_connect', type: 'node_terminal_connect',
nodeName: node.name nodeName: node.name
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { Calendar, Tag, ChevronRight, X, User } from "lucide-react"; import { Calendar, Tag, ChevronRight, X, User } from "lucide-react";
import { colors } from "../../theme/colors"; import { colors } from "../../theme/colors";
import { API_BASE_URL } from '../../config/api'; import { API_BASE_URL } from '../../config/api';
import branding from '../../branding';
const AnnouncementModal = ({ announcement, onClose }) => { const AnnouncementModal = ({ announcement, onClose }) => {
const dialogRef = useRef(null); const dialogRef = useRef(null);
@@ -194,7 +195,7 @@ const AnnouncementsSection = () => {
<section id="announcements" className="site-section site-announcements"> <section id="announcements" className="site-section site-announcements">
<div className="site-section-heading"> <div className="site-section-heading">
<div><p className="site-kicker">Platform updates</p><h2>Latest announcements.</h2></div> <div><p className="site-kicker">Platform updates</p><h2>Latest announcements.</h2></div>
<p>Service changes and notes from the AnchorHost.eu team.</p> <p>Service changes and notes from the {branding.name} team.</p>
</div> </div>
<div className="site-announcement-list"> <div className="site-announcement-list">
{displayedAnnouncements.map((item) => ( {displayedAnnouncements.map((item) => (
@@ -4,6 +4,7 @@ import { ArrowLeft } from 'lucide-react';
import { DashboardViewShell, PageHeaderBar, ActionButton } from '../../components/dashboardPrimitives'; import { DashboardViewShell, PageHeaderBar, ActionButton } from '../../components/dashboardPrimitives';
import { dashLayout } from '../../theme/dashboardTokens'; import { dashLayout } from '../../theme/dashboardTokens';
import { colors } from '../../theme/colors'; import { colors } from '../../theme/colors';
import branding from '../../branding';
/** /**
* Shared chrome for Terms / Privacy: dashboard shell, dash borders, typography. * Shared chrome for Terms / Privacy: dashboard shell, dash borders, typography.
@@ -26,9 +27,9 @@ export function LegalPageShell({ title, icon: Icon, accentColor = colors.blue, l
className="flex items-center gap-3 min-w-0 text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 group" className="flex items-center gap-3 min-w-0 text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 group"
> >
<div className="p-2 rounded-lg bg-white/5 border border-white/10 group-hover:scale-110 transition-transform"> <div className="p-2 rounded-lg bg-white/5 border border-white/10 group-hover:scale-110 transition-transform">
<img src="./assets/transparent_anchorhost.png" alt="" className="h-6 w-auto shrink-0" /> <img src={branding.logoPath} alt="" className="h-6 w-auto shrink-0" />
</div> </div>
<span className="text-xl font-bold text-dash-text truncate tracking-tight">AnchorHost.eu</span> <span className="text-xl font-bold text-dash-text truncate tracking-tight">{branding.name}</span>
</button> </button>
<ActionButton variant="secondary" className="!min-h-0 !px-4 !py-2.5 !text-sm !font-bold !rounded-xl" onClick={() => navigate('/')}> <ActionButton variant="secondary" className="!min-h-0 !px-4 !py-2.5 !text-sm !font-bold !rounded-xl" onClick={() => navigate('/')}>
<ArrowLeft className="w-4 h-4 shrink-0 mr-2" aria-hidden /> <ArrowLeft className="w-4 h-4 shrink-0 mr-2" aria-hidden />
@@ -51,7 +52,7 @@ export function LegalPageShell({ title, icon: Icon, accentColor = colors.blue, l
<div className="text-center pt-8 border-t border-dash-border"> <div className="text-center pt-8 border-t border-dash-border">
<p className="text-xs font-bold text-dash-textMuted uppercase tracking-[0.3em] opacity-40"> <p className="text-xs font-bold text-dash-textMuted uppercase tracking-[0.3em] opacity-40">
© {new Date().getFullYear()} AnchorHost.eu • High-Performance Cloud Solutions © {new Date().getFullYear()} {branding.name} • {branding.footerDescription}
</p> </p>
</div> </div>
</div> </div>
@@ -5,6 +5,7 @@ import { colors } from '../../theme/colors';
import { Dropdown } from '../../components/dashboardPrimitives'; import { Dropdown } from '../../components/dashboardPrimitives';
import { API_BASE_URL, WS_BASE_URL as WS_URL } from '../../config/api'; import { API_BASE_URL, WS_BASE_URL as WS_URL } from '../../config/api';
import branding from '../../branding';
function PluginDetails() { function PluginDetails() {
const { pluginId } = useParams(); const { pluginId } = useParams();
@@ -34,7 +35,7 @@ function PluginDetails() {
`${API_BASE_URL}/api/modrinth/project/${pluginId}`, `${API_BASE_URL}/api/modrinth/project/${pluginId}`,
{ {
headers: { headers: {
'User-Agent': 'AnchorHost.eu/1.0' 'User-Agent': `${branding.name}/1.0`
} }
} }
); );
File renamed without changes.
@@ -5,6 +5,7 @@ import {
import { colors, Dropdown, SectionCard, minecraftVersions } from '../../components/DashboardComponents'; import { colors, Dropdown, SectionCard, minecraftVersions } from '../../components/DashboardComponents';
import { API_BASE_URL } from '../../config/api'; import { API_BASE_URL } from '../../config/api';
import branding from '../../branding';
// Embedded Plugin Store Component for Dashboard // Embedded Plugin Store Component for Dashboard
const PluginStoreEmbedded = ({ serverId }) => { const PluginStoreEmbedded = ({ serverId }) => {
@@ -34,7 +35,7 @@ const PluginStoreEmbedded = ({ serverId }) => {
`${API_BASE_URL}/api/modrinth/project/${pluginId}`, `${API_BASE_URL}/api/modrinth/project/${pluginId}`,
{ {
headers: { headers: {
'User-Agent': 'AnchorHost.eu/1.0' 'User-Agent': `${branding.name}/1.0`
} }
} }
); );
File renamed without changes.
@@ -4,7 +4,7 @@ import {
Folder, File, FolderOpen, Upload, Download, Edit, FolderPlus, FileText, Archive, Image, Folder, File, FolderOpen, Upload, Download, Edit, FolderPlus, FileText, Archive, Image,
ChevronLeft, ChevronRight, Search, Check, Trash2, Copy, X, RefreshCw, CheckCircle ChevronLeft, ChevronRight, Search, Check, Trash2, Copy, X, RefreshCw, CheckCircle
} from "lucide-react"; } from "lucide-react";
import { colors, UnifiedTooltip, CustomTooltip } from '../../components/DashboardComponents.js'; import { colors, UnifiedTooltip, CustomTooltip } from '../../components/DashboardComponents.jsx';
// File Manager Component with forwardRef // File Manager Component with forwardRef
const FileManager = forwardRef(({ serverId, serverImage, sendMessage, isActive, connectionStatus, showAlert }, ref) => { const FileManager = forwardRef(({ serverId, serverImage, sendMessage, isActive, connectionStatus, showAlert }, ref) => {
File renamed without changes.
+196
View File
@@ -0,0 +1,196 @@
import React, { useEffect, useState } from 'react';
import {
Clock,
Coins,
Cpu,
Database,
Gamepad2,
Globe,
HardDrive,
MemoryStick,
Play,
Server,
Square,
Terminal,
} from 'lucide-react';
import { ActionButton, getServerStatusInfo } from '../../components/DashboardComponents.jsx';
import { colors } from '../../theme/colors.js';
const serverTypeIcons = {
vps: { icon: Server, color: colors.blue },
game: { icon: Gamepad2, color: colors.red },
website: { icon: Globe, color: colors.green },
database: { icon: Database, color: colors.magenta },
};
function calculateServerCost(server, pricing) {
if (server.is_demo === true || server.is_demo === 1) return 0;
const cpuCost = (parseInt(server.cpu, 10) || 0) * (pricing.price_cpu || 35);
const ramCost = (parseInt(server.ram, 10) || 0) * (pricing.price_ram || 35);
const storageCost = (parseInt(server.storage, 10) || 0) * (pricing.price_storage || 5);
const typeMultiplier = {
vps: pricing.multiplier_vps || 1,
game: pricing.multiplier_game || 0.8,
website: pricing.multiplier_website || 1.5,
database: pricing.multiplier_database || 1.3,
}[server.type] || 1;
return Math.round((cpuCost + ramCost + storageCost) * typeMultiplier);
}
export default function ServerCard({
server,
pricing,
userRole,
calculateCurrencyValue,
getCurrencySymbol,
onConvertDemo,
onOpenConsole,
onServerAction,
}) {
const statusInfo = getServerStatusInfo(server.status);
const { icon: ServerIcon, color: serverColor } = serverTypeIcons[server.type] || serverTypeIcons.vps;
const isDatabase = server.type === 'database';
const dbType = server.image || 'Unknown';
const isDemo = server.is_demo === true || server.is_demo === 1;
const monthlyCost = calculateServerCost(server, pricing);
const [timeRemaining, setTimeRemaining] = useState(null);
useEffect(() => {
if (!isDemo || !server.demo_expires_at) {
setTimeRemaining(null);
return undefined;
}
const updateTimer = () => {
const expiresAt = new Date(server.demo_expires_at);
const difference = expiresAt.getTime() - Date.now();
if (Number.isNaN(expiresAt.getTime())) {
setTimeRemaining(null);
} else if (difference <= 0) {
setTimeRemaining('Expired');
} else {
const hours = Math.floor(difference / 3_600_000);
const minutes = Math.floor((difference % 3_600_000) / 60_000);
const seconds = Math.floor((difference % 60_000) / 1000);
setTimeRemaining(`${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`);
}
};
updateTimer();
const interval = window.setInterval(updateTimer, 1000);
return () => window.clearInterval(interval);
}, [isDemo, server.demo_expires_at]);
return (
<article className="ah-server-card relative h-full" style={{ '--server-accent': serverColor }}>
<div className="ah-server-card-content">
<header className="ah-server-card-header">
<div className="ah-server-card-identity">
<div className="ah-server-type-icon" aria-hidden="true">
<ServerIcon className="w-5 h-5" style={{ color: serverColor }} />
</div>
<div className="ah-server-card-heading">
<div className="flex min-w-0 items-center gap-2">
<h3 className="ah-server-name" title={server.name}>{server.name}</h3>
{isDemo && <span className="ah-server-demo-tag">DEMO</span>}
</div>
<p className="ah-server-type-label">{isDatabase ? `${dbType} db` : `${server.type} node`}</p>
{isDemo && timeRemaining && (
<div className="ah-server-demo-time">
<Clock className="w-3 h-3" aria-hidden="true" />
<p>{timeRemaining === 'Expired' ? 'EXPIRED' : timeRemaining}</p>
</div>
)}
</div>
</div>
<div
className="ah-server-status"
aria-label={`Server status: ${statusInfo.label}`}
style={{
backgroundColor: `${statusInfo.color}15`,
borderColor: statusInfo.color,
color: statusInfo.color,
}}
>
{statusInfo.label}
</div>
</header>
<div className="ah-server-resource-grid">
<div className="ah-server-resource">
<div className="ah-server-resource-label"><Cpu className="w-4 h-4" aria-hidden="true" /><span>CPU</span></div>
<p>{server.cpu}</p>
</div>
<div className="ah-server-resource">
<div className="ah-server-resource-label"><MemoryStick className="w-4 h-4" aria-hidden="true" /><span>Memory</span></div>
<p>{server.ram}</p>
</div>
<div className="ah-server-resource">
<div className="ah-server-resource-label"><HardDrive className="w-4 h-4" aria-hidden="true" /><span>Storage</span></div>
<p>{server.storage}</p>
</div>
<div className="ah-server-resource ah-server-cost">
<div className="ah-server-resource-label"><Coins className="w-4 h-4" aria-hidden="true" /><span>Monthly cost</span></div>
<p className="ah-server-cost-value">
{isDemo ? 'FREE' : `${calculateCurrencyValue(monthlyCost)} ${getCurrencySymbol()}`}
</p>
</div>
</div>
<div className="ah-server-card-actions">
{isDemo && userRole !== 'admin' ? (
<ActionButton
variant="primary"
onClick={() => onConvertDemo(server.id)}
className="flex-1 !min-h-0 !py-3"
style={{ backgroundColor: colors.brightYellow, borderColor: colors.brightYellow, color: colors.black }}
>
<Coins className="w-4 h-4" />
<span className="font-black uppercase tracking-widest text-[10px]">Convert to paid</span>
</ActionButton>
) : (
<>
<ActionButton variant="secondary" onClick={() => onOpenConsole(server)} className="flex-1 !min-h-0 !py-3">
<Terminal className="w-4 h-4" />
<span className="font-black uppercase tracking-widest text-[10px]">Console</span>
</ActionButton>
{isDemo && (
<ActionButton
variant="warning"
onClick={() => onConvertDemo(server.id)}
className="aspect-square !p-0 !min-w-[48px]"
title="Convert demo to paid"
aria-label={`Convert ${server.name} demo to paid`}
>
<Coins className="w-4 h-4 text-yellow-400" />
</ActionButton>
)}
{server.status === 'Running' ? (
<ActionButton
variant="danger"
onClick={() => onServerAction(server.id, 'stop')}
className="aspect-square !p-0 !min-w-[48px]"
title={`Stop ${server.name}`}
aria-label={`Stop ${server.name}`}
>
<Square className="w-4 h-4 fill-current" />
</ActionButton>
) : (
<ActionButton
variant="success"
onClick={() => onServerAction(server.id, 'start')}
className="aspect-square !p-0 !min-w-[48px]"
title={`Start ${server.name}`}
aria-label={`Start ${server.name}`}
>
<Play className="w-4 h-4 fill-current" />
</ActionButton>
)}
</>
)}
</div>
</div>
</article>
);
}
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import branding from '../../branding';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
Server, Terminal, Play, Square, RefreshCw, Trash2, Maximize2, Minimize2, X, Server, Terminal, Play, Square, RefreshCw, Trash2, Maximize2, Minimize2, X,
@@ -888,7 +889,7 @@ const ServerDetailsModal = ({ server, onClose, onServerAction, showAlert }) => {
</div> </div>
<div> <div>
<p className="text-xs font-bold text-white">Automated Firewall</p> <p className="text-xs font-bold text-white">Automated Firewall</p>
<p className="text-[10px] opacity-40 leading-relaxed">The AnchorHost.eu firewall automatically opens all exposed ports in real-time.</p> <p className="text-[10px] opacity-40 leading-relaxed">The {branding.name} firewall automatically opens all exposed ports in real-time.</p>
</div> </div>
</div> </div>
</div> </div>
File renamed without changes.
@@ -16,6 +16,7 @@ import {
} from '../../components/DashboardComponents'; } from '../../components/DashboardComponents';
import { API_BASE_URL } from '../../config/api'; import { API_BASE_URL } from '../../config/api';
import branding from '../../branding';
const currencyFlags = { const currencyFlags = {
EUR: '🇪🇺', EUR: '🇪🇺',
@@ -203,7 +204,7 @@ function StoreSection({ userCredits, onAddCredits, onRefreshCredits, selectedCur
<SectionCard title="What are Credits?" icon={Info} color={colors.blue}> <SectionCard title="What are Credits?" icon={Info} color={colors.blue}>
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm leading-relaxed" style={{ color: colors.foreground }}> <p className="text-sm leading-relaxed" style={{ color: colors.foreground }}>
Credits are the <span className="text-brightWhite font-bold">universal currency</span> of AnchorHost.eu. Credits are the <span className="text-brightWhite font-bold">universal currency</span> of {branding.name}.
They allow you to pay for all our services with maximum flexibility. They allow you to pay for all our services with maximum flexibility.
</p> </p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -1,7 +1,7 @@
// TicketSystem.js - Updated with auto-reloading feature // TicketSystem.jsx - Updated with auto-reloading feature
import React, { useState, useEffect, useContext, useRef, useCallback } from 'react'; import React, { useState, useEffect, useContext, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { AuthContext } from '../../App.js'; import { AuthContext } from '../../App.jsx';
import useWebSocket from '../../hooks/useWebSocket.js'; import useWebSocket from '../../hooks/useWebSocket.js';
import { import {
MessageCircle, Plus, Search, Filter, Clock, AlertCircle, CheckCircle, MessageCircle, Plus, Search, Filter, Clock, AlertCircle, CheckCircle,
+9 -11
View File
@@ -1,6 +1,5 @@
@tailwind base; @import "tailwindcss";
@tailwind components; @config "../tailwind.config.js";
@tailwind utilities;
@layer base { @layer base {
html { html {
@@ -244,11 +243,11 @@ a.focus-skip-global:focus-visible {
--ah-border: #2F2F2F; --ah-border: #2F2F2F;
--ah-focus: #454545; --ah-focus: #454545;
--ah-text: #D0D0D0; --ah-text: #D0D0D0;
--ah-muted: #757575; --ah-muted: #898989;
--ah-dim: #505050; --ah-dim: #505050;
--ah-emphasis: #F5F5F5; --ah-emphasis: #F5F5F5;
--ah-accent: #6C99BB; --ah-accent: #6C99BB;
--ah-accent-secondary: #547C99; --ah-accent-secondary: #6C99BB;
--ah-warning: #D3A04D; --ah-warning: #D3A04D;
--ah-shadow: 4px 4px 0 var(--ah-border); --ah-shadow: 4px 4px 0 var(--ah-border);
} }
@@ -375,7 +374,7 @@ body { color: var(--ah-text); background: var(--ah-background); }
.ah-dropdown-empty { padding: 14px 12px; color: var(--ah-muted); font-size: 13px; } .ah-dropdown-empty { padding: 14px 12px; color: var(--ah-muted); font-size: 13px; }
.ah-inline-dropdown { display: flex; align-items: center; gap: 10px; width: 100%; min-width: 0; } .ah-inline-dropdown { display: flex; align-items: center; gap: 10px; width: 100%; min-width: 0; }
.ah-inline-dropdown-actions { display: flex; flex: 0 0 auto; gap: 6px; } .ah-inline-dropdown-actions { display: flex; flex: 0 0 auto; gap: 6px; }
.ah-dropdown-action { width: 42px; height: 42px; display: grid; place-items: center; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); box-shadow: 3px 3px 0 var(--ah-border); cursor: pointer; } .ah-dropdown-action { width: 42px; height: 42px; display: grid; place-items: center; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); cursor: pointer; }
.ah-dropdown-action--save { color: #7B9246; } .ah-dropdown-action--save { color: #7B9246; }
.ah-dropdown-action--cancel { color: #D3A04D; } .ah-dropdown-action--cancel { color: #D3A04D; }
.ah-dropdown-action:hover { background: var(--ah-hover); } .ah-dropdown-action:hover { background: var(--ah-hover); }
@@ -442,7 +441,7 @@ body { color: var(--ah-text); background: var(--ah-background); }
.app-container { background: var(--ah-background); color: var(--ah-text); } .app-container { background: var(--ah-background); color: var(--ah-text); }
.app-container [class*="backdrop-blur"] { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; } .app-container [class*="backdrop-blur"] { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
.app-container [class*="rounded-"] { border-radius: 2px !important; } .app-container [class*="rounded-"] { border-radius: 2px !important; }
.app-container [class*="shadow-"]:not(.shadow-none) { box-shadow: var(--ah-shadow) !important; } .app-container :not(button):not(a)[class*="shadow-"]:not(.shadow-none) { box-shadow: var(--ah-shadow) !important; }
.app-container [class*="hover:scale"] { transform: none !important; } .app-container [class*="hover:scale"] { transform: none !important; }
.app-container .uppercase { letter-spacing: .02em; } .app-container .uppercase { letter-spacing: .02em; }
.app-container .text-\[10px\] { font-size: 12px; } .app-container .text-\[10px\] { font-size: 12px; }
@@ -451,7 +450,8 @@ body { color: var(--ah-text); background: var(--ah-background); }
.app-container :is(button, a):focus-visible, .app-container :is(input, textarea, select):focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 2px; } .app-container :is(button, a):focus-visible, .app-container :is(input, textarea, select):focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 2px; }
/* React portals (menus and dialogs) mount beside #root, so carry the same theme there. */ /* React portals (menus and dialogs) mount beside #root, so carry the same theme there. */
body > div:not(#root) [class*="rounded-"] { border-radius: 2px !important; } body > div:not(#root) [class*="rounded-"] { border-radius: 2px !important; }
body > div:not(#root) [class*="shadow-"]:not(.shadow-none) { box-shadow: var(--ah-shadow) !important; } body > div:not(#root) :not(button):not(a)[class*="shadow-"]:not(.shadow-none) { box-shadow: var(--ah-shadow) !important; }
body > div:not(#root) :is(button, a)[class*="shadow-"] { box-shadow: none !important; transform: none !important; }
body > div:not(#root) [class*="backdrop-blur"] { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; } body > div:not(#root) [class*="backdrop-blur"] { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
body > div:not(#root) :is(button, a, input, textarea, select):focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 2px; } body > div:not(#root) :is(button, a, input, textarea, select):focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 2px; }
.dashboard-app, .admin-app { background: var(--ah-background) !important; color: var(--ah-text); } .dashboard-app, .admin-app { background: var(--ah-background) !important; color: var(--ah-text); }
@@ -485,9 +485,7 @@ body > div:not(#root) :is(button, a, input, textarea, select):focus-visible { ou
.ah-server-cost-value { color: var(--ah-emphasis) !important; } .ah-server-cost-value { color: var(--ah-emphasis) !important; }
.ah-server-card-actions { display: flex; gap: 8px; padding: 14px 16px; border-top: 2px solid var(--ah-border); background: var(--ah-surface); } .ah-server-card-actions { display: flex; gap: 8px; padding: 14px 16px; border-top: 2px solid var(--ah-border); background: var(--ah-surface); }
.ah-server-card-actions > button { min-height: 42px; } .ah-server-card-actions > button { min-height: 42px; }
.app-container .ah-server-card-actions .ah-server-action-button { box-shadow: none !important; transform: none !important; } .app-container :is(button, a)[class*="shadow-"] { box-shadow: none !important; transform: none !important; }
.app-container .ah-server-card-actions .ah-server-action-button:hover { box-shadow: none !important; transform: none !important; }
.app-container .ah-server-card-actions .ah-server-action-button:active { box-shadow: none !important; transform: none !important; }
/* Opaque tooltip panels use the same ink border and offset shadow as other surfaces. */ /* Opaque tooltip panels use the same ink border and offset shadow as other surfaces. */
.ah-tooltip { max-width: min(24rem, calc(100vw - 2rem)); border: 2px solid var(--ah-border); background: var(--ah-panel); color: var(--ah-emphasis); box-shadow: 5px 5px 0 var(--ah-border); line-height: 1.5; overflow-wrap: anywhere; } .ah-tooltip { max-width: min(24rem, calc(100vw - 2rem)); border: 2px solid var(--ah-border); background: var(--ah-panel); color: var(--ah-emphasis); box-shadow: 5px 5px 0 var(--ah-border); line-height: 1.5; overflow-wrap: anywhere; }
-17
View File
@@ -1,17 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -26,6 +26,7 @@ import AdminSection from '../features/admin/AdminSection';
import { colors } from '../theme/colors'; import { colors } from '../theme/colors';
import useWebSocket from '../hooks/useWebSocket'; import useWebSocket from '../hooks/useWebSocket';
import { API_BASE_URL, WS_BASE_URL } from '../config/api'; import { API_BASE_URL, WS_BASE_URL } from '../config/api';
import branding from '../branding';
import { import {
ActionButton, ActionButton,
UnifiedTooltip UnifiedTooltip
@@ -130,7 +131,7 @@ const AdminPage = () => {
<div className="p-6 border-b border-dash-border bg-black/10"> <div className="p-6 border-b border-dash-border bg-black/10">
<div className="flex items-center space-x-3 group cursor-pointer" onClick={() => navigate('/')}> <div className="flex items-center space-x-3 group cursor-pointer" onClick={() => navigate('/')}>
<div className="p-2 rounded-xl bg-brightRed/10 border border-brightRed/20 transition-all group-hover:scale-110 shadow-lg shadow-brightRed/5"> <div className="p-2 rounded-xl bg-brightRed/10 border border-brightRed/20 transition-all group-hover:scale-110 shadow-lg shadow-brightRed/5">
<img src="/assets/transparent_anchorhost.png" alt="" className="h-7 w-auto shrink-0" /> <img src={branding.logoPath} alt="" className="h-7 w-auto shrink-0" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-xl font-bold tracking-tight text-dash-text truncate"> <h1 className="text-xl font-bold tracking-tight text-dash-text truncate">
@@ -23,12 +23,13 @@ import {
ArrowRight, ArrowRight,
} from "lucide-react"; } from "lucide-react";
import ServerCreationForm from '../features/servers/ServerCreationForm.js' import ServerCreationForm from '../features/servers/ServerCreationForm.jsx'
import StoreSection from '../features/store/StoreSection.js' import ServerCard from '../features/servers/ServerCard.jsx'
import TicketSystem from '../features/support/TicketSystem.js' import StoreSection from '../features/store/StoreSection.jsx'
import TicketSystem from '../features/support/TicketSystem.jsx'
import useWebSocket from '../hooks/useWebSocket.js'; import useWebSocket from '../hooks/useWebSocket.js';
import { AuthContext } from '../App.js'; import { AuthContext } from '../App.jsx';
import GameConsole from '../features/servers/GameConsole.js'; import GameConsole from '../features/servers/GameConsole.jsx';
import { import {
colors, colors,
parseServerDate, parseServerDate,
@@ -51,15 +52,16 @@ import {
DashboardEmptyState, DashboardEmptyState,
DashboardLoadingSpinner, DashboardLoadingSpinner,
SettingsPanel, SettingsPanel,
} from '../components/DashboardComponents.js'; } from '../components/DashboardComponents.jsx';
import { dashLayout } from '../theme/dashboardTokens.js'; import { dashLayout } from '../theme/dashboardTokens.js';
import FileManager from '../features/servers/FileManager.js'; import FileManager from '../features/servers/FileManager.jsx';
import BackupSection from '../features/servers/BackupSection.js'; import BackupSection from '../features/servers/BackupSection.jsx';
import PluginStoreEmbedded from '../features/plugins/PluginStoreEmbedded.js'; import PluginStoreEmbedded from '../features/plugins/PluginStoreEmbedded.jsx';
import { usePanelCurrency } from '../hooks/usePanelCurrency.js'; import { usePanelCurrency } from '../hooks/usePanelCurrency.js';
import { API_BASE_URL, WS_BASE_URL as WS_URL } from '../config/api'; import { API_BASE_URL, WS_BASE_URL as WS_URL } from '../config/api';
import branding from '../branding';
const NODE_IP = '85.237.179.121'; const NODE_IP = '85.237.179.121';
@@ -1885,212 +1887,6 @@ const handleWebSocketMessage = useCallback((data) => {
); );
}; };
// Server tiles keep the name and status in separate columns so long names cannot hide state.
const ServerCard = ({ server }) => {
const statusInfo = getServerStatusInfo(server.status);
const serverTypeIcons = {
'vps': { icon: Server, color: colors.blue },
'game': { icon: Gamepad2, color: colors.red },
'website': { icon: Globe, color: colors.green },
'database': { icon: Database, color: colors.magenta }
};
const ServerIcon = serverTypeIcons[server.type]?.icon || Server;
const serverColor = serverTypeIcons[server.type]?.color || colors.blue;
const isDatabase = server.type === 'database';
const dbType = server.image || 'Unknown';
const isDemo = server.is_demo === true || server.is_demo === 1;
const calculateServerCost = (server) => {
if (isDemo) return 0;
const cpuCores = parseInt(server.cpu) || 0;
const ramGb = parseInt(server.ram) || 0;
const storageGb = parseInt(server.storage) || 0;
const cpuCost = cpuCores * (pricing.price_cpu || 35);
const ramCost = ramGb * (pricing.price_ram || 35);
const storageCost = storageGb * (pricing.price_storage || 5);
const typeMultiplier = {
'vps': pricing.multiplier_vps || 1.0,
'game': pricing.multiplier_game || 0.8,
'website': pricing.multiplier_website || 1.5,
'database': pricing.multiplier_database || 1.3
}[server.type] || 1.0;
return Math.round((cpuCost + ramCost + storageCost) * typeMultiplier);
};
const serverMonthlyCost = calculateServerCost(server);
// Demo countdown timer
const [timeRemaining, setTimeRemaining] = useState(null);
useEffect(() => {
if (!isDemo || !server.demo_expires_at) {
setTimeRemaining(null);
return;
}
const updateTimer = () => {
const expiresAt = new Date(server.demo_expires_at);
const now = new Date();
if (isNaN(expiresAt.getTime())) { setTimeRemaining(null); return; }
const diff = expiresAt - now;
if (diff <= 0) { setTimeRemaining('Expired'); return; }
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
setTimeRemaining(`${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
};
updateTimer();
const interval = setInterval(updateTimer, 1000);
return () => clearInterval(interval);
}, [isDemo, server.demo_expires_at]);
return (
<div
className="ah-server-card relative h-full"
style={{
'--server-accent': serverColor,
}}
>
<div className="ah-server-card-content">
<div className="ah-server-card-header">
<div className="ah-server-card-identity">
<div className="ah-server-type-icon" aria-hidden="true">
<ServerIcon className="w-5 h-5" style={{ color: serverColor }} />
</div>
<div className="ah-server-card-heading">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="ah-server-name" title={server.name}>{server.name}</h3>
{isDemo && (
<span className="ah-server-demo-tag">
DEMO
</span>
)}
</div>
<p className="ah-server-type-label">
{isDatabase ? `${dbType} db` : `${server.type} node`}
</p>
{isDemo && timeRemaining && (
<div className="ah-server-demo-time">
<Clock className="w-3 h-3" style={{ color: colors.brightYellow }} />
<p>
{timeRemaining === 'Expired' ? 'EXPIRED' : timeRemaining}
</p>
</div>
)}
</div>
</div>
<div className="ah-server-status"
aria-label={`Server status: ${statusInfo.label}`}
style={{
backgroundColor: `${statusInfo.color}15`,
borderColor: statusInfo.color,
color: statusInfo.color,
}}
>
{statusInfo.label}
</div>
</div>
<div className="ah-server-resource-grid">
<div className="ah-server-resource">
<div className="ah-server-resource-label">
<Cpu className="w-4 h-4" aria-hidden="true" />
<span>CPU</span>
</div>
<p>{server.cpu}</p>
</div>
<div className="ah-server-resource">
<div className="ah-server-resource-label">
<MemoryStick className="w-4 h-4" aria-hidden="true" />
<span>Memory</span>
</div>
<p>{server.ram}</p>
</div>
<div className="ah-server-resource">
<div className="ah-server-resource-label">
<HardDrive className="w-4 h-4" aria-hidden="true" />
<span>Storage</span>
</div>
<p>{server.storage}</p>
</div>
<div className="ah-server-resource ah-server-cost">
<div className="ah-server-resource-label">
<Coins className="w-4 h-4" aria-hidden="true" />
<span>Monthly cost</span>
</div>
<p className="ah-server-cost-value">
{isDemo ? 'FREE' : `${calculateCurrencyValue(serverMonthlyCost)} ${getCurrencySymbol()}`}
</p>
</div>
</div>
<div className="ah-server-card-actions">
{isDemo && user?.role !== 'admin' ? (
<ActionButton
variant="primary"
onClick={() => handleConvertDemoToPaid(server.id)}
className="ah-server-action-button flex-1 !min-h-0 !py-3"
style={{ backgroundColor: colors.brightYellow, borderColor: colors.brightYellow, color: colors.black }}
>
<Coins className="w-4 h-4" />
<span className="font-black uppercase tracking-widest text-[10px]">Convert to paid</span>
</ActionButton>
) : (
<>
<ActionButton
variant="secondary"
onClick={() => {
setSelectedServer(server);
setActiveServerDetailTab('overview');
fetchServerLogs(server.id);
}}
className="ah-server-action-button flex-1 !min-h-0 !py-3"
>
<Terminal className="w-4 h-4" />
<span className="font-black uppercase tracking-widest text-[10px]">Console</span>
</ActionButton>
{isDemo && (
<ActionButton
variant="warning"
onClick={() => handleConvertDemoToPaid(server.id)}
className="ah-server-action-button aspect-square !p-0 !min-w-[48px]"
title="Convert demo to paid"
>
<Coins className="w-4 h-4 text-yellow-400" />
</ActionButton>
)}
{server.status === 'Running' ? (
<ActionButton
variant="danger"
onClick={() => handleServerAction(server.id, 'stop')}
className="ah-server-action-button aspect-square !p-0 !min-w-[48px]"
>
<Square className="w-4 h-4 fill-current" />
</ActionButton>
) : (
<ActionButton
variant="success"
onClick={() => handleServerAction(server.id, 'start')}
className="ah-server-action-button aspect-square !p-0 !min-w-[48px]"
>
<Play className="w-4 h-4 fill-current" />
</ActionButton>
)}
</>
)}
</div>
</div>
</div>
);
};
const dashboardSidebarItems = useMemo( const dashboardSidebarItems = useMemo(
() => [ () => [
{ id: 'servers', label: 'My Servers', icon: Server, color: colors.blue }, { id: 'servers', label: 'My Servers', icon: Server, color: colors.blue },
@@ -2119,7 +1915,7 @@ const handleWebSocketMessage = useCallback((data) => {
<div className="p-6 border-b border-dash-border bg-black/10"> <div className="p-6 border-b border-dash-border bg-black/10">
<div className="flex items-center space-x-3 group cursor-pointer" onClick={() => navigate('/')}> <div className="flex items-center space-x-3 group cursor-pointer" onClick={() => navigate('/')}>
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20 transition-all group-hover:scale-110 shadow-lg shadow-primary/5"> <div className="p-2 rounded-xl bg-primary/10 border border-primary/20 transition-all group-hover:scale-110 shadow-lg shadow-primary/5">
<img src="/assets/transparent_anchorhost.png" alt="" className="h-7 w-auto shrink-0" /> <img src={branding.logoPath} alt="" className="h-7 w-auto shrink-0" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-xl font-bold tracking-tight text-dash-text leading-tight">Dashboard</h1> <h1 className="text-xl font-bold tracking-tight text-dash-text leading-tight">Dashboard</h1>
@@ -2279,7 +2075,21 @@ const handleWebSocketMessage = useCallback((data) => {
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{servers.map((server) => ( {servers.map((server) => (
<ServerCard key={server.id} server={server} /> <ServerCard
key={server.id}
server={server}
pricing={pricing}
userRole={user?.role}
calculateCurrencyValue={calculateCurrencyValue}
getCurrencySymbol={getCurrencySymbol}
onConvertDemo={handleConvertDemoToPaid}
onOpenConsole={(targetServer) => {
setSelectedServer(targetServer);
setActiveServerDetailTab('overview');
fetchServerLogs(targetServer.id);
}}
onServerAction={handleServerAction}
/>
))} ))}
</div> </div>
)} )}
@@ -2548,7 +2358,7 @@ const handleWebSocketMessage = useCallback((data) => {
<div className="rounded-lg border border-dash-border bg-brightBlack/40 px-3 py-2.5 text-sm"> <div className="rounded-lg border border-dash-border bg-brightBlack/40 px-3 py-2.5 text-sm">
{user?.google_id ? ( {user?.google_id ? (
<p className="text-dash-textMuted"> <p className="text-dash-textMuted">
No AnchorHost.eu password — you sign in with Google. No {branding.name} password — you sign in with Google.
</p> </p>
) : ( ) : (
<> <>
@@ -2564,7 +2374,7 @@ const handleWebSocketMessage = useCallback((data) => {
<UnifiedTooltip <UnifiedTooltip
content={ content={
user?.google_id user?.google_id
? 'Google sign-in accounts do not use an AnchorHost.eu password. Manage security in Google.' ? `Google sign-in accounts do not use a ${branding.name} password. Manage security in Google.`
: 'Choose a new password. You will need your current password to confirm.' : 'Choose a new password. You will need your current password to confirm.'
} }
multiline multiline
@@ -2846,33 +2656,33 @@ const handleWebSocketMessage = useCallback((data) => {
<div className="flex items-center justify-between p-3 rounded-lg" style={{ backgroundColor: colors.black }}> <div className="flex items-center justify-between p-3 rounded-lg" style={{ backgroundColor: colors.black }}>
<span style={{ color: colors.foreground }}>Email Support:</span> <span style={{ color: colors.foreground }}>Email Support:</span>
<a <a
href="mailto:support@anchorhost.eu" href={`mailto:${branding.supportEmailAddress}`}
className="font-semibold hover:underline transition-all duration-200 hover:scale-105" className="font-semibold hover:underline transition-all duration-200 hover:scale-105"
style={{ color: colors.magenta }} style={{ color: colors.magenta }}
> >
support@anchorhost.eu {branding.supportEmailAddress}
</a> </a>
</div> </div>
<div className="flex items-center justify-between p-3 rounded-lg" style={{ backgroundColor: colors.black }}> <div className="flex items-center justify-between p-3 rounded-lg" style={{ backgroundColor: colors.black }}>
<span style={{ color: colors.foreground }}>Sales Inquiries:</span> <span style={{ color: colors.foreground }}>Sales Inquiries:</span>
<a <a
href="mailto:sales@anchorhost.eu" href={`mailto:${branding.salesEmail}`}
className="font-semibold hover:underline transition-all duration-200 hover:scale-105" className="font-semibold hover:underline transition-all duration-200 hover:scale-105"
style={{ color: colors.magenta }} style={{ color: colors.magenta }}
> >
sales@anchorhost.eu {branding.salesEmail}
</a> </a>
</div> </div>
<div className="flex items-center justify-between p-3 rounded-lg" style={{ backgroundColor: colors.black }}> <div className="flex items-center justify-between p-3 rounded-lg" style={{ backgroundColor: colors.black }}>
<span style={{ color: colors.foreground }}>Business Contact:</span> <span style={{ color: colors.foreground }}>Business Contact:</span>
<a <a
href="mailto:contact@anchorhost.eu" href={`mailto:${branding.supportEmail}`}
className="font-semibold hover:underline transition-all duration-200 hover:scale-105" className="font-semibold hover:underline transition-all duration-200 hover:scale-105"
style={{ color: colors.magenta }} style={{ color: colors.magenta }}
> >
contact@anchorhost.eu {branding.supportEmail}
</a> </a>
</div> </div>
</div> </div>
@@ -2902,7 +2712,7 @@ const handleWebSocketMessage = useCallback((data) => {
</button> </button>
<a <a
href="https://docs.anchorhost.eu" href={branding.documentationUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-4 rounded-xl border transition-all duration-200 hover:scale-105 text-center" className="p-4 rounded-xl border transition-all duration-200 hover:scale-105 text-center"
@@ -3580,7 +3390,7 @@ const handleWebSocketMessage = useCallback((data) => {
<div className="px-5 sm:px-6 pb-6 border-t border-white/5 animate-in slide-in-from-top-2 duration-200"> <div className="px-5 sm:px-6 pb-6 border-t border-white/5 animate-in slide-in-from-top-2 duration-200">
<p className="text-sm leading-relaxed opacity-60 mb-6 mt-5 max-w-3xl"> <p className="text-sm leading-relaxed opacity-60 mb-6 mt-5 max-w-3xl">
Your server listens on an <span className="text-white font-semibold">internal port</span> inside its container. Your server listens on an <span className="text-white font-semibold">internal port</span> inside its container.
AnchorHost.eu maps it to a unique <span className="text-white font-semibold">external port</span> on our public IP so people on the internet can reach it — without exposing your server directly. {branding.name} maps it to a unique <span className="text-white font-semibold">external port</span> on our public IP so people on the internet can reach it — without exposing your server directly.
</p> </p>
<div className="flex flex-col lg:flex-row items-stretch justify-center gap-3 lg:gap-4"> <div className="flex flex-col lg:flex-row items-stretch justify-center gap-3 lg:gap-4">
<div className="flex-1 p-5 rounded-2xl border text-center" style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack }}> <div className="flex-1 p-5 rounded-2xl border text-center" style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack }}>
@@ -3592,7 +3402,7 @@ const handleWebSocketMessage = useCallback((data) => {
<ArrowRight className="w-5 h-5 text-primary rotate-90 lg:rotate-0 shrink-0 opacity-60" /> <ArrowRight className="w-5 h-5 text-primary rotate-90 lg:rotate-0 shrink-0 opacity-60" />
</div> </div>
<div className="flex-1 p-5 rounded-2xl border text-center" style={{ backgroundColor: `${colors.primary}10`, borderColor: `${colors.primary}25` }}> <div className="flex-1 p-5 rounded-2xl border text-center" style={{ backgroundColor: `${colors.primary}10`, borderColor: `${colors.primary}25` }}>
<p className="text-[10px] font-black uppercase tracking-widest text-primary mb-3">AnchorHost.eu gateway</p> <p className="text-[10px] font-black uppercase tracking-widest text-primary mb-3">{branding.name} gateway</p>
<NetworkIcon className="w-6 h-6 mx-auto text-primary mb-1" /> <NetworkIcon className="w-6 h-6 mx-auto text-primary mb-1" />
<p className="text-[10px] opacity-50 mt-2 leading-relaxed">Secure NodePort + firewall</p> <p className="text-[10px] opacity-50 mt-2 leading-relaxed">Secure NodePort + firewall</p>
</div> </div>
@@ -3653,7 +3463,7 @@ const handleWebSocketMessage = useCallback((data) => {
icon: Shield, icon: Shield,
iconColor: colors.brightGreen, iconColor: colors.brightGreen,
title: 'Firewall opens automatically', title: 'Firewall opens automatically',
body: 'When you expose a port, AnchorHost.eu opens it on the edge firewall in real time — no manual rules needed.', body: `When you expose a port, ${branding.name} opens it on the edge firewall in real time — no manual rules needed.`,
}, },
{ {
icon: Lock, icon: Lock,
@@ -3850,7 +3660,7 @@ const handleWebSocketMessage = useCallback((data) => {
<div className="p-6 sm:p-8 rounded-2xl border-2 border-dashed flex flex-col items-center justify-center space-y-5 w-full" style={{ borderColor: `${colors.primary}25`, backgroundColor: `${colors.primary}05` }}> <div className="p-6 sm:p-8 rounded-2xl border-2 border-dashed flex flex-col items-center justify-center space-y-5 w-full" style={{ borderColor: `${colors.primary}25`, backgroundColor: `${colors.primary}05` }}>
<div className="text-center"> <div className="text-center">
<h5 className="text-sm font-black uppercase tracking-[0.2em] mb-2" style={{ color: colors.primary }}>Expose New Port</h5> <h5 className="text-sm font-black uppercase tracking-[0.2em] mb-2" style={{ color: colors.primary }}>Expose New Port</h5>
<p className="text-xs opacity-45 font-medium max-w-md">Enter the port your app listens on inside the server. AnchorHost.eu assigns a public external port automatically.</p> <p className="text-xs opacity-45 font-medium max-w-md">Enter the port your app listens on inside the server. {branding.name} assigns a public external port automatically.</p>
</div> </div>
<form <form
onSubmit={(e) => { e.preventDefault(); submitNetworkExposePort(); }} onSubmit={(e) => { e.preventDefault(); submitNetworkExposePort(); }}
@@ -6,6 +6,7 @@ import {
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { AuthContext } from '../App'; import { AuthContext } from '../App';
import { colors } from '../theme/colors'; import { colors } from '../theme/colors';
import branding from '../branding';
import { import {
ActionButton ActionButton
} from '../components/DashboardComponents'; } from '../components/DashboardComponents';
@@ -45,13 +46,13 @@ function DocumentationPage() {
title: 'Creating Your Account', title: 'Creating Your Account',
content: `# Creating Your Account content: `# Creating Your Account
Creating an account on AnchorHost.eu gives you access to our Kubernetes-based cloud hosting platform. Creating an account on ${branding.name} gives you access to our Kubernetes-based cloud hosting platform.
## Step 1: Registration ## Step 1: Registration
Navigate to AnchorHost.eu and click the Register button. You can also sign up using Google or Apple for a faster experience. Navigate to ${branding.name} and click the Register button. You can also sign up using Google or Apple for a faster experience.
## Step 2: Required Information ## Step 2: Required Information
- **Username**: Must be unique. This determines your dedicated subdomain: \`u-{username}.anchorhost.eu\`. - **Username**: Must be unique. This determines your dedicated subdomain: \`u-{username}.${branding.domain}\`.
- **Email Address**: Used for verification and critical alerts. - **Email Address**: Used for verification and critical alerts.
- **Password**: Securely hashed using bcrypt. - **Password**: Securely hashed using bcrypt.
@@ -69,7 +70,7 @@ When you click "Create Account":
title: 'Authentication and Security', title: 'Authentication and Security',
content: `# Authentication and Security content: `# Authentication and Security
AnchorHost.eu utilizes a hybrid approach of REST and WebSockets for secure, real-time management. ${branding.name} utilizes a hybrid approach of REST and WebSockets for secure, real-time management.
## Authentication Flow ## Authentication Flow
1. Credentials are verified via secure hash comparison. 1. Credentials are verified via secure hash comparison.
@@ -112,7 +113,7 @@ Think of our main server as a **Large Apartment Building**.
**Without the Apartment Number (Port), you are just standing at the front door of a building with 10,000 people. You will never find your room!** **Without the Apartment Number (Port), you are just standing at the front door of a building with 10,000 people. You will never find your room!**
## The Format ## The Format
When connecting to anything on AnchorHost.eu, you will almost always use this format: When connecting to anything on ${branding.name}, you will almost always use this format:
\`85.237.179.121:PORT\` \`85.237.179.121:PORT\`
> **Idiot-Proof Tip**: Look at your Server Card in the Dashboard. The number after the colon (\`:\`) is your unique Port. You **MUST** include it.` > **Idiot-Proof Tip**: Look at your Server Card in the Dashboard. The number after the colon (\`:\`) is your unique Port. You **MUST** include it.`
@@ -125,7 +126,7 @@ When connecting to anything on AnchorHost.eu, you will almost always use this fo
Websites can be accessed via a custom subdomain or directly via IP. Websites can be accessed via a custom subdomain or directly via IP.
## Method 1: Using a Subdomain (Recommended) ## Method 1: Using a Subdomain (Recommended)
You can register a free subdomain (e.g., \`mysite.u-username.anchorhost.eu\`) via the **Domains** tab. You can register a free subdomain (e.g., \`mysite.u-username.${branding.domain}\`) via the **Domains** tab.
1. Go to the **Domains** tab in your Dashboard. 1. Go to the **Domains** tab in your Dashboard.
2. Register your preferred subdomain. 2. Register your preferred subdomain.
3. Once registered, you can simply type it into your browser's address bar. 3. Once registered, you can simply type it into your browser's address bar.
@@ -258,7 +259,7 @@ Imagine a large apartment building. The building itself is the physical server.
- You have your own kitchen and bathroom (dedicated resources like CPU and RAM). - You have your own kitchen and bathroom (dedicated resources like CPU and RAM).
- What you do in your apartment doesn't affect your neighbors, and vice-versa. - What you do in your apartment doesn't affect your neighbors, and vice-versa.
On AnchorHost.eu, our VPS instances are powered by **KubeVirt**, giving you a real virtual machine with its own kernel, allowing you to install any OS or software you need with full root access.` On ${branding.name}, our VPS instances are powered by **KubeVirt**, giving you a real virtual machine with its own kernel, allowing you to install any OS or software you need with full root access.`
}, },
{ {
id: 'server-types', id: 'server-types',
@@ -290,7 +291,7 @@ Managed containers for application stacks.
title: 'How Billing Works', title: 'How Billing Works',
content: `# How Billing Works content: `# How Billing Works
AnchorHost.eu operates on a transparent **pre-paid credit system**. There are no hidden fees or surprise invoices. ${branding.name} operates on a transparent **pre-paid credit system**. There are no hidden fees or surprise invoices.
## Credits and Value ## Credits and Value
- **100 credits = 1.00 EUR**. - **100 credits = 1.00 EUR**.
@@ -337,7 +338,7 @@ If you wish to host **Minecraft Bedrock** (for Console, Mobile, or Windows 10 Ed
title: 'Installing and Managing Minecraft', title: 'Installing and Managing Minecraft',
content: `# Minecraft Server Management content: `# Minecraft Server Management
AnchorHost.eu provides a specialized environment for Minecraft servers using high-performance containers. ${branding.name} provides a specialized environment for Minecraft servers using high-performance containers.
## Installation ## Installation
1. Go to **Create Server**. 1. Go to **Create Server**.
@@ -479,7 +480,7 @@ We provide an integrated **Plugin Store** powered by the Modrinth API.
title: 'Network Architecture', title: 'Network Architecture',
content: `# Network Architecture content: `# Network Architecture
All servers deployed on AnchorHost.eu share a high-performance static IP but utilize unique entry points. All servers deployed on ${branding.name} share a high-performance static IP but utilize unique entry points.
## External Connectivity ## External Connectivity
- **Global IP**: \`85.237.179.121\` - **Global IP**: \`85.237.179.121\`
@@ -488,7 +489,7 @@ All servers deployed on AnchorHost.eu share a high-performance static IP but uti
## Subdomain System ## Subdomain System
We provide automatic DNS management via the **deSEC API**. We provide automatic DNS management via the **deSEC API**.
- **Format**: \`{label}.u-{username}.anchorhost.eu\` - **Format**: \`{label}.u-{username}.${branding.domain}\`
- **SSL**: Automatic Let's Encrypt certificates are provisioned for all subdomains. - **SSL**: Automatic Let's Encrypt certificates are provisioned for all subdomains.
- **Custom Domains**: You can point your own domain (e.g., example.com) by creating an **A Record** pointing to \`85.237.179.121\`.` - **Custom Domains**: You can point your own domain (e.g., example.com) by creating an **A Record** pointing to \`85.237.179.121\`.`
}, },
@@ -607,7 +608,7 @@ Managing the state of your Virtual Machine is done directly through the dashboar
title: 'Scaling Resources', title: 'Scaling Resources',
content: `# Scaling Resources content: `# Scaling Resources
AnchorHost.eu allows you to adjust your VPS specifications as your needs grow. ${branding.name} allows you to adjust your VPS specifications as your needs grow.
## CPU and RAM ## CPU and RAM
You can increase or decrease your allocated CPU cores and RAM from the **Settings** tab. You can increase or decrease your allocated CPU cores and RAM from the **Settings** tab.
@@ -673,7 +674,7 @@ passwd
title: 'Getting Started with Databases', title: 'Getting Started with Databases',
content: `# Managed Database Hosting content: `# Managed Database Hosting
AnchorHost.eu provides pre-configured database containers for rapid development. ${branding.name} provides pre-configured database containers for rapid development.
## Supported Engines ## Supported Engines
- **MySQL**: The world's most popular open-source database. - **MySQL**: The world's most popular open-source database.
@@ -754,13 +755,13 @@ Use these templates to connect your applications. Replace \`IP\`, \`PORT\`, and
title: 'Managing Subdomains', title: 'Managing Subdomains',
content: `# Managing Your Subdomains content: `# Managing Your Subdomains
Every user on AnchorHost.eu has their own personal network space based on their username. Every user on ${branding.name} has their own personal network space based on their username.
## How to create a Subdomain ## How to create a Subdomain
1. Go to the **Domains** tab. 1. Go to the **Domains** tab.
2. Enter your desired name (e.g., \`play\`). 2. Enter your desired name (e.g., \`play\`).
3. Click **Register**. 3. Click **Register**.
4. Your full domain will be \`play.u-username.anchorhost.eu\`. 4. Your full domain will be \`play.u-username.${branding.domain}\`.
## Linking to a Server ## Linking to a Server
Once a domain is created, you can "point" it to one of your active servers. Once a domain is created, you can "point" it to one of your active servers.
@@ -775,7 +776,7 @@ Once a domain is created, you can "point" it to one of your active servers.
If you see a warning saying **"No NS records found for child domain"** or **"DNSSEC configuration issues"**, don't panic! If you see a warning saying **"No NS records found for child domain"** or **"DNSSEC configuration issues"**, don't panic!
## Why is this happening? ## Why is this happening?
AnchorHost.eu uses high-security DNSSEC to protect your subdomains from being hijacked. For this to work, our main system needs to confirm that your specific "child" space is ready. ${branding.name} uses high-security DNSSEC to protect your subdomains from being hijacked. For this to work, our main system needs to confirm that your specific "child" space is ready.
## How to Fix ## How to Fix
Usually, this is handled automatically within 5-10 minutes of creating your account. If the warning persists: Usually, this is handled automatically within 5-10 minutes of creating your account. If the warning persists:
@@ -1059,7 +1060,7 @@ Restart Apache: \`sudo systemctl restart apache2\`. Visit your IP in the browser
<div className="p-6 border-b border-dash-border bg-black/10"> <div className="p-6 border-b border-dash-border bg-black/10">
<Link to="/" className="flex items-center space-x-3 group"> <Link to="/" className="flex items-center space-x-3 group">
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20 transition-all group-hover:scale-110 shadow-lg shadow-primary/5"> <div className="p-2 rounded-xl bg-primary/10 border border-primary/20 transition-all group-hover:scale-110 shadow-lg shadow-primary/5">
<img src="/assets/transparent_anchorhost.png" alt="" className="h-7 w-auto shrink-0" /> <img src={branding.logoPath} alt="" className="h-7 w-auto shrink-0" />
</div> </div>
<div> <div>
<h1 className="text-xl font-bold tracking-tight text-dash-text leading-tight">Docs</h1> <h1 className="text-xl font-bold tracking-tight text-dash-text leading-tight">Docs</h1>
@@ -4,10 +4,11 @@ import {
ArrowDown, ArrowLeft, ArrowRight, Check, ChevronDown, Cpu, Database, ExternalLink, ArrowDown, ArrowLeft, ArrowRight, Check, ChevronDown, Cpu, Database, ExternalLink,
Gamepad2, Globe2, Menu, Monitor, X, Gamepad2, Globe2, Menu, Monitor, X,
} from 'lucide-react'; } from 'lucide-react';
import { AuthContext } from '../App.js'; import { AuthContext } from '../App.jsx';
import AnnouncementsSection from '../features/announcements/AnnouncementsSection'; import AnnouncementsSection from '../features/announcements/AnnouncementsSection';
import { Dropdown } from '../components/dashboardPrimitives'; import { Dropdown } from '../components/dashboardPrimitives';
import { API_BASE_URL, SITE_BASE_URL } from '../config/api'; import { API_BASE_URL, SITE_BASE_URL } from '../config/api';
import branding from '../branding';
const serviceOptions = [ const serviceOptions = [
{ id: 'game', label: 'Game server', description: 'Run a game server and manage it from the control panel.', icon: Gamepad2 }, { id: 'game', label: 'Game server', description: 'Run a game server and manage it from the control panel.', icon: Gamepad2 },
@@ -19,7 +20,7 @@ const serviceDropdownOptions = serviceOptions.map(({ id, label }) => ({ value: i
const questions = [ const questions = [
{ {
question: 'What can I host with AnchorHost.eu?', question: `What can I host with ${branding.name}?`,
answer: 'The panel supports game servers, virtual private servers, websites, and databases. The available options depend on your account and current platform capacity.', answer: 'The panel supports game servers, virtual private servers, websites, and databases. The available options depend on your account and current platform capacity.',
}, },
{ {
@@ -44,13 +45,13 @@ const heroGallery = [
}, },
{ {
src: '/assets/server-2.jpg', src: '/assets/server-2.jpg',
alt: 'Rows of server equipment in a data center', alt: 'Network hardware and cabling inside a blue server rack',
title: 'Infrastructure, ready', title: 'Servers and network hardware',
}, },
{ {
src: '/assets/pricing-bg.jpg', src: '/assets/pricing-bg.jpg',
alt: 'Calculator and notes beside a pricing plan', alt: 'Ethernet cables connected to a network switch',
title: 'Clear costs up front', title: 'Connections, made visible',
}, },
]; ];
@@ -154,9 +155,9 @@ function HomePage({ setIsLoginOpen, setIsRegisterOpen, pricing }) {
<main className="site-page"> <main className="site-page">
<header className={`site-header ${scrolled ? 'site-header--scrolled' : ''}`}> <header className={`site-header ${scrolled ? 'site-header--scrolled' : ''}`}>
<div className="site-nav"> <div className="site-nav">
<a className="site-brand" href="#top" onClick={(event) => { event.preventDefault(); goTo('top'); }} aria-label="AnchorHost.eu home"> <a className="site-brand" href="#top" onClick={(event) => { event.preventDefault(); goTo('top'); }} aria-label={`${branding.name} home`}>
<img src="/assets/transparent_anchorhost.png" alt="" /> <img src={branding.logoPath} alt="" />
<span>AnchorHost.eu</span> <span>{branding.name}</span>
</a> </a>
<nav className={`site-nav-links ${menuOpen ? 'site-nav-links--open' : ''}`} aria-label="Main navigation"> <nav className={`site-nav-links ${menuOpen ? 'site-nav-links--open' : ''}`} aria-label="Main navigation">
@@ -206,10 +207,10 @@ function HomePage({ setIsLoginOpen, setIsRegisterOpen, pricing }) {
<section className="site-hero" id="top"> <section className="site-hero" id="top">
<div className="site-hero-copy"> <div className="site-hero-copy">
<p className="site-kicker"><span /> Hosting, with room to work</p> <p className="site-kicker"><span /> {branding.tagline}</p>
<h1>A control panel for the things you host.</h1> <h1>{branding.heroTitle}</h1>
<p className="site-hero-description"> <p className="site-hero-description">
Run game servers, virtual machines, websites, and databases from one place. Keep the tools close and the busywork out of the way. {branding.heroDescription}
</p> </p>
<div className="site-hero-actions"> <div className="site-hero-actions">
<button type="button" className="site-button site-button--primary site-button--large" onClick={start}> <button type="button" className="site-button site-button--primary site-button--large" onClick={start}>
@@ -225,7 +226,7 @@ function HomePage({ setIsLoginOpen, setIsRegisterOpen, pricing }) {
{accountCount !== null && <><i aria-hidden="true" /><span>{accountCount.toLocaleString()} accounts</span></>} {accountCount !== null && <><i aria-hidden="true" /><span>{accountCount.toLocaleString()} accounts</span></>}
</div> </div>
</div> </div>
<figure className="site-hero-image" aria-label="AnchorHost.eu hosting gallery"> <figure className="site-hero-image" aria-label={`${branding.name} hosting gallery`}>
<div className="site-gallery-viewport"> <div className="site-gallery-viewport">
<img src={currentGalleryImage.src} alt={currentGalleryImage.alt} /> <img src={currentGalleryImage.src} alt={currentGalleryImage.alt} />
<button type="button" className="site-gallery-arrow site-gallery-arrow--previous" onClick={() => showGalleryImage(galleryIndex - 1)} aria-label="Show previous photo"> <button type="button" className="site-gallery-arrow site-gallery-arrow--previous" onClick={() => showGalleryImage(galleryIndex - 1)} aria-label="Show previous photo">
@@ -238,7 +239,7 @@ function HomePage({ setIsLoginOpen, setIsRegisterOpen, pricing }) {
<figcaption> <figcaption>
<div className="site-gallery-caption" aria-live="polite" aria-atomic="true"> <div className="site-gallery-caption" aria-live="polite" aria-atomic="true">
<strong>{currentGalleryImage.title}</strong> <strong>{currentGalleryImage.title}</strong>
<span>AnchorHost.eu infrastructure</span> <span>{branding.name} infrastructure</span>
</div> </div>
<div className="site-gallery-thumbnails" aria-label="Choose a gallery photo"> <div className="site-gallery-thumbnails" aria-label="Choose a gallery photo">
{heroGallery.map((photo, index) => ( {heroGallery.map((photo, index) => (
@@ -401,21 +402,21 @@ function HomePage({ setIsLoginOpen, setIsRegisterOpen, pricing }) {
<div className="site-footer-main"> <div className="site-footer-main">
<div> <div>
<a className="site-brand" href="#top" onClick={(event) => { event.preventDefault(); goTo('top'); }}> <a className="site-brand" href="#top" onClick={(event) => { event.preventDefault(); goTo('top'); }}>
<img src="/assets/transparent_anchorhost.png" alt="" /><span>AnchorHost.eu</span> <img src={branding.logoPath} alt="" /><span>{branding.name}</span>
</a> </a>
<p>Hosting services managed from one place.</p> <p>{branding.footerTagline}</p>
</div> </div>
<nav className="site-footer-links" aria-label="Footer"> <nav className="site-footer-links" aria-label="Footer">
<a href={`${SITE_BASE_URL}/discord`} target="_blank" rel="noopener noreferrer">Discord</a> <a href={`${SITE_BASE_URL}/discord`} target="_blank" rel="noopener noreferrer">Discord</a>
<a href="https://git.anchorhost.eu/anchorhost" target="_blank" rel="noopener noreferrer">Source Code</a> <a href={branding.sourceCodeUrl} target="_blank" rel="noopener noreferrer">Source Code</a>
<a href="mailto:contact@anchorhost.eu">Email support</a> <a href={`mailto:${branding.supportEmail}`}>Email support</a>
<Link to="/documentation">Documentation</Link> <Link to="/documentation">Documentation</Link>
<Link to="/terms">Terms</Link> <Link to="/terms">Terms</Link>
<Link to="/privacy">Privacy</Link> <Link to="/privacy">Privacy</Link>
</nav> </nav>
</div> </div>
<div className="site-footer-bottom"> <div className="site-footer-bottom">
<span>© {new Date().getFullYear()} AnchorHost.eu by <a href="https://kvj.ovh" target="_blank" rel="noopener noreferrer">Wiktor Falkowski</a></span> <span>© {new Date().getFullYear()} {branding.name} by <a href={branding.footerCredit.url} target="_blank" rel="noopener noreferrer">{branding.footerCredit.name}</a></span>
<span>Account and service controls live in the dashboard.</span> <span>Account and service controls live in the dashboard.</span>
</div> </div>
</footer> </footer>
@@ -2,6 +2,7 @@ import React from 'react';
import { Shield } from 'lucide-react'; import { Shield } from 'lucide-react';
import { colors } from '../theme/colors'; import { colors } from '../theme/colors';
import { LegalPageShell, LegalSection, LegalList, LegalLink } from '../features/legal/LegalDocumentLayout'; import { LegalPageShell, LegalSection, LegalList, LegalLink } from '../features/legal/LegalDocumentLayout';
import branding from '../branding';
const LAST_UPDATED = 'April 5, 2026'; const LAST_UPDATED = 'April 5, 2026';
@@ -14,16 +15,16 @@ function PrivacyPolicy() {
lastUpdated={LAST_UPDATED} lastUpdated={LAST_UPDATED}
> >
<LegalSection title="1. General Information"> <LegalSection title="1. General Information">
<p>This Privacy Policy explains how AnchorHost.eu processes personal data.</p> <p>This Privacy Policy explains how {branding.name} processes personal data.</p>
<p> <p>
<strong className="text-dash-text">Data Controller:</strong> <strong className="text-dash-text">Data Controller:</strong>
<br /> <br />
Wiktor Falkowski (unregistered business activity under Polish law) {branding.legalProviderName} ({branding.legalProviderStatus})
</p> </p>
<p> <p>
<strong className="text-dash-text">Contact:</strong> <strong className="text-dash-text">Contact:</strong>
<br /> <br />
<LegalLink href="mailto:privacy@anchorhost.eu">privacy@anchorhost.eu</LegalLink> <LegalLink href={`mailto:${branding.privacyEmail}`}>{branding.privacyEmail}</LegalLink>
</p> </p>
<p>We process data in accordance with GDPR.</p> <p>We process data in accordance with GDPR.</p>
</LegalSection> </LegalSection>
@@ -99,7 +100,7 @@ function PrivacyPolicy() {
]} ]}
/> />
<p> <p>
Requests: <LegalLink href="mailto:privacy@anchorhost.eu">privacy@anchorhost.eu</LegalLink> Requests: <LegalLink href={`mailto:${branding.privacyEmail}`}>{branding.privacyEmail}</LegalLink>
</p> </p>
</LegalSection> </LegalSection>
@@ -124,7 +125,7 @@ function PrivacyPolicy() {
<LegalSection title="12. Contact"> <LegalSection title="12. Contact">
<p> <p>
<LegalLink href="mailto:privacy@anchorhost.eu">privacy@anchorhost.eu</LegalLink> <LegalLink href={`mailto:${branding.privacyEmail}`}>{branding.privacyEmail}</LegalLink>
</p> </p>
</LegalSection> </LegalSection>
</LegalPageShell> </LegalPageShell>
@@ -2,6 +2,7 @@ import React from 'react';
import { FileText } from 'lucide-react'; import { FileText } from 'lucide-react';
import { colors } from '../theme/colors'; import { colors } from '../theme/colors';
import { LegalPageShell, LegalSection, LegalList, LegalLink } from '../features/legal/LegalDocumentLayout'; import { LegalPageShell, LegalSection, LegalList, LegalLink } from '../features/legal/LegalDocumentLayout';
import branding from '../branding';
const LAST_UPDATED = 'April 5, 2026'; const LAST_UPDATED = 'April 5, 2026';
@@ -15,18 +16,18 @@ function TermsOfService() {
> >
<LegalSection title="1. General Information"> <LegalSection title="1. General Information">
<p> <p>
These Terms of Service (&quot;Terms&quot;) govern the use of services provided under the brand AnchorHost.eu via the website{' '} These Terms of Service (&quot;Terms&quot;) govern the use of services provided under the brand {branding.name} via the website{' '}
<LegalLink href="https://www.anchorhost.eu">https://www.anchorhost.eu</LegalLink>. <LegalLink href={branding.siteUrl}>{branding.siteUrl}</LegalLink>.
</p> </p>
<p> <p>
<strong className="text-dash-text">Service Provider:</strong> <strong className="text-dash-text">Service Provider:</strong>
<br /> <br />
Wiktor Falkowski (unregistered business activity under Polish law) {branding.legalProviderName} ({branding.legalProviderStatus})
</p> </p>
<p> <p>
<strong className="text-dash-text">Contact:</strong> <strong className="text-dash-text">Contact:</strong>
<br /> <br />
<LegalLink href="mailto:legal@anchorhost.eu">legal@anchorhost.eu</LegalLink> <LegalLink href={`mailto:${branding.legalEmail}`}>{branding.legalEmail}</LegalLink>
</p> </p>
<p>By using the Service, you agree to these Terms.</p> <p>By using the Service, you agree to these Terms.</p>
</LegalSection> </LegalSection>
@@ -191,7 +192,7 @@ function TermsOfService() {
<LegalSection title="17. Contact"> <LegalSection title="17. Contact">
<p> <p>
<LegalLink href="mailto:legal@anchorhost.eu">legal@anchorhost.eu</LegalLink> <LegalLink href={`mailto:${branding.legalEmail}`}>{branding.legalEmail}</LegalLink>
</p> </p>
</LegalSection> </LegalSection>
</LegalPageShell> </LegalPageShell>
File renamed without changes.
-13
View File
@@ -1,13 +0,0 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+7 -7
View File
@@ -1,4 +1,4 @@
/** AnchorHost.eu palette: colors.ini is the canonical source; keep Tailwind aligned. */ /** Product palette: colors.ini is the canonical source; keep Tailwind aligned. */
export const colors = { export const colors = {
foreground: '#D0D0D0', foreground: '#D0D0D0',
background: '#151515', background: '#151515',
@@ -8,24 +8,24 @@ export const colors = {
green: '#7B9246', green: '#7B9246',
yellow: '#D3A04D', yellow: '#D3A04D',
orange: '#D3A04D', orange: '#D3A04D',
blue: '#547C99', blue: '#6C99BB',
magenta: '#547C99', magenta: '#6C99BB',
cyan: '#5EA8A2', cyan: '#5EA8A2',
white: '#757575', white: '#898989',
brightBlack: '#2F2F2F', brightBlack: '#2F2F2F',
brightRed: '#A53C23', brightRed: '#A53C23',
brightGreen: '#7B9246', brightGreen: '#7B9246',
brightYellow: '#D3A04D', brightYellow: '#D3A04D',
brightBlue: '#6C99BB', brightBlue: '#6C99BB',
brightMagenta: '#547C99', brightMagenta: '#6C99BB',
brightCyan: '#5EA8A2', brightCyan: '#5EA8A2',
brightWhite: '#F5F5F5', brightWhite: '#F5F5F5',
primary: '#6C99BB', primary: '#6C99BB',
darkBlue: '#547C99', darkBlue: '#6C99BB',
darkCyan: '#5EA8A2', darkCyan: '#5EA8A2',
darkWhite: '#505050', darkWhite: '#505050',
darkRed: '#A53C23', darkRed: '#A53C23',
darkGreen: '#7B9246', darkGreen: '#7B9246',
darkYellow: '#D3A04D', darkYellow: '#D3A04D',
darkMagenta: '#547C99', darkMagenta: '#6C99BB',
}; };
+7 -7
View File
@@ -1,6 +1,6 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"], content: ["./src/**/*.{js,jsx,ts,tsx}", "./index.html"],
theme: { theme: {
extend: { extend: {
colors: { colors: {
@@ -23,26 +23,26 @@ module.exports = {
green: "#7B9246", green: "#7B9246",
yellow: "#D3A04D", yellow: "#D3A04D",
orange: "#D3A04D", orange: "#D3A04D",
blue: "#547C99", blue: "#6C99BB",
magenta: "#547C99", magenta: "#6C99BB",
cyan: "#5EA8A2", cyan: "#5EA8A2",
white: "#757575", white: "#898989",
brightBlack: "#2F2F2F", brightBlack: "#2F2F2F",
brightRed: "#A53C23", brightRed: "#A53C23",
brightGreen: "#7B9246", brightGreen: "#7B9246",
brightYellow: "#D3A04D", brightYellow: "#D3A04D",
brightBlue: "#6C99BB", brightBlue: "#6C99BB",
brightMagenta: "#547C99", brightMagenta: "#6C99BB",
brightCyan: "#5EA8A2", brightCyan: "#5EA8A2",
brightWhite: "#F5F5F5", brightWhite: "#F5F5F5",
primary: "#6C99BB", primary: "#6C99BB",
darkBlue: "#547C99", darkBlue: "#6C99BB",
darkCyan: "#5EA8A2", darkCyan: "#5EA8A2",
darkWhite: "#505050", darkWhite: "#505050",
darkRed: "#A53C23", darkRed: "#A53C23",
darkGreen: "#7B9246", darkGreen: "#7B9246",
darkYellow: "#D3A04D", darkYellow: "#D3A04D",
darkMagenta: "#547C99", darkMagenta: "#6C99BB",
}, },
}, },
}, },
+58
View File
@@ -0,0 +1,58 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { readFileSync, writeFileSync } from 'node:fs';
const branding = JSON.parse(readFileSync(new URL('./branding.json', import.meta.url), 'utf8'));
const brandHtml = {
name: 'anchorhost-brand-html',
configureServer(server) {
server.middlewares.use((request, response, next) => {
if (request.url?.split('?')[0] !== '/manifest.json') return next();
const manifest = JSON.parse(readFileSync(new URL('./public/manifest.json', import.meta.url), 'utf8'));
manifest.name = branding.name;
manifest.short_name = branding.name;
response.setHeader('Content-Type', 'application/manifest+json');
response.end(JSON.stringify(manifest));
});
},
transformIndexHtml(html) {
return html
.replaceAll('%BRAND_NAME%', branding.name)
.replaceAll('%BRAND_DESCRIPTION%', branding.description);
},
generateBundle(_, bundle) {
const manifest = bundle['manifest.json'];
if (!manifest || manifest.type !== 'asset') return;
const data = JSON.parse(manifest.source.toString());
data.name = branding.name;
data.short_name = branding.name;
manifest.source = JSON.stringify(data, null, 2);
},
closeBundle() {
const manifestPath = new URL('./dist/manifest.json', import.meta.url);
try {
const data = JSON.parse(readFileSync(manifestPath, 'utf8'));
data.name = branding.name;
data.short_name = branding.name;
writeFileSync(manifestPath, `${JSON.stringify(data, null, 2)}\n`);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
},
};
export default defineConfig({
plugins: [react(), tailwindcss(), brandHtml],
server: {
host: '0.0.0.0',
},
preview: {
host: '0.0.0.0',
},
build: {
outDir: 'dist',
sourcemap: false,
},
});