Polish AnchorHost.eu neobrutalist interface
This commit is contained in:
1 parent
6d1ee3c128
commit
bc4542d0fb
25 files changed
+1488
-2854
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# AnchorHost frontend
|
||||
# AnchorHost.eu frontend
|
||||
|
||||
React single-page application for the AnchorHost hosting platform. The app source was recovered from the production image source maps; static runtime assets are in `public/assets`. The deployed bundle and its maps are retained under `recovered-production/` as a reference snapshot.
|
||||
React single-page application for the AnchorHost.eu hosting platform. The app source was recovered from the production image source maps; static runtime assets are in `public/assets`. The deployed bundle and its maps are retained under `recovered-production/` as a reference snapshot.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
# AnchorHost.eu frontend project guide
|
||||
|
||||
This skill is the working guide for understanding, changing, and extending the AnchorHost.eu React frontend in this repository. Read it before making substantial UI, routing, authentication, API, WebSocket, or dashboard changes. Treat the code as the source of truth when details here and implementation diverge; update this guide when a contract or convention changes.
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
- React 18.3, Create React App / `react-scripts` 5, React Router 6, Tailwind CSS 3, PostCSS, and Autoprefixer.
|
||||
- `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.
|
||||
- `src/index.js` mounts the app and imports global CSS. `public/index.html` is the HTML shell.
|
||||
- `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`.
|
||||
- `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.
|
||||
|
||||
## Repository map
|
||||
|
||||
```text
|
||||
src/
|
||||
App.js App shell, routes, auth and global notices/login/register
|
||||
config/api.js HTTP, WebSocket, and site base URLs
|
||||
index.css Global Tailwind layers, base styles, control/accessibility rules
|
||||
theme/colors.js Canonical named palette
|
||||
theme/dashboardTokens.js Dashboard semantic tokens and shared shell class names
|
||||
lib/utils.js Shared class-name helpers
|
||||
hooks/useWebSocket.js WebSocket lifecycle, reconnect, queue, ping and JSON dispatch
|
||||
hooks/usePanelCurrency.js Exchange-rate fetch and panel currency state
|
||||
pages/ Home, dashboard, admin, legal, docs, and email verification pages
|
||||
components/ Dashboard primitives, shared UI and background effects
|
||||
features/ Focused admin, server, store, plugin, support, announcement, legal UI
|
||||
public/assets/ Brand and marketing imagery; asset documentation is alongside it
|
||||
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.
|
||||
|
||||
## 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:
|
||||
|
||||
| Path | View | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `/` | `HomePage` | Public marketing and product navigation. |
|
||||
| `/dashboard/*` | `DashboardPage` | Requires client auth; unauthenticated users are redirected to `/`. |
|
||||
| `/admin/*` | `AdminPage` | Requires client auth and admin role; unauthorized users go to `/dashboard`. |
|
||||
| `/terms` | `TermsOfService` | Public legal document. |
|
||||
| `/privacy` | `PrivacyPolicy` | Public legal document. |
|
||||
| `/verify-email` | `VerifyEmail` | Reads verification token from query string and posts it. |
|
||||
| `/plugins` | `PluginStore` | Public plugin search. |
|
||||
| `/plugins/:pluginId` | `PluginDetails` | Plugin detail and optional install action. |
|
||||
| `/documentation` | `DocumentationPage` | Public long-form platform docs. |
|
||||
| `/docs` | redirect | Replaces URL with `/documentation`. |
|
||||
| `*` | 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`.
|
||||
|
||||
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.
|
||||
|
||||
## Design language and product philosophy
|
||||
|
||||
### Visual character
|
||||
|
||||
The product uses a dark neobrutalist hosting-console identity. The public site, dashboard, admin, docs, and stores share graphite planes, high-contrast neutral text, squared geometry, visible borders, and hard offset shadows. Neobrutalism is expressed through structure and type, not a novelty palette: the homepage uses a large server image framed like a physical panel; operational pages use crisp section boundaries, dense but legible controls, and direct status labels. The homepage puts hosting choices and the live-config price estimator first and avoids invented testimonials or unsupported reliability claims. The dashboard emphasizes service state and controls.
|
||||
|
||||
Use flat fills, 2px rules, 2px or near-square corners, and short hard shadows (typically 3–6px with zero blur) on prominent interactive blocks. Prefer a clear left-aligned hierarchy and deliberate asymmetry over a grid of identical floating cards. Use heavy system-sans headlines, readable system-sans body copy, and monospace only for true machine data. Reserve blue for primary actions/focus and amber for warnings or emphasis. Keep ordinary copy readable, status text explicit, and decorative elements sparse. Do not add gradients, glass effects, soft shadows, pill-shaped everything, arbitrary accent colors, or hover-only affordances. Motion is short, communicates state, and respects reduced-motion preferences.
|
||||
|
||||
Maintain the product principles of purpose, agency, familiarity, flexibility, simplicity, craft, and delight in the interaction layer: concise labels, reachable controls, visible keyboard focus, responsive layouts, and clear confirmation for destructive actions. Brutalist presentation does not excuse unclear hierarchy, poor contrast, or inaccessible controls.
|
||||
|
||||
### Canonical palette
|
||||
|
||||
`src/theme/colors.js` is the named JS palette. `tailwind.config.js` mirrors it and adds `dash` semantic colors. Keep both aligned if changing a token.
|
||||
|
||||
| Token | Value | Usage |
|
||||
| --- | --- | --- |
|
||||
| `background` | `#151515` | Main page background. |
|
||||
| `panel` / `backgroundSecondary` | `#181818` | Header, dialogs, and panel fill. |
|
||||
| `surface` | `#202020` | Raised content surface. |
|
||||
| `hover` | `#282828` | Hover surface. |
|
||||
| `border` / `brightBlack` | `#2F2F2F` | Strong outline and hard-shadow ink. |
|
||||
| `focus` | `#454545` | Focus-adjacent neutral boundary. |
|
||||
| `foreground` | `#D0D0D0` | Body copy. |
|
||||
| `muted` | `#757575` | Secondary labels; do not use for essential small text. |
|
||||
| `dim` | `#505050` | Decorative or disabled content only. |
|
||||
| `brightWhite` | `#F5F5F5` | Headings and key values. |
|
||||
| `primary` / `brightBlue` | `#6C99BB` | Primary actions and keyboard focus. |
|
||||
| `blue` | `#547C99` | Secondary accent. |
|
||||
| `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. |
|
||||
|
||||
Dashboard semantic Tailwind tokens are `dash.surface`, `dash.surfaceElevated`, `dash.border`, `dash.text`, `dash.textMuted`, `dash.accent`, `dash.danger`, `dash.success`, and `dash.warning`. `dashboardTokens.js` exports matching JS values and `dashLayout` class strings: `viewShell` is a squared, bordered panel, `headerBar` is a responsive header strip, and `bodyPadding` establishes section spacing. Prefer semantic tokens such as `text-dash-text` over repeated hex values or arbitrary colors. `colors.ini` is the palette source of truth; `src/theme/colors.js`, the `dash` tokens, and `tailwind.config.js` must stay aligned with it.
|
||||
|
||||
### Typography, spacing, and responsive behavior
|
||||
|
||||
- The global interface face is the platform system sans-serif stack. It picks up familiar system rendering across Apple devices and remains available offline. Code uses the standard monospace stack.
|
||||
- Body text uses a 16px base and 1.6 line-height. Marketing headings use a restrained responsive scale and tight tracking; interface controls use sentence case and medium weights.
|
||||
- Tailwind utility classes dominate component styling. Reuse existing button/card/dashboard primitives and the `dashLayout` shells rather than inventing one-off component chrome.
|
||||
- Surfaces use flat fills, strong neutral borders, squared corners, and crisp zero-blur offset shadows on key blocks. Use spacing and hierarchy to avoid turning every nested element into a floating card. Hover changes should be small and should not depend on scale animation.
|
||||
- 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.
|
||||
- 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.
|
||||
- `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.
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
### Interaction and content principles
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Respect reduced screen space and safe areas; global styles already account for dynamic viewport and mobile safe-area insets in some fixed UI.
|
||||
|
||||
## REST API inventory
|
||||
|
||||
All `/api/...` routes below are observed in `src/`. HTTP methods and bodies are listed as the frontend uses them. `Bearer <token>` means `Authorization: Bearer ${localStorage.getItem('anchorhost_token')}` unless stated. JSON POST/PUT requests generally set `Content-Type: application/json`. Error handling varies by call; do not infer consistent response schemas from the UI. Verify backend behavior before changing a field name, method, or URL.
|
||||
|
||||
### App/authentication and public information
|
||||
|
||||
| Method | Path | Auth | Client request / use |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/pricing` | No | App-level pricing initialization; response JSON stored as pricing. |
|
||||
| GET | `/api/auth/config` | No | App-level auth-provider/config initialization. |
|
||||
| GET | `/api/auth/google/login` | No | Starts OAuth; expects `{ auth_url, state }`; saves state in `sessionStorage`, redirects to `auth_url`. |
|
||||
| GET | `/api/auth/apple/login` | No | Same OAuth bootstrap pattern for Apple. |
|
||||
| POST | `/api/auth/forgot-password` | No | JSON `{ email }` (trimmed); consumes response JSON for user feedback. |
|
||||
| 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/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/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/exchange-rates` | No | Currency hook, store, and server creation form; expects `{ success, rates }`. |
|
||||
| GET | `https://geoapi.info/api/geo` | No, third party | Geolocation/country preselection in app and dashboard flows; external origin, response inspected at `data.location.country`. |
|
||||
|
||||
### Account/profile endpoints
|
||||
|
||||
| Method | Path | Auth | Client request / use |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/user/profile` | Bearer | Dashboard and admin page load current user/profile; `AdminPage` reads `data.user.credits`. |
|
||||
| PUT | `/api/user/profile` | Bearer | JSON `profileData`; dashboard profile save. |
|
||||
| PUT | `/api/user/email` | Bearer | JSON `{ email: newEmail }`. |
|
||||
| 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. |
|
||||
| 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). |
|
||||
|
||||
### Servers, provisioning and checkout
|
||||
|
||||
| Method | Path | Auth | Client request / use |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/api/capacity/check` | Bearer | JSON `serverConfig`; form checks capacity before server provisioning. One legacy fallback token key `jwtToken` is also checked. |
|
||||
| POST | `/api/servers/:serverId/sync` | Bearer | No JSON body; sync action in server details modal. Uses a relative `/api` URL (same-origin), not `API_BASE_URL`. |
|
||||
| POST | `/api/servers/:serverId/reinstall` | Bearer | No JSON body; reinstall action in server details modal. Uses a relative same-origin URL. |
|
||||
| POST | `/api/servers/:serverId/adjust-parameters` | Bearer | JSON `{ cpu, ram, storage }`; each value is formatted as a display string such as `"2 vCPU"` or `"4GB"`. |
|
||||
| POST | `/api/stripe/create-checkout-session` | Bearer | JSON `{ credits, currency }`; response drives checkout/payment redirection. |
|
||||
| POST | `/api/plugins/download-to-server` | Bearer | JSON `{ server_id, plugin_url, plugin_name, file_name }`, using the primary selected version file URL and filename. |
|
||||
|
||||
### Plugin/Modrinth proxy endpoints
|
||||
|
||||
| Method | Path | Auth | Client request / use |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/modrinth/search?...` | No | Query generated with `URLSearchParams`; includes plugin project type and current search/filter/page terms. Response uses `hits` and total-result metadata. Used in both standalone and embedded stores. |
|
||||
| GET | `/api/modrinth/project/:pluginId` | No | Loads project metadata; one caller sends `User-Agent: AnchorHost.eu/1.0`. |
|
||||
| GET | `/api/modrinth/project/:pluginId/versions` | No | Loads compatible versions; UI uses list and first version for initial selection. |
|
||||
|
||||
### Admin endpoints
|
||||
|
||||
All `/api/admin/...` requests use bearer auth in the client. Server-side authorization is required even if the admin route guard is bypassed.
|
||||
|
||||
| Method | Path | Client request / use |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/admin/settings` | Loads panel settings, including `demo_servers_enabled`. |
|
||||
| POST | `/api/admin/settings` | JSON settings object (or current `panelSettings`). |
|
||||
| GET | `/api/admin/pricing` | Loads pricing object. |
|
||||
| POST | `/api/admin/pricing` | JSON `newPricing` object. |
|
||||
| POST | `/api/admin/announcements` | Creates announcement from form payload; used by announcement creation and add flow. |
|
||||
| PUT | `/api/admin/announcements/:id` | Updates announcement form payload. |
|
||||
| DELETE | `/api/admin/announcements/:id` | Deletes selected announcement. |
|
||||
| GET | `/api/admin/homepage-showcase` | Loads showcase links; UI expects exactly three links to accept the result. |
|
||||
| PUT | `/api/admin/homepage-showcase` | JSON `{ links: homepageShowcase }`. |
|
||||
| GET | `/api/admin/users` | Loads user list. |
|
||||
| GET | `/api/admin/servers` | Loads all servers. |
|
||||
| GET | `/api/admin/cluster/nodes` | Loads cluster node list. |
|
||||
| GET | `/api/admin/stats` | Loads admin statistics. |
|
||||
| GET | `/api/admin/users/:userId/servers` | Loads selected user's servers. |
|
||||
| GET | `/api/admin/users/:userId/credits/history` | Loads credit history. |
|
||||
| GET | `/api/admin/users/:userId/billing/history` | Loads billing history. |
|
||||
| POST | `/api/admin/users/:userId/ban` or `/unban` | No JSON body; suffix selected from current ban state. The request includes bearer auth. |
|
||||
| DELETE | `/api/admin/users/:userId` | Deletes selected user. |
|
||||
| POST | `/api/admin/users/:userId/password` | JSON `{ password }` from new password field. |
|
||||
| POST | `/api/admin/users/:userId/2fa/disable` | Disables user's two-factor authentication. |
|
||||
| POST | `/api/admin/users/:userId/credits` | JSON `{ amount: parseInt(creditAmount), operation: creditOperation }`. |
|
||||
|
||||
### REST implementation notes
|
||||
|
||||
- The frontend uses native `fetch`; Axios is installed but is not the common request layer. There is no single API client/interceptor. Auth headers and error/JSON handling are repeated per feature.
|
||||
- Most requests use `API_BASE_URL`, but the two server detail actions above use relative URLs. Avoid introducing more inconsistent origin behavior; if consolidating requests, preserve same-origin/proxy assumptions and query/body semantics.
|
||||
- Most endpoints expect JSON responses, including some failure responses. Check `response.ok` and the expected data keys; inconsistent checks are existing behavior, not proof of backend guarantees.
|
||||
- Do not place bearer tokens in query strings for HTTP calls. Existing WebSocket URL authentication is a separate legacy pattern and is described below.
|
||||
- When adding an endpoint, document method, path, auth, query/body fields, response fields actually consumed, and owning component here.
|
||||
|
||||
## WebSocket transport and message protocol
|
||||
|
||||
`src/hooks/useWebSocket.js` is the shared transport. It takes `(url, onMessage)` and exposes the socket, `sendMessage`, `connectionStatus`, and latest JSON message. It parses incoming JSON, pings while connected, reconnects after drops with delayed retries, queues outbound messages until connection is ready, and prevents duplicate sockets for a URL. It returns status so UI can disable or explain actions while disconnected. Check hook cleanup and queue behavior before adding another socket implementation.
|
||||
|
||||
Dashboard/admin sockets use `WS_BASE_URL` plus `/dashboard?token=<token>` (query-string token). The app's login/register socket uses the base WS URL. Server management and support generally share the dashboard socket. Plugin details contains a direct `new WebSocket` implementation and sends `get_servers` after open; prefer the shared hook for new code unless a clear lifecycle reason requires otherwise. WebSocket payloads are JSON objects with a `type` discriminator; exact server-side schemas and authorization rules are not present in this repo.
|
||||
|
||||
### Client-to-server message families
|
||||
|
||||
The following message types are sent by the client. Fields shown are the fields explicitly visible in the frontend; additional server-required fields must be verified against backend code.
|
||||
|
||||
**Authentication and account security**
|
||||
|
||||
- `{ type: 'login', username, password }`
|
||||
- `{ type: 'register', username, email, password, confirmPassword, displayName, firstName, lastName, address, city, country, postalCode, phone }`; username/email are trimmed, country is resolved to a display name, and empty country/phone become `null`.
|
||||
- `{ type: '2fa_login', temp_token, otp_code }`
|
||||
- `{ type: 'authenticate', token }` after dashboard connection (token field is handled by dashboard auth setup)
|
||||
- `{ type: '2fa_setup' }`, `{ type: '2fa_enable', otp_code }`, `{ type: '2fa_disable', ... }`
|
||||
- Transport emits `{ type: 'ping' }` while open.
|
||||
|
||||
**Dashboard/server lifecycle and information**
|
||||
|
||||
- `get_servers`, `create_server` (form/config fields), `delete_server { serverId }`, `restart_server { serverId }`, `get_server_creation_progress`, `get_server_metrics { serverId }`, `get_server_logs { serverId, tailLines }`
|
||||
- `change_server_version { serverId, version }`; `get_minecraft_settings { serverId }`; `update_minecraft_settings { serverId, settings }`
|
||||
- `adjust_server_parameters { serverId, cpu, ram, storage }`; in the dashboard these resource values are formatted strings. `convert_demo_to_paid { serverId }`.
|
||||
- `get_credits`, `add_credits { ... }`, `check_demo_eligibility`, `generate_sftp { serverId }`
|
||||
- `expose_port { serverId, internalPort, protocol, ... }`, `remove_port { serverId, internalPort, protocol }`
|
||||
- Domain management: `list_domains { includeServerless }`, `list_custom_domains`, `create_domain { ... }`, `link_domain { ... }`, `unlink_domain { domainId }`, `create_custom_domain { domainName }`.
|
||||
|
||||
**Server files and uploads**
|
||||
|
||||
- Directory browsing uses `exec_command { serverId, command }` (for example `ls -la ...` and `pwd`) and consumes command output into the file list.
|
||||
- `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.
|
||||
- `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`.
|
||||
- Archive operations: `pack_zip { serverId, dirPath, zipPath }`, `unpack_zip { serverId, zipPath, ... }`.
|
||||
|
||||
**Console and terminal**
|
||||
|
||||
- Game console: `game_console_stream { serverId, tailLines }`, `game_console_command { serverId, command }`.
|
||||
- VPS terminal: `vps_terminal_connect { serverId }`, `vps_terminal_disconnect { serverId }`, `vps_terminal_input { serverId, input }`.
|
||||
- Admin node terminal: `node_terminal_connect { nodeName, ... }`, `node_terminal_disconnect { ... }`, `node_terminal_input { ... }`.
|
||||
- General terminal command: `exec_command { serverId, command }`.
|
||||
|
||||
**Backups**
|
||||
|
||||
- `get_backups { serverId }`, `create_backup { serverId, name? }`, `restore_backup { serverId, backupId }`, `delete_backup { serverId, backupId }`.
|
||||
|
||||
**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... }`.
|
||||
- Admin actions include `claim` and `close` (ticket context is sent by the caller); inspect `TicketSystem.js` before changing the exact keys.
|
||||
|
||||
### Server-to-client event families
|
||||
|
||||
The UI explicitly handles these message types; the backend's full payloads are not defined here.
|
||||
|
||||
- Auth: `authenticated`, `auth_error`, `email_verified`, `2fa_setup_details`, `2fa_enabled`, `2fa_disabled`, generic `error`.
|
||||
- Server list/lifecycle: `servers_list`, `server_created`, `server_updated`, `server_deleted`, `server_creation_progress`, `server_version_changed`, `server_metrics`, `server_logs`, `sftp_generated`.
|
||||
- Credits/demo: `credits_balance`, `credits_added`, `demo_eligibility`, `demo_converted`.
|
||||
- Port/Minecraft settings: `port_exposed`, `port_removed`, `minecraft_settings`, `minecraft_settings_updated`.
|
||||
- File manager: `files_list`, `file_content`, `file_written`, `file_uploaded`, `upload_started`, `upload_chunk_received`, `upload_error`, `file_downloaded`, `path_moved`, `path_copied`, `path_deleted`, `directory_created`, `zip_packed`, `zip_unpacked`, `zip_error`, and `command_result`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
||||
- `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.
|
||||
- `HomePage.js`: 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.
|
||||
- `AdminPage.js` / `AdminSection.js`: 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.
|
||||
- `ServerDetailsModal.js`: 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.
|
||||
- `BackupSection.js`, `GameConsole.js`, `VPSTerminal.js`: narrowly scoped server tools driven by WebSocket.
|
||||
- `StoreSection.js`: credits packages/currency and Stripe session start.
|
||||
- `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.
|
||||
- `TicketSystem.js`: support-ticket UI and live socket protocol.
|
||||
- `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.
|
||||
- `LegalDocumentLayout.js`, `TermsOfService.js`, `PrivacyPolicy.js`: shared legal shell and static legal content.
|
||||
- `VerifyEmail.js`: HTTP verification callback view.
|
||||
- `DashboardComponents.js`, `dashboardPrimitives.js`, `components/ui/*`: shared dashboard panels, forms, navigation primitives, buttons/cards; extend before duplicating patterns.
|
||||
|
||||
## Change workflow and quality rules
|
||||
|
||||
1. Trace a feature end-to-end: route or parent state, owning UI, outgoing fetch/socket message, matching response handler, loading/error/success state, and styling tokens.
|
||||
2. Check whether a message is HTTP or WebSocket before changing it. Do not replace one transport with another based only on endpoint naming.
|
||||
3. Reuse palette tokens and primitives. If a new token is necessary, update both `src/theme/colors.js` and `tailwind.config.js`; update dashboard semantic references when relevant.
|
||||
4. Keep API contracts explicit at the call site. Include auth, JSON headers, body serialization, response checks, and user feedback. Do not silently swallow failed destructive actions.
|
||||
5. Preserve responsive behavior, keyboard access, visible focus, and touch targets. Check mobile overflow when adding wide data tables or terminal output.
|
||||
6. Keep state local to the feature unless several pages genuinely share it. Avoid introducing a broad global store for one component's state.
|
||||
7. Do not edit `recovered-production/` to implement application changes. It is a forensic/reference snapshot.
|
||||
8. The frontend uses plain JavaScript and CommonJS for build config. Match existing file extensions and import conventions unless a migration is intentional.
|
||||
9. Before claiming behavior, distinguish what the UI sends or reads from backend facts. Backend contract verification requires the companion backend repository or API documentation.
|
||||
10. Update this skill when routes, REST calls, message types, storage keys, theme tokens, or feature ownership changes.
|
||||
|
||||
## Things that are easy to miss
|
||||
|
||||
- The WebSocket base already defaults to a path ending in `/ws`; dashboard/admin append `/dashboard`, resulting in `/ws/dashboard`. Do not duplicate `/ws` in environment configuration unless intended.
|
||||
- `/api/servers/:id/sync` and `/reinstall` are relative paths while most REST calls use `API_BASE_URL`; deployment/proxy behavior matters.
|
||||
- Exchange-rate requests are repeated in the store, creation form, and shared currency hook; avoid letting their assumptions diverge.
|
||||
- Modrinth logic appears in both standalone and embedded store flows. Keep search params, selected version, and install payload coherent.
|
||||
- The dashboard dispatcher forwards certain message types to child handlers before handling its own switch. Removing forwarding can silently break uploads, console, or domains.
|
||||
- Tokens are stored in browser local storage and are included in dashboard WebSocket query strings. This has security implications; do not expand token exposure, and coordinate any auth redesign across all consumers.
|
||||
- No backend source is present here; endpoint inventory is exhaustive for calls found in `src/` at the time this file was written, not a guarantee of all server endpoints.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
[colors]
|
||||
background = #151515
|
||||
panel = #181818
|
||||
surface = #202020
|
||||
hover = #282828
|
||||
border = #2F2F2F
|
||||
focus = #454545
|
||||
text = #D0D0D0
|
||||
muted = #757575
|
||||
dim = #505050
|
||||
emphasis = #F5F5F5
|
||||
accent = #6C99BB
|
||||
accent_secondary = #547C99
|
||||
warning = #D3A04D
|
||||
+3
-3
@@ -4,11 +4,11 @@
|
||||
<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 game server hosting and management." />
|
||||
<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</title>
|
||||
<title>AnchorHost.eu</title>
|
||||
</head>
|
||||
<body><noscript>You need to enable JavaScript to run AnchorHost.</noscript><div id="root"></div></body>
|
||||
<body><noscript>You need to enable JavaScript to run AnchorHost.eu.</noscript><div id="root"></div></body>
|
||||
</html>
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"short_name": "AnchorHost",
|
||||
"short_name": "AnchorHost.eu",
|
||||
"name": "AnchorHost.eu",
|
||||
"icons": [
|
||||
{
|
||||
|
||||
+17
-13
@@ -13,6 +13,7 @@ import PluginDetails from './features/plugins/PluginDetails.js'
|
||||
import DocumentationPage from './pages/DocumentationPage.js'
|
||||
import useWebSocket from './hooks/useWebSocket.js';
|
||||
import { colors } from './theme/colors';
|
||||
import { Dropdown } from './components/dashboardPrimitives';
|
||||
|
||||
import {
|
||||
X, AlertCircle, CheckCircle, AlertTriangle, Info, MapPin,
|
||||
@@ -697,7 +698,7 @@ function LoginModal({ onClose, onLogin, onSwitchToRegister, colors, authConfig }
|
||||
<div className="p-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` }}>
|
||||
<img src="/assets/transparent_anchorhost.png" alt="AnchorHost" className="h-10 w-auto" />
|
||||
<img src="/assets/transparent_anchorhost.png" alt="AnchorHost.eu" className="h-10 w-auto" />
|
||||
</div>
|
||||
<h3 className="text-3xl font-black tracking-tighter" style={{ color: colors.brightWhite }}>
|
||||
{is2faStep ? 'Verify Identity' : 'Welcome Back'}
|
||||
@@ -827,7 +828,7 @@ function LoginModal({ onClose, onLogin, onSwitchToRegister, colors, authConfig }
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-xs font-bold" style={{ color: colors.foreground, opacity: 0.5 }}>
|
||||
New to AnchorHost?{' '}
|
||||
New to AnchorHost.eu?{' '}
|
||||
<button onClick={onSwitchToRegister} className="text-primary hover:underline ml-1">Create Account</button>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1003,6 +1004,10 @@ function RegisterModal({
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const countryDropdownOptions = React.useMemo(
|
||||
() => [{ value: '', label: 'Select country...' }, ...countryOptions],
|
||||
[countryOptions],
|
||||
);
|
||||
|
||||
const detectUserLocation = useCallback(async () => {
|
||||
setDetectingLocation(true);
|
||||
@@ -1080,7 +1085,7 @@ function RegisterModal({
|
||||
<Shield className="w-10 h-10" style={{ color: colors.brightGreen }} />
|
||||
</div>
|
||||
<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-40 mt-2" style={{ color: colors.foreground }}>Join AnchorHost Cloud</p>
|
||||
<p className="text-sm font-bold uppercase tracking-widest opacity-70 mt-2" style={{ color: colors.foreground }}>Join AnchorHost.eu</p>
|
||||
{error && (
|
||||
<p className="text-sm font-semibold mt-4" style={{ color: colors.brightRed }}>{error}</p>
|
||||
)}
|
||||
@@ -1096,16 +1101,15 @@ function RegisterModal({
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">Country</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<select
|
||||
value={selectedCountry}
|
||||
onChange={(e) => setSelectedCountry(e.target.value)}
|
||||
className="w-full rounded-2xl px-5 py-3.5 text-sm font-bold focus:outline-none transition-all duration-300 border bg-black/40 focus:border-primary/50 focus:ring-4 focus:ring-primary/10 appearance-none"
|
||||
style={{ borderColor: colors.brightBlack, color: colors.brightWhite }}
|
||||
>
|
||||
<option value="">Select country...</option>
|
||||
{countryOptions.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
<div className="flex-1">
|
||||
<Dropdown
|
||||
ariaLabel="Country"
|
||||
placeholder="Select country..."
|
||||
value={selectedCountry}
|
||||
options={countryDropdownOptions}
|
||||
onChange={setSelectedCountry}
|
||||
icon={MapPin}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={detectUserLocation} className="p-4 rounded-2xl border bg-black/40 hover:bg-white/5 transition-all" style={{ borderColor: colors.brightBlack }}>
|
||||
{detectingLocation ? <RefreshCw className="w-4 h-4 animate-spin" /> : <MapPin className="w-4 h-4" />}
|
||||
|
||||
@@ -186,12 +186,12 @@ export const parseStorageValue = (storageStr) => {
|
||||
export const PortTooltip = ({ portTooltip }) => {
|
||||
return (
|
||||
<div
|
||||
className="absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-64 max-w-[min(16rem,calc(100vw-2rem))] p-3.5 rounded-xl border border-dash-border bg-dash-surfaceElevated/95 backdrop-blur-md shadow-lg shadow-black/40 text-left"
|
||||
className="ah-tooltip ah-tooltip-detail absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-64 max-w-[min(16rem,calc(100vw-2rem))] p-3.5 text-left"
|
||||
role="tooltip"
|
||||
>
|
||||
<div className="font-semibold mb-1.5 text-sm text-primary">{portTooltip.name}</div>
|
||||
<div className="mb-2.5 text-xs text-dash-textMuted leading-relaxed">{portTooltip.description}</div>
|
||||
<div className="font-mono text-xs px-2.5 py-2 rounded-lg border border-dash-border bg-brightBlack/80 text-dash-text">
|
||||
<div className="ah-tooltip-heading">{portTooltip.name}</div>
|
||||
<div className="ah-tooltip-description">{portTooltip.description}</div>
|
||||
<div className="ah-tooltip-code">
|
||||
{portTooltip.connection}
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,7 +274,7 @@ export const UnifiedTooltip = ({
|
||||
<div
|
||||
id={tooltipId}
|
||||
role="tooltip"
|
||||
className={`fixed z-[12000] px-3.5 py-2.5 rounded-xl border border-dash-border bg-dash-surfaceElevated/95 backdrop-blur-md shadow-lg shadow-black/35 text-sm text-dash-text pointer-events-none ${wrapClass}`}
|
||||
className={`ah-tooltip fixed z-[12000] px-3.5 py-2.5 text-sm pointer-events-none ${wrapClass}`}
|
||||
style={{
|
||||
top: getTopPosition(),
|
||||
left: `${position.left}px`,
|
||||
@@ -1291,4 +1291,4 @@ export {
|
||||
NumberSpinner,
|
||||
Dropdown,
|
||||
InlineDropdown
|
||||
} from './dashboardPrimitives';
|
||||
} from './dashboardPrimitives';
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { RefreshCw, ChevronDown, ChevronUp, Check, X } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Check, X } from 'lucide-react';
|
||||
import { colors } from '../theme/colors';
|
||||
import { dashLayout } from '../theme/dashboardTokens';
|
||||
|
||||
/** Inset ring only — avoids ring-offset gaps that read as a light outline on dark UI */
|
||||
/** Keep a visible keyboard outline against the shared dark panel surface. */
|
||||
const focusRing =
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/45';
|
||||
|
||||
@@ -29,19 +29,19 @@ export function PageHeaderBar({
|
||||
return (
|
||||
<div
|
||||
className={`${dashLayout.headerBar} ${className}`.trim()}
|
||||
style={{
|
||||
backgroundColor: `${accentColor}0D`,
|
||||
borderBottom: `1px solid ${accentColor}15`
|
||||
style={{
|
||||
borderBottom: `2px solid ${colors.brightBlack}`,
|
||||
borderLeft: `4px solid ${accentColor}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{Icon && (
|
||||
<div className="p-2.5 rounded-xl shrink-0 shadow-lg shadow-black/20" style={{ backgroundColor: `${accentColor}18`, border: `1px solid ${accentColor}25` }}>
|
||||
<Icon className="w-6 h-6" style={{ color: accentColor }} aria-hidden />
|
||||
<div className="shrink-0 text-dash-textMuted/70">
|
||||
<Icon className="w-5 h-5" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-dash-text truncate" style={{ color: colors.brightWhite }}>{title}</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-dash-text truncate" style={{ color: colors.brightWhite }}>{title}</h2>
|
||||
{description && (
|
||||
<p className="text-sm text-dash-textMuted/90 mt-0.5 truncate">{description}</p>
|
||||
)}
|
||||
@@ -58,32 +58,33 @@ export function PageHeaderBar({
|
||||
export function StatTile({ icon: Icon, label, value, accentColor = colors.blue, className = '' }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center p-5 rounded-2xl border text-center transition-all duration-300 hover:scale-[1.02] hover:bg-black/40 shadow-lg shadow-black/10 ${className}`.trim()}
|
||||
className={`flex flex-col items-start justify-center p-5 rounded-sm border-2 text-left bg-dash-surface shadow-[4px_4px_0_#2F2F2F] ${className}`.trim()}
|
||||
style={{
|
||||
backgroundColor: `${accentColor}0A`,
|
||||
borderColor: `${accentColor}20`,
|
||||
borderColor: colors.brightBlack,
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon className="w-6 h-6 mb-3 shrink-0" style={{ color: accentColor }} aria-hidden />}
|
||||
<p className="text-xs font-bold uppercase tracking-wider mb-1" style={{ color: `${accentColor}CC` }}>
|
||||
<p className="text-xs font-medium mb-1 text-dash-textMuted">
|
||||
{label}
|
||||
</p>
|
||||
<p className="font-bold text-2xl tabular-nums text-dash-text tracking-tight" style={{ color: colors.brightWhite }}>{value}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="w-4 h-4 shrink-0" style={{ color: accentColor }} aria-hidden />}
|
||||
<p className="font-semibold text-2xl tabular-nums text-dash-text tracking-tight" style={{ color: colors.brightWhite }}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actionBase = `inline-flex items-center justify-center gap-2 rounded-xl text-sm font-bold transition-all duration-200 motion-reduce:transition-none ${focusRing} min-h-[44px] sm:min-h-[40px] px-6 py-3 sm:py-2.5 disabled:opacity-40 disabled:pointer-events-none active:scale-95`;
|
||||
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`;
|
||||
|
||||
/**
|
||||
* Inset panel for settings rows (matches dash border / surface).
|
||||
*/
|
||||
export function SettingsPanel({ title, description, children, className = '' }) {
|
||||
return (
|
||||
<div className={`rounded-2xl border border-dash-border bg-brightBlack/20 backdrop-blur-sm overflow-hidden ${className}`.trim()}>
|
||||
<div className={`rounded-sm border-2 border-dash-border bg-dash-surface overflow-hidden shadow-[4px_4px_0_#2F2F2F] ${className}`.trim()}>
|
||||
{(title || description) && (
|
||||
<div className="px-5 py-4 border-b border-dash-border bg-white/[0.02]">
|
||||
{title && <h4 className="text-sm font-bold tracking-wide text-dash-text" style={{ color: colors.brightWhite }}>{title}</h4>}
|
||||
<div className="px-5 py-4 border-b border-dash-border">
|
||||
{title && <h4 className="text-sm font-semibold text-dash-text" style={{ color: colors.brightWhite }}>{title}</h4>}
|
||||
{description && (
|
||||
<p className="text-xs text-dash-textMuted mt-1 leading-relaxed">{description}</p>
|
||||
)}
|
||||
@@ -97,14 +98,11 @@ export function SettingsPanel({ title, description, children, className = '' })
|
||||
export function ActionButton({ variant = 'secondary', children, className = '', type = 'button', ...props }) {
|
||||
const variants = {
|
||||
primary:
|
||||
'border-2 border-primary/40 bg-primary/90 text-brightWhite hover:bg-primary hover:border-primary/60 hover:shadow-[0_0_15px_rgba(90,124,167,0.3)]',
|
||||
secondary: 'border border-dash-border bg-black/30 text-dash-text hover:bg-brightBlack/80 hover:border-white/20',
|
||||
danger:
|
||||
'border border-brightRed/30 bg-brightRed/10 text-brightRed hover:bg-brightRed/20 hover:border-brightRed/50',
|
||||
success:
|
||||
'border border-brightGreen/30 bg-brightGreen/10 text-brightGreen hover:bg-brightGreen/20 hover:border-brightGreen/50',
|
||||
warning:
|
||||
'border border-brightYellow/30 bg-brightYellow/10 text-brightYellow hover:bg-brightYellow/20 hover:border-brightYellow/50',
|
||||
'border border-primary bg-primary text-background hover:bg-brightBlue hover:border-brightBlue',
|
||||
secondary: 'border border-dash-border bg-dash-surfaceElevated text-dash-text hover:bg-white/5',
|
||||
danger: 'border border-brightRed/50 bg-brightRed/10 text-brightRed hover:bg-brightRed/15',
|
||||
success: 'border border-brightGreen/45 bg-brightGreen/10 text-brightGreen hover:bg-brightGreen/15',
|
||||
warning: 'border border-brightYellow/45 bg-brightYellow/10 text-brightYellow hover:bg-brightYellow/15',
|
||||
ghost: 'border border-transparent text-foreground hover:bg-white/5',
|
||||
};
|
||||
return (
|
||||
@@ -115,27 +113,24 @@ export function ActionButton({ variant = 'secondary', children, className = '',
|
||||
}
|
||||
|
||||
export function DashboardEmptyState({ icon: Icon, title, description, action = null, onClick = null }) {
|
||||
const interactive = typeof onClick === 'function';
|
||||
const interactive = typeof onClick === 'function' && !action;
|
||||
const Wrapper = interactive ? 'button' : 'div';
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
type={interactive ? 'button' : undefined}
|
||||
onClick={interactive ? onClick : undefined}
|
||||
className={`w-full text-center py-20 px-6 rounded-3xl border-2 border-dashed border-white/5 bg-white/[0.01] backdrop-blur-sm transition-all duration-200 ${
|
||||
interactive ? 'cursor-pointer hover:border-primary/25 hover:bg-white/[0.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/40' : ''
|
||||
className={`w-full text-left py-12 px-6 rounded-sm border-2 border-dash-border bg-dash-surface transition-colors duration-150 ${interactive ? 'shadow-[4px_4px_0_#2F2F2F]' : ''} ${
|
||||
interactive ? 'cursor-pointer hover:border-primary/50 hover:bg-white/[0.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/40' : ''
|
||||
}`.trim()}
|
||||
>
|
||||
{Icon && (
|
||||
<div className="w-20 h-20 rounded-full bg-white/5 flex items-center justify-center mx-auto mb-6 border border-white/5">
|
||||
<Icon className="w-10 h-10 text-dash-textMuted opacity-40" aria-hidden />
|
||||
<div className="w-14 h-14 rounded-lg bg-white/5 flex items-center justify-center mb-5 border border-dash-border">
|
||||
<Icon className="w-7 h-7 text-dash-textMuted opacity-60" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-2xl font-bold text-dash-text mb-2" style={{ color: colors.brightWhite }}>{title}</h3>
|
||||
{description && <p className="text-base text-dash-textMuted max-w-md mx-auto mb-8 leading-relaxed">{description}</p>}
|
||||
{interactive && !action && (
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-primary/80">Click to add a domain</p>
|
||||
)}
|
||||
<h3 className="text-xl font-semibold text-dash-text mb-2" style={{ color: colors.brightWhite }}>{title}</h3>
|
||||
{description && <p className="text-sm text-dash-textMuted max-w-xl mb-6 leading-relaxed">{description}</p>}
|
||||
{action && <div className="flex justify-center">{action}</div>}
|
||||
</Wrapper>
|
||||
);
|
||||
@@ -144,15 +139,14 @@ export function DashboardEmptyState({ icon: Icon, title, description, action = n
|
||||
export function DashboardLoadingSpinner({ label = 'Loading...' }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col justify-center items-center gap-4 min-h-[20rem]"
|
||||
className="flex flex-col justify-center items-stretch gap-4 min-h-[20rem] max-w-xl mx-auto px-6"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="relative">
|
||||
<RefreshCw className="w-10 h-10 animate-spin text-primary" aria-hidden />
|
||||
<div className="absolute inset-0 w-10 h-10 animate-ping text-primary opacity-20" aria-hidden />
|
||||
</div>
|
||||
<span className="text-sm font-medium tracking-wide opacity-60" style={{ color: colors.foreground }}>{label}</span>
|
||||
<span className="text-sm font-medium" style={{ color: colors.foreground }}>{label}</span>
|
||||
<span className="dash-skeleton h-10 w-full rounded-lg" aria-hidden="true" />
|
||||
<span className="dash-skeleton h-10 w-4/5 rounded-lg" aria-hidden="true" />
|
||||
<span className="dash-skeleton h-10 w-3/5 rounded-lg" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -164,25 +158,19 @@ export function SectionCard({ title, icon: Icon, children, color = colors.blue,
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={`group rounded-2xl p-6 border transition-all duration-300 bg-brightBlack/20 backdrop-blur-sm w-full shadow-xl shadow-black/10 ${className}`.trim()}
|
||||
className={`group rounded-sm p-5 md:p-6 border-2 border-dash-border bg-dash-surface w-full shadow-[4px_4px_0_#2F2F2F] ${className}`.trim()}
|
||||
style={{
|
||||
borderColor: `${color}25`,
|
||||
boxShadow: `0 10px 30px -15px rgba(0,0,0,0.5), 0 0 0 1px ${color}10`
|
||||
borderColor: colors.brightBlack,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 mb-8 pb-5 border-b border-white/5">
|
||||
<div className="flex items-center justify-between gap-4 mb-5 pb-4 border-b border-dash-border">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{Icon && (
|
||||
<div className="p-3 rounded-xl shadow-lg shadow-black/20 border transition-all duration-300 group-hover:scale-110 shrink-0"
|
||||
style={{
|
||||
backgroundColor: `${color}15`,
|
||||
borderColor: `${color}30`,
|
||||
boxShadow: `0 4px 15px -3px ${color}20`
|
||||
}}>
|
||||
<div className="shrink-0 text-dash-textMuted/70">
|
||||
<Icon className="w-5 h-5" style={{ color }} aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-xl font-bold tracking-tight truncate" style={{ color: colors.brightWhite }}>{title}</h3>
|
||||
<h3 className="text-lg font-semibold tracking-tight truncate" style={{ color: colors.brightWhite }}>{title}</h3>
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2 shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
@@ -240,115 +228,171 @@ export function Slider({ value, min, max, step, onChange, label, unit, icon: Ico
|
||||
/**
|
||||
* Standard dropdown for general use.
|
||||
*/
|
||||
export function Dropdown({ value, options, onChange, placeholder, icon: Icon }) {
|
||||
export function Dropdown({ value, options, onChange, placeholder, icon: Icon, ariaLabel }) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [position, setPosition] = React.useState({ top: 0, left: 0, width: 0 });
|
||||
const ref = React.useRef(null);
|
||||
const dropdownRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (
|
||||
ref.current && !ref.current.contains(e.target) &&
|
||||
dropdownRef.current && !dropdownRef.current.contains(e.target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrollOrResize = (e) => {
|
||||
if (!isOpen) return;
|
||||
if (dropdownRef.current && (dropdownRef.current === e.target || dropdownRef.current.contains(e.target))) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
const [activeIndex, setActiveIndex] = React.useState(0);
|
||||
const [position, setPosition] = React.useState(null);
|
||||
const wrapperRef = React.useRef(null);
|
||||
const triggerRef = React.useRef(null);
|
||||
const menuRef = React.useRef(null);
|
||||
const optionRefs = React.useRef([]);
|
||||
const id = React.useId();
|
||||
const menuId = `dropdown-options-${id}`;
|
||||
const selectedIndex = options.findIndex((option) => option.value === value);
|
||||
const selected = selectedIndex >= 0 ? options[selectedIndex] : null;
|
||||
const typeaheadRef = React.useRef({ value: '', timestamp: 0 });
|
||||
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
window.addEventListener('scroll', handleScrollOrResize, true);
|
||||
window.addEventListener('resize', handleScrollOrResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||
window.removeEventListener('resize', handleScrollOrResize);
|
||||
const close = React.useCallback((restoreFocus = false) => {
|
||||
setIsOpen(false);
|
||||
if (restoreFocus) requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const choose = React.useCallback((option) => {
|
||||
onChange(option.value);
|
||||
close(true);
|
||||
}, [close, onChange]);
|
||||
|
||||
const open = React.useCallback((index = selectedIndex >= 0 ? selectedIndex : 0) => {
|
||||
if (!options.length) return;
|
||||
setActiveIndex(Math.max(0, Math.min(index, options.length - 1)));
|
||||
setIsOpen(true);
|
||||
}, [options.length, selectedIndex]);
|
||||
|
||||
// Global listeners only exist for the one open popover; closed dropdowns are inert.
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const onPointerDown = (event) => {
|
||||
if (wrapperRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
}, [isOpen]);
|
||||
const onScrollOrResize = (event) => {
|
||||
if (event.type === 'scroll' && menuRef.current?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
window.addEventListener('scroll', onScrollOrResize, true);
|
||||
window.addEventListener('resize', onScrollOrResize);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
window.removeEventListener('scroll', onScrollOrResize, true);
|
||||
window.removeEventListener('resize', onScrollOrResize);
|
||||
};
|
||||
}, [close, isOpen]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isOpen && ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
left: rect.left,
|
||||
width: rect.width
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
if (!isOpen || !triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const viewportPadding = 8;
|
||||
const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2);
|
||||
const left = Math.max(viewportPadding, Math.min(rect.left, window.innerWidth - width - viewportPadding));
|
||||
const below = window.innerHeight - rect.bottom - viewportPadding;
|
||||
const above = rect.top - viewportPadding;
|
||||
const opensAbove = below < Math.min(256, options.length * 46) && above > below;
|
||||
const maxHeight = Math.max(96, Math.min(256, opensAbove ? above : below));
|
||||
setPosition({
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
top: opensAbove ? undefined : rect.bottom + 6,
|
||||
bottom: opensAbove ? window.innerHeight - rect.top + 6 : undefined,
|
||||
});
|
||||
}, [isOpen, options.length]);
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
const dropdownMenu = isOpen ? (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[11000] mt-2 rounded-2xl shadow-2xl border overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200 backdrop-blur-xl"
|
||||
style={{
|
||||
backgroundColor: `${colors.black}E6`,
|
||||
borderColor: `${colors.blue}40`,
|
||||
boxShadow: `0 10px 40px rgba(0, 0, 0, 0.7), 0 0 0 1px ${colors.blue}20`,
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
width: `${position.width}px`
|
||||
}}
|
||||
React.useLayoutEffect(() => {
|
||||
if (isOpen) optionRefs.current[activeIndex]?.scrollIntoView?.({ block: 'nearest' });
|
||||
}, [activeIndex, isOpen]);
|
||||
|
||||
const onTriggerKeyDown = (event) => {
|
||||
if (!options.length) return;
|
||||
if (event.key.trim().length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
const now = Date.now();
|
||||
const previous = now - typeaheadRef.current.timestamp < 700 ? typeaheadRef.current.value : '';
|
||||
const query = `${previous}${event.key}`.toLocaleLowerCase();
|
||||
typeaheadRef.current = { value: query, timestamp: now };
|
||||
const matchIndex = options.findIndex((option) => String(option.label).toLocaleLowerCase().startsWith(query));
|
||||
if (matchIndex >= 0) {
|
||||
setActiveIndex(matchIndex);
|
||||
if (!isOpen) setIsOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
open(event.key === 'ArrowUp' ? options.length - 1 : selectedIndex >= 0 ? selectedIndex : 0);
|
||||
return;
|
||||
}
|
||||
setActiveIndex((index) => (index + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length);
|
||||
} else if (isOpen && event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
setActiveIndex(0);
|
||||
} else if (isOpen && event.key === 'End') {
|
||||
event.preventDefault();
|
||||
setActiveIndex(options.length - 1);
|
||||
} else if (isOpen && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (options[activeIndex]) choose(options[activeIndex]);
|
||||
} else if (isOpen && event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close(true);
|
||||
} else if (isOpen && event.key === 'Tab') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const dropdownMenu = isOpen ? createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
id={menuId}
|
||||
role="listbox"
|
||||
aria-label={ariaLabel || placeholder || 'Options'}
|
||||
className="ah-dropdown-popover"
|
||||
style={position || undefined}
|
||||
>
|
||||
<div className="max-h-64 overflow-y-auto custom-scrollbar overscroll-contain p-1.5">
|
||||
{options.map((option) => (
|
||||
<div className="ah-dropdown-options" style={{ maxHeight: position?.maxHeight || 256 }}>
|
||||
{options.length ? options.map((option, index) => (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 flex items-center justify-between transition-all duration-150 text-sm hover:bg-white/5 cursor-pointer rounded-xl"
|
||||
style={{
|
||||
color: colors.brightWhite,
|
||||
backgroundColor: value === option.value ? `${colors.blue}20` : 'transparent'
|
||||
}}
|
||||
id={`${menuId}-option-${index}`}
|
||||
key={String(option.value)}
|
||||
ref={(element) => { optionRefs.current[index] = element; }}
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
className={`ah-dropdown-option ${index === activeIndex ? 'ah-dropdown-option--active' : ''}`}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => choose(option)}
|
||||
>
|
||||
<span className="flex-1 pointer-events-none font-medium">{option.label}</span>
|
||||
{value === option.value && (
|
||||
<Check className="w-4 h-4 flex-shrink-0 ml-2 pointer-events-none" style={{ color: colors.blue }} />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{option.value === value && <Check size={17} aria-hidden />}
|
||||
</div>
|
||||
))}
|
||||
)) : <div className="ah-dropdown-empty" role="status">No options available</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div ref={wrapperRef} className="ah-dropdown">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full rounded-xl px-4 py-3 flex items-center justify-between transition-all duration-200 border border-dash-border bg-brightBlack/60 text-dash-text hover:bg-brightBlack/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/40"
|
||||
style={{
|
||||
borderColor: isOpen ? colors.primary : undefined,
|
||||
boxShadow: isOpen ? `0 0 15px ${colors.primary}20` : 'none',
|
||||
}}
|
||||
role="combobox"
|
||||
aria-label={ariaLabel || placeholder || selected?.label || 'Select an option'}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={isOpen ? menuId : undefined}
|
||||
aria-activedescendant={isOpen && options[activeIndex] ? `${menuId}-option-${activeIndex}` : undefined}
|
||||
disabled={!options.length}
|
||||
onClick={() => (isOpen ? close() : open())}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
className="ah-dropdown-trigger"
|
||||
>
|
||||
<div className="flex items-center space-x-2.5 flex-1 min-w-0 pointer-events-none">
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" style={{ color: colors.foreground, opacity: 0.7 }} />}
|
||||
<span className="text-sm font-bold truncate">{selected?.label || placeholder}</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 flex-shrink-0 transition-transform duration-300 ${isOpen ? 'rotate-180' : ''} pointer-events-none`}
|
||||
style={{ color: colors.foreground, opacity: 0.6 }}
|
||||
/>
|
||||
<span className="ah-dropdown-trigger-value">
|
||||
{Icon && <Icon size={17} aria-hidden />}
|
||||
<span className="ah-dropdown-label">{selected?.label || placeholder || 'Choose an option'}</span>
|
||||
</span>
|
||||
<ChevronDown className={isOpen ? 'ah-dropdown-chevron ah-dropdown-chevron--open' : 'ah-dropdown-chevron'} size={18} aria-hidden />
|
||||
</button>
|
||||
|
||||
{dropdownMenu && createPortal(dropdownMenu, document.body)}
|
||||
{dropdownMenu}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -399,133 +443,16 @@ export function NumberSpinner({ value, onChange, step = 1, min = 0, color = colo
|
||||
/**
|
||||
* Inline dropdown for quick settings changes.
|
||||
*/
|
||||
export function InlineDropdown({ value, options, onChange, onSave, onCancel, placeholder, icon: Icon, color = colors.blue }) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [position, setPosition] = React.useState({ top: 0, left: 0, width: 0 });
|
||||
const ref = React.useRef(null);
|
||||
const dropdownRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (
|
||||
ref.current && !ref.current.contains(e.target) &&
|
||||
dropdownRef.current && !dropdownRef.current.contains(e.target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrollOrResize = (e) => {
|
||||
if (!isOpen) return;
|
||||
if (dropdownRef.current && (dropdownRef.current === e.target || dropdownRef.current.contains(e.target))) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
window.addEventListener('scroll', handleScrollOrResize, true);
|
||||
window.addEventListener('resize', handleScrollOrResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||
window.removeEventListener('resize', handleScrollOrResize);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isOpen && ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 160)
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
const dropdownMenu = isOpen ? (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[11000] mt-2 rounded-2xl shadow-2xl border overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200 backdrop-blur-xl"
|
||||
style={{
|
||||
backgroundColor: `${colors.black}F2`,
|
||||
borderColor: `${color}40`,
|
||||
boxShadow: `0 10px 40px rgba(0, 0, 0, 0.7), 0 0 0 1px ${color}20`,
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
width: `${position.width}px`
|
||||
}}
|
||||
>
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar overscroll-contain p-1.5">
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 flex items-center justify-between transition-all duration-150 text-sm hover:bg-white/5 cursor-pointer rounded-xl"
|
||||
style={{
|
||||
color: colors.brightWhite,
|
||||
backgroundColor: value === option.value ? `${color}20` : 'transparent'
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 pointer-events-none font-medium">{option.label}</span>
|
||||
{value === option.value && (
|
||||
<Check className="w-4 h-4 flex-shrink-0 ml-2 pointer-events-none" style={{ color }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
export function InlineDropdown({ value, options, onChange, onSave, onCancel, placeholder, icon: Icon }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div ref={ref} className="relative flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="focus-skip-global w-full rounded-xl px-4 py-3 flex items-center justify-between transition-all duration-200 border bg-brightBlack/40 hover:bg-brightBlack/60 text-left outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-offset-0"
|
||||
style={{
|
||||
borderColor: isOpen ? `${color}55` : `${color}28`,
|
||||
boxShadow: isOpen ? `0 0 15px ${color}15` : 'none',
|
||||
'--tw-ring-color': color,
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-bold truncate" style={{ color: colors.brightWhite }}>
|
||||
{selected?.label || placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 ml-2 flex-shrink-0 transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`}
|
||||
style={{ color: colors.foreground, opacity: 0.5 }}
|
||||
/>
|
||||
<div className="ah-inline-dropdown">
|
||||
<Dropdown value={value} options={options} onChange={onChange} placeholder={placeholder} icon={Icon} ariaLabel={placeholder} />
|
||||
<div className="ah-inline-dropdown-actions">
|
||||
<button type="button" className="ah-dropdown-action ah-dropdown-action--save" onClick={onSave} aria-label="Save changes" title="Save changes">
|
||||
<Check size={17} aria-hidden />
|
||||
</button>
|
||||
{dropdownMenu && createPortal(dropdownMenu, document.body)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="focus-skip-global p-3 rounded-xl bg-green-500/15 hover:bg-green-500/25 text-green-400 transition-all active:scale-90 hover:scale-105 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-green-500/45 focus-visible:ring-offset-0"
|
||||
title="Save changes"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="focus-skip-global p-3 rounded-xl bg-red-500/15 hover:bg-red-500/25 text-red-400 transition-all active:scale-90 hover:scale-105 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500/45 focus-visible:ring-offset-0"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<button type="button" className="ah-dropdown-action ah-dropdown-action--cancel" onClick={onCancel} aria-label="Cancel" title="Cancel">
|
||||
<X size={17} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@ const NodeTerminalModal = ({ node, onClose }) => {
|
||||
const connectTerminal = useCallback(() => {
|
||||
if (!node || !sendMessage) return;
|
||||
setIsLoading(true);
|
||||
setTerminalOutput(prev => prev + `\r\n\x1b[36m[AnchorHost]\x1b[0m Requesting shell session on node \x1b[1m${node.name}\x1b[0m...\r\n`);
|
||||
setTerminalOutput(prev => prev + `\r\n\x1b[36m[AnchorHost.eu]\x1b[0m Requesting shell session on node \x1b[1m${node.name}\x1b[0m...\r\n`);
|
||||
sendMessage({
|
||||
type: 'node_terminal_connect',
|
||||
nodeName: node.name
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Calendar, Tag, ChevronRight, X, User } from "lucide-react";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
import Beams from "../../components/backgrounds/Beams";
|
||||
import { colors } from "../../theme/colors";
|
||||
import { API_BASE_URL } from '../../config/api';
|
||||
|
||||
const AnnouncementModal = ({ announcement, onClose }) => {
|
||||
const dialogRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent scrolling when modal is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const previousFocus = document.activeElement;
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
if (event.key !== 'Tab' || !dialogRef.current) return;
|
||||
const controls = dialogRef.current.querySelectorAll('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
dialogRef.current?.focus();
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previousFocus?.focus?.();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!announcement) return null;
|
||||
|
||||
@@ -41,13 +62,10 @@ const AnnouncementModal = ({ announcement, onClose }) => {
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 backdrop-blur-xl bg-black/80 animate-in fade-in duration-300">
|
||||
<div
|
||||
className="relative w-full max-w-3xl max-h-[90vh] overflow-hidden rounded-3xl border border-white/10 shadow-2xl animate-in zoom-in-95 duration-300"
|
||||
style={{ backgroundColor: colors.backgroundSecondary || '#161616' }}
|
||||
>
|
||||
<div className="announcement-overlay">
|
||||
<div ref={dialogRef} className="announcement-dialog" role="dialog" aria-modal="true" aria-labelledby="announcement-title" tabIndex={-1}>
|
||||
{/* Header */}
|
||||
<div className="p-6 sm:p-8 border-b border-white/5 flex justify-between items-start sticky top-0 z-10" style={{ backgroundColor: '#161616' }}>
|
||||
<div className="announcement-dialog-header">
|
||||
<div className="flex-1 pr-8">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{announcement.tags && announcement.tags.map((tag, idx) => {
|
||||
@@ -55,7 +73,7 @@ const AnnouncementModal = ({ announcement, onClose }) => {
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-3 py-1 rounded-full text-[10px] uppercase font-bold tracking-widest flex items-center"
|
||||
className="announcement-tag"
|
||||
style={{ backgroundColor: `${tagColor}20`, color: tagColor, border: `1px solid ${tagColor}30` }}
|
||||
>
|
||||
<Tag className="w-3 h-3 mr-1.5" />
|
||||
@@ -64,33 +82,34 @@ const AnnouncementModal = ({ announcement, onClose }) => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white leading-tight">
|
||||
<h2 id="announcement-title" className="text-2xl sm:text-3xl font-semibold leading-tight">
|
||||
{announcement.title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 transition-colors text-white/60 hover:text-white"
|
||||
className="announcement-close"
|
||||
aria-label="Close announcement"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 sm:p-8 overflow-y-auto custom-scrollbar" style={{ maxHeight: 'calc(90vh - 180px)' }}>
|
||||
<div className="flex items-center space-x-6 mb-8 text-sm text-white/40 border-b border-white/5 pb-6">
|
||||
<div className="announcement-dialog-content">
|
||||
<div className="announcement-meta">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-2 text-blue-400" />
|
||||
<Calendar className="w-4 h-4 mr-2" aria-hidden />
|
||||
{formatDate(announcement.created_at)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<User className="w-4 h-4 mr-2 text-blue-400" />
|
||||
Posted by <span className="text-blue-400 font-semibold ml-1.5">{announcement.author_name || 'Administrator'}</span>
|
||||
<User className="w-4 h-4 mr-2" aria-hidden />
|
||||
Posted by <span className="font-semibold ml-1.5">{announcement.author_name || 'Administrator'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-white/80 text-base sm:text-lg leading-relaxed whitespace-pre-wrap selection:bg-blue-500/30"
|
||||
className="announcement-body"
|
||||
style={{ fontFamily: 'inherit' }}
|
||||
>
|
||||
{announcement.content}
|
||||
@@ -98,10 +117,10 @@ const AnnouncementModal = ({ announcement, onClose }) => {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-white/5 text-center bg-black/20">
|
||||
<div className="announcement-dialog-footer">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-8 py-3 rounded-xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all hover:scale-105 active:scale-95"
|
||||
className="site-button site-button--primary"
|
||||
>
|
||||
Close Announcement
|
||||
</button>
|
||||
@@ -156,28 +175,14 @@ const AnnouncementsSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getTagColor = (tag) => {
|
||||
const lowerTag = tag.toLowerCase();
|
||||
if (lowerTag.includes('update') || lowerTag.includes('feature')) return colors.green;
|
||||
if (lowerTag.includes('alert') || lowerTag.includes('maintenance') || lowerTag.includes('downtime')) return colors.red;
|
||||
if (lowerTag.includes('technical') || lowerTag.includes('info')) return colors.blue;
|
||||
return colors.yellow;
|
||||
};
|
||||
|
||||
const displayedAnnouncements = showAll ? announcements : announcements.slice(0, 3);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="py-20 relative overflow-hidden bg-[#101010]">
|
||||
<div className="max-w-6xl mx-auto px-4 text-center">
|
||||
<div className="animate-pulse space-y-8">
|
||||
<div className="h-12 w-64 bg-white/5 rounded-2xl mx-auto"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-80 bg-white/5 rounded-3xl border border-white/5"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<section className="site-section site-announcements" aria-busy="true" aria-label="Loading announcements">
|
||||
<div className="site-section-heading"><div><p className="site-kicker">Updates</p><h2>Latest announcements.</h2></div></div>
|
||||
<div className="site-announcement-list">
|
||||
{[1, 2, 3].map((item) => <div key={item} className="site-announcement-skeleton" />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -186,114 +191,30 @@ const AnnouncementsSection = () => {
|
||||
if (error || announcements.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id="announcements" className="relative overflow-hidden py-12 sm:py-16 md:py-20">
|
||||
{/* Dynamic Background */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Beams
|
||||
beamWidth={2}
|
||||
beamHeight={15}
|
||||
beamNumber={12}
|
||||
lightColor="#33373acc"
|
||||
speed={2}
|
||||
noiseIntensity={1.75}
|
||||
scale={0.2}
|
||||
rotation={0}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[#101010]/80" />
|
||||
<section id="announcements" className="site-section site-announcements">
|
||||
<div className="site-section-heading">
|
||||
<div><p className="site-kicker">Platform updates</p><h2>Latest announcements.</h2></div>
|
||||
<p>Service changes and notes from the AnchorHost.eu team.</p>
|
||||
</div>
|
||||
<div className="site-announcement-list">
|
||||
{displayedAnnouncements.map((item) => (
|
||||
<button key={item.id} type="button" className="site-announcement-item" onClick={() => setSelectedAnnouncement(item)}>
|
||||
<span className="site-announcement-meta">
|
||||
<span>{formatDate(item.created_at)}</span>
|
||||
{item.tags?.[0] && <span className="site-announcement-tag">{item.tags[0]}</span>}
|
||||
</span>
|
||||
<span className="site-announcement-title">{item.title}</span>
|
||||
<span className="site-announcement-summary">{item.content}</span>
|
||||
<span className="site-announcement-open">Read announcement <ChevronRight size={16} aria-hidden /></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8 sm:mb-12 md:mb-16">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold mb-4 sm:mb-6 bg-gradient-to-r from-white via-[#e0e7ff] to-[#8b7bff] bg-clip-text text-transparent">
|
||||
Latest News & Announcements
|
||||
</h2>
|
||||
<p className="text-base sm:text-lg md:text-xl max-w-4xl mx-auto px-4" style={{ color: colors.blue }}>
|
||||
Stay informed about platform updates, maintenance, and new features
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Announcements Grid */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 w-full ${announcements.length < 3 ? 'md:max-w-4xl' : ''}`}>
|
||||
{displayedAnnouncements.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
onClick={() => setSelectedAnnouncement(item)}
|
||||
className="group relative cursor-pointer border rounded-lg sm:rounded-xl shadow-lg transition-all duration-500 hover:-translate-y-1 sm:hover:-translate-y-2 hover:shadow-xl overflow-hidden flex flex-col h-full"
|
||||
style={{ backgroundColor: `${colors.black}80`, borderColor: colors.brightBlack }}
|
||||
>
|
||||
<CardContent className="p-6 sm:p-8 h-full flex flex-col relative z-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center text-xs font-medium text-white/40">
|
||||
<Calendar className="w-3.5 h-3.5 mr-2" style={{ color: colors.blue }} />
|
||||
{formatDate(item.created_at)}
|
||||
</div>
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[10px] uppercase font-bold tracking-wider"
|
||||
style={{
|
||||
backgroundColor: `${getTagColor(item.tags[0])}20`,
|
||||
color: getTagColor(item.tags[0]),
|
||||
border: `1px solid ${getTagColor(item.tags[0])}30`
|
||||
}}
|
||||
>
|
||||
{item.tags[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg sm:text-xl font-bold mb-3 line-clamp-2 transition-colors group-hover:text-blue-400" style={{ color: colors.brightWhite }}>
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm line-clamp-4 mb-6 flex-grow" style={{ color: colors.foreground, lineHeight: '1.6' }}>
|
||||
{item.content}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto pt-4 border-t flex items-center justify-between" style={{ borderColor: `${colors.brightWhite}10` }}>
|
||||
<div className="flex items-center text-xs" style={{ color: colors.blue }}>
|
||||
<span className="opacity-70">By</span>
|
||||
<span className="ml-1 font-semibold">{item.author_name || 'Admin'}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-xs font-medium transition-colors" style={{ color: colors.brightBlue }}>
|
||||
Read more <ChevronRight className="w-3 h-3 ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toggle Button */}
|
||||
{announcements.length > 3 && (
|
||||
<div className="mt-12 text-center">
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="px-8 py-3 rounded-lg font-medium transition-all duration-200 hover:scale-105 border flex items-center space-x-2 mx-auto"
|
||||
style={{
|
||||
backgroundColor: `${colors.blue}20`,
|
||||
borderColor: `${colors.blue}30`,
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.backgroundColor = `${colors.blue}30`;
|
||||
e.target.style.borderColor = colors.blue;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.backgroundColor = `${colors.blue}20`;
|
||||
e.target.style.borderColor = `${colors.blue}30`;
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{showAll ? 'Show Less' : `Explore All Updates (${announcements.length})`}
|
||||
</span>
|
||||
<ChevronRight className={`w-4 h-4 transition-transform duration-300 ${showAll ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{announcements.length > 3 && (
|
||||
<button type="button" className="site-button site-button--quiet site-announcement-more" onClick={() => setShowAll((value) => !value)}>
|
||||
{showAll ? 'Show fewer updates' : `Show all ${announcements.length} updates`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Modal Overlay */}
|
||||
{selectedAnnouncement && (
|
||||
@@ -303,24 +224,8 @@ const AnnouncementsSection = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(37, 99, 235, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(37, 99, 235, 0.5);
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementsSection;
|
||||
export default AnnouncementsSection;
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Download, Star, Calendar, Package, ExternalLink, Tag, Server, ChevronDown } from 'lucide-react';
|
||||
import { ArrowLeft, Download, Star, Calendar, Package, ExternalLink, Tag, Server } from 'lucide-react';
|
||||
import { colors } from '../../theme/colors';
|
||||
import { Dropdown } from '../../components/dashboardPrimitives';
|
||||
|
||||
import { API_BASE_URL, WS_BASE_URL as WS_URL } from '../../config/api';
|
||||
|
||||
@@ -15,9 +16,12 @@ function PluginDetails() {
|
||||
const [selectedVersion, setSelectedVersion] = useState(null);
|
||||
const [servers, setServers] = useState([]);
|
||||
const [selectedServer, setSelectedServer] = useState(null);
|
||||
const [showServerDropdown, setShowServerDropdown] = useState(false);
|
||||
const [downloadingToServer, setDownloadingToServer] = useState(false);
|
||||
const [downloadStatus, setDownloadStatus] = useState('');
|
||||
const serverDropdownOptions = useMemo(
|
||||
() => servers.map((server) => ({ value: server.id, label: server.name })),
|
||||
[servers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPluginDetails = async () => {
|
||||
@@ -30,7 +34,7 @@ function PluginDetails() {
|
||||
`${API_BASE_URL}/api/modrinth/project/${pluginId}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'AnchorHost/1.0'
|
||||
'User-Agent': 'AnchorHost.eu/1.0'
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -407,51 +411,14 @@ function PluginDetails() {
|
||||
<div className="sticky bottom-4 space-y-3">
|
||||
{/* Server Selection Dropdown */}
|
||||
{servers.length > 0 && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowServerDropdown(!showServerDropdown)}
|
||||
className="w-full py-3 rounded-xl border font-medium transition-all hover:scale-105 flex items-center justify-between px-4"
|
||||
style={{
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderColor: colors.brightBlack,
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
<span>{selectedServer ? selectedServer.name : 'Select a server to download to'}</span>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${showServerDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showServerDropdown && (
|
||||
<div
|
||||
className="absolute bottom-full mb-2 w-full rounded-xl border overflow-hidden z-10 max-h-60 overflow-y-auto"
|
||||
style={{
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderColor: colors.brightBlack
|
||||
}}
|
||||
>
|
||||
{servers.map((server) => (
|
||||
<button
|
||||
key={server.id}
|
||||
onClick={() => {
|
||||
setSelectedServer(server);
|
||||
setShowServerDropdown(false);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left hover:bg-opacity-50 transition-all"
|
||||
style={{
|
||||
backgroundColor: selectedServer?.id === server.id ? colors.blue : 'transparent',
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
>
|
||||
<div className="font-medium">{server.name}</div>
|
||||
<div className="text-xs opacity-75">{server.version || 'Unknown version'}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Dropdown
|
||||
ariaLabel="Download target server"
|
||||
placeholder="Select a server to download to"
|
||||
value={selectedServer?.id || ''}
|
||||
options={serverDropdownOptions}
|
||||
onChange={(serverId) => setSelectedServer(servers.find((server) => server.id === serverId) || null)}
|
||||
icon={Server}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Download to Server Button */}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
ChevronDown,
|
||||
Check,
|
||||
Package,
|
||||
Calendar
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { colors } from '../../theme/colors';
|
||||
import { Dropdown } from '../../components/dashboardPrimitives';
|
||||
|
||||
import { API_BASE_URL } from '../../config/api';
|
||||
|
||||
@@ -88,73 +87,6 @@ const PluginStore = () => {
|
||||
return text.substring(0, maxLength) + '...';
|
||||
};
|
||||
|
||||
// Custom Dropdown Component (same style as ticket management)
|
||||
const Dropdown = ({ value, options, onChange, placeholder, icon: Icon }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full rounded-xl px-4 py-3 flex items-center justify-between transition-all duration-200 border-2"
|
||||
style={{
|
||||
backgroundColor: colors.brightBlack,
|
||||
borderColor: isOpen ? colors.blue : 'transparent',
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{Icon && <Icon className="w-4 h-4" style={{ color: colors.foreground }} />}
|
||||
<span className="text-sm">{selected?.label || placeholder}</span>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-20 w-full mt-2 rounded-xl shadow-xl border overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: colors.black,
|
||||
borderColor: colors.blue
|
||||
}}>
|
||||
<div className="max-h-48 overflow-auto">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 flex items-center justify-between transition-all duration-150 text-sm hover:opacity-80"
|
||||
style={{
|
||||
color: colors.brightWhite,
|
||||
backgroundColor: value === option.value ? `${colors.blue}20` : 'transparent'
|
||||
}}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{value === option.value && (
|
||||
<Check className="w-4 h-4" style={{ color: colors.blue }} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-app antialiased" style={{ backgroundColor: colors.background }}>
|
||||
|
||||
@@ -34,7 +34,7 @@ const PluginStoreEmbedded = ({ serverId }) => {
|
||||
`${API_BASE_URL}/api/modrinth/project/${pluginId}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'AnchorHost/1.0'
|
||||
'User-Agent': 'AnchorHost.eu/1.0'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -246,10 +246,8 @@ const GameConsole = ({ server, sendMessage, isActive }) => {
|
||||
|
||||
const tooltipContent = isVisible ? (
|
||||
<div
|
||||
className="fixed p-3 rounded-lg border text-xs z-[9999] shadow-lg whitespace-nowrap"
|
||||
className="ah-tooltip fixed p-3 text-xs z-[9999] whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: colors.backgroundSecondary, borderColor: colors.blue,
|
||||
color: colors.foreground,
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
transform: 'translate(-50%, calc(-100% - 8px))'
|
||||
@@ -492,4 +490,4 @@ const GameConsole = ({ server, sendMessage, isActive }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default GameConsole;
|
||||
export default GameConsole;
|
||||
@@ -1,130 +1,16 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Monitor, Gamepad2, Globe, Plus, Coins, Cpu, MemoryStick, HardDrive,
|
||||
ChevronDown, Check, Zap, Crown, Award, Rocket, Server, Star, MapPin,
|
||||
Check, Zap, Crown, Award, Rocket, Server, Star, MapPin,
|
||||
Layers, Box, Settings, Database, RefreshCw
|
||||
} from "lucide-react";
|
||||
import { colors } from '../../theme/colors';
|
||||
import { dashLayout } from '../../theme/dashboardTokens';
|
||||
import { DashboardViewShell, PageHeaderBar, ActionButton } from '../../components/dashboardPrimitives';
|
||||
import { DashboardViewShell, PageHeaderBar, ActionButton, Dropdown } from '../../components/dashboardPrimitives';
|
||||
import { minecraftVersions } from '../../components/DashboardComponents';
|
||||
|
||||
import { API_BASE_URL } from '../../config/api';
|
||||
|
||||
// Shared Dropdown Component
|
||||
const Dropdown = ({ value, options, onChange, placeholder, icon: Icon }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const ref = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (
|
||||
ref.current && !ref.current.contains(e.target) &&
|
||||
dropdownRef.current && !dropdownRef.current.contains(e.target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrollOrResize = (e) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (dropdownRef.current && (dropdownRef.current === e.target || dropdownRef.current.contains(e.target))) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
window.addEventListener('scroll', handleScrollOrResize, true);
|
||||
window.addEventListener('resize', handleScrollOrResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||
window.removeEventListener('resize', handleScrollOrResize);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
left: rect.left,
|
||||
width: rect.width
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
const dropdownMenu = isOpen ? (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[9999] mt-1.5 rounded-xl shadow-xl border overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200"
|
||||
style={{
|
||||
backgroundColor: colors.black,
|
||||
borderColor: colors.blue,
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
width: `${position.width}px`
|
||||
}}
|
||||
>
|
||||
<div className="max-h-48 overflow-y-auto custom-scrollbar overscroll-contain">
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 flex items-center justify-between transition-all duration-150 text-sm hover:opacity-80 cursor-pointer"
|
||||
style={{
|
||||
color: colors.brightWhite,
|
||||
backgroundColor: value === option.value ? `${colors.blue}20` : 'transparent'
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 pointer-events-none">{option.label}</span>
|
||||
{value === option.value && (
|
||||
<Check className="w-4 h-4 pointer-events-none" style={{ color: colors.blue }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full rounded-xl px-4 py-3 flex items-center justify-between transition-all duration-200 border-2"
|
||||
style={{
|
||||
backgroundColor: colors.brightBlack,
|
||||
borderColor: isOpen ? colors.blue : 'transparent',
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2 pointer-events-none">
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" style={{ color: colors.foreground }} />}
|
||||
<span className="text-sm truncate">{selected?.label || placeholder}</span>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 flex-shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''} pointer-events-none`} />
|
||||
</button>
|
||||
|
||||
{dropdownMenu && createPortal(dropdownMenu, document.body)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Slider = ({ value, min, max, step, onChange, label, unit, icon: Icon, color }) => {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
return (
|
||||
|
||||
@@ -888,7 +888,7 @@ const ServerDetailsModal = ({ server, onClose, onServerAction, showAlert }) => {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-white">Automated Firewall</p>
|
||||
<p className="text-[10px] opacity-40 leading-relaxed">The AnchorHost firewall automatically opens all exposed ports in real-time.</p>
|
||||
<p className="text-[10px] opacity-40 leading-relaxed">The AnchorHost.eu firewall automatically opens all exposed ports in real-time.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -203,7 +203,7 @@ function StoreSection({ userCredits, onAddCredits, onRefreshCredits, selectedCur
|
||||
<SectionCard title="What are Credits?" icon={Info} color={colors.blue}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm leading-relaxed" style={{ color: colors.foreground }}>
|
||||
Credits are the <span className="text-brightWhite font-bold">universal currency</span> of AnchorHost.
|
||||
Credits are the <span className="text-brightWhite font-bold">universal currency</span> of AnchorHost.eu.
|
||||
They allow you to pay for all our services with maximum flexibility.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
|
||||
+337
-8
@@ -15,11 +15,9 @@
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow-x: clip;
|
||||
font-family: 'Poppins', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: clamp(0.9375rem, 0.88rem + 0.22vw, 1.0625rem);
|
||||
line-height: 1.55;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: theme('colors.foreground');
|
||||
background-color: theme('colors.background');
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -168,9 +166,7 @@ input[type="range"]::-moz-range-thumb:hover {
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.28;
|
||||
margin-top: 1.35em;
|
||||
margin-bottom: 0.65em;
|
||||
line-height: 1.22;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,3 +222,336 @@ a.focus-skip-global:focus-visible {
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dash-skeleton {
|
||||
display: block;
|
||||
background: linear-gradient(90deg, #20272d 0%, #283139 50%, #20272d 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: dash-skeleton-shift 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dash-skeleton-shift {
|
||||
50% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
/* AnchorHost.eu neobrutalist system: the colors.ini palette, visible rules, and
|
||||
offset shadows do the visual work. Keep data and controls easy to scan. */
|
||||
:root {
|
||||
--ah-background: #151515;
|
||||
--ah-panel: #181818;
|
||||
--ah-surface: #202020;
|
||||
--ah-hover: #282828;
|
||||
--ah-border: #2F2F2F;
|
||||
--ah-focus: #454545;
|
||||
--ah-text: #D0D0D0;
|
||||
--ah-muted: #757575;
|
||||
--ah-dim: #505050;
|
||||
--ah-emphasis: #F5F5F5;
|
||||
--ah-accent: #6C99BB;
|
||||
--ah-accent-secondary: #547C99;
|
||||
--ah-warning: #D3A04D;
|
||||
--ah-shadow: 4px 4px 0 var(--ah-border);
|
||||
}
|
||||
|
||||
body { color: var(--ah-text); background: var(--ah-background); }
|
||||
.site-page {
|
||||
--site-bg: var(--ah-background);
|
||||
--site-panel: var(--ah-panel);
|
||||
--site-panel-soft: var(--ah-surface);
|
||||
--site-rule: var(--ah-border);
|
||||
--site-copy: var(--ah-text);
|
||||
--site-strong: var(--ah-emphasis);
|
||||
--site-accent: var(--ah-accent);
|
||||
color: var(--ah-text);
|
||||
background: var(--ah-background);
|
||||
overflow: clip;
|
||||
}
|
||||
.site-page button, .site-page input, .site-page select { font: inherit; }
|
||||
.site-page a { color: inherit; text-decoration: none; }
|
||||
.site-page button { color: inherit; }
|
||||
.site-page :is(button, a, input, select):focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 3px; }
|
||||
|
||||
.site-header { position: sticky; top: 0; z-index: 40; background: var(--ah-background); border-bottom: 2px solid var(--ah-border); }
|
||||
.site-nav { width: min(1200px, calc(100% - 48px)); min-height: 72px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 28px; }
|
||||
.site-brand { display: inline-flex; align-items: center; gap: 10px; flex: 0 0 auto; color: var(--ah-emphasis) !important; font-size: 16px; font-weight: 800; letter-spacing: -.04em; }
|
||||
.site-brand img { display: block; width: 34px; height: 34px; object-fit: contain; }
|
||||
.site-nav-links, .site-nav-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.site-nav-links { margin-left: auto; }
|
||||
.site-nav-links > button, .site-nav-links > a, .site-user-name { padding: 9px 11px; color: var(--ah-text); font-size: 14px; font-weight: 650; }
|
||||
.site-nav-links > button { border: 0; background: transparent; cursor: pointer; }
|
||||
.site-nav-links > button:hover, .site-nav-links > a:hover { color: var(--ah-emphasis); background: var(--ah-surface); }
|
||||
.site-user-name { padding-right: 4px; color: var(--ah-muted); }
|
||||
.site-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; padding: 0 16px; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); color: var(--ah-emphasis); font-size: 14px; font-weight: 750; line-height: 1.2; cursor: pointer; box-shadow: 3px 3px 0 var(--ah-border); transition: transform .12s ease, box-shadow .12s ease, background-color .12s ease; }
|
||||
.site-button:hover { background: var(--ah-hover); border-color: var(--ah-focus); box-shadow: 2px 2px 0 var(--ah-focus); transform: translate(1px, 1px); }
|
||||
.site-button:active { box-shadow: none; transform: translate(3px, 3px); }
|
||||
.site-button--primary, .site-button--primary:hover { background: var(--ah-accent); border-color: var(--ah-border); color: var(--ah-background) !important; }
|
||||
.site-button--quiet, .site-button--quiet:hover { color: var(--ah-text) !important; border-color: transparent; box-shadow: none; }
|
||||
.site-button--outline { border-color: var(--ah-accent-secondary); }
|
||||
.site-button--large { min-height: 52px; padding: 0 19px; }
|
||||
.site-menu-toggle, .site-mobile-actions { display: none; }
|
||||
.site-menu-toggle { width: 44px; height: 44px; place-items: center; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); color: var(--ah-emphasis); box-shadow: 3px 3px 0 var(--ah-border); }
|
||||
|
||||
.site-hero { width: min(1200px, calc(100% - 48px)); min-height: 620px; margin: 0 auto; display: grid; grid-template-columns: minmax(0, .94fr) minmax(360px, 1.06fr); align-items: center; gap: clamp(30px, 5.5vw, 76px); padding: 68px 0 84px; }
|
||||
.site-hero-copy { max-width: 580px; }
|
||||
.site-kicker { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; color: var(--ah-accent); font-size: 13px; font-weight: 750; letter-spacing: .02em; }
|
||||
.site-kicker > span { width: 10px; height: 10px; border-radius: 0; background: var(--ah-warning); }
|
||||
.site-hero h1 { max-width: 660px; margin: 0; color: var(--ah-emphasis); font-size: clamp(48px, 6.4vw, 78px); line-height: .98; letter-spacing: -.07em; font-weight: 850; }
|
||||
.site-hero-description { max-width: 470px; margin: 24px 0 0; color: var(--ah-text); font-size: 17px; line-height: 1.55; }
|
||||
.site-hero-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 30px; }
|
||||
.site-hero-note { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 30px; color: var(--ah-muted); font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.site-hero-note i { display: inline-block; width: 5px; height: 5px; background: var(--ah-warning); }
|
||||
.site-hero-image { position: relative; min-height: 430px; margin: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; border: 2px solid var(--ah-border); border-radius: 2px; overflow: hidden; background: var(--ah-panel); box-shadow: 10px 10px 0 var(--ah-border); }
|
||||
.site-gallery-viewport { position: relative; min-height: 340px; overflow: hidden; background: var(--ah-background); }
|
||||
.site-gallery-viewport > img { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: cover; object-position: center; filter: saturate(.82) contrast(1.05); }
|
||||
.site-gallery-arrow { position: absolute; top: 50%; z-index: 1; width: 44px; height: 44px; display: grid; place-items: center; transform: translateY(-50%); border: 2px solid var(--ah-emphasis); border-radius: 2px; background: var(--ah-background); color: var(--ah-emphasis); box-shadow: 3px 3px 0 var(--ah-border); cursor: pointer; }
|
||||
.site-gallery-arrow:hover { background: var(--ah-accent); color: var(--ah-background); }
|
||||
.site-gallery-arrow--previous { left: 14px; }
|
||||
.site-gallery-arrow--next { right: 14px; }
|
||||
.site-hero-image figcaption { min-height: 76px; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 14px; padding: 12px 14px; border-top: 2px solid var(--ah-border); background: var(--ah-panel); color: var(--ah-emphasis); font-size: 12px; }
|
||||
.site-gallery-caption { display: grid; gap: 3px; min-width: 0; }
|
||||
.site-gallery-caption strong { color: var(--ah-emphasis); font-size: 14px; }
|
||||
.site-gallery-caption span { color: var(--ah-muted); }
|
||||
.site-gallery-thumbnails { display: flex; gap: 6px; }
|
||||
.site-gallery-thumbnail { width: 48px; height: 36px; padding: 0; overflow: hidden; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-background); cursor: pointer; }
|
||||
.site-gallery-thumbnail--active { border-color: var(--ah-accent); box-shadow: 2px 2px 0 var(--ah-accent-secondary); }
|
||||
.site-gallery-thumbnail img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.site-gallery-counter { color: var(--ah-warning); font-variant-numeric: tabular-nums; font-weight: 800; white-space: nowrap; }
|
||||
|
||||
.site-section { width: min(1200px, calc(100% - 48px)); margin: 0 auto; padding: 82px 0; scroll-margin-top: 78px; }
|
||||
.site-section-heading { display: flex; align-items: end; justify-content: space-between; gap: 30px; margin-bottom: 30px; }
|
||||
.site-section-heading .site-kicker { margin-bottom: 12px; }
|
||||
.site-section-heading h2, .site-estimate-intro h2, .site-platform-copy h2 { margin: 0; color: var(--ah-emphasis); font-size: clamp(30px, 4vw, 44px); line-height: 1.1; letter-spacing: -.055em; font-weight: 800; }
|
||||
.site-section-heading > p { max-width: 360px; margin: 0 0 4px; color: var(--ah-text); font-size: 15px; line-height: 1.55; }
|
||||
.site-service-list { border-top: 2px solid var(--ah-border); }
|
||||
.site-service-row { width: 100%; min-height: 104px; display: grid; grid-template-columns: 42px 48px 1fr 24px; align-items: center; gap: 18px; padding: 18px 10px; text-align: left; border: 0; border-bottom: 2px solid var(--ah-border); background: transparent; cursor: pointer; transition: background-color .15s ease, padding .15s ease; }
|
||||
.site-service-row:hover { padding-right: 16px; padding-left: 16px; background: var(--ah-surface); }
|
||||
.site-service-index { color: var(--ah-muted); font-size: 12px; font-weight: 750; font-variant-numeric: tabular-nums; }
|
||||
.site-service-icon { width: 42px; height: 42px; display: grid; place-items: center; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); color: var(--ah-accent); }
|
||||
.site-service-copy { display: grid; gap: 4px; }
|
||||
.site-service-copy strong { color: var(--ah-emphasis); font-size: 17px; font-weight: 750; }
|
||||
.site-service-copy span { color: var(--ah-text); font-size: 14px; }
|
||||
.site-service-arrow { color: var(--ah-warning); }
|
||||
|
||||
.site-estimate-section { display: grid; grid-template-columns: minmax(0, .8fr) minmax(440px, 1.2fr); align-items: center; gap: clamp(38px, 8vw, 110px); padding: 78px max(24px, calc((100% - 1200px) / 2)); background: var(--ah-panel); border-top: 2px solid var(--ah-border); border-bottom: 2px solid var(--ah-border); scroll-margin-top: 78px; }
|
||||
.site-estimate-intro { max-width: 430px; }
|
||||
.site-estimate-intro .site-kicker { margin-bottom: 16px; }
|
||||
.site-estimate-intro > p:not(.site-kicker) { margin: 18px 0 22px; color: var(--ah-text); font-size: 15px; line-height: 1.7; }
|
||||
.site-text-link { display: inline-flex; align-items: center; gap: 8px; color: var(--ah-accent) !important; font-size: 14px; font-weight: 750; }
|
||||
.site-text-link:hover { color: var(--ah-emphasis) !important; }
|
||||
.site-estimator { padding: 24px; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-surface); box-shadow: 6px 6px 0 var(--ah-border); }
|
||||
.site-field { position: relative; display: grid; gap: 8px; color: var(--ah-text); font-size: 13px; font-weight: 700; }
|
||||
.site-field select, .site-number-control { width: 100%; min-height: 48px; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-background); color: var(--ah-emphasis); }
|
||||
.site-field select { padding: 0 12px; }
|
||||
.site-number-control { display: flex; align-items: center; justify-content: space-between; padding: 0 10px; }
|
||||
.site-number-control input { width: 100%; min-width: 0; border: 0; outline: none; background: transparent; color: var(--ah-emphasis); font-size: 16px; font-variant-numeric: tabular-nums; }
|
||||
.site-number-control input:focus-visible { outline: none; }
|
||||
.site-number-control span { flex: 0 0 auto; color: var(--ah-muted); font-size: 12px; }
|
||||
.site-field-icon { display: none; }
|
||||
.site-resource-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.site-estimate-total { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin-top: 22px; padding-top: 20px; border-top: 2px solid var(--ah-border); }
|
||||
.site-estimate-total > div { display: grid; gap: 3px; }
|
||||
.site-estimate-total > div > span { color: var(--ah-muted); font-size: 12px; }
|
||||
.site-estimate-total strong { color: var(--ah-emphasis); font-size: 34px; line-height: 1.1; font-weight: 850; font-variant-numeric: tabular-nums; }
|
||||
.site-estimate-total small { color: var(--ah-muted); font-size: 13px; font-weight: 450; }
|
||||
|
||||
/* Shared custom listbox used across public, account, and dashboard screens. */
|
||||
.ah-dropdown { position: relative; width: 100%; min-width: 0; }
|
||||
.ah-dropdown-trigger { width: 100%; min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 13px; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-background); color: var(--ah-emphasis); text-align: left; font: inherit; font-size: 14px; font-weight: 700; box-shadow: 3px 3px 0 var(--ah-border); cursor: pointer; }
|
||||
.ah-dropdown-trigger:hover { background: var(--ah-hover); }
|
||||
.ah-dropdown-trigger:focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 3px; }
|
||||
.ah-dropdown-trigger:disabled { cursor: not-allowed; opacity: .6; box-shadow: none; }
|
||||
.ah-dropdown-trigger-value { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.ah-dropdown-trigger-value > svg { flex: 0 0 auto; color: var(--ah-accent); }
|
||||
.ah-dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ah-dropdown-chevron { flex: 0 0 auto; color: var(--ah-warning); transition: transform .12s ease; }
|
||||
.ah-dropdown-chevron--open { transform: rotate(180deg); }
|
||||
.ah-dropdown-popover { position: fixed; z-index: 11000; overflow: hidden; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); color: var(--ah-text); box-shadow: 6px 6px 0 var(--ah-border); }
|
||||
.ah-dropdown-options { overflow-y: auto; overscroll-behavior: contain; padding: 4px; scrollbar-width: thin; scrollbar-color: var(--ah-accent-secondary) var(--ah-panel); }
|
||||
.ah-dropdown-option { width: 100%; min-height: 42px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 11px; border: 1px solid transparent; border-radius: 0; background: transparent; color: var(--ah-text); text-align: left; font: inherit; font-size: 14px; cursor: pointer; }
|
||||
.ah-dropdown-option:hover, .ah-dropdown-option--active { border-color: var(--ah-border); background: var(--ah-hover); color: var(--ah-emphasis); }
|
||||
.ah-dropdown-option[aria-selected="true"] { border-color: var(--ah-accent-secondary); background: var(--ah-accent); color: var(--ah-background); font-weight: 800; }
|
||||
.ah-dropdown-option[aria-selected="true"] svg { color: var(--ah-background); }
|
||||
.ah-dropdown-option:focus-visible { outline: 2px solid var(--ah-accent); outline-offset: -2px; }
|
||||
.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-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--save { color: #7B9246; }
|
||||
.ah-dropdown-action--cancel { color: #D3A04D; }
|
||||
.ah-dropdown-action:hover { background: var(--ah-hover); }
|
||||
.ah-dropdown-action:focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 2px; }
|
||||
|
||||
.site-platform { width: min(1200px, calc(100% - 48px)); display: grid; grid-template-columns: 1fr 1fr; gap: clamp(42px, 8vw, 110px); align-items: center; margin: 0 auto; padding: 96px 0; }
|
||||
.site-platform-image { min-height: 360px; margin: 0; border: 2px solid var(--ah-border); border-radius: 2px; overflow: hidden; background: var(--ah-panel); box-shadow: 8px 8px 0 var(--ah-border); }
|
||||
.site-platform-image img { display: block; width: 100%; height: 100%; min-height: 360px; object-fit: cover; filter: saturate(.78) contrast(1.04); }
|
||||
.site-platform-copy { max-width: 480px; }
|
||||
.site-platform-copy > p:not(.site-kicker) { margin: 18px 0; color: var(--ah-text); font-size: 15px; line-height: 1.7; }
|
||||
.site-platform-copy ul { display: grid; gap: 12px; margin: 24px 0 28px; padding: 0; list-style: none; color: var(--ah-text); font-size: 14px; }
|
||||
.site-platform-copy li { display: flex; align-items: center; gap: 10px; }
|
||||
.site-platform-copy li svg { flex: 0 0 auto; color: var(--ah-warning); }
|
||||
|
||||
.site-showcase, .site-announcements, .site-faq { border-top: 2px solid var(--ah-border); }
|
||||
.site-showcase-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-top: 2px solid var(--ah-border); }
|
||||
.site-showcase-list a { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 14px 16px; border-right: 2px solid var(--ah-border); border-bottom: 2px solid var(--ah-border); color: var(--ah-emphasis); font-size: 14px; font-weight: 700; }
|
||||
.site-showcase-list a:hover { background: var(--ah-surface); }
|
||||
.site-showcase-list svg { flex: 0 0 auto; color: var(--ah-accent); }
|
||||
.site-announcement-list, .site-faq-list { border-top: 2px solid var(--ah-border); }
|
||||
.site-announcement-item { width: 100%; display: grid; grid-template-columns: minmax(150px, .45fr) minmax(180px, .9fr) minmax(220px, 1.6fr) minmax(130px, .5fr); align-items: center; gap: 20px; padding: 20px 8px; border: 0; border-bottom: 2px solid var(--ah-border); background: transparent; color: var(--ah-text); text-align: left; cursor: pointer; }
|
||||
.site-announcement-item:hover { background: var(--ah-surface); }
|
||||
.site-announcement-meta { display: grid; justify-items: start; gap: 6px; color: var(--ah-muted); font-size: 12px; }
|
||||
.site-announcement-tag { padding: 2px 7px; border: 1px solid var(--ah-border); border-radius: 2px; color: var(--ah-accent); font-size: 11px; font-weight: 750; }
|
||||
.site-announcement-title { color: var(--ah-emphasis); font-size: 15px; font-weight: 750; line-height: 1.45; }
|
||||
.site-announcement-summary { display: -webkit-box; overflow: hidden; color: var(--ah-text); font-size: 13px; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.site-announcement-open { display: inline-flex; align-items: center; justify-content: end; gap: 6px; color: var(--ah-accent); font-size: 13px; font-weight: 700; }
|
||||
.site-announcement-more { margin-top: 18px; }
|
||||
.site-announcement-skeleton { height: 78px; border-bottom: 2px solid var(--ah-border); background: var(--ah-surface); }
|
||||
|
||||
.announcement-overlay { position: fixed; inset: 0; z-index: 9999; display: grid; place-items: center; padding: 20px; background: rgba(0, 0, 0, .82); }
|
||||
.announcement-dialog { width: min(760px, 100%); max-height: min(760px, 88dvh); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-panel); color: var(--ah-text); box-shadow: 8px 8px 0 var(--ah-border); }
|
||||
.announcement-dialog:focus { outline: none; }
|
||||
.announcement-dialog-header { display: flex; align-items: start; justify-content: space-between; gap: 20px; padding: 24px; border-bottom: 2px solid var(--ah-border); }
|
||||
.announcement-dialog-header h2 { margin: 0; color: var(--ah-emphasis); }
|
||||
.announcement-tag { display: inline-flex; align-items: center; padding: 4px 9px; border: 1px solid var(--ah-border); border-radius: 2px; font-size: 12px; }
|
||||
.announcement-close { width: 42px; height: 42px; display: grid; flex: 0 0 auto; place-items: center; border: 2px solid var(--ah-border); border-radius: 2px; background: var(--ah-surface); color: var(--ah-text); cursor: pointer; }
|
||||
.announcement-dialog-content { overflow: auto; padding: 24px; }
|
||||
.announcement-meta { display: flex; flex-wrap: wrap; gap: 10px 24px; margin-bottom: 22px; padding-bottom: 18px; border-bottom: 2px solid var(--ah-border); color: var(--ah-muted); font-size: 13px; }
|
||||
.announcement-meta > div { display: flex; align-items: center; }
|
||||
.announcement-meta svg { color: var(--ah-accent); }
|
||||
.announcement-meta span { color: var(--ah-emphasis); }
|
||||
.announcement-body { color: var(--ah-text); font-size: 16px; line-height: 1.7; white-space: pre-wrap; }
|
||||
.announcement-dialog-footer { display: flex; justify-content: end; padding: 16px 24px; border-top: 2px solid var(--ah-border); }
|
||||
|
||||
.site-faq-item { border-bottom: 2px solid var(--ah-border); }
|
||||
.site-faq-item h3 { margin: 0; }
|
||||
.site-faq-item button { width: 100%; min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 4px; border: 0; background: transparent; color: var(--ah-emphasis); text-align: left; font-size: 16px; font-weight: 750; cursor: pointer; }
|
||||
.site-faq-item button svg { flex: 0 0 auto; color: var(--ah-warning); transition: transform .16s ease; }
|
||||
.site-faq-item--open button svg { transform: rotate(180deg); }
|
||||
.site-faq-item > p { max-width: 760px; margin: 0; padding: 0 36px 20px 4px; color: var(--ah-text); font-size: 15px; line-height: 1.7; }
|
||||
.site-footer { padding: 42px max(24px, calc((100% - 1200px) / 2)) 24px; border-top: 2px solid var(--ah-border); background: var(--ah-panel); }
|
||||
.site-footer-main { display: flex; justify-content: space-between; gap: 36px; padding-bottom: 35px; }
|
||||
.site-footer-main > div:first-child > p { margin: 13px 0 0; color: var(--ah-text); font-size: 14px; }
|
||||
.site-footer-links { display: grid; grid-template-columns: repeat(3, minmax(112px, 1fr)); align-content: start; gap: 8px; width: min(100%, 430px); }
|
||||
.site-footer-links a { min-height: 40px; display: flex; align-items: center; padding: 7px 10px; border: 1px solid var(--ah-border); background: var(--ah-surface); color: var(--ah-text); font-size: 13px; font-weight: 650; line-height: 1.25; }
|
||||
.site-footer-links a:hover { border-color: var(--ah-accent-secondary); background: var(--ah-hover); color: var(--ah-emphasis); }
|
||||
.site-footer-links a:focus-visible { outline: 2px solid var(--ah-accent); outline-offset: 2px; }
|
||||
.site-footer-bottom { display: flex; justify-content: space-between; gap: 16px; padding-top: 18px; border-top: 2px solid var(--ah-border); color: var(--ah-muted); font-size: 12px; }
|
||||
.site-footer-bottom a { color: var(--ah-accent); font-weight: 750; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; }
|
||||
.site-footer-bottom a:hover { color: var(--ah-emphasis); }
|
||||
|
||||
/* Shared geometry across dashboard, admin, docs, legal and plugin routes. */
|
||||
.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*="rounded-"] { border-radius: 2px !important; }
|
||||
.app-container [class*="shadow-"]:not(.shadow-none) { box-shadow: var(--ah-shadow) !important; }
|
||||
.app-container [class*="hover:scale"] { transform: none !important; }
|
||||
.app-container .uppercase { letter-spacing: .02em; }
|
||||
.app-container .text-\[10px\] { font-size: 12px; }
|
||||
.app-container :is(button, a, input, textarea, select) { border-radius: 2px; }
|
||||
.app-container :is(input, textarea, select) { background-color: var(--ah-background); border-color: var(--ah-border); color: var(--ah-emphasis); }
|
||||
.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. */
|
||||
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) [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; }
|
||||
.dashboard-app, .admin-app { background: var(--ah-background) !important; color: var(--ah-text); }
|
||||
.dashboard-app .bg-dash-surface, .admin-app .bg-dash-surface { background-color: var(--ah-surface); }
|
||||
.dashboard-app .bg-dash-surfaceElevated, .admin-app .bg-dash-surfaceElevated { background-color: var(--ah-panel); }
|
||||
.dashboard-app .border-dash-border, .admin-app .border-dash-border { border-color: var(--ah-border); }
|
||||
.dashboard-app .rounded-xl.border-dash-border, .dashboard-app .rounded-2xl.border-dash-border,
|
||||
.admin-app .rounded-xl.border-dash-border, .admin-app .rounded-2xl.border-dash-border { box-shadow: var(--ah-shadow); }
|
||||
.dashboard-app .dash-skeleton, .admin-app .dash-skeleton { background: var(--ah-surface); background-image: none; animation: none; border: 1px solid var(--ah-border); }
|
||||
|
||||
/* Server tiles: compact, readable data panels with a single consistent hover state. */
|
||||
.ah-server-card { min-width: 0; overflow: hidden; border: 2px solid var(--ah-border); background: var(--ah-panel); color: var(--ah-text); box-shadow: 6px 6px 0 var(--ah-border); }
|
||||
.ah-server-card-content { display: flex; min-height: 100%; flex-direction: column; }
|
||||
.ah-server-card-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; min-width: 0; padding: 18px; border-bottom: 2px solid var(--ah-border); background: var(--ah-surface); }
|
||||
.ah-server-card-identity { display: flex; min-width: 0; align-items: center; gap: 12px; }
|
||||
.ah-server-type-icon { display: grid; width: 42px; height: 42px; flex: 0 0 auto; place-items: center; border: 2px solid var(--server-accent); background: var(--ah-background); }
|
||||
.ah-server-card-heading { min-width: 0; flex: 1; }
|
||||
.ah-server-card-heading > .flex { min-width: 0; align-items: center; }
|
||||
.ah-server-name { min-width: 0; max-width: 100%; overflow: hidden; color: var(--ah-emphasis); font-size: 17px; font-weight: 800; line-height: 1.25; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ah-server-demo-tag { flex: 0 0 auto; padding: 2px 6px; border: 1px solid var(--ah-warning); background: var(--ah-background); color: var(--ah-warning); font-size: 10px; font-weight: 800; letter-spacing: .04em; }
|
||||
.ah-server-type-label { overflow: hidden; margin: 5px 0 0; color: var(--ah-muted); font-size: 11px; font-weight: 700; line-height: 1.3; text-overflow: ellipsis; text-transform: uppercase; letter-spacing: .035em; }
|
||||
.ah-server-demo-time { display: inline-flex; align-items: center; gap: 6px; width: fit-content; margin-top: 8px; padding: 4px 7px; border: 1px solid var(--ah-warning); background: var(--ah-background); }
|
||||
.ah-server-demo-time p { margin: 0; color: var(--ah-warning); font-size: 11px; font-weight: 750; font-variant-numeric: tabular-nums; }
|
||||
.ah-server-status { display: inline-flex; min-height: 30px; align-items: center; justify-content: center; flex: 0 0 auto; padding: 4px 8px; border: 2px solid; font-size: 10px; font-weight: 850; line-height: 1; letter-spacing: .035em; text-align: center; text-transform: uppercase; white-space: nowrap; }
|
||||
.ah-server-resource-grid { display: grid; flex: 1 0 auto; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 16px; background: var(--ah-panel); }
|
||||
.ah-server-resource { display: flex; min-width: 0; min-height: 82px; flex-direction: column; justify-content: space-between; gap: 10px; padding: 11px 12px; border: 2px solid var(--ah-border); background: var(--ah-background); color: var(--ah-text); text-align: left; }
|
||||
.ah-server-resource-label { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ah-muted); font-size: 11px; font-weight: 750; line-height: 1.2; }
|
||||
.ah-server-resource-label svg { flex: 0 0 auto; color: var(--server-accent, var(--ah-accent)); }
|
||||
.ah-server-resource > p { overflow: hidden; margin: 0; color: var(--ah-emphasis); font-size: 17px; font-weight: 800; line-height: 1.15; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.ah-server-cost { width: 100%; border-color: var(--ah-border); }
|
||||
.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 > button { min-height: 42px; }
|
||||
.app-container .ah-server-card-actions .ah-server-action-button { 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. */
|
||||
.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-heading { margin-bottom: 7px; color: var(--ah-accent); font-size: 14px; font-weight: 850; line-height: 1.3; }
|
||||
.ah-tooltip-description { margin-bottom: 11px; color: var(--ah-text); font-size: 12px; line-height: 1.5; }
|
||||
.ah-tooltip-code { padding: 8px 9px; border: 1px solid var(--ah-border); background: var(--ah-background); color: var(--ah-emphasis); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.site-nav, .site-hero, .site-section, .site-platform { width: min(100% - 36px, 720px); }
|
||||
.site-hero { min-height: unset; grid-template-columns: 1fr; gap: 36px; padding: 66px 0 72px; }
|
||||
.site-hero-image { min-height: 350px; }
|
||||
.site-estimate-section { grid-template-columns: 1fr; gap: 32px; padding: 66px max(18px, calc((100% - 720px) / 2)); }
|
||||
.site-platform { gap: 34px; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.site-nav { width: calc(100% - 32px); min-height: 62px; }
|
||||
.site-nav-actions { display: none; }
|
||||
.site-menu-toggle { display: grid; }
|
||||
.site-nav-links { position: absolute; top: 61px; right: 0; left: 0; display: none; align-items: stretch; gap: 2px; padding: 12px 16px 18px; border-bottom: 2px solid var(--ah-border); background: var(--ah-panel); }
|
||||
.site-nav-links--open { display: flex; flex-direction: column; }
|
||||
.site-nav-links > button, .site-nav-links > a { min-height: 44px; display: flex; align-items: center; padding: 8px 10px; text-align: left; }
|
||||
.site-mobile-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; padding-top: 12px; border-top: 2px solid var(--ah-border); }
|
||||
.site-hero { width: calc(100% - 36px); gap: 28px; padding: 54px 0 58px; }
|
||||
.site-hero h1 { font-size: clamp(42px, 12vw, 58px); }
|
||||
.site-hero-description { margin-top: 17px; font-size: 16px; }
|
||||
.site-hero-actions { align-items: stretch; }
|
||||
.site-hero-actions .site-button { width: 100%; }
|
||||
.site-hero-note { gap: 8px; font-size: 12px; }
|
||||
.site-hero-image { min-height: 0; box-shadow: 6px 6px 0 var(--ah-border); }
|
||||
.site-gallery-viewport { min-height: 230px; }
|
||||
.site-hero-image figcaption { grid-template-columns: minmax(0, 1fr) auto; gap: 9px; padding: 10px; }
|
||||
.site-gallery-thumbnails { grid-column: 1 / 2; grid-row: 2; }
|
||||
.site-gallery-thumbnail { width: 40px; height: 30px; }
|
||||
.site-gallery-counter { grid-column: 2; grid-row: 1 / 3; }
|
||||
.site-section, .site-platform { width: calc(100% - 36px); padding-top: 62px; padding-bottom: 62px; }
|
||||
.site-section-heading { align-items: start; flex-direction: column; gap: 12px; margin-bottom: 22px; }
|
||||
.site-section-heading h2, .site-estimate-intro h2, .site-platform-copy h2 { font-size: 33px; }
|
||||
.site-service-row { grid-template-columns: 26px 42px 1fr 18px; gap: 10px; min-height: 88px; padding: 13px 0; }
|
||||
.site-service-icon { width: 38px; height: 38px; }
|
||||
.site-service-copy strong { font-size: 15px; }
|
||||
.site-service-copy span { font-size: 13px; line-height: 1.45; }
|
||||
.site-estimate-section { padding: 58px 18px; }
|
||||
.site-estimator { padding: 16px; box-shadow: 4px 4px 0 var(--ah-border); }
|
||||
.site-resource-grid { grid-template-columns: 1fr; gap: 12px; }
|
||||
.site-estimate-total { align-items: stretch; flex-direction: column; }
|
||||
.site-estimate-total .site-button { width: 100%; }
|
||||
.site-platform { grid-template-columns: 1fr; }
|
||||
.site-platform-image { min-height: 240px; box-shadow: 5px 5px 0 var(--ah-border); }
|
||||
.site-showcase-list { grid-template-columns: 1fr; }
|
||||
.site-showcase-list a { border-right: 0; }
|
||||
.site-announcement-item { grid-template-columns: 1fr auto; gap: 9px 12px; padding: 16px 4px; }
|
||||
.site-announcement-meta { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; }
|
||||
.site-announcement-title, .site-announcement-summary, .site-announcement-open { grid-column: 1 / -1; }
|
||||
.site-announcement-open { justify-content: start; }
|
||||
.announcement-dialog-header, .announcement-dialog-content { padding: 18px; }
|
||||
.announcement-dialog-footer { padding: 14px 18px; }
|
||||
.announcement-dialog-footer .site-button { width: 100%; }
|
||||
.announcement-dialog { box-shadow: 4px 4px 0 var(--ah-border); }
|
||||
.site-footer { padding: 34px 18px 20px; }
|
||||
.site-footer-main { flex-direction: column; gap: 26px; }
|
||||
.site-footer-links { grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; }
|
||||
.site-footer-bottom { flex-direction: column; gap: 6px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
@@ -119,7 +119,7 @@ const AdminPage = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-dash-surface font-sans selection:bg-brightRed/30 text-dash-textMuted antialiased">
|
||||
<div className="admin-app flex h-screen overflow-hidden bg-dash-surface font-sans selection:bg-primary/30 text-dash-textMuted antialiased">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-72 transform transition-transform duration-300 ease-in-out md:relative md:translate-x-0 border-r border-dash-border bg-dash-surfaceElevated/50 backdrop-blur-md pt-[env(safe-area-inset-top,0px)] ${
|
||||
|
||||
+55
-80
@@ -1855,10 +1855,8 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
|
||||
const tooltipContent = isVisible ? (
|
||||
<div
|
||||
className="fixed w-72 p-4 rounded-lg border text-sm z-[9999] shadow-lg"
|
||||
className="ah-tooltip ah-tooltip-detail fixed w-72 max-w-[calc(100vw-2rem)] p-4 text-sm z-[9999]"
|
||||
style={{
|
||||
backgroundColor: colors.backgroundSecondary, borderColor: colors.blue,
|
||||
color: colors.foreground,
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
transform: 'translate(-50%, calc(-100% - 8px))'
|
||||
@@ -1866,9 +1864,9 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
onMouseEnter={handleTooltipMouseEnter}
|
||||
onMouseLeave={handleTooltipMouseLeave}
|
||||
>
|
||||
<div className="font-semibold mb-2 text-base" style={{ color: colors.blue }}>{portTooltip.name}</div>
|
||||
<div className="mb-3">{portTooltip.description}</div>
|
||||
<div className="font-mono text-sm p-2 rounded" style={{ backgroundColor: colors.brightBlack, color: colors.brightWhite }}>{portTooltip.connection}</div>
|
||||
<div className="ah-tooltip-heading">{portTooltip.name}</div>
|
||||
<div className="ah-tooltip-description">{portTooltip.description}</div>
|
||||
<div className="ah-tooltip-code">{portTooltip.connection}</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -1887,7 +1885,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Modern Server Card Component
|
||||
// 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 = {
|
||||
@@ -1949,116 +1947,93 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative rounded-3xl border transition-all duration-500 hover:scale-[1.02] hover:-translate-y-1 overflow-hidden h-full bg-brightBlack/20 backdrop-blur-md shadow-2xl shadow-black/20"
|
||||
className="ah-server-card relative h-full"
|
||||
style={{
|
||||
borderColor: `${serverColor}25`,
|
||||
'--server-accent': serverColor,
|
||||
}}
|
||||
>
|
||||
{/* Hover Glow */}
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"
|
||||
style={{ background: `radial-gradient(circle at top right, ${serverColor}10 0%, transparent 60%)` }} />
|
||||
|
||||
<div className="p-0 h-full flex flex-col relative z-10">
|
||||
<div className="p-6 border-b border-white/5 bg-white/[0.01]">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3.5 rounded-2xl shadow-lg border transition-transform duration-500 group-hover:rotate-3"
|
||||
style={{ backgroundColor: `${serverColor}15`, borderColor: `${serverColor}30` }}>
|
||||
<ServerIcon className="w-6 h-6" style={{ color: 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="min-w-0">
|
||||
<div className="ah-server-card-heading">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-xl font-black tracking-tight truncate" style={{ color: colors.brightWhite }}>{server.name}</h3>
|
||||
<h3 className="ah-server-name" title={server.name}>{server.name}</h3>
|
||||
{isDemo && (
|
||||
<span className="px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-widest border border-yellow-500/30"
|
||||
style={{ backgroundColor: `${colors.brightYellow}15`, color: colors.brightYellow }}>
|
||||
<span className="ah-server-demo-tag">
|
||||
DEMO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest opacity-40 mt-1" style={{ color: colors.foreground }}>
|
||||
<p className="ah-server-type-label">
|
||||
{isDatabase ? `${dbType} db` : `${server.type} node`}
|
||||
</p>
|
||||
{isDemo && timeRemaining && (
|
||||
<div className="flex items-center gap-1.5 mt-2 px-2 py-1 rounded-lg bg-yellow-500/10 border border-yellow-500/20 w-fit">
|
||||
<div className="ah-server-demo-time">
|
||||
<Clock className="w-3 h-3" style={{ color: colors.brightYellow }} />
|
||||
<p className="text-[10px] font-black tracking-tighter" style={{ color: colors.brightYellow }}>
|
||||
<p>
|
||||
{timeRemaining === 'Expired' ? 'EXPIRED' : timeRemaining}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span
|
||||
className="px-3 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest border"
|
||||
<div className="ah-server-status"
|
||||
aria-label={`Server status: ${statusInfo.label}`}
|
||||
style={{
|
||||
backgroundColor: `${statusInfo.color}15`,
|
||||
borderColor: `${statusInfo.color}30`,
|
||||
borderColor: statusInfo.color,
|
||||
color: statusInfo.color,
|
||||
boxShadow: `0 0 15px ${statusInfo.color}10`
|
||||
}}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex-1 bg-gradient-to-b from-transparent to-black/20">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="p-4 rounded-2xl border border-white/5 bg-black/40 shadow-inner group/stat hover:border-primary/30 transition-all duration-300">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cpu className="w-3.5 h-3.5 opacity-40 transition-all" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest opacity-30">Compute</span>
|
||||
<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 className="font-black text-lg tracking-tight truncate" style={{ color: colors.brightWhite }}>{server.cpu}</p>
|
||||
<p>{server.cpu}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-2xl border border-white/5 bg-black/40 shadow-inner group/stat hover:border-primary/30 transition-all duration-300">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MemoryStick className="w-3.5 h-3.5 opacity-40 transition-all" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest opacity-30">Memory</span>
|
||||
<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 className="font-black text-lg tracking-tight truncate" style={{ color: colors.brightWhite }}>{server.ram}</p>
|
||||
<p>{server.ram}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-2xl border border-white/5 bg-black/40 shadow-inner group/stat hover:border-primary/30 transition-all duration-300">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<HardDrive className="w-3.5 h-3.5 opacity-40 transition-all" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest opacity-30">Storage</span>
|
||||
<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 className="font-black text-lg tracking-tight truncate" style={{ color: colors.brightWhite }}>{server.storage}</p>
|
||||
<p>{server.storage}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-2xl border border-white/5 bg-black/40 shadow-inner group/stat transition-all duration-500 hover:border-primary/60 relative overflow-hidden cursor-pointer"
|
||||
onClick={() => setActiveTab('store')}>
|
||||
<div className="transition-all duration-500 group-hover/stat:opacity-0 group-hover/stat:scale-95 group-hover/stat:-translate-y-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Coins className="w-3.5 h-3.5 opacity-40 transition-all" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest opacity-30">Monthly Cost</span>
|
||||
<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="font-black text-lg tracking-tight truncate" style={{ color: colors.brightWhite }}>
|
||||
<p className="ah-server-cost-value">
|
||||
{isDemo ? 'FREE' : `${calculateCurrencyValue(serverMonthlyCost)} ${getCurrencySymbol()}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute inset-0 p-4 flex flex-col items-center justify-center transition-all duration-500 opacity-0 scale-110 translate-y-2 group-hover/stat:opacity-100 group-hover/stat:scale-100 group-hover/stat:translate-y-0 bg-primary/10">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-primary mb-1">Low Balance?</p>
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold text-white uppercase tracking-tighter">
|
||||
<span>Refill Credits</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-white/5 flex gap-3 bg-black/40">
|
||||
<div className="ah-server-card-actions">
|
||||
{isDemo && user?.role !== 'admin' ? (
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
onClick={() => handleConvertDemoToPaid(server.id)}
|
||||
className="flex-1 !min-h-0 !py-3 shadow-xl shadow-yellow-500/10"
|
||||
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" />
|
||||
@@ -2073,7 +2048,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
setActiveServerDetailTab('overview');
|
||||
fetchServerLogs(server.id);
|
||||
}}
|
||||
className="flex-1 !min-h-0 !py-3 hover:border-primary/40"
|
||||
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>
|
||||
@@ -2083,7 +2058,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
<ActionButton
|
||||
variant="warning"
|
||||
onClick={() => handleConvertDemoToPaid(server.id)}
|
||||
className="aspect-square !p-0 !min-w-[48px]"
|
||||
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" />
|
||||
@@ -2094,7 +2069,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
onClick={() => handleServerAction(server.id, 'stop')}
|
||||
className="aspect-square !p-0 !min-w-[48px]"
|
||||
className="ah-server-action-button aspect-square !p-0 !min-w-[48px]"
|
||||
>
|
||||
<Square className="w-4 h-4 fill-current" />
|
||||
</ActionButton>
|
||||
@@ -2102,7 +2077,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
<ActionButton
|
||||
variant="success"
|
||||
onClick={() => handleServerAction(server.id, 'start')}
|
||||
className="aspect-square !p-0 !min-w-[48px]"
|
||||
className="ah-server-action-button aspect-square !p-0 !min-w-[48px]"
|
||||
>
|
||||
<Play className="w-4 h-4 fill-current" />
|
||||
</ActionButton>
|
||||
@@ -2133,7 +2108,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-screen overflow-hidden bg-dash-surface font-sans selection:bg-primary/30 text-dash-text antialiased"
|
||||
className="dashboard-app flex h-screen overflow-hidden bg-dash-surface font-sans selection:bg-primary/30 text-dash-text antialiased"
|
||||
>
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-72 transform transition-all duration-300 ease-in-out md:relative md:translate-x-0 border-r border-dash-border bg-dash-surfaceElevated/50 backdrop-blur-xl pt-[env(safe-area-inset-top,0px)] ${
|
||||
@@ -2573,7 +2548,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
<div className="rounded-lg border border-dash-border bg-brightBlack/40 px-3 py-2.5 text-sm">
|
||||
{user?.google_id ? (
|
||||
<p className="text-dash-textMuted">
|
||||
No AnchorHost password — you sign in with Google.
|
||||
No AnchorHost.eu password — you sign in with Google.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
@@ -2589,7 +2564,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
<UnifiedTooltip
|
||||
content={
|
||||
user?.google_id
|
||||
? 'Google sign-in accounts do not use an AnchorHost password. Manage security in Google.'
|
||||
? 'Google sign-in accounts do not use an AnchorHost.eu password. Manage security in Google.'
|
||||
: 'Choose a new password. You will need your current password to confirm.'
|
||||
}
|
||||
multiline
|
||||
@@ -3605,7 +3580,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">
|
||||
<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.
|
||||
AnchorHost 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.
|
||||
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.
|
||||
</p>
|
||||
<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 }}>
|
||||
@@ -3617,7 +3592,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
<ArrowRight className="w-5 h-5 text-primary rotate-90 lg:rotate-0 shrink-0 opacity-60" />
|
||||
</div>
|
||||
<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 gateway</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-primary mb-3">AnchorHost.eu gateway</p>
|
||||
<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>
|
||||
</div>
|
||||
@@ -3678,7 +3653,7 @@ const handleWebSocketMessage = useCallback((data) => {
|
||||
icon: Shield,
|
||||
iconColor: colors.brightGreen,
|
||||
title: 'Firewall opens automatically',
|
||||
body: 'When you expose a port, AnchorHost opens it on the edge firewall in real time — no manual rules needed.',
|
||||
body: 'When you expose a port, AnchorHost.eu opens it on the edge firewall in real time — no manual rules needed.',
|
||||
},
|
||||
{
|
||||
icon: Lock,
|
||||
@@ -3875,7 +3850,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="text-center">
|
||||
<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 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. AnchorHost.eu assigns a public external port automatically.</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); submitNetworkExposePort(); }}
|
||||
|
||||
@@ -45,7 +45,7 @@ function DocumentationPage() {
|
||||
title: 'Creating Your Account',
|
||||
content: `# Creating Your Account
|
||||
|
||||
Creating an account on AnchorHost gives you access to our Kubernetes-based cloud hosting platform.
|
||||
Creating an account on AnchorHost.eu gives you access to our Kubernetes-based cloud hosting platform.
|
||||
|
||||
## 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.
|
||||
@@ -69,7 +69,7 @@ When you click "Create Account":
|
||||
title: 'Authentication and Security',
|
||||
content: `# Authentication and Security
|
||||
|
||||
AnchorHost utilizes a hybrid approach of REST and WebSockets for secure, real-time management.
|
||||
AnchorHost.eu utilizes a hybrid approach of REST and WebSockets for secure, real-time management.
|
||||
|
||||
## Authentication Flow
|
||||
1. Credentials are verified via secure hash comparison.
|
||||
@@ -112,7 +112,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!**
|
||||
|
||||
## The Format
|
||||
When connecting to anything on AnchorHost, you will almost always use this format:
|
||||
When connecting to anything on AnchorHost.eu, you will almost always use this format:
|
||||
\`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.`
|
||||
@@ -258,7 +258,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).
|
||||
- What you do in your apartment doesn't affect your neighbors, and vice-versa.
|
||||
|
||||
On AnchorHost, 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 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.`
|
||||
},
|
||||
{
|
||||
id: 'server-types',
|
||||
@@ -290,7 +290,7 @@ Managed containers for application stacks.
|
||||
title: 'How Billing Works',
|
||||
content: `# How Billing Works
|
||||
|
||||
AnchorHost operates on a transparent **pre-paid credit system**. There are no hidden fees or surprise invoices.
|
||||
AnchorHost.eu operates on a transparent **pre-paid credit system**. There are no hidden fees or surprise invoices.
|
||||
|
||||
## Credits and Value
|
||||
- **100 credits = 1.00 EUR**.
|
||||
@@ -337,7 +337,7 @@ If you wish to host **Minecraft Bedrock** (for Console, Mobile, or Windows 10 Ed
|
||||
title: 'Installing and Managing Minecraft',
|
||||
content: `# Minecraft Server Management
|
||||
|
||||
AnchorHost provides a specialized environment for Minecraft servers using high-performance containers.
|
||||
AnchorHost.eu provides a specialized environment for Minecraft servers using high-performance containers.
|
||||
|
||||
## Installation
|
||||
1. Go to **Create Server**.
|
||||
@@ -479,7 +479,7 @@ We provide an integrated **Plugin Store** powered by the Modrinth API.
|
||||
title: 'Network Architecture',
|
||||
content: `# Network Architecture
|
||||
|
||||
All servers deployed on AnchorHost share a high-performance static IP but utilize unique entry points.
|
||||
All servers deployed on AnchorHost.eu share a high-performance static IP but utilize unique entry points.
|
||||
|
||||
## External Connectivity
|
||||
- **Global IP**: \`85.237.179.121\`
|
||||
@@ -607,7 +607,7 @@ Managing the state of your Virtual Machine is done directly through the dashboar
|
||||
title: 'Scaling Resources',
|
||||
content: `# Scaling Resources
|
||||
|
||||
AnchorHost allows you to adjust your VPS specifications as your needs grow.
|
||||
AnchorHost.eu allows you to adjust your VPS specifications as your needs grow.
|
||||
|
||||
## CPU and RAM
|
||||
You can increase or decrease your allocated CPU cores and RAM from the **Settings** tab.
|
||||
@@ -673,7 +673,7 @@ passwd
|
||||
title: 'Getting Started with Databases',
|
||||
content: `# Managed Database Hosting
|
||||
|
||||
AnchorHost provides pre-configured database containers for rapid development.
|
||||
AnchorHost.eu provides pre-configured database containers for rapid development.
|
||||
|
||||
## Supported Engines
|
||||
- **MySQL**: The world's most popular open-source database.
|
||||
@@ -754,7 +754,7 @@ Use these templates to connect your applications. Replace \`IP\`, \`PORT\`, and
|
||||
title: 'Managing Subdomains',
|
||||
content: `# Managing Your Subdomains
|
||||
|
||||
Every user on AnchorHost has their own personal network space based on their username.
|
||||
Every user on AnchorHost.eu has their own personal network space based on their username.
|
||||
|
||||
## How to create a Subdomain
|
||||
1. Go to the **Domains** tab.
|
||||
@@ -775,7 +775,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!
|
||||
|
||||
## Why is this happening?
|
||||
AnchorHost 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.
|
||||
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.
|
||||
|
||||
## How to Fix
|
||||
Usually, this is handled automatically within 5-10 minutes of creating your account. If the warning persists:
|
||||
|
||||
+351
-1965
File diff suppressed because it is too large.
Load diff
+29
-38
@@ -1,40 +1,31 @@
|
||||
/**
|
||||
* Canonical AnchorHost palette — keep in sync with tailwind.config.js theme.extend.colors
|
||||
*
|
||||
* Dashboard semantic UI colors (surface, borders, text emphasis) live as `theme.colors.dash`
|
||||
* in tailwind.config.js and in [dashboardTokens.js](./dashboardTokens.js) for JS references.
|
||||
*/
|
||||
/** AnchorHost.eu palette: colors.ini is the canonical source; keep Tailwind aligned. */
|
||||
export const colors = {
|
||||
/** Body / secondary UI (improved contrast on #101010) */
|
||||
foreground: '#c8c8d0',
|
||||
background: '#101010',
|
||||
backgroundSecondary: '#161616',
|
||||
black: '#121212',
|
||||
red: '#8b2d32',
|
||||
green: '#5a6b47',
|
||||
yellow: '#9c6d2f',
|
||||
/** Alias used by server status / accents (was implicit on modal) */
|
||||
orange: '#b8843f',
|
||||
blue: '#4a6385',
|
||||
magenta: '#6d4779',
|
||||
cyan: '#45615b',
|
||||
white: '#8a8a8a',
|
||||
brightBlack: '#1a1a1a',
|
||||
brightRed: '#a83a40',
|
||||
brightGreen: '#6b7d55',
|
||||
brightYellow: '#b8843f',
|
||||
brightBlue: '#5a7ca7',
|
||||
brightMagenta: '#8a4a9a',
|
||||
brightCyan: '#4a8a82',
|
||||
/** Headings on dark surfaces */
|
||||
brightWhite: '#e8e8ec',
|
||||
primary: '#5a7ca7',
|
||||
/** Muted accents for marketing sections, hovers, and secondary text */
|
||||
darkBlue: '#4e6e8f',
|
||||
darkCyan: '#4d7976',
|
||||
darkWhite: '#7a7a7a',
|
||||
darkRed: '#6b2528',
|
||||
darkGreen: '#4a5a3c',
|
||||
darkYellow: '#7a5526',
|
||||
darkMagenta: '#52385a',
|
||||
foreground: '#D0D0D0',
|
||||
background: '#151515',
|
||||
backgroundSecondary: '#181818',
|
||||
black: '#151515',
|
||||
red: '#A53C23',
|
||||
green: '#7B9246',
|
||||
yellow: '#D3A04D',
|
||||
orange: '#D3A04D',
|
||||
blue: '#547C99',
|
||||
magenta: '#547C99',
|
||||
cyan: '#5EA8A2',
|
||||
white: '#757575',
|
||||
brightBlack: '#2F2F2F',
|
||||
brightRed: '#A53C23',
|
||||
brightGreen: '#7B9246',
|
||||
brightYellow: '#D3A04D',
|
||||
brightBlue: '#6C99BB',
|
||||
brightMagenta: '#547C99',
|
||||
brightCyan: '#5EA8A2',
|
||||
brightWhite: '#F5F5F5',
|
||||
primary: '#6C99BB',
|
||||
darkBlue: '#547C99',
|
||||
darkCyan: '#5EA8A2',
|
||||
darkWhite: '#505050',
|
||||
darkRed: '#A53C23',
|
||||
darkGreen: '#7B9246',
|
||||
darkYellow: '#D3A04D',
|
||||
darkMagenta: '#547C99',
|
||||
};
|
||||
@@ -5,23 +5,23 @@
|
||||
import { colors } from './colors';
|
||||
|
||||
export const dash = {
|
||||
surface: colors.background,
|
||||
surface: '#202020',
|
||||
surfaceElevated: colors.backgroundSecondary,
|
||||
border: '#2c2c32',
|
||||
border: '#2F2F2F',
|
||||
text: colors.brightWhite,
|
||||
textMuted: colors.foreground,
|
||||
textMuted: '#D0D0D0',
|
||||
accent: colors.primary,
|
||||
danger: colors.brightRed,
|
||||
success: colors.brightGreen,
|
||||
warning: colors.brightYellow,
|
||||
danger: '#A53C23',
|
||||
success: '#7B9246',
|
||||
warning: '#D3A04D',
|
||||
};
|
||||
|
||||
/** Shared layout class strings for panel chrome */
|
||||
export const dashLayout = {
|
||||
/** Outer view container: one border, subtle fill, no heavy shadow */
|
||||
viewShell: 'w-full rounded-2xl border border-dash-border bg-dash-surfaceElevated/95 overflow-hidden backdrop-blur-sm',
|
||||
/** Outer panel uses hard corners and a stamped offset outline. */
|
||||
viewShell: 'w-full rounded-sm border-2 border-dash-border bg-dash-surfaceElevated overflow-hidden',
|
||||
/** Header strip under accent tint */
|
||||
headerBar: 'p-6 border-b border-dash-border flex flex-col md:flex-row md:items-center justify-between gap-4',
|
||||
headerBar: 'px-5 py-5 md:px-7 border-b border-dash-border flex flex-col md:flex-row md:items-center justify-between gap-4',
|
||||
/** Main content padding inside shell */
|
||||
bodyPadding: 'p-6 space-y-6',
|
||||
bodyPadding: 'p-5 md:p-7 space-y-6',
|
||||
};
|
||||
+37
-37
@@ -5,44 +5,44 @@ module.exports = {
|
||||
extend: {
|
||||
colors: {
|
||||
dash: {
|
||||
surface: "#101010",
|
||||
surfaceElevated: "#161616",
|
||||
border: "#2c2c32",
|
||||
text: "#e8e8ec",
|
||||
textMuted: "#c8c8d0",
|
||||
accent: "#5a7ca7",
|
||||
danger: "#a83a40",
|
||||
success: "#6b7d55",
|
||||
warning: "#b8843f",
|
||||
surface: "#202020",
|
||||
surfaceElevated: "#181818",
|
||||
border: "#2F2F2F",
|
||||
text: "#F5F5F5",
|
||||
textMuted: "#D0D0D0",
|
||||
accent: "#6C99BB",
|
||||
danger: "#A53C23",
|
||||
success: "#7B9246",
|
||||
warning: "#D3A04D",
|
||||
},
|
||||
foreground: "#c8c8d0",
|
||||
background: "#101010",
|
||||
backgroundSecondary: "#161616",
|
||||
black: "#121212",
|
||||
red: "#8b2d32",
|
||||
green: "#5a6b47",
|
||||
yellow: "#9c6d2f",
|
||||
orange: "#b8843f",
|
||||
blue: "#4a6385",
|
||||
magenta: "#6d4779",
|
||||
cyan: "#45615b",
|
||||
white: "#8a8a8a",
|
||||
brightBlack: "#1a1a1a",
|
||||
brightRed: "#a83a40",
|
||||
brightGreen: "#6b7d55",
|
||||
brightYellow: "#b8843f",
|
||||
brightBlue: "#5a7ca7",
|
||||
brightMagenta: "#8a4a9a",
|
||||
brightCyan: "#4a8a82",
|
||||
brightWhite: "#e8e8ec",
|
||||
primary: "#5a7ca7",
|
||||
darkBlue: "#4e6e8f",
|
||||
darkCyan: "#4d7976",
|
||||
darkWhite: "#7a7a7a",
|
||||
darkRed: "#6b2528",
|
||||
darkGreen: "#4a5a3c",
|
||||
darkYellow: "#7a5526",
|
||||
darkMagenta: "#52385a",
|
||||
foreground: "#D0D0D0",
|
||||
background: "#151515",
|
||||
backgroundSecondary: "#181818",
|
||||
black: "#151515",
|
||||
red: "#A53C23",
|
||||
green: "#7B9246",
|
||||
yellow: "#D3A04D",
|
||||
orange: "#D3A04D",
|
||||
blue: "#547C99",
|
||||
magenta: "#547C99",
|
||||
cyan: "#5EA8A2",
|
||||
white: "#757575",
|
||||
brightBlack: "#2F2F2F",
|
||||
brightRed: "#A53C23",
|
||||
brightGreen: "#7B9246",
|
||||
brightYellow: "#D3A04D",
|
||||
brightBlue: "#6C99BB",
|
||||
brightMagenta: "#547C99",
|
||||
brightCyan: "#5EA8A2",
|
||||
brightWhite: "#F5F5F5",
|
||||
primary: "#6C99BB",
|
||||
darkBlue: "#547C99",
|
||||
darkCyan: "#5EA8A2",
|
||||
darkWhite: "#505050",
|
||||
darkRed: "#A53C23",
|
||||
darkGreen: "#7B9246",
|
||||
darkYellow: "#D3A04D",
|
||||
darkMagenta: "#547C99",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user