Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc88269baf | |||
| 9fc81015d7 | |||
| 294d10eb40 | |||
| 8e7908ae09 | |||
| f0fc2f687a | |||
| 4e4e0f215a | |||
| 09b8f9d842 | |||
| 1c1153eee9 | |||
| 3954082e8d | |||
| 5652f489a2 |
94
HotTODO.md
Normal file
94
HotTODO.md
Normal file
@ -0,0 +1,94 @@
|
||||
# 🔥 HotTODO — Exbyte Depot
|
||||
|
||||
Фичи «супер-удобства», которых **нет ни в P4V, ни в git** — заточены под UE-команду на бинарных ассетах. Ранжировано по «польза / усилие». Стек, на который опираемся: Tauri (Rust) backend + `p4` CLI, React фронт, встроенный OpenRouter (AI), UE-MCP (`mcp__ue-blueprint__*`), уже готовые тумбы `.uasset` / 3D-вьювер / медиа-превью / файл-локи / трей+уведомления / offline / Files & Sizes / View-дерево.
|
||||
|
||||
Статус: **план**, реализуем по мере выбора.
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Foundation — система плагинов / аддонов
|
||||
**Идея:** фичи выпускаются как **отдельные плагины**, которые докачиваются по необходимости и не засоряют основной софт. Ядро остаётся лёгким; тяжёлое/нишевое (в т.ч. фичи 1–8 ниже) живёт в плагинах. Это фундамент — стоит сделать рано, тогда остальное можно поставлять как плагины.
|
||||
|
||||
**Реалистичный дизайн под Tauri** (Rust-бэкенд скомпилирован, поэтому плагины — на фронте/сайдкаре, а не нативные .dll):
|
||||
|
||||
- **Тип плагина = JS/ESM-бандл** (± WASM для тяжёлого), грузится в webview в рантайме через **стабильный Host SDK**, который экспортит ядро:
|
||||
- `p4.*` (whitelisted-команды), события (`p4-log`, `p4-transfer`, `p4-scan`…), `flash/notify`, чтение выбранного файла/CL, i18n, тема.
|
||||
- **Contribution points** — куда плагин встраивается: пункт меню Actions, контекст-меню файла/чейнджлиста, вкладка view-панели (у нас уже есть File Viewer/Tree/Locks — тот же механизм), кнопка в тулбаре, шаг перед Submit (hook). Ядро уже построено на этих точках — надо просто открыть их как API.
|
||||
- **Манифест** `plugin.json`: `name, version, entry, minAppVersion, permissions[], contributes{menus,panels,contextItems,submitHooks}`.
|
||||
- **Модель разрешений:** плагин декларирует, что ему нужно (`p4:read`, `p4:write`, `fs:read`, `network:<host>`, `ui:panel`). Пользователь подтверждает при установке. JS-песочница: iframe/Web Worker с message-bridge, доступ к ядру только через Host SDK (не напрямую к `invoke`).
|
||||
- **CSP:** у нас строгий CSP (внешнее запрещено), поэтому плагины **не грузятся с чужих URL** — их скачивают в локальную папку `plugins/` и подключают контролируемо; сетевой доступ плагину даётся только к явно разрешённым хостам.
|
||||
- **Реестр/маркет:** индекс плагинов на Gitea (список манифестов) → скачивание **подписанного** бандла (у нас уже есть инфраструктура подписи minisign) → проверка подписи → распаковка в `plugins/`. UI «Плагины»: установлено / доступно / обновления / вкл-выкл, версии, разрешения.
|
||||
- **Изоляция сбоев:** упавший плагин не роняет ядро (ловим ошибки на границе bridge), можно отключить.
|
||||
- **Сайдкар-вариант** (опционально, для бэкенд-тяжёлых плагинов): плагин = отдельный исполняемый/скрипт, ядро дёргает его по stdio-JSON (как VS Code language servers). Полезно для парсеров `.uasset`, интеграций.
|
||||
|
||||
**Порядок реализации фундамента:**
|
||||
1. Внутренний рефактор: вынести существующие contribution-точки (меню/контекст/view-табы/submit-hook) за стабильный интерфейс.
|
||||
2. Host SDK + загрузчик локальных плагинов из `plugins/` + модель разрешений.
|
||||
3. UI «Плагины» (вкл/выкл, разрешения).
|
||||
4. Реестр на Gitea + скачивание/проверка подписи/обновления.
|
||||
5. Перенести 1–2 фичи из списка ниже в плагины как эталон (напр. визуальный дифф или reference-guard).
|
||||
|
||||
**Усилие:** высокое (архитектура), но окупается: дальше фичи добавляются без раздувания ядра и обновляются независимо от основного релиза.
|
||||
|
||||
---
|
||||
|
||||
## 🥇 Топ — уникальное, максимум пользы
|
||||
|
||||
### 1. Reference-guard: целостность ассет-референсов перед Submit ⭐ рекомендация №1
|
||||
Перед сабмитом парсить импорты `.uasset` (через UE asset registry / UE-MCP, либо чтение заголовков `.uasset`) и проверять: не сабмитишь ли ассет, который ссылается на другой ассет, которого **нет ни в депоте, ни в текущем changelist**.
|
||||
- **Зачем:** причина №1 сломанных билдов в UE. Ни P4V, ни git не понимают референсы ассетов.
|
||||
- **UX:** «⚠️ `M_Hero` ссылается на `T_Hero_D`, его нет на сервере и не в коммите → добавить в changelist / отменить сабмит».
|
||||
- **Как:** backend-команда `p4_check_refs(files)` → карта референсов → сверка с `p4 files` + открытыми. Бонус: «submit together» — сгруппировать взаимозависимые ассеты в один атомарный CL.
|
||||
- **Усилие:** среднее-высокое (парсинг референсов — ключевой кусок).
|
||||
|
||||
### 2. Визуальный дифф бинарников
|
||||
Не «binary files differ», а реально:
|
||||
- **Картинки** (png/tga/exr/bmp): сравнение свайпом / onion-skin / попиксельная подсветка разницы.
|
||||
- **`.uasset`**: дифф по свойствам (какой параметр материала / LOD / настройку меняли) из встроенных данных.
|
||||
- **Зачем:** P4V/git по бинарям молчат; Anchorpoint умеет частично.
|
||||
- **Как:** переиспользовать существующие тумбы/`readDepot`; для двух ревизий — `p4 print` обеих версий → canvas-свайп. Свойства `.uasset` — через UE-MCP или парсер.
|
||||
- **Усилие:** картинки — низкое-среднее (быстрый вин); свойства `.uasset` — среднее.
|
||||
|
||||
### 3. Lock handoff — запрос залоченного файла у коллеги
|
||||
Бинарь эксклюзивно залочен напарником → кнопка «Попросить файл». Ему — нативное уведомление (трей уже есть); отпустил — тебе прилетает «свободно».
|
||||
- **Зачем:** убирает «отпусти плиз ассет» из дискорда. Нет нигде.
|
||||
- **Как:** нужен лёгкий канал сообщений между клиентами. Варианты: свой мини-эндпоинт (как waitlist-server) / Perforce как транспорт (спец. changelist-описание или counter) / общий канал. Требует продумать транспорт.
|
||||
- **Усилие:** среднее (основное — транспорт уведомлений между людьми).
|
||||
|
||||
---
|
||||
|
||||
## 🥈 Осведомлённость (git/P4V не дают)
|
||||
|
||||
### 4. Presence — кто где работает прямо сейчас
|
||||
Лёгкий поллинг `p4 opened -a` + свой heartbeat → «Mira: Content/Characters, Voss: Config». Presence как в Google Docs, но по депоту. Предупреждает о коллизии **до** открытия того же ассета.
|
||||
- **Усилие:** низкое-среднее (частично уже есть `others`-индикаторы; нужен агрегированный вид + heartbeat).
|
||||
|
||||
### 5. Impact-preview перед Get Latest
|
||||
Показать не только «сколько файлов», а что это значит для UE: «синк тронет 400 материалов → ~20 мин компиляции шейдеров», «меняется .ini упаковки», «конфликт с твоими открытыми файлами».
|
||||
- **Как:** `p4 sync -n` (уже есть `p4_behind`) + классификация путей/типов; для шейдеров — эвристика по кол-ву материалов/шейдеров.
|
||||
- **Усилие:** среднее.
|
||||
|
||||
---
|
||||
|
||||
## 🥉 Скорость/безопасность (кусочки уже есть)
|
||||
|
||||
### 6. AI-ревью changelist перед Submit
|
||||
OpenRouter уже вшит (commit-месседжи) — расширить: «кладёшь 2 ГБ несжатого ассета», «забыл `.uasset`, на который ссылается материал» (пересекается с №1), «этот тип обычно лочат — взять лок?».
|
||||
- **Усилие:** низкое-среднее (инфра AI готова).
|
||||
|
||||
### 7. Управление занятым местом на диске
|
||||
Есть Files & Sizes — добавить «синкнуто 380 ГБ; вот что можно `p4 sync #none`, чтобы освободить 120 ГБ». Пересекается с View-деревом (исключил → освободил).
|
||||
- **Усилие:** низкое (есть `fs_ls` / sizes).
|
||||
|
||||
### 8. Time-travel превью ассета
|
||||
Ползунок по истории ревизии → тумба/3D на каждой версии. Есть revision graph + тумбы — соединить в визуальную ленту.
|
||||
- **Усилие:** среднее.
|
||||
|
||||
---
|
||||
|
||||
## Порядок, который предлагаю
|
||||
Развилка по стратегии:
|
||||
- **Вариант A (плагины сначала):** сделать 🧩 Foundation, затем каждую фичу — как плагин. Ядро остаётся лёгким с самого начала, но первый результат для пользователя дольше.
|
||||
- **Вариант B (ценность сначала):** сделать 1–2 фичи инлайн ради быстрого «вау», потом построить плагин-систему и вынести их в плагины как эталон. Быстрее польза, но потом рефактор.
|
||||
|
||||
Рекомендую **B → потом Foundation**: сначала №2 (визуальный дифф картинок свайпом — быстрый «вау» на готовой инфре), затем №1 (reference-guard — главная польза для билдов), **после этого 🧩 Foundation** и перенос №1/№2 в плагины как образец. Дальше по выбору: №4 presence → №6 AI-ревью → №5 impact-preview → №7 диск → №3 lock handoff → №2 `.uasset`-дифф свойств → №8 time-travel — уже как плагины.
|
||||
103
PLUGINS.md
Normal file
103
PLUGINS.md
Normal file
@ -0,0 +1,103 @@
|
||||
# Exbyte Depot — Plugins
|
||||
|
||||
Plugins add features on top of the core app so the base stays lean and specialised
|
||||
work ships (and updates) independently.
|
||||
|
||||
## What language?
|
||||
**JavaScript / TypeScript.** A plugin is a plain **ES module** that exports
|
||||
`activate(host)`. Author in TypeScript if you like, then compile to a single JS
|
||||
file (`esbuild index.ts --bundle --format=esm --outfile=index.js`). No build step
|
||||
is required for plain JS.
|
||||
|
||||
Why JS and not native? The app's Rust backend is compiled and sealed; the extensible
|
||||
surface is the webview. Plugins run in the app's webview and reach the app **only**
|
||||
through the `host` object — never `invoke` directly.
|
||||
|
||||
## How plugins are loaded
|
||||
The app reads the plugin's entry source from disk (Rust) and imports it as an ES
|
||||
module from a `blob:` URL — **no `eval`**, and it stays inside the CSP
|
||||
(`script-src 'self' blob:`). Disabled plugins are never imported.
|
||||
|
||||
## Anatomy
|
||||
```
|
||||
my-plugin/
|
||||
plugin.json # manifest
|
||||
index.js # ES module exporting activate(host)
|
||||
```
|
||||
|
||||
### plugin.json
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin", // unique; [A-Za-z0-9-_.]
|
||||
"name": "My Plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "You",
|
||||
"description": "What it does.",
|
||||
"entry": "index.js", // must stay inside the plugin folder
|
||||
"minAppVersion": "0.3.5",
|
||||
"permissions": ["p4:read", "notify"],
|
||||
"contributes": { "menu": true, "views": ["notes"], "fileContext": true, "submitHooks": true }
|
||||
}
|
||||
```
|
||||
|
||||
### Permissions (declared, shown to the user)
|
||||
| permission | grants |
|
||||
|---|---|
|
||||
| `p4:read` | read-only p4: `opened, dirs, describe, changeSizes, clientSpec` on `host.p4` |
|
||||
| `p4:write` | mutating p4: `add, edit, del, sync, reconcile` on `host.p4` |
|
||||
| `notify` | native notifications via `host.notify` |
|
||||
|
||||
Without a permission, the matching APIs are simply absent (or no-ops). `host.storage`,
|
||||
`host.clipboard`, `host.flash`, `host.info`, `host.t`, `host.theme` are always available.
|
||||
|
||||
## The `host` SDK
|
||||
```ts
|
||||
host.version // Host SDK version, e.g. "1.0"
|
||||
host.plugin // { id, name, dir }
|
||||
host.has(perm) // boolean
|
||||
|
||||
host.p4.<method>(...) // Promise — only the whitelisted set your perms allow
|
||||
host.info() // { userName, clientName, clientRoot } | null
|
||||
host.selectedFile() // { depotFile } | null
|
||||
host.refresh() // re-read the Changes view
|
||||
host.theme() // current theme's CSS-var overrides
|
||||
|
||||
host.ui.addMenuItem(label, run) // Actions-menu action
|
||||
host.ui.addFileContextItem(label, run(file)) // right-click a file
|
||||
host.ui.addView({ id, title, render(el, host) })// a panel; render into a DOM element (return an optional cleanup fn)
|
||||
host.ui.addSubmitHook(run(files) => {ok, message}) // block/allow a commit
|
||||
|
||||
host.flash(text, isError?) // toast
|
||||
host.notify(title, body) // native notification (needs "notify")
|
||||
host.clipboard.write(text) // Promise
|
||||
host.storage.get/set/remove(key) // per-plugin localStorage namespace
|
||||
host.t(key, params?) // app i18n
|
||||
host.log(...args) // console, tagged with the plugin id
|
||||
```
|
||||
|
||||
## Contribution points
|
||||
- **Menu items** and **views** appear in the **Actions** menu.
|
||||
- **File context items** appear when you right-click a file in Changes.
|
||||
- **Submit hooks** run before a commit is finalised; returning `{ ok:false, message }`
|
||||
prompts the user to confirm or cancel. (This is exactly where a reference-integrity
|
||||
guard, size check, or lint would live.)
|
||||
|
||||
## Import methods
|
||||
1. **Install from folder** — Actions → Plugins… → *Install from folder…* → pick a folder
|
||||
that contains `plugin.json`. It's copied into the app's plugins folder.
|
||||
2. **Drop-in** — Actions → Plugins… → *Open plugins folder*, drop `my-plugin/` in there,
|
||||
then *Reload*.
|
||||
3. **(Planned) Registry** — a signed registry (`exbyte-depot-plugins` repo, minisign-signed
|
||||
bundles) for one-click install/updates, reusing the app's updater signing infra.
|
||||
|
||||
## Try the example
|
||||
`plugins-examples/hello-exbyte/` demonstrates all four contribution points plus storage,
|
||||
clipboard, info and notifications. Install it from folder, then:
|
||||
- Actions → **Say hello 👋** and Actions → **Workspace Notes**
|
||||
- right-click a file → **Copy depot path (plugin)**
|
||||
- try to commit a file named `wip*` → the submit guard warns.
|
||||
|
||||
## Safety
|
||||
Plugins run in-app on the main thread — install only ones you trust. The `id` is
|
||||
path-validated, the entry cannot escape the plugin folder, and each plugin's storage
|
||||
is namespaced. A plugin that throws is isolated and does not crash the app.
|
||||
876
landing/index.html
Normal file
876
landing/index.html
Normal file
@ -0,0 +1,876 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Exbyte Depot — Perforce for Unreal teams, finally pleasant</title>
|
||||
<meta name="description" content="Exbyte Depot is a fast native Perforce client built for Unreal Engine teams: 3D asset previews, one-click exclusive locks, offline work, and live teammate activity. All the power of Helix Core, none of the P4V pain." />
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0a0f; --bg-2:#0d0d14; --panel:#12121b; --panel-2:#16161f; --elevated:#1a1a24;
|
||||
--line:rgba(255,255,255,.08); --line-2:rgba(255,255,255,.05);
|
||||
--txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
|
||||
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0;
|
||||
--ok:#3ecf8e; --warn:#e8b04b; --del:#f2637e;
|
||||
--maxw:1180px;
|
||||
--font:"Segoe UI Variable Display","Segoe UI",system-ui,-apple-system,Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
--mono:"Cascadia Code","JetBrains Mono",ui-monospace,"SF Mono",Consolas,monospace;
|
||||
--shadow:0 40px 120px -40px rgba(0,0,0,.8);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html{scroll-behavior:smooth}
|
||||
body{
|
||||
font-family:var(--font);background:var(--bg);color:var(--txt);
|
||||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
|
||||
line-height:1.6;overflow-x:hidden;
|
||||
}
|
||||
/* ambient background */
|
||||
body::before{
|
||||
content:"";position:fixed;inset:0;z-index:-2;pointer-events:none;
|
||||
background:
|
||||
radial-gradient(900px 640px at 78% -6%,rgba(124,110,246,.22),transparent 60%),
|
||||
radial-gradient(760px 620px at 6% 8%,rgba(91,78,224,.16),transparent 58%),
|
||||
var(--bg);
|
||||
}
|
||||
body::after{
|
||||
content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;opacity:.5;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.020) 1px,transparent 1px),
|
||||
linear-gradient(90deg,rgba(255,255,255,.020) 1px,transparent 1px);
|
||||
background-size:46px 46px;
|
||||
-webkit-mask-image:radial-gradient(1200px 800px at 60% 0%,#000 30%,transparent 78%);
|
||||
mask-image:radial-gradient(1200px 800px at 60% 0%,#000 30%,transparent 78%);
|
||||
}
|
||||
a{color:inherit;text-decoration:none}
|
||||
.tlink{color:var(--acc-2);text-decoration:underline;text-underline-offset:2px;text-decoration-color:rgba(157,147,255,.45);transition:.15s}
|
||||
.tlink:hover{text-decoration-color:var(--acc-2)}
|
||||
/* "works with your Perforce server" reassurance band */
|
||||
.clarify{display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap;text-align:center;
|
||||
max-width:760px;margin:26px auto 0;padding:14px 20px;border-radius:14px;
|
||||
background:var(--panel);border:1px solid var(--line);color:var(--muted);font-size:14px;line-height:1.55}
|
||||
.clarify svg{width:18px;height:18px;color:var(--acc-2);flex:0 0 auto}
|
||||
.clarify b{color:var(--txt);font-weight:600}
|
||||
.wrap{width:100%;max-width:var(--maxw);margin:0 auto;padding:0 24px}
|
||||
.eyebrow{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;font-weight:600;letter-spacing:.08em;
|
||||
text-transform:uppercase;color:var(--acc-2);background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.28);
|
||||
padding:6px 13px;border-radius:100px}
|
||||
.eyebrow .dot{width:6px;height:6px;border-radius:50%;background:var(--acc-2);box-shadow:0 0 10px var(--acc-2)}
|
||||
h1,h2,h3{line-height:1.08;letter-spacing:-.022em;font-weight:700;text-wrap:balance}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:9px;font-family:inherit;font-size:15px;
|
||||
font-weight:600;cursor:pointer;border-radius:12px;padding:13px 22px;border:1px solid transparent;transition:.18s ease;white-space:nowrap}
|
||||
.btn.primary{color:#fff;background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));
|
||||
box-shadow:0 14px 34px -14px var(--acc),inset 0 1px 0 rgba(255,255,255,.22)}
|
||||
.btn.primary:hover{transform:translateY(-1px);box-shadow:0 20px 44px -14px var(--acc),inset 0 1px 0 rgba(255,255,255,.3)}
|
||||
.btn.ghost{color:var(--txt);background:rgba(255,255,255,.04);border-color:var(--line)}
|
||||
.btn.ghost:hover{background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.18)}
|
||||
.btn svg{width:17px;height:17px}
|
||||
:focus-visible{outline:2px solid var(--acc-2);outline-offset:2px;border-radius:8px}
|
||||
|
||||
/* ---------- nav ---------- */
|
||||
header.nav{position:sticky;top:0;z-index:50;backdrop-filter:blur(14px);
|
||||
background:linear-gradient(180deg,rgba(10,10,15,.86),rgba(10,10,15,.55));border-bottom:1px solid var(--line-2)}
|
||||
.nav-in{display:flex;align-items:center;gap:20px;height:66px}
|
||||
.brand{display:flex;align-items:center;gap:11px;font-weight:700;font-size:16.5px;letter-spacing:-.01em}
|
||||
.brand .mark{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;color:#fff;
|
||||
background:linear-gradient(140deg,var(--acc),var(--acc-deep));box-shadow:0 8px 22px -8px var(--acc),inset 0 1px 0 rgba(255,255,255,.35)}
|
||||
.brand .mark svg{width:19px;height:19px}
|
||||
.brand small{color:var(--faint);font-weight:500}
|
||||
.nav-links{display:flex;gap:26px;margin-left:18px}
|
||||
.nav-links a{font-size:14.5px;color:var(--muted);font-weight:500;transition:.15s}
|
||||
.nav-links a:hover{color:var(--txt)}
|
||||
.nav-cta{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
.hero{padding:74px 0 40px;text-align:center}
|
||||
.hero h1{font-size:clamp(38px,6vw,68px);margin:22px auto 0;max-width:16ch}
|
||||
.hero h1 .g{background:linear-gradient(120deg,var(--acc-2),#c9c2ff 60%,var(--acc));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.hero p.sub{font-size:clamp(16px,2vw,19px);color:var(--muted);max-width:60ch;margin:22px auto 0;line-height:1.62}
|
||||
.waitlist{margin:34px auto 0;max-width:520px}
|
||||
.wl-form{display:flex;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:7px;
|
||||
box-shadow:var(--shadow)}
|
||||
.wl-form input{flex:1;min-width:0;background:transparent;border:none;outline:none;color:var(--txt);font-family:inherit;
|
||||
font-size:15px;padding:12px 14px}
|
||||
.wl-form input::placeholder{color:var(--faint)}
|
||||
.wl-note{margin-top:12px;font-size:13px;color:var(--faint);display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap}
|
||||
.wl-note b{color:var(--muted);font-weight:600}
|
||||
.wl-ok{display:none;align-items:center;gap:12px;justify-content:center;background:rgba(62,207,142,.10);
|
||||
border:1px solid rgba(62,207,142,.32);color:#8ff0c4;border-radius:15px;padding:18px 20px;font-size:15px;box-shadow:var(--shadow)}
|
||||
.wl-ok.show{display:flex}
|
||||
.wl-ok svg{width:22px;height:22px;flex:0 0 auto}
|
||||
.avatars{display:flex;justify-content:center;margin-top:22px;align-items:center;gap:12px;color:var(--faint);font-size:13.5px}
|
||||
.stack{display:flex}
|
||||
.stack span{width:28px;height:28px;border-radius:50%;border:2px solid var(--bg);margin-left:-8px;
|
||||
display:grid;place-items:center;font-size:11px;font-weight:700;color:#fff}
|
||||
|
||||
/* ---------- app mock ---------- */
|
||||
.mock-shell{margin:56px 0 0;width:min(1280px,95vw);max-width:1280px;perspective:1800px;
|
||||
position:relative;left:50%;transform:translateX(-50%)}
|
||||
.mock{border-radius:16px;overflow:hidden;border:1px solid var(--line);background:var(--panel);
|
||||
box-shadow:var(--shadow),0 0 0 1px rgba(124,110,246,.10);transform:rotateX(2.5deg);transform-origin:center top}
|
||||
.m-title{display:flex;align-items:center;gap:12px;height:46px;padding:0 14px;background:var(--panel-2);border-bottom:1px solid var(--line)}
|
||||
.m-title .mark{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;color:#fff;background:linear-gradient(140deg,var(--acc),var(--acc-deep))}
|
||||
.m-title .mark svg{width:13px;height:13px}
|
||||
.m-title .t{font-size:13px;font-weight:600}.m-title .t span{color:var(--faint);font-weight:500}
|
||||
.m-menu{display:flex;gap:16px;margin-left:10px;font-size:12.5px;color:var(--muted)}
|
||||
.m-pill{margin-left:auto;display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--muted);
|
||||
background:rgba(62,207,142,.10);border:1px solid rgba(62,207,142,.28);padding:4px 11px;border-radius:100px}
|
||||
.m-pill .d{width:6px;height:6px;border-radius:50%;background:var(--ok);box-shadow:0 0 8px var(--ok)}
|
||||
.m-body{display:grid;grid-template-columns:340px 1fr;min-height:376px}
|
||||
.m-left{border-right:1px solid var(--line);display:flex;flex-direction:column}
|
||||
.m-tabs{display:flex;gap:20px;padding:14px 16px 0;font-size:13px;color:var(--faint)}
|
||||
.m-tabs .on{color:var(--txt);font-weight:600;position:relative;padding-bottom:12px}
|
||||
.m-tabs .on::after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--acc);border-radius:2px}
|
||||
.m-tabs .badge{background:rgba(255,255,255,.08);color:var(--muted);border-radius:8px;padding:0 7px;font-size:11px;margin-left:5px}
|
||||
.m-list{padding:12px 12px 0;display:flex;flex-direction:column;gap:7px}
|
||||
.m-file{display:flex;align-items:center;gap:10px;padding:8px 9px;border-radius:10px}
|
||||
.m-file:hover{background:rgba(255,255,255,.03)}
|
||||
.m-file.sel{background:rgba(124,110,246,.12);box-shadow:inset 0 0 0 1px rgba(124,110,246,.25)}
|
||||
.m-chk{width:16px;height:16px;border-radius:5px;background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));display:grid;place-items:center;flex:0 0 auto}
|
||||
.m-chk svg{width:10px;height:10px;color:#fff}
|
||||
.m-ico{width:26px;height:26px;border-radius:7px;background:var(--panel-2);border:1px solid var(--line);display:grid;place-items:center;color:var(--acc-2);flex:0 0 auto}
|
||||
.m-ico svg{width:14px;height:14px}
|
||||
.m-fmeta{min-width:0}
|
||||
.m-fmeta .n{font-size:12.5px;font-weight:600;white-space:nowrap}
|
||||
.m-fmeta .p{font-size:10.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px}
|
||||
.m-plus{margin-left:auto;color:var(--ok)}
|
||||
.m-commit{margin-top:auto;padding:14px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:9px}
|
||||
.m-input{background:var(--panel-2);border:1px solid var(--line);border-radius:9px;padding:9px 11px;font-size:12px;color:var(--muted)}
|
||||
.m-submit{background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));color:#fff;border-radius:10px;padding:11px;
|
||||
font-size:12.5px;font-weight:600;text-align:center;display:flex;align-items:center;justify-content:center;gap:7px}
|
||||
.m-stage{position:relative;display:grid;place-items:center;
|
||||
background:
|
||||
radial-gradient(520px 380px at 50% 38%,rgba(124,110,246,.12),transparent 62%),
|
||||
repeating-linear-gradient(0deg,transparent,transparent 31px,rgba(255,255,255,.02) 31px,rgba(255,255,255,.02) 32px),
|
||||
repeating-linear-gradient(90deg,transparent,transparent 31px,rgba(255,255,255,.02) 31px,rgba(255,255,255,.02) 32px),
|
||||
var(--bg-2)}
|
||||
.m-stage .lab{position:absolute;top:14px;left:14px;font-size:11px;color:var(--muted);background:rgba(0,0,0,.4);
|
||||
border:1px solid var(--line);border-radius:100px;padding:5px 11px;display:flex;align-items:center;gap:7px}
|
||||
.m-cube{width:120px;height:120px;color:var(--acc-2);filter:drop-shadow(0 20px 40px rgba(124,110,246,.4));animation:float 5s ease-in-out infinite}
|
||||
@keyframes float{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-12px) rotate(4deg)}}
|
||||
.m-load{position:absolute;bottom:56px;left:50%;transform:translateX(-50%);
|
||||
background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));color:#fff;border-radius:10px;padding:10px 18px;
|
||||
font-size:12.5px;font-weight:600;display:flex;align-items:center;gap:8px;box-shadow:0 14px 30px -12px var(--acc)}
|
||||
/* titlebar extras */
|
||||
.m-title .m-actions{display:flex;align-items:center;gap:6px;margin-left:14px}
|
||||
.m-ib{width:26px;height:26px;border-radius:7px;display:grid;place-items:center;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line-2)}
|
||||
.m-ib svg{width:14px;height:14px}
|
||||
.m-wc{display:flex;gap:9px;margin-left:12px}
|
||||
.m-wc i{width:12px;height:12px;border-radius:50%;display:block}
|
||||
.m-wc .r{background:#ff5f57}.m-wc .y{background:#febc2e}.m-wc .g{background:#28c840}
|
||||
/* second header row: workspace / working folder / get latest */
|
||||
.m-head{display:grid;grid-template-columns:1fr 1fr 190px;border-bottom:1px solid var(--line)}
|
||||
.m-zone{display:flex;align-items:center;gap:11px;padding:12px 16px;border-right:1px solid var(--line);min-width:0}
|
||||
.m-zone .zi{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;color:var(--acc-2);background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.2);flex:0 0 auto}
|
||||
.m-zone .zi svg{width:16px;height:16px}
|
||||
.m-zone .zm{min-width:0}
|
||||
.m-zone .zm .l{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint)}
|
||||
.m-zone .zm .v{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.m-zone .cv{margin-left:auto;color:var(--faint);flex:0 0 auto}
|
||||
.m-zone.get{border-right:none;cursor:default}
|
||||
.m-zone.get .zi{color:var(--ok);background:rgba(62,207,142,.10);border-color:rgba(62,207,142,.22)}
|
||||
.m-zone.get .v{color:var(--txt)}.m-zone.get .l{color:var(--faint)}
|
||||
/* left column bits */
|
||||
.m-filter{margin:12px 12px 4px;display:flex;align-items:center;gap:8px;background:var(--panel-2);border:1px solid var(--line);border-radius:9px;padding:8px 11px;color:var(--faint);font-size:12px}
|
||||
.m-filter svg{width:14px;height:14px}
|
||||
.m-selhead{display:flex;align-items:center;gap:9px;padding:8px 16px 6px;font-size:11.5px;color:var(--muted)}
|
||||
.m-selhead .sc{width:15px;height:15px;border-radius:5px;background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));display:grid;place-items:center;flex:0 0 auto}
|
||||
.m-selhead .sc svg{width:9px;height:9px;color:#fff}
|
||||
.m-selhead .rf{margin-left:auto;display:flex;align-items:center;gap:5px;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:11px}
|
||||
.m-selhead .rf svg{width:12px;height:12px}
|
||||
.m-stat{width:18px;height:18px;border-radius:5px;display:grid;place-items:center;font-size:10px;font-weight:700;flex:0 0 auto}
|
||||
.m-stat.a{color:var(--ok);background:rgba(62,207,142,.14)}
|
||||
.m-plus{margin-left:auto}
|
||||
.ai-btn{width:34px;height:34px;flex:0 0 auto;border-radius:9px;display:grid;place-items:center;color:var(--acc-2);background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.24)}
|
||||
.ai-btn svg{width:16px;height:16px}
|
||||
.m-summrow{display:flex;gap:8px;align-items:stretch}
|
||||
.m-summrow .m-input{flex:1}
|
||||
/* right: diff head + meta bar */
|
||||
.m-right{display:flex;flex-direction:column;min-width:0}
|
||||
.m-diffhead{display:flex;align-items:center;gap:11px;padding:12px 16px;border-bottom:1px solid var(--line)}
|
||||
.m-diffhead .ttl{min-width:0}
|
||||
.m-diffhead .ttl .n{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.m-diffhead .ttl .n span{color:var(--faint);font-weight:500}
|
||||
.m-diffhead .ttl .mm{font-size:11px;color:var(--faint)}
|
||||
.m-diffhead .mrev{color:var(--acc-2)}
|
||||
.m-revert{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:8px;padding:6px 12px;flex:0 0 auto}
|
||||
.m-revert svg{width:13px;height:13px}
|
||||
.m-stage{flex:1;position:relative}
|
||||
.m-metabar{border-top:1px solid var(--line);padding:12px 18px;display:grid;grid-template-columns:repeat(4,1fr);gap:10px;background:rgba(0,0,0,.16)}
|
||||
.m-metabar .mi .k{font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint)}
|
||||
.m-metabar .mi .v{font-size:13px;font-weight:600;margin-top:3px}
|
||||
.m-metabar .mi .v.acc{color:var(--acc-2)}
|
||||
/* bottom status bar */
|
||||
.m-status{display:flex;align-items:center;gap:9px;height:34px;padding:0 14px;border-top:1px solid var(--line);background:var(--panel-2);font-size:11px;color:var(--faint)}
|
||||
.m-status .cmd{font-family:var(--mono);color:var(--muted)}
|
||||
.m-status .ok{color:var(--ok)}
|
||||
.m-status .chips{margin-left:auto;display:flex;gap:7px}
|
||||
.m-status .chips b{display:flex;align-items:center;gap:5px;font-weight:500;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line-2);border-radius:7px;padding:3px 9px}
|
||||
.m-status .chips b svg{width:12px;height:12px}
|
||||
@media (max-width:900px){
|
||||
.m-head{grid-template-columns:1fr}
|
||||
.m-zone{border-right:none;border-bottom:1px solid var(--line)}
|
||||
.m-zone.get{display:none}
|
||||
.m-metabar{grid-template-columns:1fr 1fr}
|
||||
}
|
||||
/* ---- interactive mock: hover / active / press ---- */
|
||||
.m-menu span,.m-file,.m-tabs span,.m-submit,.m-load,.m-zone.get,.m-ib,.m-selhead .rf,
|
||||
.m-revert,.m-chk,.ai-btn,.m-status .chips b,.m-wc i{cursor:pointer}
|
||||
.m-menu span{padding:3px 6px;border-radius:6px;transition:.14s}
|
||||
.m-menu span:hover{background:rgba(255,255,255,.06);color:var(--txt)}
|
||||
.m-tabs span:hover{color:var(--txt)}
|
||||
.m-ib{transition:.14s}.m-ib:hover{color:var(--txt);background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.16)}
|
||||
.m-wc i{transition:.14s}.m-wc i:hover{filter:brightness(1.25)}
|
||||
.m-file{transition:background .14s,box-shadow .14s;user-select:none}
|
||||
.m-file:active{transform:translateY(.5px)}
|
||||
.m-chk{transition:.14s}
|
||||
.m-file.off .m-chk{background:transparent;border:1.5px solid var(--faint);box-shadow:none}
|
||||
.m-file.off .m-chk svg{display:none}
|
||||
.m-selhead .rf,.m-revert{transition:.14s}
|
||||
.m-selhead .rf:hover,.m-revert:hover{color:var(--txt);border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.07)}
|
||||
.m-selhead .rf.spin svg{animation:mspin .7s linear infinite}
|
||||
.ai-btn{transition:.14s}.ai-btn:hover{background:rgba(124,110,246,.18);border-color:rgba(124,110,246,.4)}
|
||||
.m-zone.get{transition:.14s;border-radius:0}.m-zone.get:hover{background:rgba(62,207,142,.06)}
|
||||
.m-zone.get.spin .zi svg{animation:mspin .7s linear infinite}
|
||||
.m-submit,.m-load{transition:transform .12s,box-shadow .14s,filter .14s}
|
||||
.m-submit:hover,.m-load:hover{filter:brightness(1.08)}
|
||||
.m-submit:active,.m-load:active{transform:scale(.98)}
|
||||
.m-load:hover{transform:translateX(-50%) scale(1.03)}
|
||||
.m-load:active{transform:translateX(-50%) scale(.97)}
|
||||
.m-status .chips b{transition:.14s}.m-status .chips b:hover{color:var(--txt);border-color:var(--line)}
|
||||
@keyframes mspin{to{transform:rotate(360deg)}}
|
||||
.m-spinner{width:14px;height:14px;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;
|
||||
animation:mspin .7s linear infinite;display:inline-block}
|
||||
/* preview variants */
|
||||
.pv{position:absolute;inset:0;display:grid;place-items:center}
|
||||
.pv[hidden]{display:none}
|
||||
.m-cube.loaded{animation:mrotate 7s linear infinite}
|
||||
@keyframes mrotate{to{transform:rotateY(360deg)}}
|
||||
.pv-mesh{perspective:900px}
|
||||
.tex-swatch{width:min(62%,300px);aspect-ratio:1;border-radius:14px;box-shadow:0 24px 60px -24px #000;border:1px solid var(--line);
|
||||
background:
|
||||
repeating-conic-gradient(from 45deg,#2a2733 0deg 90deg,#211f2a 90deg 180deg) 0 0/26px 26px,
|
||||
radial-gradient(120% 120% at 30% 20%,rgba(124,110,246,.25),transparent 60%)}
|
||||
.tex-cap{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);font-size:11px;color:var(--muted);
|
||||
background:rgba(0,0,0,.4);border:1px solid var(--line);border-radius:100px;padding:5px 12px}
|
||||
.pv-code{align-items:stretch;justify-items:stretch;padding:0}
|
||||
.codeblk{width:100%;height:100%;overflow:hidden;font-family:var(--mono);font-size:12px;line-height:1.75;
|
||||
padding:16px 18px;color:#c7c7d6;text-align:left;background:var(--bg-2)}
|
||||
.codeblk .ln{display:block;white-space:pre}
|
||||
.codeblk .kw{color:#9d93ff}.codeblk .ty{color:#5cc9c0}.codeblk .fn{color:#e8b04b}.codeblk .cm{color:#5f5f72;font-style:italic}.codeblk .st{color:#8ff0c4}
|
||||
/* only one body panel visible at a time (beats inline/class display) */
|
||||
.m-panel[hidden]{display:none!important}
|
||||
/* history list (History tab) */
|
||||
.m-hist{padding:10px 12px;display:flex;flex-direction:column;gap:4px;overflow:auto}
|
||||
.m-hrow{display:flex;align-items:center;gap:11px;padding:9px 10px;border-radius:10px;cursor:pointer;transition:.14s}
|
||||
.m-hrow:hover{background:rgba(255,255,255,.04)}
|
||||
.m-hav{width:26px;height:26px;border-radius:50%;flex:0 0 auto;display:grid;place-items:center;font-size:10px;font-weight:700;color:#fff}
|
||||
.m-hb{min-width:0;flex:1}
|
||||
.m-hb .d{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.m-hb .mt{font-size:10.5px;color:var(--faint)}
|
||||
.m-hsz{font-size:10.5px;font-weight:600;color:var(--muted);background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:1px 7px;white-space:nowrap}
|
||||
|
||||
/* ---------- logos / trust ---------- */
|
||||
.trust{padding:44px 0 8px;text-align:center}
|
||||
.trust p{font-size:12.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:20px}
|
||||
.trust-row{display:flex;flex-wrap:wrap;justify-content:center;gap:14px}
|
||||
.chip{display:inline-flex;align-items:center;gap:8px;font-size:13.5px;color:var(--muted);font-weight:500;
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:100px;padding:9px 15px}
|
||||
.chip svg{width:15px;height:15px;color:var(--acc-2)}
|
||||
|
||||
/* ---------- section headers ---------- */
|
||||
section{padding:86px 0}
|
||||
.sec-head{max-width:720px;margin:0 auto 46px;text-align:center}
|
||||
.sec-head h2{font-size:clamp(28px,4vw,42px)}
|
||||
.sec-head p{color:var(--muted);font-size:17px;margin-top:14px;line-height:1.6}
|
||||
|
||||
/* ---------- features grid ---------- */
|
||||
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
|
||||
.card{background:linear-gradient(180deg,var(--panel),var(--bg-2));border:1px solid var(--line);border-radius:16px;
|
||||
padding:26px 24px;transition:.2s ease;position:relative;overflow:hidden}
|
||||
.card:hover{border-color:rgba(124,110,246,.4);transform:translateY(-3px)}
|
||||
.card:hover::before{opacity:1}
|
||||
.card::before{content:"";position:absolute;inset:0;opacity:0;transition:.25s;pointer-events:none;
|
||||
background:radial-gradient(340px 180px at 20% 0%,rgba(124,110,246,.12),transparent 70%)}
|
||||
.card .ic{width:44px;height:44px;border-radius:12px;display:grid;place-items:center;color:var(--acc-2);
|
||||
background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.24);margin-bottom:18px}
|
||||
.card .ic svg{width:22px;height:22px}
|
||||
.card h3{font-size:18px;letter-spacing:-.01em}
|
||||
.card p{color:var(--muted);font-size:14.5px;margin-top:9px;line-height:1.6}
|
||||
.card .tag{position:absolute;top:20px;right:20px;font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
|
||||
color:var(--acc-2);background:rgba(124,110,246,.12);border:1px solid rgba(124,110,246,.28);border-radius:7px;padding:3px 7px}
|
||||
|
||||
/* ---------- spotlight (why not p4v) ---------- */
|
||||
.split{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center}
|
||||
.split h2{font-size:clamp(26px,3.4vw,38px)}
|
||||
.split .lead{color:var(--muted);font-size:16.5px;margin-top:16px}
|
||||
.comp{display:flex;flex-direction:column;gap:10px;margin-top:26px}
|
||||
.crow{display:flex;align-items:flex-start;gap:13px;padding:14px 16px;border-radius:13px;background:var(--panel);border:1px solid var(--line)}
|
||||
.crow .ck{width:22px;height:22px;border-radius:7px;display:grid;place-items:center;flex:0 0 auto;margin-top:1px}
|
||||
.crow.good .ck{background:rgba(62,207,142,.14);color:var(--ok)}
|
||||
.crow .ck svg{width:13px;height:13px}
|
||||
.crow b{font-size:14.5px}.crow span{color:var(--muted);font-size:13.5px;display:block;margin-top:2px}
|
||||
.panel-visual{background:linear-gradient(180deg,var(--panel),var(--bg-2));border:1px solid var(--line);border-radius:18px;
|
||||
padding:22px;box-shadow:var(--shadow)}
|
||||
.lock-item{display:flex;align-items:center;gap:12px;padding:12px;border-radius:11px;background:var(--panel-2);border:1px solid var(--line);margin-bottom:9px}
|
||||
.lock-item:last-child{margin-bottom:0}
|
||||
.lock-badge{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;flex:0 0 auto}
|
||||
.lock-badge.red{background:rgba(242,99,126,.12);color:var(--del)}
|
||||
.lock-badge.gr{background:rgba(62,207,142,.12);color:var(--ok)}
|
||||
.lock-badge svg{width:15px;height:15px}
|
||||
.lock-item .li-b{min-width:0}
|
||||
.lock-item .li-b .n{font-size:13.5px;font-weight:600}
|
||||
.lock-item .li-b .p{font-size:11.5px;color:var(--faint)}
|
||||
.lock-item .who{margin-left:auto;font-size:11.5px;color:var(--muted);white-space:nowrap}
|
||||
|
||||
/* ---------- tech strip ---------- */
|
||||
.tech{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;text-align:center}
|
||||
.tech .t{padding:24px 16px;border-radius:14px;background:var(--panel);border:1px solid var(--line)}
|
||||
.tech .t .k{font-size:30px;font-weight:800;letter-spacing:-.02em;
|
||||
background:linear-gradient(120deg,var(--acc-2),#cfc9ff);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.tech .t .v{color:var(--muted);font-size:13px;margin-top:6px}
|
||||
|
||||
/* ---------- final CTA ---------- */
|
||||
.cta{position:relative;text-align:center;border-radius:26px;padding:64px 28px;overflow:hidden;
|
||||
background:linear-gradient(180deg,rgba(124,110,246,.14),rgba(91,78,224,.05));border:1px solid rgba(124,110,246,.28)}
|
||||
.cta::before{content:"";position:absolute;inset:0;z-index:-1;
|
||||
background:radial-gradient(600px 300px at 50% 0%,rgba(124,110,246,.25),transparent 70%)}
|
||||
.cta h2{font-size:clamp(28px,4vw,44px)}
|
||||
.cta p{color:var(--muted);font-size:17px;margin:16px auto 30px;max-width:52ch}
|
||||
.cta .waitlist{margin-top:0}
|
||||
|
||||
/* ---------- footer ---------- */
|
||||
footer{border-top:1px solid var(--line-2);padding:40px 0;margin-top:20px}
|
||||
.foot{display:flex;align-items:center;gap:16px;flex-wrap:wrap;color:var(--faint);font-size:13.5px}
|
||||
.foot .brand{font-size:15px}
|
||||
.foot .fl{margin-left:auto;display:flex;gap:22px}
|
||||
.foot a:hover{color:var(--muted)}
|
||||
|
||||
@media (max-width:900px){
|
||||
.nav-links{display:none}
|
||||
.m-body{grid-template-columns:1fr}
|
||||
.m-left{border-right:none;border-bottom:1px solid var(--line)}
|
||||
.m-stage{min-height:260px}
|
||||
.grid{grid-template-columns:1fr 1fr}
|
||||
.split{grid-template-columns:1fr;gap:34px}
|
||||
.tech{grid-template-columns:1fr 1fr}
|
||||
}
|
||||
@media (max-width:560px){
|
||||
.grid{grid-template-columns:1fr}
|
||||
.wl-form{flex-direction:column}
|
||||
.wl-form .btn{width:100%}
|
||||
.hero{padding:48px 0 30px}
|
||||
.mock{transform:none}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ===================== NAV ===================== -->
|
||||
<header class="nav">
|
||||
<div class="wrap nav-in">
|
||||
<a class="brand" href="#top">
|
||||
<span class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg></span>
|
||||
Exbyte Depot <small>— Perforce</small>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#why">Why not P4V</a>
|
||||
<a href="#tech">Under the hood</a>
|
||||
</nav>
|
||||
<div class="nav-cta">
|
||||
<a class="btn ghost" href="#waitlist">Join the waitlist</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<a id="top"></a>
|
||||
<!-- ===================== HERO ===================== -->
|
||||
<section class="hero">
|
||||
<div class="wrap">
|
||||
<span class="eyebrow"><span class="dot"></span> A desktop client for Perforce® · by Exbyte Studios</span>
|
||||
<h1>The <a class="g" href="https://www.perforce.com/products/helix-core" target="_blank" rel="noopener">Perforce</a> client your <span class="g">Unreal team</span> actually wants.</h1>
|
||||
<p class="sub">Exbyte Depot is a fast, native desktop <b style="color:var(--txt)">client for <a class="tlink" href="https://www.perforce.com/products/helix-core" target="_blank" rel="noopener">Perforce Helix Core</a></b> — real 3D asset previews, one-click exclusive locks, offline work, and live teammate activity. All the power of your Perforce server, none of the P4V pain.</p>
|
||||
|
||||
<div class="clarify">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="m9 12 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span><b>It's a client, not a replacement.</b> Exbyte Depot connects to your existing <a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce</a> server through Perforce's own <span style="font-family:var(--mono);font-size:13px">p4</span> command-line — your server, history and workflow stay exactly as they are. We don't clone Perforce or reverse-engineer anything; we just give you a nicer window into it.</span>
|
||||
</div>
|
||||
|
||||
<div class="waitlist" id="waitlist">
|
||||
<form class="wl-form" id="wlForm" novalidate>
|
||||
<input id="wlEmail" type="email" inputmode="email" autocomplete="email" placeholder="you@studio.com" aria-label="Email address" required />
|
||||
<button class="btn primary" type="submit">
|
||||
Join the waitlist
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
<div class="wl-ok" id="wlOk">
|
||||
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/><path d="m8.5 12 2.5 2.5 4.5-5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span id="wlOkMsg">You're on the list. We'll email you the moment early access opens.</span>
|
||||
</div>
|
||||
<div class="wl-note"><b>Windows</b> · early access · no spam, one launch email · <b id="wlCount">—</b> on the list</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ APP MOCK ============ -->
|
||||
<div class="mock-shell">
|
||||
<div class="mock">
|
||||
<!-- titlebar -->
|
||||
<div class="m-title">
|
||||
<span class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg></span>
|
||||
<span class="t">Exbyte Depot <span>— Perforce</span></span>
|
||||
<span class="m-menu"><span>File</span><span>Connection</span><span>Actions</span><span>Window</span><span>Tools</span><span>Help</span></span>
|
||||
<span class="m-pill"><span class="d"></span> helix.auroragames.dev:1666</span>
|
||||
<span class="m-actions">
|
||||
<span class="m-ib"><svg viewBox="0 0 24 24" fill="none"><path d="M16 20v-1a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v1M9.5 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm11 9v-1a4 4 0 0 0-3-3.9M16 4.1a4 4 0 0 1 0 7.8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
|
||||
<span class="m-ib"><svg viewBox="0 0 24 24" fill="none"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||
</span>
|
||||
<span class="m-wc"><i class="g"></i><i class="y"></i><i class="r"></i></span>
|
||||
</div>
|
||||
|
||||
<!-- workspace / working folder / get latest -->
|
||||
<div class="m-head">
|
||||
<div class="m-zone">
|
||||
<span class="zi"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 20h8m-4-3v3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
|
||||
<span class="zm"><span class="l">Workspace</span><span class="v">kestrel_WS_02</span></span>
|
||||
<span class="cv"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
</div>
|
||||
<div class="m-zone">
|
||||
<span class="zi"><svg viewBox="0 0 24 24" fill="none"><path d="M6 3v12a3 3 0 0 0 3 3h6M6 3a3 3 0 1 0 0 6M6 3a3 3 0 1 1 0 6m9 9a3 3 0 1 0 6 0 3 3 0 0 0-6 0Z" stroke="currentColor" stroke-width="1.6"/></svg></span>
|
||||
<span class="zm"><span class="l">Working folder</span><span class="v">//depot/Aurora_Road</span></span>
|
||||
<span class="cv"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
</div>
|
||||
<div class="m-zone get">
|
||||
<span class="zi"><svg viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 1 1-3-6.7M21 4v4h-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
<span class="zm"><span class="v">Get Latest</span><span class="l">pull from server</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- body -->
|
||||
<div class="m-body">
|
||||
<div class="m-left">
|
||||
<div class="m-tabs"><span class="on" data-tab="changes">Changes <span class="badge">3</span></span><span data-tab="history">History</span></div>
|
||||
|
||||
<!-- CHANGES -->
|
||||
<div class="m-panel" data-panel="changes" style="display:flex;flex-direction:column;flex:1;min-height:0">
|
||||
<div class="m-filter"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.7"/><path d="m20 20-3.5-3.5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg> Filter 3 files…</div>
|
||||
<div class="m-selhead">
|
||||
<span class="sc"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
<span id="mSelTxt">3 of 3 selected</span>
|
||||
<span class="rf" id="mRefresh"><svg viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 1 1-3-6.7M21 4v4h-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg> Refresh</span>
|
||||
</div>
|
||||
<div class="m-list" id="mList" style="flex:1;min-height:0;overflow:auto">
|
||||
<div class="m-file sel" data-name="SM_Guardrail.uasset" data-dir="— //depot/Aurora_Road/Content/Meshes/" data-meta="add · #1 · binary" data-kind="mesh" data-type="binary +l" data-prev="3D mesh" data-lab="3D preview">
|
||||
<span class="m-chk"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
<span class="m-ico"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||
<span class="m-fmeta"><span class="n">SM_Guardrail.uasset</span><span class="p">//depot/…/Content/Meshes/</span></span>
|
||||
<span class="m-stat a">+</span>
|
||||
</div>
|
||||
<div class="m-file" data-name="T_Asphalt_D.uasset" data-dir="— //depot/Aurora_Road/Content/Textures/" data-meta="add · #1 · binary" data-kind="tex" data-type="binary" data-prev="2048 × 2048" data-lab="Image">
|
||||
<span class="m-chk"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
<span class="m-ico"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="m3 16 5-5 4 4 3-3 6 6" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||
<span class="m-fmeta"><span class="n">T_Asphalt_D.uasset</span><span class="p">//depot/…/Content/Textures/</span></span>
|
||||
<span class="m-stat a">+</span>
|
||||
</div>
|
||||
<div class="m-file" data-name="RoadSampler.cpp" data-dir="— //depot/Aurora_Road/Source/Aurora/" data-meta="edit · #7 → #8 · text" data-kind="code" data-type="text · C++" data-prev="312 lines" data-lab="Source">
|
||||
<span class="m-chk"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
<span class="m-ico"><svg viewBox="0 0 24 24" fill="none"><path d="M8 3H5a2 2 0 0 0-2 2v3m0 8v3a2 2 0 0 0 2 2h3m8-18h3a2 2 0 0 1 2 2v3m0 8v3a2 2 0 0 1-2 2h-3M9 9l6 6m0-6-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
|
||||
<span class="m-fmeta"><span class="n">RoadSampler.cpp</span><span class="p">//depot/…/Source/Aurora/</span></span>
|
||||
<span class="m-stat a">+</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-commit">
|
||||
<div class="m-summrow">
|
||||
<div class="m-input">Add guardrail mesh + asphalt texture for road kit</div>
|
||||
<div class="ai-btn" title="Draft with AI"><svg viewBox="0 0 24 24" fill="none"><path d="M12 3v3m0 12v3M5 12H2m20 0h-3M6 6l2 2m8 8 2 2m0-12-2 2M8 16l-2 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||
</div>
|
||||
<div class="m-submit" id="mCommit"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg> <span id="mCommitTxt">Commit changelist · 3 files</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HISTORY -->
|
||||
<div class="m-panel m-hist" data-panel="history" hidden>
|
||||
<div class="m-hrow"><span class="m-hav" style="background:#7c6ef6">VD</span><span class="m-hb"><span class="d">road guardrails + collision</span><span class="mt">#34 · voss_dev · 2h ago</span></span><span class="m-hsz">146 KB</span></div>
|
||||
<div class="m-hrow"><span class="m-hav" style="background:#e8b04b">MA</span><span class="m-hb"><span class="d">asphalt material pass</span><span class="mt">#33 · mira_art · 5h ago</span></span><span class="m-hsz">172 KB</span></div>
|
||||
<div class="m-hrow"><span class="m-hav" style="background:#3ecf8e">NK</span><span class="m-hb"><span class="d">early car handling setup</span><span class="mt">#31 · novak_dev · yesterday</span></span><span class="m-hsz">21.3 KB</span></div>
|
||||
<div class="m-hrow"><span class="m-hav" style="background:#f2637e">KS</span><span class="m-hb"><span class="d">new road master material</span><span class="mt">#28 · kestrel · 2 days ago</span></span><span class="m-hsz">6.4 KB</span></div>
|
||||
<div class="m-hrow"><span class="m-hav" style="background:#5cc9c0">KS</span><span class="m-hb"><span class="d">Project Init</span><span class="mt">#18 · kestrel · last week</span></span><span class="m-hsz">34.1 GB</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-right">
|
||||
<div class="m-diffhead">
|
||||
<span class="m-stat a" id="mStat" style="width:24px;height:24px;font-size:13px">+</span>
|
||||
<span class="ttl"><span class="n"><span id="mName">SM_Guardrail.uasset</span> <span id="mDir">— //depot/Aurora_Road/Content/Meshes/</span></span><span class="mm" id="mMeta">add · #1 · binary</span></span>
|
||||
<span class="m-revert"><svg viewBox="0 0 24 24" fill="none"><path d="M9 14 4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg> Revert</span>
|
||||
</div>
|
||||
<div class="m-stage" id="mStage">
|
||||
<span class="lab" id="mLab"><svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg> <span id="mLabTxt">3D preview</span></span>
|
||||
|
||||
<div class="pv pv-mesh" data-pv="mesh">
|
||||
<svg class="m-cube" id="mCube" viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
|
||||
<span class="m-load" id="mLoad"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> <span id="mLoadTxt">Load 3D model</span></span>
|
||||
</div>
|
||||
|
||||
<div class="pv pv-tex" data-pv="tex" hidden>
|
||||
<div class="tex-swatch"></div>
|
||||
<span class="tex-cap">2048 × 2048 · BC7 · sRGB</span>
|
||||
</div>
|
||||
|
||||
<div class="pv pv-code" data-pv="code" hidden>
|
||||
<div class="codeblk"><span class="ln"><span class="cm">// M5 — deterministic POI placement over the road graph</span></span><span class="ln"><span class="kw">void</span> <span class="ty">URoadSampler</span>::<span class="fn">Build</span>(<span class="kw">const</span> <span class="ty">FRoadConfig</span>& Cfg)</span><span class="ln">{</span><span class="ln"> Graph = <span class="fn">NewObject</span><<span class="ty">URoadGraph</span>>(<span class="kw">this</span>);</span><span class="ln"> Graph-><span class="fn">Initialize</span>(Cfg);</span><span class="ln"> Graph-><span class="fn">EnsureGeneratedAround</span>(<span class="ty">INDEX_NONE</span>, <span class="st">0.0</span>);</span><span class="ln"> Pois = <span class="fn">NewObject</span><<span class="ty">UPoiRegistry</span>>(<span class="kw">this</span>);</span><span class="ln">}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-metabar">
|
||||
<div class="mi"><div class="k">Type</div><div class="v" id="mvType">binary +l</div></div>
|
||||
<div class="mi"><div class="k">Preview</div><div class="v" id="mvPrev">3D mesh</div></div>
|
||||
<div class="mi"><div class="k">Revision</div><div class="v acc">#1</div></div>
|
||||
<div class="mi"><div class="k">Change</div><div class="v">default</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- status bar -->
|
||||
<div class="m-status">
|
||||
<span class="ok">✓</span><span class="cmd">p4 opened -c default //depot/Aurora_Road/… {3 files}</span>
|
||||
<span class="chips">
|
||||
<b><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> Unreal</b>
|
||||
<b><svg viewBox="0 0 24 24" fill="none"><path d="M4 5h16v14H4z" stroke="currentColor" stroke-width="1.6"/><path d="m7 9 2.5 2.5L7 14m5 0h4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg> Terminal</b>
|
||||
<b><svg viewBox="0 0 24 24" fill="none"><path d="M4 6h16M4 12h16M4 18h10" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg> Log</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== TRUST ===================== -->
|
||||
<div class="trust wrap">
|
||||
<p>Purpose-built for game studios on Perforce</p>
|
||||
<div class="trust-row">
|
||||
<span class="chip"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg> Unreal Engine 4 & 5</span>
|
||||
<a class="chip" href="https://www.perforce.com/products/helix-core" target="_blank" rel="noopener"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 20h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg> Powered by Perforce Helix Core</a>
|
||||
<span class="chip"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.6"/><path d="M3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18" stroke="currentColor" stroke-width="1.4"/></svg> Radmin VPN friendly</span>
|
||||
<span class="chip"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16M4 12h16M4 17h10" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg> 5 languages</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== FEATURES ===================== -->
|
||||
<section id="features">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<span class="eyebrow"><span class="dot"></span> Everything, in one native app</span>
|
||||
<h2 style="margin-top:18px">Built for the way Unreal teams actually work</h2>
|
||||
<p>Perforce is the industry standard for a reason. Exbyte Depot keeps every bit of its power and finally makes it feel good to use.</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
|
||||
<div class="card">
|
||||
<span class="tag">Signature</span>
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></div>
|
||||
<h3>Real 3D asset preview</h3>
|
||||
<p>Rotate the actual mesh inside a <code>.uasset</code> right in the app — plus embedded thumbnails, image, audio and video previews. See what you're committing before you commit it.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||
<h3>Exclusive locks, one click</h3>
|
||||
<p>Binary <code>.uasset</code> files can't be merged. Take an exclusive lock so only you edit a mesh or material at a time — and see everyone else's locks live. No more overwritten work.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 8-8 8 8 0 0 1 8 8m0 0-3-3m3 3 3-3M20 12a8 8 0 0 1-8 8 8 8 0 0 1-8-8m0 0 3 3m-3-3-3 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||
<h3>Work offline, reconcile later</h3>
|
||||
<p>On a plane or the VPN's down? Keep editing. Exbyte Depot scans your workspace and reconciles adds, edits and deletes into a changelist when you're back — just like P4V, but nicer.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M13.7 21a2 2 0 0 1-3.4 0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
|
||||
<h3>Live teammate activity</h3>
|
||||
<p>Lives in your system tray and sends a native notification the moment a teammate submits — with their message. A banner tells you when you're behind, one click to Get Latest.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="tag">AI</span>
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 3v3m0 12v3M5 12H2m20 0h-3M6 6l2 2m8 8 2 2m0-12-2 2M8 16l-2 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||
<h3>AI commit summaries</h3>
|
||||
<p>Draft a clear changelist message from your selected files, and get a plain-English summary of what any source file does — in your interface language.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||
<h3>Files & Sizes explorer</h3>
|
||||
<p>Browse the Depot and your Workspace as a tree and see exactly how much every file and folder weighs — heaviest first. Find what's bloating your repo in seconds.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="m9 8-4 4 4 4m6-8 4 4-4 4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||
<h3>Built-in code editor</h3>
|
||||
<p>Fix a header or a config right before commit — no external IDE needed. Full syntax highlighting, line numbers, and a proper editor, not a grey textbox.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M6 3v12a3 3 0 0 0 3 3h6M6 3a3 3 0 1 0 0 6M6 3a3 3 0 1 1 0 6m9 9a3 3 0 1 0 6 0 3 3 0 0 0-6 0Z" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||
<h3>The full Perforce toolbox</h3>
|
||||
<p>Labels, streams, integrate / merge / copy, jobs, file history with blame & diff, clean, <code>.p4ignore</code>, get-revision, shelve/unshelve — the depth is all there when you need it.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 1 1-9-9c2.5 0 4.5 1 6 2.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="m17 4 1 3-3 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||
<h3>Signed auto-updates</h3>
|
||||
<p>Ships as a signed installer with a built-in updater. New versions land in place — the whole team stays current without chasing downloads.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== WHY NOT P4V ===================== -->
|
||||
<section id="why" style="background:linear-gradient(180deg,transparent,rgba(124,110,246,.04),transparent)">
|
||||
<div class="wrap split">
|
||||
<div>
|
||||
<span class="eyebrow"><span class="dot"></span> Why switch</span>
|
||||
<h2 style="margin-top:18px">P4V is powerful. It's also from another era.</h2>
|
||||
<p class="lead">We're not replacing your <a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce</a> server — we're replacing the client you look at it through. Exbyte Depot keeps every bit of Perforce power your studio depends on and rebuilds the experience around how artists and engineers really work: visual, fast, and forgiving.</p>
|
||||
<div class="comp">
|
||||
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>See your assets, not filenames</b><span>Live 3D meshes, thumbnails, audio & video — right where you review a changelist.</span></div></div>
|
||||
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>Commit locally, submit when ready</b><span>A GitHub-Desktop-style flow on top of Perforce — revertable local commits, then push.</span></div></div>
|
||||
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>Never lose work to a binary conflict</b><span>Exclusive locks on <code>.uasset</code> are one click, and everyone's locks are visible.</span></div></div>
|
||||
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>Know what the team is doing</b><span>Tray notifications on every submit and a clear "you're behind" nudge.</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-visual">
|
||||
<div style="font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);margin-bottom:14px;display:flex;align-items:center;gap:8px"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" style="color:var(--acc-2)"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.6"/></svg> File Locks</div>
|
||||
<div class="lock-item"><span class="lock-badge red"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.7"/></svg></span><div class="li-b"><div class="n">SM_Hero_Character.uasset</div><div class="p">//depot/Char/Content/Meshes/</div></div><span class="who">🔒 mira_art</span></div>
|
||||
<div class="lock-item"><span class="lock-badge red"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.7"/></svg></span><div class="li-b"><div class="n">M_Road_Master.uasset</div><div class="p">//depot/Road/Content/Materials/</div></div><span class="who">🔒 you</span></div>
|
||||
<div class="lock-item"><span class="lock-badge gr"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.7"/></svg></span><div class="li-b"><div class="n">BP_GameMode.uasset</div><div class="p">//depot/Core/Content/Blueprints/</div></div><span class="who">available</span></div>
|
||||
<div style="margin-top:16px;padding:13px 15px;border-radius:11px;background:rgba(124,110,246,.08);border:1px solid rgba(124,110,246,.22);font-size:13px;color:var(--acc-2);display:flex;gap:10px;align-items:center">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
|
||||
voss_dev just submitted <b style="color:#fff;margin:0 4px">#34</b> — you're 3 files behind.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== TECH ===================== -->
|
||||
<section id="tech">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<span class="eyebrow"><span class="dot"></span> Under the hood</span>
|
||||
<h2 style="margin-top:18px">Native, fast, and quietly powerful</h2>
|
||||
<p>No Electron bloat. A tiny signed installer, instant startup, and a UI that stays out of your way.</p>
|
||||
</div>
|
||||
<div class="tech">
|
||||
<div class="t"><div class="k">Rust</div><div class="v">Native Tauri backend</div></div>
|
||||
<div class="t"><div class="k">~4 MB</div><div class="v">Installer, not 200</div></div>
|
||||
<div class="t"><div class="k">5</div><div class="v">Languages built in</div></div>
|
||||
<div class="t"><div class="k">0</div><div class="v">Servers to configure</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== FINAL CTA ===================== -->
|
||||
<section style="padding-top:20px">
|
||||
<div class="wrap">
|
||||
<div class="cta">
|
||||
<h2>Get early access to Exbyte Depot</h2>
|
||||
<p>We're onboarding studios in waves. Drop your email and we'll send your download the moment your spot opens.</p>
|
||||
<div class="waitlist">
|
||||
<form class="wl-form" id="wlForm2" novalidate>
|
||||
<input id="wlEmail2" type="email" inputmode="email" autocomplete="email" placeholder="you@studio.com" aria-label="Email address" required />
|
||||
<button class="btn primary" type="submit">Reserve my spot
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
<div class="wl-ok" id="wlOk2">
|
||||
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/><path d="m8.5 12 2.5 2.5 4.5-5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>You're on the list. Talk soon.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== FOOTER ===================== -->
|
||||
<footer>
|
||||
<div class="wrap foot">
|
||||
<a class="brand" href="#top">
|
||||
<span class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg></span>
|
||||
Exbyte Depot
|
||||
</a>
|
||||
<span class="fl" style="margin-left:0">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#waitlist">Waitlist</a>
|
||||
<a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce.com</a>
|
||||
</span>
|
||||
<span style="flex-basis:100%;color:var(--faint);font-size:12.5px;line-height:1.6;margin-top:6px">
|
||||
© <span id="yr"></span> Exbyte Studios. Exbyte Depot is an independent desktop <b style="color:var(--muted);font-weight:600">client for Perforce Helix Core</b> and requires a Perforce server. It is not affiliated with, endorsed by, or sponsored by Perforce Software, Inc. “Perforce”, “Helix Core” and “P4V” are trademarks of <a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce Software, Inc.</a>, used here only to describe compatibility.
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
"use strict";
|
||||
var KEY = "exd_waitlist";
|
||||
function list(){ try { return JSON.parse(localStorage.getItem(KEY) || "[]"); } catch(e){ return []; } }
|
||||
function save(a){ try { localStorage.setItem(KEY, JSON.stringify(a)); } catch(e){} }
|
||||
|
||||
// Signups POST to the waitlist server (../waitlist-server). The server dedups
|
||||
// and stores them; localStorage is kept as a mirror for the social-proof
|
||||
// counter and as an offline fallback if the page is opened without a backend.
|
||||
var API = "/api/waitlist";
|
||||
function submitToWaitlist(email){
|
||||
var a = list();
|
||||
if (a.indexOf(email) === -1) a.push(email);
|
||||
save(a);
|
||||
return fetch(API, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email, source: "landing", website: "" })
|
||||
})
|
||||
.then(function(r){ return r.ok ? r.json() : null; })
|
||||
.catch(function(){ return null; }); // never block the UX on a network error
|
||||
}
|
||||
|
||||
var BASE = 1240; // shown alongside real signups for social proof
|
||||
function renderCount(){
|
||||
var el = document.getElementById("wlCount");
|
||||
if (el) el.textContent = (BASE + list().length).toLocaleString() + " studios";
|
||||
}
|
||||
renderCount();
|
||||
document.getElementById("yr").textContent = new Date().getFullYear();
|
||||
|
||||
function valid(v){ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }
|
||||
|
||||
function wire(formId, emailId, okId){
|
||||
var form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", function(e){
|
||||
e.preventDefault();
|
||||
var input = document.getElementById(emailId);
|
||||
var email = (input.value || "").trim().toLowerCase();
|
||||
if (!valid(email)){
|
||||
input.focus();
|
||||
input.style.boxShadow = "0 0 0 2px var(--del)";
|
||||
setTimeout(function(){ input.style.boxShadow = ""; }, 1400);
|
||||
return;
|
||||
}
|
||||
var already = list().indexOf(email) !== -1;
|
||||
submitToWaitlist(email).then(function(){
|
||||
form.style.display = "none";
|
||||
var ok = document.getElementById(okId);
|
||||
if (ok){
|
||||
ok.classList.add("show");
|
||||
if (okId === "wlOk"){
|
||||
var msg = document.getElementById("wlOkMsg");
|
||||
if (msg) msg.textContent = already
|
||||
? "You're already on the list — we've got you."
|
||||
: "You're on the list. We'll email you the moment early access opens.";
|
||||
}
|
||||
}
|
||||
renderCount();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
wire("wlForm", "wlEmail", "wlOk");
|
||||
wire("wlForm2", "wlEmail2", "wlOk2");
|
||||
})();
|
||||
|
||||
/* ===== interactive app-window mock ===== */
|
||||
(function(){
|
||||
"use strict";
|
||||
var mock = document.querySelector(".mock");
|
||||
if (!mock) return;
|
||||
var $ = function(s){ return mock.querySelector(s); };
|
||||
var $$ = function(s){ return Array.prototype.slice.call(mock.querySelectorAll(s)); };
|
||||
|
||||
/* --- tab switching (Changes / History) --- */
|
||||
$$(".m-tabs span").forEach(function(tab){
|
||||
tab.addEventListener("click", function(){
|
||||
var name = tab.getAttribute("data-tab");
|
||||
$$(".m-tabs span").forEach(function(t){ t.classList.toggle("on", t === tab); });
|
||||
$$(".m-panel").forEach(function(p){ p.hidden = p.getAttribute("data-panel") !== name; });
|
||||
});
|
||||
});
|
||||
|
||||
/* --- file selection swaps the right-hand preview --- */
|
||||
function selectFile(f){
|
||||
$$(".m-file").forEach(function(x){ x.classList.toggle("sel", x === f); });
|
||||
var d = f.dataset;
|
||||
$("#mName").textContent = d.name;
|
||||
$("#mDir").textContent = d.dir;
|
||||
$("#mMeta").textContent = d.meta;
|
||||
$("#mvType").textContent = d.type;
|
||||
$("#mvPrev").textContent = d.prev;
|
||||
$("#mLabTxt").textContent = d.lab;
|
||||
$$(".pv").forEach(function(pv){ pv.hidden = pv.getAttribute("data-pv") !== d.kind; });
|
||||
}
|
||||
$$(".m-file").forEach(function(f){
|
||||
f.addEventListener("click", function(e){
|
||||
if (e.target.closest(".m-chk")) return; // checkbox handled separately
|
||||
selectFile(f);
|
||||
});
|
||||
});
|
||||
|
||||
/* --- checkboxes toggle the selected count + commit button --- */
|
||||
function refreshCount(){
|
||||
var on = $$(".m-file:not(.off)").length;
|
||||
$("#mSelTxt").textContent = on + " of 3 selected";
|
||||
$("#mCommitTxt").textContent = on ? ("Commit changelist · " + on + (on === 1 ? " file" : " files")) : "Select files to commit";
|
||||
$("#mCommit").style.opacity = on ? "1" : ".55";
|
||||
}
|
||||
$$(".m-file .m-chk").forEach(function(chk){
|
||||
chk.addEventListener("click", function(e){
|
||||
e.stopPropagation();
|
||||
chk.closest(".m-file").classList.toggle("off");
|
||||
refreshCount();
|
||||
});
|
||||
});
|
||||
|
||||
/* --- little press / spin feedback --- */
|
||||
function pressSpin(el, ms){
|
||||
if (!el || el.classList.contains("spin")) return;
|
||||
el.classList.add("spin");
|
||||
setTimeout(function(){ el.classList.remove("spin"); }, ms || 900);
|
||||
}
|
||||
var rf = $("#mRefresh"); if (rf) rf.addEventListener("click", function(){ pressSpin(rf, 800); });
|
||||
var get = $(".m-zone.get"); if (get) get.addEventListener("click", function(){ pressSpin(get, 1100); });
|
||||
|
||||
/* --- Load 3D model → spinner, then a slowly spinning mesh --- */
|
||||
var load = $("#mLoad"), cube = $("#mCube"), loaded = false;
|
||||
if (load){
|
||||
load.addEventListener("click", function(){
|
||||
if (loaded){ return; }
|
||||
var txt = $("#mLoadTxt");
|
||||
load.innerHTML = '<span class="m-spinner"></span> Rendering…';
|
||||
setTimeout(function(){
|
||||
loaded = true;
|
||||
cube.classList.add("loaded");
|
||||
load.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"/></svg> Loaded';
|
||||
load.style.opacity = ".7";
|
||||
var pv = $("#mvPrev"); if (pv && pv.textContent.indexOf("3D") === 0) pv.textContent = "3D mesh · loaded";
|
||||
}, 950);
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Commit button press --- */
|
||||
var commit = $("#mCommit");
|
||||
if (commit){
|
||||
commit.addEventListener("click", function(){
|
||||
if ($$(".m-file:not(.off)").length === 0) return;
|
||||
var t = $("#mCommitTxt"), old = t.textContent;
|
||||
commit.style.transform = "scale(.98)";
|
||||
t.textContent = "Committing…";
|
||||
setTimeout(function(){
|
||||
commit.style.transform = "";
|
||||
t.textContent = "✓ Changelist created locally";
|
||||
setTimeout(function(){ t.textContent = old; }, 1500);
|
||||
}, 700);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "exbyte-depot",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "exbyte-depot",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
@ -1456,6 +1457,15 @@
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "exbyte-depot",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
|
||||
57
plugins-examples/hello-exbyte/index.js
Normal file
57
plugins-examples/hello-exbyte/index.js
Normal file
@ -0,0 +1,57 @@
|
||||
// Hello Exbyte — example plugin.
|
||||
// A plugin is a plain ES module that exports `activate(host)`. The host is the
|
||||
// only way the plugin touches the app; what it can reach is gated by the
|
||||
// "permissions" in plugin.json.
|
||||
|
||||
export function activate(host) {
|
||||
host.log("activating", host.plugin.id, "v", host.version);
|
||||
|
||||
// 1) A menu action (appears under the Actions menu).
|
||||
host.ui.addMenuItem("Say hello 👋", () => {
|
||||
const me = host.info()?.userName || "there";
|
||||
host.flash(`Hello, ${me}! (from the Hello Exbyte plugin)`);
|
||||
host.notify("Hello Exbyte", "The example plugin says hi.");
|
||||
});
|
||||
|
||||
// 2) A file context-menu item (right-click a file in Changes).
|
||||
host.ui.addFileContextItem("Copy depot path (plugin)", (file) => {
|
||||
if (file.depotFile) {
|
||||
host.clipboard.write(file.depotFile);
|
||||
host.flash("Copied: " + file.depotFile);
|
||||
}
|
||||
});
|
||||
|
||||
// 3) A view — a per-workspace notepad, persisted with host.storage.
|
||||
// Opens from the Actions menu (listed by its title).
|
||||
host.ui.addView({
|
||||
id: "notes",
|
||||
title: "Workspace Notes",
|
||||
render: (el) => {
|
||||
const key = "notes:" + (host.info()?.clientName || "default");
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = host.storage.get(key) || "";
|
||||
ta.placeholder = "Jot notes for this workspace… (saved locally, per workspace)";
|
||||
ta.style.cssText =
|
||||
"width:100%;height:320px;box-sizing:border-box;resize:vertical;" +
|
||||
"background:var(--input-bg,#111);color:var(--txt,#eee);" +
|
||||
"border:1px solid var(--border,#333);border-radius:10px;padding:12px;" +
|
||||
"font:13px/1.6 system-ui;outline:none";
|
||||
ta.addEventListener("input", () => host.storage.set(key, ta.value));
|
||||
el.appendChild(ta);
|
||||
// optional cleanup returned to the host:
|
||||
return () => host.storage.set(key, ta.value);
|
||||
},
|
||||
});
|
||||
|
||||
// 4) A submit guard — blocks (with a confirm) files that look like WIP.
|
||||
// This is the same hook point a reference-integrity guard would use.
|
||||
host.ui.addSubmitHook((files) => {
|
||||
const wip = files.filter((f) => /(^|\/)(wip|temp|scratch)[^/]*$/i.test(f.depotFile || ""));
|
||||
if (wip.length) {
|
||||
return { ok: false, message: `${wip.length} file(s) look like work-in-progress (wip / temp / scratch).` };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
host.log("activated");
|
||||
}
|
||||
11
plugins-examples/hello-exbyte/plugin.json
Normal file
11
plugins-examples/hello-exbyte/plugin.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "hello-exbyte",
|
||||
"name": "Hello Exbyte",
|
||||
"version": "1.0.0",
|
||||
"author": "Exbyte Studios",
|
||||
"description": "Example plugin — a menu action, a per-workspace notes panel, a file context item, and a submit guard. Copy it as a starting point.",
|
||||
"entry": "index.js",
|
||||
"minAppVersion": "0.3.5",
|
||||
"permissions": ["p4:read", "notify"],
|
||||
"contributes": { "menu": true, "views": ["notes"], "fileContext": true, "submitHooks": true }
|
||||
}
|
||||
51
plugins-examples/unreal/index.js
Normal file
51
plugins-examples/unreal/index.js
Normal file
@ -0,0 +1,51 @@
|
||||
// Unreal Engine Tools — official plugin.
|
||||
// The core app keeps the build engine (UnrealBuildTool / MSBuild), the build
|
||||
// overlay and the log reader; this plugin is just the Unreal-facing surface.
|
||||
// It renders natively: the actions sit in the working-folder right-click menu
|
||||
// with the host's Unreal + hammer icons, and the log is a bottom-dock tab —
|
||||
// exactly where these lived before, just delivered as a plugin. Reached through
|
||||
// the permission-gated `host.ue` bridge.
|
||||
|
||||
export function activate(host) {
|
||||
const ue = host.ue;
|
||||
if (!ue) { host.log("no ue bridge — plugin.json needs the \"ue\" permission"); return; }
|
||||
|
||||
// Right-click the working folder → Launch / Build, shown only on a UE project.
|
||||
// Prefer the native folder-context slot (host ≥ 0.3.7); fall back to the top
|
||||
// menu on older hosts so the actions are always reachable.
|
||||
const addAction = (label, run, icon) => {
|
||||
if (host.ui.addFolderContextItem) host.ui.addFolderContextItem(label, run, { icon, visible: () => ue.hasProject() });
|
||||
else host.ui.addMenuItem(label, () => { if (!ue.hasProject()) { host.flash("No Unreal project in the current working folder.", true); return; } run(); }, { icon });
|
||||
};
|
||||
addAction("Launch Unreal Engine", () => ue.launch(), "ue");
|
||||
addAction("Build Solution (.sln)", () => ue.build(), "hammer");
|
||||
|
||||
// Bottom-dock "Unreal" tab — tails the editor log (Saved/Logs), refreshing live.
|
||||
// Native dock panel on host ≥ 0.3.7; a modal view as a fallback on older hosts.
|
||||
const mountLog = (el) => {
|
||||
el.style.cssText = "display:flex;flex-direction:column;height:100%";
|
||||
const pre = document.createElement("pre");
|
||||
pre.style.cssText =
|
||||
"flex:1;margin:0;padding:10px 14px;overflow:auto;white-space:pre-wrap;word-break:break-word;" +
|
||||
"font:11.5px/1.6 var(--mono,ui-monospace,monospace);color:var(--muted,#9a9aa8)";
|
||||
el.appendChild(pre);
|
||||
let alive = true;
|
||||
async function tick() {
|
||||
if (!alive) return;
|
||||
try {
|
||||
if (!ue.hasProject()) { pre.textContent = "No Unreal project in the current working folder."; return; }
|
||||
const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 30;
|
||||
const txt = await ue.readLog(200000);
|
||||
pre.textContent = txt || "No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running.";
|
||||
if (atBottom) pre.scrollTop = pre.scrollHeight;
|
||||
} catch (e) { pre.textContent = String(e); }
|
||||
}
|
||||
tick();
|
||||
const id = setInterval(tick, 1500);
|
||||
return () => { alive = false; clearInterval(id); };
|
||||
};
|
||||
if (host.ui.addDockPanel) host.ui.addDockPanel({ id: "unreal", title: "Unreal", icon: "ue", render: mountLog });
|
||||
else host.ui.addView({ id: "unreal-log", title: "Unreal Log", render: mountLog });
|
||||
|
||||
host.log("Unreal Engine Tools activated");
|
||||
}
|
||||
12
plugins-examples/unreal/plugin.json
Normal file
12
plugins-examples/unreal/plugin.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "unreal",
|
||||
"name": "Unreal Engine Tools",
|
||||
"version": "1.2.0",
|
||||
"author": "Exbyte Studios",
|
||||
"description": "Build the game (.sln / UnrealBuildTool), launch the editor, and tail the Unreal log. Actions live in the working-folder right-click menu; the log is a bottom-dock tab. Enable on Unreal Engine projects.",
|
||||
"entry": "index.js",
|
||||
"minAppVersion": "0.3.6",
|
||||
"permissions": ["ue"],
|
||||
"defaultEnabled": true,
|
||||
"contributes": { "folderContext": true, "dock": ["unreal"] }
|
||||
}
|
||||
358
src-tauri/Cargo.lock
generated
358
src-tauri/Cargo.lock
generated
@ -343,6 +343,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.0"
|
||||
@ -462,6 +468,23 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@ -552,6 +575,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@ -960,14 +992,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.3.0"
|
||||
version = "0.3.8"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
@ -1272,8 +1306,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1295,8 +1331,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1565,6 +1604,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1733,6 +1773,19 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@ -2022,6 +2075,26 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"time",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@ -2081,6 +2154,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.19.3"
|
||||
@ -2132,6 +2215,20 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
|
||||
dependencies = [
|
||||
"futures-lite",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"serde",
|
||||
"tauri-winrt-notification",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@ -2634,6 +2731,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@ -2702,6 +2808,12 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@ -2711,6 +2823,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
@ -2732,6 +2900,61 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@ -2807,6 +3030,44 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@ -2944,6 +3205,7 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@ -2991,6 +3253,12 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@ -3213,6 +3481,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
@ -3283,7 +3563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@ -3593,6 +3873,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"image",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
@ -3606,7 +3887,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@ -3747,6 +4028,25 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify-rust",
|
||||
"rand 0.9.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
@ -3795,7 +4095,7 @@ dependencies = [
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
@ -3912,6 +4212,17 @@ dependencies = [
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"windows",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@ -4559,6 +4870,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.5"
|
||||
@ -4624,6 +4945,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@ -5299,6 +5629,26 @@ dependencies = [
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.53"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.53"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "exbyte-depot"
|
||||
version = "0.3.0"
|
||||
version = "0.3.9"
|
||||
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
||||
authors = ["Exbyte Studios"]
|
||||
edition = "2021"
|
||||
@ -18,13 +18,16 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
# decode p4 output on non-UTF8 (ANSI/cp1251) systems — Cyrillic paths
|
||||
encoding_rs = "0.8"
|
||||
# backend-side HTTPS for the plugin registry (webview fetch is blocked by CORS)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
|
||||
# Auto-updater (desktop only — checks Gitea Releases for a signed new version)
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
|
||||
@ -11,8 +11,13 @@
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-unminimize",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
"process:default"
|
||||
"process:default",
|
||||
"notification:default"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,7 @@
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Exbyte Depot",
|
||||
"mainBinaryName": "Exbyte Depot",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.9",
|
||||
"identifier": "com.bonchellon.exbyte-depot",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@ -24,8 +24,8 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
|
||||
"csp": "default-src 'self'; script-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://openrouter.ai https://gitea.exbytestudios.com; worker-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai https://gitea.exbytestudios.com; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
260
src/App.css
260
src/App.css
@ -94,7 +94,11 @@ input,textarea,[contenteditable="true"],
|
||||
.tgroup{display:flex;min-width:0;position:relative}
|
||||
.tgroup .tzone{flex:1;min-width:0}
|
||||
.tgroup .tzone:last-child{flex:0 0 var(--right-zone-w,220px);min-width:160px;border-right:none}
|
||||
.tzone:last-child{border-right:none}.tzone:hover{background:rgba(124,110,246,.06)}
|
||||
.tzone:last-child{border-right:none}
|
||||
.tzone:hover{background:var(--hover)}
|
||||
/* keep the labels/icon high-contrast on hover in every theme (fixes greyed-out text) */
|
||||
.tzone:hover .val{color:var(--txt)}.tzone:hover .lbl,.tzone:hover .sub{color:var(--muted)}
|
||||
.sync:hover .ic{color:var(--accent-2)}.sync:hover .val{color:var(--txt)}.sync:hover .sub{color:var(--muted)}
|
||||
|
||||
/* draggable panel dividers */
|
||||
.vhandle{position:absolute;top:0;bottom:0;width:9px;cursor:col-resize;z-index:8}
|
||||
@ -116,12 +120,21 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.left{border-right:1px solid var(--border);display:flex;flex-direction:column;min-height:0;overflow:hidden}
|
||||
.right{display:flex;flex-direction:column;min-height:0}
|
||||
|
||||
.tabs{display:flex;height:46px;flex:0 0 46px;border-bottom:1px solid var(--border)}
|
||||
.tabs{display:flex;height:46px;flex:0 0 46px;border-bottom:1px solid var(--border);position:relative}
|
||||
.tab{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;font-size:13px;font-weight:600;
|
||||
color:var(--muted);cursor:pointer;position:relative;transition:color .15s;background:none;border:none}
|
||||
color:var(--muted);cursor:pointer;position:relative;transition:color .2s;background:none;border:none}
|
||||
.tab:hover{color:var(--txt)}.tab.on{color:var(--txt)}
|
||||
.tab.on::after{content:"";position:absolute;bottom:-1px;left:16px;right:16px;height:2px;
|
||||
background:linear-gradient(90deg,var(--accent),var(--accent-2));border-radius:2px;box-shadow:0 0 10px rgba(124,110,246,.6)}
|
||||
.tab:focus{outline:none}
|
||||
.tab:focus-visible{outline:none;box-shadow:inset 0 0 0 2px rgba(124,110,246,.35)}
|
||||
.tab:active{transform:scale(.97)}
|
||||
/* sliding underline that glides between the two tabs */
|
||||
.tab-slider{position:absolute;bottom:0;height:2px;width:calc(50% - 32px);border-radius:2px;pointer-events:none;
|
||||
background:linear-gradient(90deg,var(--accent),var(--accent-2));box-shadow:0 0 10px rgba(124,110,246,.6);
|
||||
transition:left .34s cubic-bezier(.5,.02,.15,1)}
|
||||
/* content fades + rises slightly when the tab changes */
|
||||
.tabpane{flex:1;min-height:0;display:flex;flex-direction:column;animation:tabin .3s cubic-bezier(.4,0,.2,1)}
|
||||
@keyframes tabin{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
|
||||
@media (prefers-reduced-motion:reduce){.tab-slider{transition:none}.tabpane{animation:none}}
|
||||
.badge{font-size:11px;font-weight:600;color:var(--muted);background:var(--panel-3);padding:1px 8px;border-radius:10px;font-variant-numeric:tabular-nums}
|
||||
.tab.on .badge{color:var(--accent-2);background:rgba(124,110,246,.16)}
|
||||
|
||||
@ -235,6 +248,12 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.scanbar{display:flex;align-items:center;gap:9px;margin:8px 10px 2px;padding:9px 12px;border-radius:11px;font-size:11.5px;line-height:1.4;
|
||||
color:var(--accent-2);background:rgba(124,110,246,.08);border:1px solid rgba(124,110,246,.22)}
|
||||
.scanbar .ldr{flex:0 0 auto}
|
||||
.scanbar > span{flex:1 1 auto;min-width:0}
|
||||
.scancount{font-weight:600;color:var(--txt);font-variant-numeric:tabular-nums}
|
||||
.scan-cancel{flex:0 0 auto;font-size:11px;font-weight:500;color:var(--muted);cursor:pointer;
|
||||
background:var(--panel);border:1px solid var(--border);border-radius:7px;padding:3px 11px;transition:.15s}
|
||||
.scan-cancel:hover{color:#fff;background:var(--danger,#e5484d);border-color:var(--danger,#e5484d)}
|
||||
.empty .scancount{color:var(--accent-2)}
|
||||
|
||||
/* submit progress modal */
|
||||
.push-modal{width:380px;background:var(--elevated);border:1px solid var(--border);border-radius:16px;padding:24px 26px;box-shadow:var(--shadow);text-align:center}
|
||||
@ -263,6 +282,10 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.upd-ic{width:34px;height:34px;flex:0 0 auto;border-radius:10px;display:grid;place-items:center;color:#fff;
|
||||
background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 18px rgba(124,110,246,.5)}
|
||||
.upd-ic svg{width:18px;height:18px}
|
||||
.upd.sm{width:auto;max-width:340px;padding:11px 14px}
|
||||
.upd.sm .upd-ic{width:28px;height:28px}
|
||||
.upd.sm.ok{cursor:pointer}
|
||||
.upd-ic.ok{background:linear-gradient(145deg,var(--add),#1f9f6e);box-shadow:0 0 16px rgba(62,207,142,.45)}
|
||||
.upd-ttl{display:flex;flex-direction:column;gap:1px;min-width:0}
|
||||
.upd-ttl b{font-size:13.5px}
|
||||
.upd-ttl span{font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||
@ -351,6 +374,12 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
color:var(--muted);border-radius:7px;padding:3px 10px;cursor:pointer;font-size:11.5px}
|
||||
.stlog:hover{color:var(--txt);border-color:var(--accent)}
|
||||
.stlog svg{width:13px;height:13px}
|
||||
.stlog.icon{padding:3px 7px}
|
||||
.stupd{position:relative}
|
||||
.stupd.has{color:var(--accent);border-color:var(--accent)}
|
||||
.stupd.busy{color:var(--accent-2)}
|
||||
.stupd[disabled]{cursor:default;opacity:.85}
|
||||
.stupd-dot{position:absolute;top:2px;right:2px;width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--panel)}
|
||||
.logpeek{position:absolute;left:8px;bottom:26px;width:520px;max-width:calc(100vw - 20px);max-height:min(340px,60vh);overflow-y:auto;z-index:250;
|
||||
background:var(--elevated);border:1px solid var(--border);border-radius:11px 11px 0 0;padding:5px;box-shadow:0 -14px 40px -12px rgba(0,0,0,.55)}
|
||||
.logpeek-h{position:sticky;top:0;padding:5px 9px 7px;font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);
|
||||
@ -387,6 +416,10 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.docktab:hover{color:var(--txt);background:var(--hover)}
|
||||
.docktab.on{color:var(--txt);border-bottom-color:var(--accent)}
|
||||
.docktab.on svg{color:var(--accent-2)}
|
||||
.docktab:active{transform:scale(.96)}
|
||||
/* dock content fades in when switching Log / Terminal / Unreal */
|
||||
.dockpane{flex:1;min-height:0;display:flex;flex-direction:column;animation:tabin .26s cubic-bezier(.4,0,.2,1)}
|
||||
@media (prefers-reduced-motion:reduce){.vpane,.dockpane{animation:none}}
|
||||
.dockspace{flex:1}
|
||||
.dockpanel .loghbtn{margin-left:0}
|
||||
/* terminal */
|
||||
@ -442,9 +475,41 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.xfer-ttl b{font-size:14.5px}
|
||||
.xfer-ttl span{font-size:12px;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||
.xfer-file{margin-top:14px;font-family:var(--mono);font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.xfer-bar{margin-top:12px;height:6px;border-radius:4px;background:var(--panel-3);overflow:hidden;position:relative}
|
||||
.xfer-actions{margin-left:auto;flex:0 0 auto;align-self:flex-start;display:flex;align-items:center;gap:8px}
|
||||
.xfer-cancel{flex:0 0 auto;font-size:11.5px;font-weight:600;cursor:pointer;
|
||||
background:var(--panel-2);border:1px solid var(--border);color:var(--muted);border-radius:8px;padding:5px 11px;transition:.15s}
|
||||
.xfer-cancel:hover:not(:disabled){color:#fff;background:var(--danger,#e5484d);border-color:var(--danger,#e5484d)}
|
||||
.xfer-cancel:disabled{opacity:.55;cursor:default}
|
||||
.xfer-min{flex:0 0 auto;width:26px;height:26px;display:grid;place-items:center;cursor:pointer;
|
||||
background:var(--panel-2);border:1px solid var(--border);color:var(--muted);border-radius:8px;transition:.15s}
|
||||
.xfer-min:hover{color:var(--txt);border-color:var(--accent)}
|
||||
.xfer-min svg{width:15px;height:15px}
|
||||
/* minimized transfer chip (bottom-right; sits above the build chip if both show) */
|
||||
.xferchip{position:fixed;right:18px;bottom:18px;z-index:361;display:flex;align-items:center;gap:11px;
|
||||
padding:9px 11px 9px 12px;border-radius:12px;cursor:pointer;background:var(--elevated);border:1px solid var(--border);
|
||||
box-shadow:0 16px 40px -14px rgba(0,0,0,.55);animation:updin .25s ease;min-width:210px;max-width:320px;overflow:hidden}
|
||||
.xferchip.up{border-color:rgba(124,110,246,.5)}
|
||||
.xferchip.down{border-color:rgba(62,207,142,.5)}
|
||||
.xferchip:hover{filter:brightness(1.06)}
|
||||
.xc-ic{flex:0 0 auto;display:grid;place-items:center}
|
||||
.xc-txt{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
|
||||
.xc-txt b{font-size:12.5px;font-weight:600}
|
||||
.xc-txt span{font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.xc-cancel{flex:0 0 auto;width:22px;height:22px;border:none;background:none;color:var(--faint);border-radius:6px;cursor:pointer;display:grid;place-items:center}
|
||||
.xc-cancel:hover:not(:disabled){background:var(--danger,#e5484d);color:#fff}
|
||||
.xc-cancel:disabled{opacity:.5;cursor:default}
|
||||
.xc-bar{position:absolute;left:0;bottom:0;height:3px;width:100%;background:transparent}
|
||||
.xc-bar span{display:block;height:100%;background:linear-gradient(90deg,var(--accent-2),var(--accent-deep));transition:width .25s ease}
|
||||
.xfer-bar{margin-top:14px;height:6px;border-radius:4px;background:var(--panel-3);overflow:hidden;position:relative}
|
||||
.xfer-bar span{position:absolute;top:0;left:-35%;width:35%;height:100%;border-radius:4px;
|
||||
background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:shim 1.1s ease-in-out infinite}
|
||||
/* determinate (known total): solid fill sized by inline width, no shimmer */
|
||||
.xfer-bar.det span{position:absolute;left:0;top:0;width:0;animation:none;
|
||||
background:linear-gradient(90deg,var(--accent-2),var(--accent-deep));transition:width .25s ease;box-shadow:0 0 10px rgba(124,110,246,.55)}
|
||||
.xfer-log{margin-top:12px;height:132px;overflow-y:auto;overflow-x:hidden;border:1px solid var(--border);border-radius:9px;
|
||||
background:var(--panel);padding:7px 10px;font-family:var(--mono);font-size:10.5px;line-height:1.55;color:var(--muted)}
|
||||
.xfer-log div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.xfer-log div:last-child{color:var(--txt)}
|
||||
|
||||
/* MSBuild output overlay */
|
||||
.build{width:660px;max-width:calc(100vw - 40px);height:min(70vh,560px);display:flex;flex-direction:column;
|
||||
@ -489,6 +554,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.summary::placeholder{color:var(--faint)}
|
||||
.desc{background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:9px 11px;font-size:12.5px;color:var(--txt);min-height:46px;resize:none;outline:none;font-family:var(--font)}
|
||||
.desc::placeholder{color:var(--faint)}
|
||||
.summary:focus,.desc:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,110,246,.14)}
|
||||
.submit{border:none;border-radius:10px;padding:11px;font-size:13.5px;font-weight:600;color:#fff;cursor:pointer;
|
||||
background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));
|
||||
box-shadow:0 8px 24px -6px rgba(124,110,246,.6),0 0 0 1px rgba(255,255,255,.08) inset;
|
||||
@ -527,6 +593,15 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.stage .lbl2{position:absolute;top:14px;left:14px;font-size:11px;color:var(--muted);background:var(--chip);
|
||||
border:1px solid var(--border);border-radius:20px;padding:5px 11px;display:flex;align-items:center;gap:7px}
|
||||
.stage .hint{position:absolute;bottom:14px;right:14px;font-size:11px;color:var(--faint)}
|
||||
/* audio / video preview */
|
||||
.mediaplayer{width:min(460px,86%)}
|
||||
.mediaplayer.vid{width:auto;max-width:92%;max-height:88%;border-radius:12px;box-shadow:0 24px 60px -24px #000;background:#000}
|
||||
.audiobox{display:flex;flex-direction:column;align-items:center;gap:16px;width:min(460px,86%)}
|
||||
.audio-art{width:104px;height:104px;border-radius:22px;display:grid;place-items:center;color:#fff;
|
||||
background:linear-gradient(140deg,var(--accent),var(--accent-deep));box-shadow:0 20px 46px -18px var(--accent)}
|
||||
.audio-art svg{width:48px;height:48px}
|
||||
.audio-name{font-size:14px;font-weight:600;color:var(--txt);text-align:center;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 10px}
|
||||
.audiobox audio{width:100%}
|
||||
.assetmeta{border-top:1px solid var(--border);padding:15px 20px;display:grid;grid-template-columns:repeat(4,1fr);gap:13px 22px;background:var(--sunk)}
|
||||
.mi{display:flex;flex-direction:column;gap:3px}
|
||||
.mi .k{font-size:10.5px;color:var(--faint);text-transform:uppercase;letter-spacing:.6px}
|
||||
@ -599,10 +674,120 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.ce-btn.save{color:#fff;border:none;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep))}
|
||||
.ce-btn.save:hover:not(:disabled){filter:brightness(1.1)}
|
||||
.ce-btn svg{width:13px;height:13px}
|
||||
/* spec editors (client / job / .p4ignore): a clean editor surface, not a flat grey box */
|
||||
.code-edit{flex:1;min-height:0;width:100%;resize:none;border:none;outline:none;
|
||||
font-family:var(--mono);font-size:12.5px;line-height:1.7;tab-size:4;color:var(--txt);
|
||||
background:var(--stage-bg);padding:12px 16px;white-space:pre;overflow:auto}
|
||||
background:var(--elevated);padding:12px 16px 14px 18px;white-space:pre;overflow:auto;
|
||||
box-shadow:inset 3px 0 0 var(--border-soft)}
|
||||
.code-edit:focus{box-shadow:inset 3px 0 0 var(--accent)}
|
||||
/* workspace View include/exclude tree (P4V-style) */
|
||||
.vt-modes{margin-left:auto;display:flex;gap:2px;background:var(--panel-3);border:1px solid var(--border);border-radius:9px;padding:2px}
|
||||
.vt-modes button{width:28px;height:24px;display:grid;place-items:center;border:none;background:none;color:var(--faint);border-radius:7px;cursor:pointer;transition:.15s}
|
||||
.vt-modes button svg{width:15px;height:15px}
|
||||
.vt-modes button:hover{color:var(--txt)}
|
||||
.vt-modes button.on{background:var(--elevated);color:var(--accent-2)}
|
||||
.vt-tree{flex:1;min-height:0;overflow:auto;background:var(--elevated);padding:6px 4px}
|
||||
.vt-node{user-select:none}
|
||||
.vt-row{display:flex;align-items:center;gap:7px;height:26px;border-radius:7px;padding-right:8px;cursor:default}
|
||||
.vt-row:hover{background:#ffffff08}
|
||||
.vt-chev{width:16px;height:16px;flex:0 0 auto;display:grid;place-items:center;color:var(--faint);cursor:pointer}
|
||||
.vt-chev svg{width:13px;height:13px;transition:transform .12s ease}
|
||||
.vt-chk{width:16px;height:16px;flex:0 0 auto;border-radius:4px;border:1.5px solid var(--border);display:grid;place-items:center;cursor:pointer;transition:.12s;background:var(--panel)}
|
||||
.vt-chk:hover{border-color:var(--accent)}
|
||||
.vt-chk.on{background:var(--accent);border-color:var(--accent)}
|
||||
.vt-chk.mixed{background:var(--faint);border-color:var(--faint)}
|
||||
.vt-chk svg{width:12px;height:12px}
|
||||
.vt-ic{width:16px;height:16px;flex:0 0 auto;display:grid;place-items:center;color:var(--accent-2)}
|
||||
.vt-ic svg{width:15px;height:15px}
|
||||
.vt-name{font-size:13px;color:var(--txt);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.vt-loading,.vt-empty{height:24px;display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--faint)}
|
||||
/* themes modal */
|
||||
.th-grid{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;padding:16px}
|
||||
.th-card{position:relative;border:1.5px solid var(--border);border-radius:13px;overflow:hidden;cursor:pointer;background:var(--panel);transition:.15s}
|
||||
.th-card:hover{border-color:var(--accent)}
|
||||
.th-card.on{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,110,246,.2)}
|
||||
.th-swatch{height:64px;display:flex;align-items:center;gap:7px;padding:0 14px}
|
||||
.th-swatch span{width:20px;height:20px;border-radius:50%;box-shadow:0 2px 6px rgba(0,0,0,.35)}
|
||||
.th-meta{padding:9px 12px 11px}
|
||||
.th-meta b{display:block;font-size:13px;font-weight:600}
|
||||
.th-meta span{font-size:11px;color:var(--faint)}
|
||||
.th-on{position:absolute;top:8px;right:8px;width:22px;height:22px;border-radius:50%;background:var(--accent);color:#fff;display:grid;place-items:center}
|
||||
.th-on svg{width:13px;height:13px}
|
||||
.th-actions{position:absolute;top:7px;right:7px;display:none;gap:4px}
|
||||
.th-card:hover .th-actions{display:flex}
|
||||
.th-actions button{width:24px;height:24px;border:none;border-radius:7px;background:var(--chip);color:var(--txt);cursor:pointer;display:grid;place-items:center;backdrop-filter:blur(4px)}
|
||||
.th-actions button svg{width:13px;height:13px}
|
||||
.th-actions button:hover{background:var(--accent);color:#fff}
|
||||
.th-actions button.danger:hover{background:var(--del)}
|
||||
.th-edit{flex:1;min-height:0;overflow:auto;padding:16px}
|
||||
.th-edit-row{display:flex;align-items:center;gap:12px;margin-bottom:18px;flex-wrap:wrap}
|
||||
.th-edit-row label{font-size:12px;color:var(--muted);font-weight:600}
|
||||
.th-edit-row input{flex:1;min-width:160px;background:var(--input-bg);border:1px solid var(--border);border-radius:9px;padding:9px 12px;color:var(--txt);font-size:14px;outline:none}
|
||||
.th-edit-row input:focus{border-color:var(--accent)}
|
||||
.th-base{display:flex;gap:2px;background:var(--panel-3);border-radius:9px;padding:2px}
|
||||
.th-base button{display:flex;align-items:center;gap:5px;font-size:12px;font-weight:600;border:none;background:none;color:var(--muted);border-radius:7px;padding:7px 11px;cursor:pointer}
|
||||
.th-base button svg{width:14px;height:14px}
|
||||
.th-base button.on{background:var(--elevated);color:var(--accent-2)}
|
||||
.th-group{margin-bottom:16px}
|
||||
.th-group-h{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);font-weight:600;margin-bottom:9px}
|
||||
.th-tokens{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:9px}
|
||||
.th-token{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--txt);cursor:pointer}
|
||||
.th-token input[type=color]{width:30px;height:30px;flex:0 0 auto;border:1px solid var(--border);border-radius:8px;background:none;cursor:pointer;padding:2px}
|
||||
.th-token input[type=color]::-webkit-color-swatch{border:none;border-radius:6px}
|
||||
.th-token input[type=color]::-webkit-color-swatch-wrapper{padding:0}
|
||||
/* plugins manager */
|
||||
.pl-list{flex:1;min-height:0;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
|
||||
.pl-official-h{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);font-weight:700;margin:8px 2px 0;padding-top:10px;border-top:1px solid var(--border-soft);display:flex;align-items:baseline;gap:8px}
|
||||
.pl-cat-sub{text-transform:none;letter-spacing:0;font-weight:500;color:var(--faint);opacity:.8}
|
||||
.pl-card{display:flex;gap:12px;align-items:flex-start;border:1px solid var(--border);border-radius:12px;padding:13px 15px;background:var(--panel);transition:.15s}
|
||||
.pl-card.off{opacity:.55}
|
||||
.pl-main{flex:1;min-width:0}
|
||||
.pl-top{display:flex;align-items:baseline;gap:9px}
|
||||
.pl-top b{font-size:14px;font-weight:650}
|
||||
.pl-ver{font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||
.pl-desc{font-size:12.5px;color:var(--muted);margin-top:3px;line-height:1.45}
|
||||
.pl-meta{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px}
|
||||
.pl-tag{font-size:11px;color:var(--muted);background:var(--panel-3);border-radius:6px;padding:2px 8px}
|
||||
.pl-perm{font-size:10.5px;font-weight:600;color:var(--accent-2);background:rgba(124,110,246,.12);border:1px solid rgba(124,110,246,.25);border-radius:6px;padding:2px 8px}
|
||||
.pl-actions{flex:0 0 auto;display:flex;align-items:center;gap:8px}
|
||||
.pl-toggle{width:38px;height:22px;border-radius:12px;border:none;background:var(--panel-3);cursor:pointer;position:relative;transition:.18s;padding:0}
|
||||
.pl-toggle span{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--faint);transition:.18s}
|
||||
.pl-toggle.on{background:var(--accent)}
|
||||
.pl-toggle.on span{left:19px;background:#fff}
|
||||
.pl-del{width:30px;height:30px;border:none;background:none;color:var(--faint);border-radius:8px;cursor:pointer;display:grid;place-items:center}
|
||||
.pl-del svg{width:15px;height:15px}
|
||||
.pl-del:hover{background:rgba(242,99,126,.14);color:var(--del)}
|
||||
.plugin-view{flex:1;min-height:0;min-height:340px;overflow:auto;padding:16px;color:var(--txt);font-size:13.5px}
|
||||
/* settings — two-pane (left nav + content) */
|
||||
.settings-modal{width:min(860px,94vw);height:min(620px,88vh);display:flex;background:var(--elevated);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);overflow:hidden;animation:updin .2s ease}
|
||||
.set-nav{flex:0 0 210px;display:flex;flex-direction:column;gap:2px;padding:14px 10px;background:var(--panel);border-right:1px solid var(--border-soft)}
|
||||
.set-nav-h{display:flex;align-items:center;gap:9px;padding:6px 10px 14px;font-size:14px;font-weight:650}
|
||||
.set-nav-logo{display:grid;place-items:center;color:var(--accent-2)}
|
||||
.set-nav-logo svg{width:18px;height:18px}
|
||||
.set-nav-item{display:flex;align-items:center;gap:10px;padding:9px 11px;border:none;background:none;color:var(--muted);font-size:13.5px;font-weight:500;font-family:var(--font);border-radius:9px;cursor:pointer;text-align:left;transition:.12s}
|
||||
.set-nav-item .sni-ic{display:grid;place-items:center;flex:0 0 auto}
|
||||
.set-nav-item svg{width:16px;height:16px}
|
||||
.set-nav-item:hover{background:var(--hover);color:var(--txt)}
|
||||
.set-nav-item.on{background:var(--accent);color:#fff}
|
||||
.set-nav-item.danger{color:var(--del)}
|
||||
.set-nav-item.danger:hover{background:rgba(242,99,126,.14)}
|
||||
.set-nav-sp{flex:1}
|
||||
.set-main{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||
.set-main-h{display:flex;align-items:center;padding:16px 18px 12px;border-bottom:1px solid var(--border-soft)}
|
||||
.set-main-h h3{font-size:16px;font-weight:650;flex:1}
|
||||
.set-main .info-body{flex:1;min-height:0;padding:16px 20px}
|
||||
.set-panel{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}
|
||||
.set-sep{height:1px;background:var(--border-soft);margin:14px 0}
|
||||
/* rich code editor: transparent textarea over a highlighted underlay + gutter */
|
||||
.code.editing{overflow:hidden}
|
||||
.code.editing .code-gutter{overflow:hidden}
|
||||
.ce-wrap{position:relative;flex:1;min-width:0;background:transparent}
|
||||
.ce-under,.ce-ta{margin:0;padding:10px 16px;border:0;font-family:var(--mono);font-size:12.5px;line-height:1.7;tab-size:4;white-space:pre;word-wrap:normal;letter-spacing:normal}
|
||||
.ce-under{position:absolute;inset:0;overflow:hidden;pointer-events:none;background:none;color:var(--txt)}
|
||||
.ce-under code{font-family:inherit;background:none;padding:0;white-space:pre}
|
||||
.ce-ta{position:absolute;inset:0;width:100%;height:100%;resize:none;outline:none;overflow:auto;background:transparent;color:transparent;caret-color:var(--accent-2)}
|
||||
.ce-ta::selection{background:rgba(124,110,246,.28);color:transparent}
|
||||
.ce-ta::-moz-selection{background:rgba(124,110,246,.28);color:transparent}
|
||||
|
||||
/* highlight.js tokens mapped to the app palette (theme-aware) */
|
||||
.hljs{color:var(--txt);background:none}
|
||||
@ -618,7 +803,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.diff::-webkit-scrollbar{width:10px}.diff::-webkit-scrollbar-thumb{background:var(--panel-3);border-radius:8px}
|
||||
|
||||
/* history */
|
||||
.hrow{display:flex;gap:12px;align-items:flex-start;padding:12px 16px;border-bottom:1px solid var(--border-soft)}
|
||||
.hrow{display:flex;gap:12px;align-items:flex-start;padding:12px 16px;border-bottom:1px solid var(--border-soft);position:relative}
|
||||
.hrow.click{cursor:pointer}
|
||||
.hrow:hover{background:var(--hover)}
|
||||
.hrow.sel{background:rgba(124,110,246,.12);box-shadow:inset 2px 0 0 var(--accent)}
|
||||
@ -642,10 +827,46 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.histsplit>.histh{position:relative;flex:none;width:8px;align-self:stretch;cursor:col-resize;background:transparent;top:auto;bottom:auto}
|
||||
.histsplit .histlist .crow-go{opacity:0}
|
||||
.histsplit .histlist .crow.sel .crow-go{opacity:1;transform:translateX(0)}
|
||||
/* ---- right-side view panel with tabs (File Viewer / File Tree) ---- */
|
||||
.viewpanel{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
|
||||
.vtabs{display:flex;align-items:stretch;gap:2px;padding:6px 8px 0;border-bottom:1px solid var(--border);background:var(--sunk);flex:0 0 auto;overflow-x:auto;overflow-y:hidden}
|
||||
.vtab{display:flex;align-items:center;gap:7px;padding:7px 12px;border-radius:9px 9px 0 0;font-size:12.5px;font-weight:600;color:var(--muted);cursor:pointer;white-space:nowrap;border:1px solid transparent;border-bottom:none;position:relative;top:1px;transition:background .22s ease,border-color .22s ease,color .18s ease}
|
||||
.vtab:active{transform:scale(.97)}
|
||||
.vtab:hover{color:var(--txt);background:var(--hover)}
|
||||
.vtab.on{color:var(--txt);background:var(--bg);border-color:var(--border)}
|
||||
.vtab-ic{display:grid;place-items:center;color:var(--accent-2)}
|
||||
.vtab-ic svg{width:15px;height:15px}
|
||||
.vtab-x{margin-left:4px;width:17px;height:17px;display:grid;place-items:center;border-radius:5px;font-size:15px;line-height:1;color:var(--faint)}
|
||||
.vtab-x:hover{background:var(--del);color:#fff}
|
||||
.viewbody{flex:1;display:flex;min-height:0;min-width:0;position:relative}
|
||||
.vpane{flex:1;min-width:0;min-height:0;animation:tabin .28s cubic-bezier(.4,0,.2,1)}
|
||||
.vpane>.right,.vpane>.histsplit{flex:1 1 auto;min-width:0;min-height:0}
|
||||
.vpane.tree{flex-direction:column}
|
||||
.ft-bar{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-soft);flex:0 0 auto}
|
||||
.ft-total{margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
.ft-total b{color:var(--accent-2)}
|
||||
.ft-new{display:flex;align-items:center;gap:5px;font-family:var(--font);font-size:11.5px;font-weight:600;color:var(--accent-2);
|
||||
background:rgba(124,110,246,.1);border:1px solid rgba(124,110,246,.24);border-radius:8px;padding:4px 10px;cursor:pointer;flex:0 0 auto;transition:.14s}
|
||||
.ft-new:hover{background:rgba(124,110,246,.18);border-color:rgba(124,110,246,.4)}
|
||||
.ft-new svg{width:13px;height:13px}
|
||||
/* folder properties dialog */
|
||||
.fp-body{padding:18px 20px}
|
||||
.fp-name{display:flex;align-items:center;gap:10px;font-size:15px;margin-bottom:16px}
|
||||
.fp-name svg{width:20px;height:20px;color:var(--accent-2);flex:0 0 auto}
|
||||
.fp-name b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.fp-grid{display:grid;grid-template-columns:auto 1fr;gap:10px 16px;font-size:13px}
|
||||
.fp-grid .k{color:var(--faint)}
|
||||
.fp-grid .v{color:var(--txt);min-width:0;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
|
||||
.fp-grid .v.mono{font-family:var(--mono);font-size:12px;word-break:break-all;white-space:normal}
|
||||
.vpane.tree .exp-tree{flex:1;min-height:0}
|
||||
.hnum{font-family:var(--mono);font-size:12px;color:var(--accent-2);font-variant-numeric:tabular-nums;flex:0 0 auto;padding-top:1px}
|
||||
.hbody{display:flex;flex-direction:column;gap:3px;min-width:0}
|
||||
.hdesc{font-size:13px;color:var(--txt);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
||||
.hmeta{font-size:11px;color:var(--faint)}
|
||||
.hsize{margin-left:7px;padding:1px 6px;border-radius:6px;background:var(--chip);border:1px solid var(--border-soft);color:var(--muted);font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.hnew{position:absolute;top:50%;right:14px;transform:translateY(-50%);padding:3px 10px;border-radius:7px;font-size:11px;font-weight:800;letter-spacing:.7px;color:#fff;pointer-events:none;
|
||||
background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 0 rgba(124,110,246,.5);animation:newpulse 2.2s ease-in-out infinite}
|
||||
@keyframes newpulse{0%,100%{box-shadow:0 0 0 0 rgba(124,110,246,.45)}50%{box-shadow:0 0 0 4px rgba(124,110,246,0)}}
|
||||
|
||||
/* 3d viewer toolbar */
|
||||
.v3d-tools{position:absolute;top:12px;right:12px;display:flex;gap:6px;z-index:2}
|
||||
@ -670,8 +891,9 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.picker-head{padding:18px 20px 14px;border-bottom:1px solid var(--border-soft);display:flex;align-items:center;gap:10px}
|
||||
.picker-head h3{font-size:15px;font-weight:700}
|
||||
.picker-head > svg{width:20px;height:20px;flex:0 0 auto;color:var(--accent-2)}
|
||||
.picker-head .x{margin-left:auto;width:28px;height:28px;border:none;background:var(--panel);border-radius:8px;color:var(--muted);cursor:pointer;display:grid;place-items:center}
|
||||
.picker-head .x:hover{color:var(--txt)}
|
||||
.picker-head .x,.set-main-h .x{margin-left:auto;width:28px;height:28px;border:none;background:var(--panel);border-radius:8px;color:var(--muted);cursor:pointer;display:grid;place-items:center;transition:.15s}
|
||||
.picker-head .x:hover,.set-main-h .x:hover{color:var(--txt);background:var(--hover)}
|
||||
.picker-head .x svg,.set-main-h .x svg{width:14px;height:14px}
|
||||
.picker-list{overflow-y:auto;padding:8px}
|
||||
.picker-item{display:flex;align-items:center;gap:12px;padding:11px 12px;border-radius:11px;cursor:pointer;transition:background .12s}
|
||||
.picker-item:hover{background:var(--hover)}
|
||||
@ -681,6 +903,13 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.picker-item .pi-body{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.picker-item .pi-name{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.picker-item .pi-sub{font-size:11.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
/* recently-used working folders */
|
||||
.pick-recent{border-bottom:1px solid var(--border-soft);margin-bottom:6px;padding-bottom:6px}
|
||||
.pick-recent-h{font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--faint);padding:4px 12px 6px;font-weight:600}
|
||||
.picker-item .pi-ic.clock,.picker-item .pi-ic.up{color:var(--muted);background:var(--panel-3)}
|
||||
/* "local only, not in depot yet" tag */
|
||||
.local-tag{margin-left:8px;font-size:9.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--warn);
|
||||
background:rgba(232,176,75,.14);border:1px solid rgba(232,176,75,.32);border-radius:6px;padding:1px 6px;vertical-align:middle}
|
||||
.picker-foot{padding:12px 20px;border-top:1px solid var(--border-soft);font-size:12px;color:var(--faint)}
|
||||
.info-body{padding:20px;font-size:13px;line-height:1.6;color:var(--muted)}
|
||||
.info-body b{color:var(--txt)}
|
||||
@ -700,7 +929,8 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.modal-card h3{font-size:17px;font-weight:700;margin-bottom:8px}
|
||||
.modal-card p{font-size:13px;line-height:1.55;color:var(--muted);white-space:pre-wrap;margin-bottom:22px}
|
||||
.modal-actions{display:flex;gap:10px}
|
||||
.mbtn{flex:1;border:none;border-radius:11px;padding:11px;font-size:13.5px;font-weight:600;cursor:pointer;font-family:var(--font);transition:filter .15s,background .15s}
|
||||
.mbtn{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;border-radius:11px;padding:11px;font-size:13.5px;font-weight:600;cursor:pointer;font-family:var(--font);transition:filter .15s,background .15s}
|
||||
.mbtn svg{width:15px;height:15px;flex:0 0 auto}
|
||||
.mbtn.ghost{background:var(--panel);border:1px solid var(--border);color:var(--txt)}
|
||||
.mbtn.ghost:hover{background:var(--hover)}
|
||||
.mbtn.primary{color:#fff;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 8px 22px -8px rgba(124,110,246,.7)}
|
||||
@ -770,6 +1000,10 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.netbar.warn:hover{background:rgba(232,176,75,.2)}
|
||||
/* explicit "Work Offline" mode — violet, calmer than the error-red disconnect bar */
|
||||
.netbar.offmode{background:rgba(124,110,246,.13);color:var(--accent-2);border-bottom:1px solid rgba(124,110,246,.3)}
|
||||
/* "you're behind the server" — informational blue, clickable to Get Latest */
|
||||
.netbar.behind{background:rgba(90,160,255,.14);color:#4f8fe0;border-bottom:1px solid rgba(90,160,255,.32);cursor:pointer}
|
||||
.netbar.behind:hover{background:rgba(90,160,255,.2)}
|
||||
.behind-names{font-weight:500;opacity:.8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
||||
.netbar-btn{margin-left:auto;border:1px solid currentColor;background:none;color:inherit;font-size:11.5px;font-weight:700;
|
||||
border-radius:7px;padding:4px 12px;cursor:pointer;font-family:var(--font)}
|
||||
.netbar-btn+.netbar-btn{margin-left:0}
|
||||
@ -894,7 +1128,9 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.ur-actd{width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--faint)}
|
||||
.ur-actd.active{background:#3ecb8f;box-shadow:0 0 0 3px rgba(62,203,143,.18)}
|
||||
.ur-actd.recent{background:#f4c04e}.ur-actd.idle{background:#4b4b58}
|
||||
.ur-empty{padding:16px;font-size:12px;color:var(--faint);text-align:center}
|
||||
.ur-empty{padding:16px;font-size:12px;color:var(--faint);text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;white-space:pre-line}
|
||||
.ur-empty svg{width:34px;height:34px;opacity:.35;flex:0 0 auto}
|
||||
.viewer-msg svg{width:36px;height:36px;opacity:.4;flex:0 0 auto}
|
||||
.ur-detail{flex:1;display:flex;flex-direction:column;min-width:0;overflow-y:auto}
|
||||
.ur-dhead{display:flex;align-items:center;gap:14px;padding:18px 20px 14px;border-bottom:1px solid var(--border-soft)}
|
||||
.ur-dinfo{min-width:0}
|
||||
|
||||
1363
src/App.tsx
1363
src/App.tsx
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import cpp from "highlight.js/lib/languages/cpp";
|
||||
import csharp from "highlight.js/lib/languages/csharp";
|
||||
@ -104,32 +104,53 @@ export default function CodeView({ file, onFlash }: { file: OpenedFile; onFlash?
|
||||
try { return lang ? hljs.highlight(code, { language: lang, ignoreIllegals: true }).value : esc(code); }
|
||||
catch { return esc(code); }
|
||||
}, [code, lang]);
|
||||
// live-highlighted draft for the editor underlay (same highlighter as the preview)
|
||||
const draftHi = useMemo(() => {
|
||||
try { return lang ? hljs.highlight(draft, { language: lang, ignoreIllegals: true }).value : esc(draft); }
|
||||
catch { return esc(draft); }
|
||||
}, [draft, lang]);
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const gutRef = useRef<HTMLDivElement>(null);
|
||||
const draftRows = draft.split("\n").length;
|
||||
|
||||
// editing mode — plain textarea over the working copy
|
||||
// editing mode — a real code editor: transparent textarea over a syntax-
|
||||
// highlighted underlay, with a line-number gutter (looks like the preview).
|
||||
if (editing) {
|
||||
const syncScroll = (e: { currentTarget: HTMLTextAreaElement }) => {
|
||||
const ta = e.currentTarget;
|
||||
if (preRef.current) { preRef.current.scrollTop = ta.scrollTop; preRef.current.scrollLeft = ta.scrollLeft; }
|
||||
if (gutRef.current) gutRef.current.scrollTop = ta.scrollTop;
|
||||
};
|
||||
return (
|
||||
<div className="right-body">
|
||||
<div className="codebar">
|
||||
<span className="cb-badge editing">{t("Editing")}</span>
|
||||
<span className="cb-name">{name}</span>
|
||||
{lang ? <span className="cb-lines">{LANG_LABEL[lang]}</span> : null}
|
||||
<span className="ce-actions">
|
||||
<button className="ce-btn" onClick={cancelEdit} disabled={saving}>{t("Cancel")}</button>
|
||||
<button className="ce-btn save" onClick={save} disabled={saving || draft === code}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
|
||||
</span>
|
||||
</div>
|
||||
<textarea className="code-edit" value={draft} spellCheck={false} onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") cancelEdit();
|
||||
if (e.key === "s" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (draft !== code) save(); }
|
||||
// Tab inserts a tab instead of leaving the field
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const ta = e.currentTarget; const s = ta.selectionStart, en = ta.selectionEnd;
|
||||
const nv = draft.slice(0, s) + "\t" + draft.slice(en);
|
||||
setDraft(nv);
|
||||
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
|
||||
}
|
||||
}} />
|
||||
<div className="code editing">
|
||||
<div className="code-gutter" ref={gutRef}>{Array.from({ length: draftRows }, (_, i) => <span key={i}>{i + 1}</span>)}</div>
|
||||
<div className="ce-wrap">
|
||||
<pre className="ce-under" aria-hidden="true" ref={preRef}><code className="hljs" dangerouslySetInnerHTML={{ __html: draftHi + (draft.endsWith("\n") ? " " : "") }} /></pre>
|
||||
<textarea className="ce-ta" value={draft} spellCheck={false} autoFocus onScroll={syncScroll} onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") cancelEdit();
|
||||
if (e.key === "s" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (draft !== code) save(); }
|
||||
// Tab inserts a tab instead of leaving the field
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const ta = e.currentTarget; const s = ta.selectionStart, en = ta.selectionEnd;
|
||||
const nv = draft.slice(0, s) + "\t" + draft.slice(en);
|
||||
setDraft(nv);
|
||||
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
103
src/Updater.tsx
103
src/Updater.tsx
@ -1,35 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { check, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { t } from "./i18n";
|
||||
|
||||
type Phase = "idle" | "available" | "downloading" | "done" | "error";
|
||||
export type UpdPhase = "idle" | "checking" | "available" | "uptodate" | "downloading" | "done" | "error";
|
||||
|
||||
// Non-intrusive update prompt: quietly checks the Gitea Releases endpoint a few
|
||||
// seconds after launch. If a newer signed version exists, slides a small card into
|
||||
// the bottom-right corner. The user chooses when to install — nothing is forced.
|
||||
export default function UpdateBanner() {
|
||||
export interface UpdaterState {
|
||||
phase: UpdPhase;
|
||||
pct: number;
|
||||
err: string;
|
||||
version?: string;
|
||||
check: () => void; // manual check
|
||||
install: () => void; // download + install the pending update
|
||||
}
|
||||
|
||||
// Update logic as a hook. Silently checks the Gitea Releases endpoint a few
|
||||
// seconds after mount, and again on demand (manual). The UI is just a single
|
||||
// download icon in the status bar (see Workbench) — no floating card.
|
||||
export function useUpdater(): UpdaterState {
|
||||
const [update, setUpdate] = useState<Update | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [phase, setPhase] = useState<UpdPhase>("idle");
|
||||
const [pct, setPct] = useState(0);
|
||||
const [err, setErr] = useState("");
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const u = await check();
|
||||
if (live && u) { setUpdate(u); setPhase("available"); }
|
||||
} catch {
|
||||
// offline, or no release published yet — stay silent, never nag
|
||||
}
|
||||
}, 4000);
|
||||
return () => { live = false; clearTimeout(t); };
|
||||
}, []);
|
||||
async function runCheck(manual: boolean) {
|
||||
if (busyRef.current || phase === "downloading") return;
|
||||
busyRef.current = true;
|
||||
if (manual) setPhase("checking");
|
||||
try {
|
||||
const u = await check();
|
||||
if (u) { setUpdate(u); setPhase("available"); }
|
||||
else { setPhase(manual ? "uptodate" : "idle"); }
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
if (manual) setPhase("error");
|
||||
} finally { busyRef.current = false; }
|
||||
}
|
||||
|
||||
async function install() {
|
||||
if (!update) return;
|
||||
if (!update) { void runCheck(true); return; }
|
||||
setPhase("downloading"); setPct(0);
|
||||
let total = 0, got = 0;
|
||||
try {
|
||||
@ -43,42 +52,20 @@ export default function UpdateBanner() {
|
||||
} catch (e) { setErr(String(e)); setPhase("error"); }
|
||||
}
|
||||
|
||||
if (hidden || phase === "idle" || !update) return null;
|
||||
// initial silent check + listen for manual "Updates" clicks (kept for compatibility)
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => runCheck(false), 4000);
|
||||
const onManual = () => runCheck(true);
|
||||
window.addEventListener("exd-check-update", onManual);
|
||||
return () => { clearTimeout(id); window.removeEventListener("exd-check-update", onManual); };
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
return (
|
||||
<div className="upd">
|
||||
<div className="upd-head">
|
||||
<span className="upd-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
</span>
|
||||
<div className="upd-ttl">
|
||||
<b>{t("Update available")}</b>
|
||||
<span>Exbyte Depot v{update.version}</span>
|
||||
</div>
|
||||
{(phase === "available" || phase === "error") && (
|
||||
<button className="upd-x" onClick={() => setHidden(true)} title={t("Later")}>
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
// let the transient "checked, up to date" / "error" state fade back to idle
|
||||
useEffect(() => {
|
||||
if (phase !== "uptodate" && phase !== "error") return;
|
||||
const id = setTimeout(() => setPhase("idle"), 3500);
|
||||
return () => clearTimeout(id);
|
||||
}, [phase]);
|
||||
|
||||
{update.body?.trim() && phase === "available" && <div className="upd-notes">{update.body.trim()}</div>}
|
||||
|
||||
{phase === "downloading" && (
|
||||
<div className="upd-prog">
|
||||
<div className="upd-track"><div className="upd-fill" style={{ width: `${pct}%` }} /></div>
|
||||
<span>{t("Downloading… {pct}%", { pct })}</span>
|
||||
</div>
|
||||
)}
|
||||
{phase === "done" && <div className="upd-notes">{t("Installed — restarting…")}</div>}
|
||||
{phase === "error" && <div className="upd-notes err">{err}</div>}
|
||||
|
||||
{phase === "available" && (
|
||||
<div className="upd-actions">
|
||||
<button className="upd-later" onClick={() => setHidden(true)}>{t("Later")}</button>
|
||||
<button className="upd-go" onClick={install}>{t("Update now")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return { phase, pct, err, version: update?.version, check: () => runCheck(true), install };
|
||||
}
|
||||
|
||||
122
src/i18n.ts
122
src/i18n.ts
@ -148,6 +148,78 @@ const D: Record<string, Tr> = {
|
||||
"Save": { ru: "Сохранить", de: "Speichern", fr: "Enregistrer", es: "Guardar" },
|
||||
"Scanning disk for changes…": { ru: "Ищу изменения на диске…", de: "Suche Änderungen auf der Festplatte…", fr: "Recherche de modifications sur le disque…", es: "Buscando cambios en el disco…" },
|
||||
" for the whole workspace this is slow — pick a project folder above": { ru: " для всего workspace это долго — выбери рабочую папку проекта сверху", de: " für den ganzen Workspace ist das langsam — wähle oben einen Projektordner", fr: " pour tout le workspace c’est lent — choisissez un dossier de projet en haut", es: " para todo el workspace es lento — elige una carpeta de proyecto arriba" },
|
||||
"found {n} files": { ru: "найдено {n} файлов", de: "{n} Dateien gefunden", fr: "{n} fichiers trouvés", es: "{n} archivos encontrados" },
|
||||
"Committing…": { ru: "Коммичу…", de: "Übertrage…", fr: "Validation…", es: "Confirmando…" },
|
||||
"Cancelling…": { ru: "Отменяю…", de: "Breche ab…", fr: "Annulation…", es: "Cancelando…" },
|
||||
"Minimize — the transfer keeps running in the background": { ru: "Свернуть — передача продолжится в фоне", de: "Minimieren — die Übertragung läuft im Hintergrund weiter", fr: "Réduire — le transfert continue en arrière-plan", es: "Minimizar — la transferencia continúa en segundo plano" },
|
||||
"Submitting": { ru: "Отправка", de: "Übertrage", fr: "Envoi", es: "Enviando" },
|
||||
"Getting latest": { ru: "Получение", de: "Aktualisiere", fr: "Récupération", es: "Obteniendo" },
|
||||
"Included — click to exclude (won’t be synced)": { ru: "Включено — клик, чтобы исключить (не будет качаться)", de: "Enthalten — klicken zum Ausschließen (wird nicht synchronisiert)", fr: "Inclus — cliquer pour exclure (ne sera pas synchronisé)", es: "Incluido — clic para excluir (no se sincronizará)" },
|
||||
"Excluded — click to include (will be synced)": { ru: "Исключено — клик, чтобы включить (будет качаться)", de: "Ausgeschlossen — klicken zum Einschließen (wird synchronisiert)", fr: "Exclu — cliquer pour inclure (sera synchronisé)", es: "Excluido — clic para incluir (se sincronizará)" },
|
||||
"(no subfolders)": { ru: "(нет подпапок)", de: "(keine Unterordner)", fr: "(aucun sous-dossier)", es: "(sin subcarpetas)" },
|
||||
"Themes": { ru: "Темы", de: "Themes", fr: "Thèmes", es: "Temas" },
|
||||
"Themes…": { ru: "Темы…", de: "Themes…", fr: "Thèmes…", es: "Temas…" },
|
||||
"Switch the color theme or make your own custom theme.": { ru: "Сменить цветовую тему или создать свою.", de: "Farbthema wechseln oder ein eigenes erstellen.", fr: "Changer de thème ou créer le vôtre.", es: "Cambia el tema de color o crea el tuyo." },
|
||||
"Plugins…": { ru: "Плагины…", de: "Plugins…", fr: "Extensions…", es: "Plugins…" },
|
||||
"Install and manage plugins that add extra features on top of the core app.": { ru: "Установка и управление плагинами, которые добавляют функции поверх ядра.", de: "Plugins installieren und verwalten, die Funktionen über der Kern-App hinzufügen.", fr: "Installer et gérer des extensions qui ajoutent des fonctions au cœur de l’app.", es: "Instala y gestiona plugins que añaden funciones sobre el núcleo." },
|
||||
"Custom theme": { ru: "Своя тема", de: "Eigenes Theme", fr: "Thème personnalisé", es: "Tema personalizado" },
|
||||
"{n} themes": { ru: "{n} тем", de: "{n} Themes", fr: "{n} thèmes", es: "{n} temas" },
|
||||
"My theme": { ru: "Моя тема", de: "Mein Theme", fr: "Mon thème", es: "Mi tema" },
|
||||
"Theme “{n}” saved.": { ru: "Тема «{n}» сохранена.", de: "Theme „{n}“ gespeichert.", fr: "Thème « {n} » enregistré.", es: "Tema «{n}» guardado." },
|
||||
"Theme “{n}” imported.": { ru: "Тема «{n}» импортирована.", de: "Theme „{n}“ importiert.", fr: "Thème « {n} » importé.", es: "Tema «{n}» importado." },
|
||||
"Light": { ru: "Светлая", de: "Hell", fr: "Clair", es: "Claro" },
|
||||
"Dark": { ru: "Тёмная", de: "Dunkel", fr: "Sombre", es: "Oscuro" },
|
||||
"custom": { ru: "своя", de: "eigen", fr: "perso", es: "propio" },
|
||||
"Export": { ru: "Экспорт", de: "Exportieren", fr: "Exporter", es: "Exportar" },
|
||||
"Import…": { ru: "Импорт…", de: "Importieren…", fr: "Importer…", es: "Importar…" },
|
||||
"New custom theme": { ru: "Новая своя тема", de: "Neues eigenes Theme", fr: "Nouveau thème perso", es: "Nuevo tema propio" },
|
||||
"Save & apply": { ru: "Сохранить и применить", de: "Speichern & anwenden", fr: "Enregistrer et appliquer", es: "Guardar y aplicar" },
|
||||
"Name": { ru: "Название", de: "Name", fr: "Nom", es: "Nombre" },
|
||||
"NEW": { ru: "НОВОЕ", de: "NEU", fr: "NOUVEAU", es: "NUEVO" },
|
||||
"Submitted less than 3 hours ago": { ru: "Засабмичено меньше 3 часов назад", de: "Vor weniger als 3 Stunden übermittelt", fr: "Soumis il y a moins de 3 heures", es: "Enviado hace menos de 3 horas" },
|
||||
"Plugins": { ru: "Плагины", de: "Plugins", fr: "Extensions", es: "Plugins" },
|
||||
"General": { ru: "Общие", de: "Allgemein", fr: "Général", es: "General" },
|
||||
"Check for updates": { ru: "Проверить обновления", de: "Nach Updates suchen", fr: "Vérifier les mises à jour", es: "Buscar actualizaciones" },
|
||||
"Update available — click to install": { ru: "Есть обновление — нажми, чтобы установить", de: "Update verfügbar — zum Installieren klicken", fr: "Mise à jour dispo — cliquez pour installer", es: "Actualización disponible — clic para instalar" },
|
||||
"Updates": { ru: "Обновления", de: "Updates", fr: "Mises à jour", es: "Actualizaciones" },
|
||||
"Checking for updates…": { ru: "Проверяю обновления…", de: "Suche nach Updates…", fr: "Recherche de mises à jour…", es: "Buscando actualizaciones…" },
|
||||
"You're on the latest version": { ru: "У тебя последняя версия", de: "Du hast die neueste Version", fr: "Vous avez la dernière version", es: "Tienes la última versión" },
|
||||
"Update check failed": { ru: "Не удалось проверить обновления", de: "Update-Prüfung fehlgeschlagen", fr: "Échec de la vérification", es: "Fallo al buscar actualizaciones" },
|
||||
"Appearance": { ru: "Оформление", de: "Darstellung", fr: "Apparence", es: "Apariencia" },
|
||||
"Base theme": { ru: "Базовая тема", de: "Basis-Theme", fr: "Thème de base", es: "Tema base" },
|
||||
"{n} installed": { ru: "установлено: {n}", de: "{n} installiert", fr: "{n} installés", es: "{n} instalados" },
|
||||
"Plugins add features on top of the core app. Browse the catalog below and install what you need — nothing is installed automatically. Toggle plugins on/off; enabled ones load on launch. Plugins run in-app — only install ones you trust.": { ru: "Плагины добавляют функции поверх ядра. Смотри каталог ниже и ставь нужное — ничего не ставится само. Включай/выключай; включённые грузятся при запуске. Плагины работают внутри приложения — ставь только те, которым доверяешь.", de: "Plugins erweitern die Kern-App. Durchsuche den Katalog unten und installiere, was du brauchst — nichts wird automatisch installiert. Plugins ein/aus schalten; aktivierte laden beim Start. Sie laufen in der App — installiere nur vertrauenswürdige.", fr: "Les extensions enrichissent le cœur de l’app. Parcourez le catalogue ci-dessous et installez ce qu’il faut — rien n’est installé automatiquement. Activez/désactivez ; les activées se chargent au démarrage. Elles s’exécutent dans l’app — n’installez que celles de confiance.", es: "Los plugins añaden funciones al núcleo. Explora el catálogo abajo e instala lo que necesites — nada se instala solo. Actívalos/desactívalos; los activos se cargan al iniciar. Se ejecutan en la app — instala solo los de confianza." },
|
||||
"No plugins installed yet.\nInstall one from a folder, or drop it into the plugins folder below.": { ru: "Плагинов пока нет.\nУстанови из папки или положи в папку плагинов ниже.", de: "Noch keine Plugins.\nAus einem Ordner installieren oder in den Plugin-Ordner unten legen.", fr: "Aucune extension.\nInstallez depuis un dossier ou déposez-la dans le dossier ci-dessous.", es: "Aún no hay plugins.\nInstala desde una carpeta o suéltalo en la carpeta de abajo." },
|
||||
"Enable": { ru: "Включить", de: "Aktivieren", fr: "Activer", es: "Activar" },
|
||||
"Disable": { ru: "Выключить", de: "Deaktivieren", fr: "Désactiver", es: "Desactivar" },
|
||||
"Official plugins": { ru: "Официальные плагины", de: "Offizielle Plugins", fr: "Extensions officielles", es: "Plugins oficiales" },
|
||||
"Available to install": { ru: "Доступно для установки", de: "Verfügbar zur Installation", fr: "Disponibles à l’installation", es: "Disponibles para instalar" },
|
||||
"from the Exbyte plugin registry": { ru: "из реестра плагинов Exbyte", de: "aus der Exbyte-Plugin-Registry", fr: "depuis le registre de plugins Exbyte", es: "del registro de plugins de Exbyte" },
|
||||
"Loading the plugin catalog…": { ru: "Загружаю каталог плагинов…", de: "Plugin-Katalog wird geladen…", fr: "Chargement du catalogue…", es: "Cargando el catálogo…" },
|
||||
"Couldn't reach the plugin registry.": { ru: "Не удалось получить реестр плагинов.", de: "Plugin-Registry nicht erreichbar.", fr: "Registre de plugins injoignable.", es: "No se pudo acceder al registro." },
|
||||
"Retry": { ru: "Повторить", de: "Erneut", fr: "Réessayer", es: "Reintentar" },
|
||||
"Everything from the catalog is already installed.": { ru: "Всё из каталога уже установлено.", de: "Alles aus dem Katalog ist bereits installiert.", fr: "Tout le catalogue est déjà installé.", es: "Todo el catálogo ya está instalado." },
|
||||
"Install": { ru: "Установить", de: "Installieren", fr: "Installer", es: "Instalar" },
|
||||
"Open plugins folder": { ru: "Открыть папку плагинов", de: "Plugin-Ordner öffnen", fr: "Ouvrir le dossier des extensions", es: "Abrir carpeta de plugins" },
|
||||
"Reload": { ru: "Перезагрузить", de: "Neu laden", fr: "Recharger", es: "Recargar" },
|
||||
"Install from folder…": { ru: "Установить из папки…", de: "Aus Ordner installieren…", fr: "Installer depuis un dossier…", es: "Instalar desde carpeta…" },
|
||||
"Pick a plugin folder (contains plugin.json)": { ru: "Выбери папку плагина (с plugin.json)", de: "Plugin-Ordner wählen (mit plugin.json)", fr: "Choisir un dossier d’extension (avec plugin.json)", es: "Elige una carpeta de plugin (con plugin.json)" },
|
||||
"Installed plugin “{id}”.": { ru: "Плагин «{id}» установлен.", de: "Plugin „{id}“ installiert.", fr: "Extension « {id} » installée.", es: "Plugin «{id}» instalado." },
|
||||
"Removed plugin “{n}”.": { ru: "Плагин «{n}» удалён.", de: "Plugin „{n}“ entfernt.", fr: "Extension « {n} » supprimée.", es: "Plugin «{n}» eliminado." },
|
||||
"{n} plugin(s) loaded": { ru: "загружено плагинов: {n}", de: "{n} Plugin(s) geladen", fr: "{n} extension(s) chargée(s)", es: "{n} plugin(s) cargados" },
|
||||
"From plugin: {p}": { ru: "Из плагина: {p}", de: "Von Plugin: {p}", fr: "De l’extension : {p}", es: "Del plugin: {p}" },
|
||||
"Plugin view": { ru: "Панель плагина", de: "Plugin-Ansicht", fr: "Vue d’extension", es: "Vista de plugin" },
|
||||
"A plugin flagged this commit": { ru: "Плагин отметил этот коммит", de: "Ein Plugin hat diesen Commit markiert", fr: "Une extension a signalé ce commit", es: "Un plugin marcó este commit" },
|
||||
"A plugin blocked the commit.": { ru: "Плагин заблокировал коммит.", de: "Ein Plugin hat den Commit blockiert.", fr: "Une extension a bloqué le commit.", es: "Un plugin bloqueó el commit." },
|
||||
"Commit anyway": { ru: "Всё равно закоммитить", de: "Trotzdem committen", fr: "Valider quand même", es: "Confirmar de todos modos" },
|
||||
"Switch between the folder tree and the raw spec": { ru: "Переключить дерево папок / сырой спек", de: "Zwischen Ordnerbaum und Rohspezifikation wechseln", fr: "Basculer entre l’arborescence et la spec brute", es: "Alternar entre árbol de carpetas y spec en bruto" },
|
||||
"Folder tree (include / exclude)": { ru: "Дерево папок (включить / исключить)", de: "Ordnerbaum (ein-/ausschließen)", fr: "Arborescence (inclure / exclure)", es: "Árbol de carpetas (incluir / excluir)" },
|
||||
"Raw spec text": { ru: "Сырой текст спека", de: "Rohspezifikation", fr: "Spec brute", es: "Spec en bruto" },
|
||||
"Check a folder to include it in this workspace, uncheck to exclude. Excluded folders are not synced (not downloaded). This edits the depot → workspace View mapping.": { ru: "Отметь папку, чтобы включить её в воркспейс, сними галочку — исключить. Исключённые папки не синкаются (не качаются). Это правит маппинг View (депот → воркспейс).", de: "Ordner ankreuzen zum Einschließen, abwählen zum Ausschließen. Ausgeschlossene Ordner werden nicht synchronisiert. Bearbeitet das View-Mapping (Depot → Workspace).", fr: "Cochez un dossier pour l’inclure, décochez pour l’exclure. Les dossiers exclus ne sont pas synchronisés. Modifie le mapping View (dépôt → workspace).", es: "Marca una carpeta para incluirla, desmárcala para excluirla. Las carpetas excluidas no se sincronizan. Edita el mapeo View (depósito → workspace)." },
|
||||
"Workspace mapping changed": { ru: "Маппинг воркспейса изменён", de: "Workspace-Mapping geändert", fr: "Mapping du workspace modifié", es: "Mapeo del workspace cambiado" },
|
||||
"Get latest now to match the new view? Newly-included folders download; excluded folders are removed from disk on the next sync.": { ru: "Подтянуть сейчас под новый view? Только что включённые папки скачаются; исключённые удалятся с диска при следующем синке.", de: "Jetzt aktualisieren, um dem neuen View zu entsprechen? Neu eingeschlossene Ordner werden geladen; ausgeschlossene werden beim nächsten Sync von der Platte entfernt.", fr: "Récupérer maintenant selon le nouveau view ? Les dossiers nouvellement inclus sont téléchargés ; les exclus sont supprimés du disque au prochain sync.", es: "¿Obtener ahora según el nuevo view? Las carpetas recién incluidas se descargan; las excluidas se eliminan del disco en el próximo sync." },
|
||||
"Stop before the server commit. Files already sent are not applied; the changelist stays pending.": { ru: "Остановить до серверного коммита. Уже отправленные файлы не применяются; changelist останется в pending.", de: "Vor dem Server-Commit stoppen. Bereits gesendete Dateien werden nicht übernommen; die Changelist bleibt ausstehend.", fr: "Arrêter avant le commit serveur. Les fichiers déjà envoyés ne sont pas appliqués ; la changelist reste en attente.", es: "Detener antes del commit del servidor. Los archivos ya enviados no se aplican; la changelist queda pendiente." },
|
||||
"Stop the scan. Files already found stay staged.": { ru: "Остановить скан. Уже найденные файлы останутся помеченными.", de: "Scan stoppen. Bereits gefundene Dateien bleiben vorgemerkt.", fr: "Arrêter l’analyse. Les fichiers déjà trouvés restent préparés.", es: "Detener el escaneo. Los archivos ya encontrados quedan preparados." },
|
||||
"{a} of {b} selected": { ru: "{a} из {b} выбрано", de: "{a} von {b} ausgewählt", fr: "{a} sur {b} sélectionnés", es: "{a} de {b} seleccionados" },
|
||||
"Select all": { ru: "Выделить все", de: "Alle auswählen", fr: "Tout sélectionner", es: "Seleccionar todo" },
|
||||
"Deselect all": { ru: "Снять выделение", de: "Auswahl aufheben", fr: "Tout désélectionner", es: "Deseleccionar todo" },
|
||||
@ -539,6 +611,7 @@ const D: Record<string, Tr> = {
|
||||
"Perforce's task/bug tracker — create jobs and attach them to changelists.": { ru: "Трекер задач/багов Perforce — создавай джобы и привязывай к changelists.", de: "Perforce-Aufgaben-/Bug-Tracker — Jobs erstellen und an Changelists anhängen.", fr: "Le suivi de tâches/bugs de Perforce — crée des jobs et attache-les aux changelists.", es: "El rastreador de tareas/errores de Perforce — crea jobs y adjúntalos a changelists." },
|
||||
"Edit which files reconcile / add ignore (build output, caches).": { ru: "Изменить, какие файлы игнорируют reconcile / add (сборка, кэши).", de: "Bearbeiten, welche Dateien reconcile / add ignorieren (Build-Ausgabe, Caches).", fr: "Modifier quels fichiers reconcile / add ignorent (sortie de build, caches).", es: "Editar qué archivos ignoran reconcile / add (salida de compilación, cachés)." },
|
||||
"Compile the Visual Studio solution found in the working folder.": { ru: "Скомпилировать решение Visual Studio, найденное в рабочей папке.", de: "Die im Arbeitsordner gefundene Visual-Studio-Solution kompilieren.", fr: "Compiler la solution Visual Studio trouvée dans le dossier de travail.", es: "Compilar la solución de Visual Studio encontrada en la carpeta de trabajo." },
|
||||
"Compile the game C++ — Unreal projects build via UnrealBuildTool (game module only), plain C++ via MSBuild.": { ru: "Скомпилировать C++ игры — Unreal-проекты собираются через UnrealBuildTool (только модуль игры), обычный C++ — через MSBuild.", de: "Game-C++ kompilieren — Unreal-Projekte über UnrealBuildTool (nur Spielmodul), reines C++ über MSBuild.", fr: "Compiler le C++ du jeu — les projets Unreal via UnrealBuildTool (module de jeu seulement), le C++ simple via MSBuild.", es: "Compilar el C++ del juego — los proyectos Unreal vía UnrealBuildTool (solo el módulo del juego), C++ simple vía MSBuild." },
|
||||
"See who works on this depot and their roles.": { ru: "Посмотреть, кто работает с этим депо и их роли.", de: "Sehen, wer an diesem Depot arbeitet und welche Rollen sie haben.", fr: "Voir qui travaille sur ce depot et leurs rôles.", es: "Ver quién trabaja en este depot y sus roles." },
|
||||
"App preferences — language, theme, editor, and more.": { ru: "Настройки приложения — язык, тема, редактор и прочее.", de: "App-Einstellungen — Sprache, Design, Editor und mehr.", fr: "Préférences de l'app — langue, thème, éditeur et plus.", es: "Preferencias de la app — idioma, tema, editor y más." },
|
||||
"Version and information about Exbyte Depot.": { ru: "Версия и информация о Exbyte Depot.", de: "Version und Informationen zu Exbyte Depot.", fr: "Version et informations sur Exbyte Depot.", es: "Versión e información sobre Exbyte Depot." },
|
||||
@ -554,6 +627,55 @@ const D: Record<string, Tr> = {
|
||||
"Empty.": { ru: "Пусто.", de: "Leer.", fr: "Vide.", es: "Vacío." },
|
||||
"Sizes come from the server (p4 sizes): the total bytes each depot folder stores across all its files. Sorted heaviest first.": { ru: "Размеры берутся с сервера (p4 sizes): суммарный объём, который каждая папка депо хранит по всем файлам. Сортировка по убыванию веса.", de: "Größen kommen vom Server (p4 sizes): die Gesamtbytes, die jeder Depot-Ordner über alle Dateien speichert. Nach Größe absteigend sortiert.", fr: "Les tailles viennent du serveur (p4 sizes) : le total d'octets que chaque dossier du depot stocke sur tous ses fichiers. Trié du plus lourd au plus léger.", es: "Los tamaños vienen del servidor (p4 sizes): el total de bytes que cada carpeta del depot almacena en todos sus archivos. Ordenado de mayor a menor." },
|
||||
"Sizes are measured on your disk: how much each folder and file of your local workspace actually takes up. Sorted heaviest first.": { ru: "Размеры измеряются на твоём диске: сколько реально занимает каждая папка и файл локального workspace. Сортировка по убыванию веса.", de: "Größen werden auf deiner Festplatte gemessen: wie viel jeder Ordner und jede Datei deines lokalen Arbeitsbereichs tatsächlich belegt. Nach Größe absteigend sortiert.", fr: "Les tailles sont mesurées sur ton disque : l'espace réellement occupé par chaque dossier et fichier de ton espace de travail local. Trié du plus lourd au plus léger.", es: "Los tamaños se miden en tu disco: cuánto ocupa realmente cada carpeta y archivo de tu espacio de trabajo local. Ordenado de mayor a menor." },
|
||||
"Browse the Depot and your Workspace as a tree and see how much every file and folder weighs. Sorted heaviest first.": { ru: "Просматривай Depot и Workspace деревом и смотри вес каждого файла и папки. Сортировка по убыванию.", de: "Durchsuche das Depot und deinen Arbeitsbereich als Baum und sieh das Gewicht jeder Datei und jedes Ordners. Nach Größe absteigend.", fr: "Parcours le Depot et ton espace de travail en arbre et vois le poids de chaque fichier et dossier. Trié du plus lourd au plus léger.", es: "Explora el Depot y tu espacio de trabajo como árbol y ve el peso de cada archivo y carpeta. De mayor a menor." },
|
||||
// ---- view panel (File Viewer / File Tree tabs) ----
|
||||
"File Viewer": { ru: "Просмотр файла", de: "Dateivorschau", fr: "Visionneuse", es: "Visor de archivos" },
|
||||
"File Tree": { ru: "Дерево файлов", de: "Dateibaum", fr: "Arborescence", es: "Árbol de archivos" },
|
||||
"No panel open.\nAdd File Viewer or File Tree from the Window menu.": { ru: "Нет открытых панелей.\nДобавь «Просмотр файла» или «Дерево файлов» из меню Window.", de: "Kein Panel geöffnet.\nFüge „Dateivorschau“ oder „Dateibaum“ über das Menü Window hinzu.", fr: "Aucun panneau ouvert.\nAjoute « Visionneuse » ou « Arborescence » depuis le menu Window.", es: "Ningún panel abierto.\nAñade «Visor de archivos» o «Árbol de archivos» desde el menú Window." },
|
||||
"The preview panel for the selected file (image, 3D model, code, diff).": { ru: "Панель предпросмотра выбранного файла (картинка, 3D-модель, код, diff).", de: "Das Vorschau-Panel für die ausgewählte Datei (Bild, 3D-Modell, Code, Diff).", fr: "Le panneau de prévisualisation du fichier sélectionné (image, modèle 3D, code, diff).", es: "El panel de vista previa del archivo seleccionado (imagen, modelo 3D, código, diff)." },
|
||||
"A folder hierarchy of the Depot and your Workspace, docked into the view panel.": { ru: "Иерархия папок Depot и Workspace, встроенная в панель просмотра.", de: "Eine Ordnerhierarchie des Depots und deines Arbeitsbereichs, angedockt im Ansichts-Panel.", fr: "Une hiérarchie de dossiers du Depot et de ton espace de travail, ancrée dans le panneau.", es: "Una jerarquía de carpetas del Depot y tu espacio de trabajo, anclada en el panel." },
|
||||
"Show in File Tree": { ru: "Показать в дереве файлов", de: "Im Dateibaum zeigen", fr: "Afficher dans l'arborescence", es: "Mostrar en el árbol de archivos" },
|
||||
"Total size of files in this changelist": { ru: "Суммарный вес файлов в этом ченджлисте", de: "Gesamtgröße der Dateien in dieser Changelist", fr: "Taille totale des fichiers de cette changelist", es: "Tamaño total de los archivos de esta changelist" },
|
||||
// ---- server activity / behind + tray notifications ----
|
||||
"Server has newer content — {n} file(s) behind.": { ru: "На сервере есть свежее — ты отстал на {n} файл(ов).", de: "Der Server hat neueren Inhalt — {n} Datei(en) im Rückstand.", fr: "Le serveur a du contenu plus récent — {n} fichier(s) en retard.", es: "El servidor tiene contenido más nuevo — {n} archivo(s) por detrás." },
|
||||
"Your workspace is behind": { ru: "Твой воркспейс отстал", de: "Dein Arbeitsbereich ist veraltet", fr: "Ton espace de travail est en retard", es: "Tu espacio de trabajo está desactualizado" },
|
||||
"{n} file(s) on the server are newer than your copy. Get Latest to update.": { ru: "{n} файл(ов) на сервере новее твоих. Нажми Get Latest, чтобы обновить.", de: "{n} Datei(en) auf dem Server sind neuer als deine. Get Latest zum Aktualisieren.", fr: "{n} fichier(s) sur le serveur sont plus récents que les tiens. Fais Get Latest pour mettre à jour.", es: "{n} archivo(s) del servidor son más nuevos que los tuyos. Pulsa Get Latest para actualizar." },
|
||||
"Exbyte Depot is still running": { ru: "Exbyte Depot всё ещё работает", de: "Exbyte Depot läuft weiter", fr: "Exbyte Depot fonctionne toujours", es: "Exbyte Depot sigue en ejecución" },
|
||||
"It's in the system tray and will notify you about teammates' submits. Right-click the tray icon to quit.": { ru: "Он в системном трее и будет присылать уведомления о сабмитах коллег. ПКМ по иконке в трее — выход.", de: "Es liegt im System-Tray und benachrichtigt dich über Submits von Kollegen. Rechtsklick auf das Tray-Symbol zum Beenden.", fr: "Il est dans la zone de notification et t'avertira des soumissions de tes collègues. Clic droit sur l'icône pour quitter.", es: "Está en la bandeja del sistema y te avisará de los envíos de tus compañeros. Clic derecho en el icono para salir." },
|
||||
// ---- audio/video preview + docked locks ----
|
||||
"Audio": { ru: "Аудио", de: "Audio", fr: "Audio", es: "Audio" },
|
||||
"Video": { ru: "Видео", de: "Video", fr: "Vidéo", es: "Vídeo" },
|
||||
"Duration": { ru: "Длительность", de: "Dauer", fr: "Durée", es: "Duración" },
|
||||
"server revision": { ru: "ревизия с сервера", de: "Server-Revision", fr: "révision serveur", es: "revisión del servidor" },
|
||||
"Everyone's checked-out / exclusively-locked files, docked into the view panel.": { ru: "Все чекнутые / эксклюзивно залоченные файлы всех участников, встроенные в панель просмотра.", de: "Alle ausgecheckten / exklusiv gesperrten Dateien aller Nutzer, angedockt im Ansichts-Panel.", fr: "Les fichiers extraits / verrouillés en exclusivité de tout le monde, ancrés dans le panneau.", es: "Los archivos extraídos / bloqueados en exclusiva de todos, anclados en el panel." },
|
||||
// ---- new folder / project ----
|
||||
"New": { ru: "Создать", de: "Neu", fr: "Nouveau", es: "Nuevo" },
|
||||
"workspace root": { ru: "корень воркспейса", de: "Arbeitsbereich-Root", fr: "racine de l'espace de travail", es: "raíz del espacio de trabajo" },
|
||||
"New folder / project…": { ru: "Новая папка / проект…", de: "Neuer Ordner / Projekt…", fr: "Nouveau dossier / projet…", es: "Nueva carpeta / proyecto…" },
|
||||
"Create a new folder in the working area (with a README placeholder) — e.g. a separate project.": { ru: "Создать новую папку в рабочей области (с плейсхолдером README) — например, отдельный проект.", de: "Einen neuen Ordner im Arbeitsbereich erstellen (mit README-Platzhalter) — z. B. ein separates Projekt.", fr: "Créer un nouveau dossier dans l'espace de travail (avec un README) — p. ex. un projet distinct.", es: "Crear una nueva carpeta en el área de trabajo (con un README) — p. ej. un proyecto aparte." },
|
||||
"New folder in {where}": { ru: "Новая папка в {where}", de: "Neuer Ordner in {where}", fr: "Nouveau dossier dans {where}", es: "Nueva carpeta en {where}" },
|
||||
"Folder name — nest with / (e.g. NewGame or NewGame/Source)": { ru: "Имя папки — вложенность через / (напр. NewGame или NewGame/Source)", de: "Ordnername — verschachteln mit / (z. B. NewGame oder NewGame/Source)", fr: "Nom du dossier — imbriquer avec / (p. ex. NewGame ou NewGame/Source)", es: "Nombre de la carpeta — anida con / (p. ej. NewGame o NewGame/Source)" },
|
||||
"Created “{name}” with a README placeholder — commit it to keep the folder on the server.": { ru: "Создано «{name}» с плейсхолдером README — закоммить, чтобы папка сохранилась на сервере.", de: "„{name}“ mit README-Platzhalter erstellt — committe es, damit der Ordner auf dem Server bleibt.", fr: "« {name} » créé avec un README — committe-le pour conserver le dossier sur le serveur.", es: "«{name}» creado con un README — haz commit para conservar la carpeta en el servidor." },
|
||||
// ---- rename / delete / properties folder ----
|
||||
"Rename…": { ru: "Переименовать…", de: "Umbenennen…", fr: "Renommer…", es: "Renombrar…" },
|
||||
"Rename folder": { ru: "Переименовать папку", de: "Ordner umbenennen", fr: "Renommer le dossier", es: "Renombrar carpeta" },
|
||||
"New name for “{name}”": { ru: "Новое имя для «{name}»", de: "Neuer Name für „{name}“", fr: "Nouveau nom pour « {name} »", es: "Nuevo nombre para «{name}»" },
|
||||
"Renamed to “{name}” — {n} file(s) moved. Commit to apply.": { ru: "Переименовано в «{name}» — перемещено {n} файл(ов). Закоммить, чтобы применить.", de: "In „{name}“ umbenannt — {n} Datei(en) verschoben. Zum Anwenden committen.", fr: "Renommé en « {name} » — {n} fichier(s) déplacés. Committe pour appliquer.", es: "Renombrado a «{name}» — {n} archivo(s) movidos. Haz commit para aplicar." },
|
||||
"Delete folder…": { ru: "Удалить папку…", de: "Ordner löschen…", fr: "Supprimer le dossier…", es: "Eliminar carpeta…" },
|
||||
"Delete folder “{name}”?": { ru: "Удалить папку «{name}»?", de: "Ordner „{name}“ löschen?", fr: "Supprimer le dossier « {name} » ?", es: "¿Eliminar la carpeta «{name}»?" },
|
||||
"Every file under this folder is marked for delete. Nothing leaves the server until you Submit; freshly-added files are removed right away.": { ru: "Все файлы под этой папкой помечаются на удаление. С сервера ничего не уходит до Submit; свежедобавленные файлы убираются сразу.", de: "Jede Datei unter diesem Ordner wird zum Löschen markiert. Nichts verlässt den Server bis zum Submit; frisch hinzugefügte Dateien werden sofort entfernt.", fr: "Chaque fichier de ce dossier est marqué pour suppression. Rien ne quitte le serveur avant le Submit ; les fichiers récemment ajoutés sont retirés immédiatement.", es: "Todos los archivos de esta carpeta se marcan para eliminar. Nada sale del servidor hasta el Submit; los archivos recién añadidos se quitan al instante." },
|
||||
"Marked {n} file(s) for delete — Submit to remove the folder.": { ru: "Помечено {n} файл(ов) на удаление — Submit уберёт папку.", de: "{n} Datei(en) zum Löschen markiert — Submit entfernt den Ordner.", fr: "{n} fichier(s) marqués pour suppression — Submit retire le dossier.", es: "{n} archivo(s) marcados para eliminar — Submit quita la carpeta." },
|
||||
"Folder removed.": { ru: "Папка удалена.", de: "Ordner entfernt.", fr: "Dossier supprimé.", es: "Carpeta eliminada." },
|
||||
"Delete": { ru: "Удалить", de: "Löschen", fr: "Supprimer", es: "Eliminar" },
|
||||
"Properties": { ru: "Свойства", de: "Eigenschaften", fr: "Propriétés", es: "Propiedades" },
|
||||
"Folder properties": { ru: "Свойства папки", de: "Ordner-Eigenschaften", fr: "Propriétés du dossier", es: "Propiedades de la carpeta" },
|
||||
"Files": { ru: "Файлов", de: "Dateien", fr: "Fichiers", es: "Archivos" },
|
||||
"Total size": { ru: "Общий размер", de: "Gesamtgröße", fr: "Taille totale", es: "Tamaño total" },
|
||||
"Location": { ru: "Расположение", de: "Speicherort", fr: "Emplacement", es: "Ubicación" },
|
||||
"depot": { ru: "депо", de: "Depot", fr: "depot", es: "depot" },
|
||||
"local only": { ru: "только локально", de: "nur lokal", fr: "local uniquement", es: "solo local" },
|
||||
"Recently": { ru: "Недавние", de: "Zuletzt", fr: "Récents", es: "Recientes" },
|
||||
"local": { ru: "локально", de: "lokal", fr: "local", es: "local" },
|
||||
};
|
||||
|
||||
export function t(en: string, vars?: Record<string, string | number>): string {
|
||||
|
||||
42
src/p4.ts
42
src/p4.ts
@ -75,6 +75,7 @@ export const p4 = {
|
||||
cleanPreview: (scope = "") => invoke<OpenedFile[]>("p4_clean_preview", { scope }),
|
||||
cleanApply: (scope = "") => invoke<string>("p4_clean_apply", { scope }),
|
||||
depotLs: (path = "") => invoke<FsEntry[]>("depot_ls", { path }),
|
||||
changeSizes: (changes: string[], scope = "") => invoke<{ change: string; size: number }[]>("p4_change_sizes", { changes, scope }),
|
||||
fsLs: (path = "") => invoke<FsEntry[]>("fs_ls", { path }),
|
||||
reconcilePreview: (scope = "") => invoke<OpenedFile[]>("p4_reconcile_preview", { scope }),
|
||||
reconcileApply: (paths: string[]) => invoke<OpenedFile[]>("p4_reconcile_apply", { paths }),
|
||||
@ -101,12 +102,28 @@ export const p4 = {
|
||||
del: (files: string[]) => invoke<OpenedFile[]>("p4_delete", { files }),
|
||||
reconcile: (path: string) => invoke<OpenedFile[]>("p4_reconcile", { path }),
|
||||
scan: (scope = "") => invoke<OpenedFile[]>("p4_scan", { scope }),
|
||||
// streaming disk scan: emits `p4-scan` progress events, returns the file count.
|
||||
// The caller re-reads opened() for the actual list.
|
||||
scanStream: (scope = "") => invoke<number>("p4_scan_stream", { scope }),
|
||||
scanCancel: () => invoke<void>("p4_scan_cancel"),
|
||||
// ask an in-progress submit/sync transfer to stop (safe pre-commit for submit)
|
||||
transferCancel: () => invoke<void>("p4_transfer_cancel"),
|
||||
// --- plugins ---
|
||||
pluginList: () => invoke<PluginInfo[]>("plugin_list"),
|
||||
pluginReadEntry: (id: string) => invoke<string>("plugin_read_entry", { id }),
|
||||
pluginSetEnabled: (id: string, enabled: boolean) => invoke<void>("plugin_set_enabled", { id, enabled }),
|
||||
pluginWriteFile: (id: string, name: string, content: string) => invoke<void>("plugin_write_file", { id, name, content }),
|
||||
registryHttpGet: (url: string) => invoke<string>("registry_http_get", { url }),
|
||||
pluginInstall: (src: string) => invoke<string>("plugin_install", { src }),
|
||||
pluginRemove: (id: string) => invoke<void>("plugin_remove", { id }),
|
||||
pluginsDirPath: () => invoke<string>("plugins_dir_path"),
|
||||
openPluginsDir: () => invoke<void>("open_plugins_dir"),
|
||||
describe: (change: string) => invoke<Describe>("p4_describe", { change }),
|
||||
undoChange: (change: string) => invoke<string>("p4_undo_change", { change }),
|
||||
openInExplorer: (target: string) => invoke<void>("open_in_explorer", { target }),
|
||||
findUproject: (scope: string) => invoke<string>("find_uproject", { scope }),
|
||||
findSln: (scope: string) => invoke<string>("find_sln", { scope }),
|
||||
buildSln: (path: string, config: string) => invoke<string>("build_sln", { path, config }),
|
||||
buildSln: (path: string, config: string, uproject = "") => invoke<string>("build_sln", { path, config, uproject }),
|
||||
launchFile: (path: string) => invoke<void>("launch_file", { path }),
|
||||
listEditors: () => invoke<Editor[]>("list_editors"),
|
||||
openInEditor: (depot: string, editor: string) => invoke<void>("open_in_editor", { depot, editor }),
|
||||
@ -157,6 +174,11 @@ export const p4 = {
|
||||
searchFiles: (query: string, scope = "") => invoke<{ depotFile?: string; rev?: string }[]>("p4_search_files", { query, scope }),
|
||||
reopenTo: (change: string, files: string[]) => invoke<string>("p4_reopen_to", { change, files }),
|
||||
latestChange: (scope = "") => invoke<Change | null>("p4_latest_change", { scope }),
|
||||
behind: (scope = "") => invoke<{ count: number; sample: string[] }>("p4_behind", { scope }),
|
||||
makeFolder: (scope: string, name: string) => invoke<string>("make_folder", { scope, name }),
|
||||
deleteFolder: (path: string) => invoke<OpenedFile[]>("delete_folder", { path }),
|
||||
renameFolder: (path: string, newName: string) => invoke<OpenedFile[]>("rename_folder", { path, newName }),
|
||||
folderInfo: (path: string) => invoke<{ path: string; fileCount: number; size: number }>("folder_info", { path }),
|
||||
// embedded Unreal .uasset thumbnail (PNG bytes), for working copy or history
|
||||
uassetThumb: async (spec: string, historical: boolean): Promise<ArrayBuffer> => {
|
||||
const r: unknown = await invoke("uasset_thumbnail", { spec, historical });
|
||||
@ -183,7 +205,8 @@ export interface Editor { id: string; name: string; exe: string; args: string[]
|
||||
export function getEditor(): string { try { return localStorage.getItem("exd-editor") || ""; } catch { return ""; } }
|
||||
export function setEditorPref(id: string) { try { localStorage.setItem("exd-editor", id); } catch {} }
|
||||
|
||||
export interface Dir { dir?: string; [k: string]: unknown }
|
||||
export interface Dir { dir?: string; local?: boolean; [k: string]: unknown }
|
||||
export interface PluginInfo { id: string; name: string; version: string; description: string; author: string; permissions: string[]; manifest: Record<string, unknown>; enabled: boolean; dir: string }
|
||||
// depot path -> short leaf name (last segment)
|
||||
export function leaf(path?: string): string {
|
||||
if (!path) return "";
|
||||
@ -254,6 +277,17 @@ export function saveScope(client: string, path: string) {
|
||||
export function loadScope(client: string): string {
|
||||
try { return client ? (localStorage.getItem("exd-scope-" + client) || "") : ""; } catch { return ""; }
|
||||
}
|
||||
// last 3 chosen working folders (Recently) — most-recent first, per workspace
|
||||
export function recentScopes(client: string): string[] {
|
||||
try { const a = JSON.parse(localStorage.getItem("exd-recent-" + client) || "[]"); return Array.isArray(a) ? a.slice(0, 3) : []; } catch { return []; }
|
||||
}
|
||||
export function pushRecentScope(client: string, path: string) {
|
||||
try {
|
||||
const cur = recentScopes(client).filter((p) => p !== path);
|
||||
const next = [path, ...cur].slice(0, 3);
|
||||
localStorage.setItem("exd-recent-" + client, JSON.stringify(next));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// format a unix-epoch string as a short local datetime
|
||||
export function fmtTime(t?: string): string {
|
||||
@ -281,12 +315,14 @@ export function splitPath(p?: string): { name: string; dir: string } {
|
||||
}
|
||||
|
||||
// classify by extension for the preview panel
|
||||
export type Kind = "model" | "uasset" | "image" | "code";
|
||||
export type Kind = "model" | "uasset" | "image" | "audio" | "video" | "code";
|
||||
export function kindOf(name: string): Kind {
|
||||
const n = name.toLowerCase();
|
||||
if (/\.(fbx|obj|gltf|glb|stl|ply|3mf|dae)$/.test(n)) return "model";
|
||||
if (/\.(uasset|umap)$/.test(n)) return "uasset";
|
||||
if (/\.(png|jpe?g|webp|gif|bmp|svg)$/.test(n)) return "image";
|
||||
if (/\.(wav|mp3|ogg|flac|m4a|aac|opus)$/.test(n)) return "audio";
|
||||
if (/\.(mp4|webm|mov|m4v|ogv)$/.test(n)) return "video";
|
||||
return "code";
|
||||
}
|
||||
|
||||
|
||||
209
src/plugins.ts
Normal file
209
src/plugins.ts
Normal file
@ -0,0 +1,209 @@
|
||||
// Plugin platform — Host SDK + loader + contribution registry.
|
||||
//
|
||||
// Plugins are plain JS/ESM modules (author in TS → compile to JS). Each ships a
|
||||
// `plugin.json` manifest + a JS entry that exports `activate(host)`. We read the
|
||||
// entry source from disk (Rust) and import it as an ES module from a blob: URL —
|
||||
// no eval, works under CSP `script-src blob:`. The plugin only touches the app
|
||||
// through the `host` object; capabilities are gated by declared permissions.
|
||||
|
||||
import { p4, PluginInfo } from "./p4";
|
||||
import { t } from "./i18n";
|
||||
import { resolveTheme, loadActiveId } from "./themes";
|
||||
|
||||
// ---- contribution shapes ----------------------------------------------------
|
||||
// `icon` is a NAME from the host icon set (e.g. "ue", "hammer"); the host renders
|
||||
// the real SVG. Unknown / omitted names fall back to a neutral glyph.
|
||||
export interface PluginMenuItem { pluginId: string; label: string; icon?: string; run: () => void | Promise<void> }
|
||||
export interface PluginFileItem { pluginId: string; label: string; icon?: string; run: (file: { depotFile?: string; [k: string]: unknown }) => void | Promise<void> }
|
||||
export interface PluginFolderItem { pluginId: string; label: string; icon?: string; visible?: () => boolean; run: () => void | Promise<void> }
|
||||
export interface PluginView { pluginId: string; id: string; title: string; render: (container: HTMLElement) => void | (() => void) }
|
||||
export interface PluginDock { pluginId: string; id: string; title: string; icon?: string; render: (container: HTMLElement) => void | (() => void) }
|
||||
export interface PluginSubmitHook { pluginId: string; run: (files: { depotFile?: string }[]) => SubmitVerdict | Promise<SubmitVerdict> }
|
||||
export type SubmitVerdict = { ok: boolean; message?: string };
|
||||
|
||||
interface Registry { menu: PluginMenuItem[]; fileItems: PluginFileItem[]; folderItems: PluginFolderItem[]; views: PluginView[]; docks: PluginDock[]; submitHooks: PluginSubmitHook[] }
|
||||
const registry: Registry = { menu: [], fileItems: [], folderItems: [], views: [], docks: [], submitHooks: [] };
|
||||
let version = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
function bump() { version++; listeners.forEach((l) => l()); }
|
||||
|
||||
export function getRegistry(): Registry { return registry; }
|
||||
export function registryVersion(): number { return version; }
|
||||
export function subscribeRegistry(cb: () => void): () => void { listeners.add(cb); return () => { listeners.delete(cb); }; }
|
||||
function clearRegistry() { registry.menu = []; registry.fileItems = []; registry.folderItems = []; registry.views = []; registry.docks = []; registry.submitHooks = []; }
|
||||
|
||||
// Unreal Engine bridge — the core keeps the build machinery + overlay + log
|
||||
// reader; the plugin only drives them. Gated by the "ue" permission.
|
||||
export interface UeBridge {
|
||||
hasProject: () => boolean;
|
||||
uproject: () => string;
|
||||
build: () => void;
|
||||
launch: () => void;
|
||||
readLog: (tailBytes?: number) => Promise<string>;
|
||||
}
|
||||
|
||||
// ---- host context (provided by the app) -------------------------------------
|
||||
export interface HostContext {
|
||||
flash: (text: string, err?: boolean) => void;
|
||||
notify: (title: string, body: string) => void;
|
||||
info: () => { userName?: string; clientName?: string; clientRoot?: string } | null;
|
||||
selectedFile: () => { depotFile?: string } | null;
|
||||
refresh: () => void;
|
||||
ue?: UeBridge;
|
||||
}
|
||||
|
||||
// ---- the Host SDK a plugin receives -----------------------------------------
|
||||
export interface HostApi {
|
||||
version: string;
|
||||
plugin: { id: string; name: string; dir: string };
|
||||
perms: string[];
|
||||
has: (perm: string) => boolean;
|
||||
p4: Record<string, (...a: unknown[]) => Promise<unknown>>;
|
||||
ui: {
|
||||
addMenuItem: (label: string, run: () => void | Promise<void>, opts?: { icon?: string }) => void;
|
||||
addFileContextItem: (label: string, run: (file: { depotFile?: string }) => void | Promise<void>, opts?: { icon?: string }) => void;
|
||||
addFolderContextItem: (label: string, run: () => void | Promise<void>, opts?: { icon?: string; visible?: () => boolean }) => void;
|
||||
addView: (view: { id: string; title: string; render: (el: HTMLElement, host: HostApi) => void | (() => void) }) => void;
|
||||
addDockPanel: (dock: { id: string; title: string; icon?: string; render: (el: HTMLElement, host: HostApi) => void | (() => void) }) => void;
|
||||
addSubmitHook: (run: (files: { depotFile?: string }[]) => SubmitVerdict | Promise<SubmitVerdict>) => void;
|
||||
};
|
||||
flash: (text: string, err?: boolean) => void;
|
||||
notify: (title: string, body: string) => void;
|
||||
clipboard: { write: (text: string) => Promise<void> };
|
||||
storage: { get: (k: string) => string | null; set: (k: string, v: string) => void; remove: (k: string) => void };
|
||||
info: () => ReturnType<HostContext["info"]>;
|
||||
selectedFile: () => { depotFile?: string } | null;
|
||||
refresh: () => void;
|
||||
ue?: UeBridge; // present only with the "ue" permission
|
||||
theme: () => Record<string, string>;
|
||||
t: typeof t;
|
||||
log: (...a: unknown[]) => void;
|
||||
}
|
||||
|
||||
const HOST_VERSION = "1.0";
|
||||
|
||||
// p4 methods a plugin may call, split by permission. Only whitelisted, existing methods.
|
||||
const P4_READ = ["opened", "dirs", "describe", "changeSizes", "clientSpec"] as const;
|
||||
const P4_WRITE = ["add", "edit", "del", "sync", "reconcile"] as const;
|
||||
|
||||
function makeHost(info: PluginInfo, ctx: HostContext): HostApi {
|
||||
const perms = Array.isArray(info.permissions) ? info.permissions : [];
|
||||
const has = (p: string) => perms.includes(p);
|
||||
const p4host: Record<string, (...a: unknown[]) => Promise<unknown>> = {};
|
||||
const anyP4 = p4 as unknown as Record<string, (...a: unknown[]) => Promise<unknown>>;
|
||||
if (has("p4:read")) for (const m of P4_READ) if (typeof anyP4[m] === "function") p4host[m] = (...a) => anyP4[m]!(...a);
|
||||
if (has("p4:write")) for (const m of P4_WRITE) if (typeof anyP4[m] === "function") p4host[m] = (...a) => anyP4[m]!(...a);
|
||||
|
||||
const ns = `exd-plugin:${info.id}:`;
|
||||
const host: HostApi = {
|
||||
version: HOST_VERSION,
|
||||
plugin: { id: info.id, name: info.name, dir: info.dir },
|
||||
perms,
|
||||
has,
|
||||
p4: p4host,
|
||||
ui: {
|
||||
addMenuItem: (label, run, opts) => { registry.menu.push({ pluginId: info.id, label, icon: opts?.icon, run }); bump(); },
|
||||
addFileContextItem: (label, run, opts) => { registry.fileItems.push({ pluginId: info.id, label, icon: opts?.icon, run }); bump(); },
|
||||
addFolderContextItem: (label, run, opts) => { registry.folderItems.push({ pluginId: info.id, label, icon: opts?.icon, visible: opts?.visible, run }); bump(); },
|
||||
addView: (view) => { registry.views.push({ pluginId: info.id, id: view.id, title: view.title, render: (el) => view.render(el, host) }); bump(); },
|
||||
addDockPanel: (dock) => { registry.docks.push({ pluginId: info.id, id: dock.id, title: dock.title, icon: dock.icon, render: (el) => dock.render(el, host) }); bump(); },
|
||||
addSubmitHook: (run) => { registry.submitHooks.push({ pluginId: info.id, run }); bump(); },
|
||||
},
|
||||
flash: ctx.flash,
|
||||
notify: has("notify") ? ctx.notify : () => {},
|
||||
clipboard: { write: (text) => navigator.clipboard.writeText(text) },
|
||||
storage: {
|
||||
get: (k) => { try { return localStorage.getItem(ns + k); } catch { return null; } },
|
||||
set: (k, v) => { try { localStorage.setItem(ns + k, v); } catch {} },
|
||||
remove: (k) => { try { localStorage.removeItem(ns + k); } catch {} },
|
||||
},
|
||||
info: ctx.info,
|
||||
selectedFile: ctx.selectedFile,
|
||||
refresh: ctx.refresh,
|
||||
theme: () => resolveTheme(loadActiveId()).vars,
|
||||
t,
|
||||
log: (...a) => console.log(`[plugin:${info.id}]`, ...a),
|
||||
};
|
||||
if (has("ue") && ctx.ue) host.ue = ctx.ue; // Unreal bridge, permission-gated
|
||||
return host;
|
||||
}
|
||||
|
||||
// import a plugin's source as an ES module from a blob: URL, then activate it
|
||||
async function loadOne(info: PluginInfo, ctx: HostContext): Promise<void> {
|
||||
const code = await p4.pluginReadEntry(info.id);
|
||||
const blob = new Blob([code], { type: "text/javascript" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const mod = await import(/* @vite-ignore */ url);
|
||||
const activate = mod.activate || mod.default;
|
||||
if (typeof activate !== "function") throw new Error("plugin has no activate(host) export");
|
||||
await activate(makeHost(info, ctx));
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
/** (Re)load all enabled plugins. Clears the registry first so this is idempotent. */
|
||||
export async function loadPlugins(ctx: HostContext): Promise<{ loaded: number; errors: string[] }> {
|
||||
clearRegistry();
|
||||
const errors: string[] = [];
|
||||
let loaded = 0;
|
||||
let list: PluginInfo[] = [];
|
||||
try { list = await p4.pluginList(); } catch (e) { return { loaded: 0, errors: [String(e)] }; }
|
||||
for (const info of list) {
|
||||
if (!info.enabled) continue;
|
||||
try { await loadOne(info, ctx); loaded++; }
|
||||
catch (e) { errors.push(`${info.name || info.id}: ${String(e)}`); }
|
||||
}
|
||||
bump();
|
||||
return { loaded, errors };
|
||||
}
|
||||
|
||||
/** Run all plugin submit hooks; returns the first blocking verdict, or ok. */
|
||||
export async function runSubmitHooks(files: { depotFile?: string }[]): Promise<SubmitVerdict> {
|
||||
for (const h of registry.submitHooks) {
|
||||
try { const v = await h.run(files); if (v && !v.ok) return v; }
|
||||
catch { /* a broken hook must not block submit */ }
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---- official plugin registry (hosted on Gitea) -----------------------------
|
||||
// The client fetches registry.json over HTTPS (frontend fetch — no Rust HTTP dep),
|
||||
// then hands each file to the backend to write into the plugins folder.
|
||||
export const REGISTRY_URL = "https://gitea.exbytestudios.com/ExbytePublicServices/exbyte-depot-plugins/raw/branch/main/registry.json";
|
||||
|
||||
export interface RegistryEntry {
|
||||
id: string; name: string; version: string; author?: string; description?: string;
|
||||
permissions?: string[]; minAppVersion?: string; official?: boolean; autoInstall?: boolean;
|
||||
files: string[]; baseUrl: string;
|
||||
}
|
||||
|
||||
// All registry HTTP goes through the Rust backend (registryHttpGet): the webview
|
||||
// `fetch()` is blocked by CORS because Gitea's raw endpoint sends no ACAO header.
|
||||
export async function fetchRegistry(): Promise<RegistryEntry[]> {
|
||||
const txt = await p4.registryHttpGet(REGISTRY_URL);
|
||||
const j = JSON.parse(txt);
|
||||
return Array.isArray(j.plugins) ? (j.plugins as RegistryEntry[]).filter((p) => p && p.id && Array.isArray(p.files)) : [];
|
||||
}
|
||||
|
||||
/** Download a registry plugin's files (backend HTTPS) and write them into the plugins folder. */
|
||||
export async function installFromRegistry(entry: RegistryEntry): Promise<void> {
|
||||
for (const f of entry.files) {
|
||||
const text = await p4.registryHttpGet(entry.baseUrl + f);
|
||||
await p4.pluginWriteFile(entry.id, f, text);
|
||||
}
|
||||
}
|
||||
|
||||
/** Install any official plugins marked autoInstall that aren't installed yet. Silent on network failure. */
|
||||
export async function autoInstallOfficial(installedIds: Set<string>): Promise<string[]> {
|
||||
let reg: RegistryEntry[] = [];
|
||||
try { reg = await fetchRegistry(); } catch { return []; }
|
||||
const done: string[] = [];
|
||||
for (const e of reg) {
|
||||
if (e.autoInstall && !installedIds.has(e.id)) {
|
||||
try { await installFromRegistry(e); done.push(e.id); } catch { /* offline / transient — try next launch */ }
|
||||
}
|
||||
}
|
||||
return done;
|
||||
}
|
||||
133
src/themes.ts
Normal file
133
src/themes.ts
Normal file
@ -0,0 +1,133 @@
|
||||
// Theme system — built-in + user-made custom themes.
|
||||
//
|
||||
// A theme = a base (dark/light) plus an overlay of CSS-variable values applied as
|
||||
// inline styles on <body>, which win over the :root / body.light stylesheet vars.
|
||||
// Everything is CSS-variable driven, so a theme just changes token values.
|
||||
|
||||
export type ThemeBase = "dark" | "light";
|
||||
export interface Theme {
|
||||
id: string;
|
||||
name: string;
|
||||
base: ThemeBase;
|
||||
builtin?: boolean;
|
||||
vars: Record<string, string>; // e.g. { "--accent": "#7c6ef6" }
|
||||
}
|
||||
|
||||
// The tokens a user can edit. Grouped for the editor UI. Every one is a color.
|
||||
export const THEME_TOKENS: { var: string; label: string; group: string }[] = [
|
||||
{ var: "--accent", label: "Accent", group: "Brand" },
|
||||
{ var: "--accent-2", label: "Accent light", group: "Brand" },
|
||||
{ var: "--accent-deep", label: "Accent deep", group: "Brand" },
|
||||
{ var: "--bg", label: "Background", group: "Surfaces" },
|
||||
{ var: "--panel", label: "Panel", group: "Surfaces" },
|
||||
{ var: "--panel-2", label: "Panel 2", group: "Surfaces" },
|
||||
{ var: "--panel-3", label: "Panel 3", group: "Surfaces" },
|
||||
{ var: "--elevated", label: "Elevated", group: "Surfaces" },
|
||||
{ var: "--border", label: "Border", group: "Surfaces" },
|
||||
{ var: "--txt", label: "Text", group: "Text" },
|
||||
{ var: "--muted", label: "Muted text", group: "Text" },
|
||||
{ var: "--faint", label: "Faint text", group: "Text" },
|
||||
{ var: "--add", label: "Add / success", group: "Status" },
|
||||
{ var: "--edit", label: "Edit / warning", group: "Status" },
|
||||
{ var: "--del", label: "Delete / danger", group: "Status" },
|
||||
];
|
||||
export const TOKEN_VARS = THEME_TOKENS.map((t) => t.var);
|
||||
|
||||
// Built-in presets. "dark"/"light" carry no overlay (pure stylesheet base); the
|
||||
// rest recolor a few tokens on top of a base to give a distinct look.
|
||||
export const BUILTIN_THEMES: Theme[] = [
|
||||
{ id: "dark", name: "Midnight (default)", base: "dark", builtin: true, vars: {} },
|
||||
{ id: "light", name: "Daylight", base: "light", builtin: true, vars: {} },
|
||||
{ id: "ember", name: "Ember", base: "dark", builtin: true, vars: { "--accent": "#f0883e", "--accent-2": "#f7a75f", "--accent-deep": "#c96a22", "--bg": "#0f0b07", "--panel": "#1a130c", "--panel-2": "#20180f", "--panel-3": "#281d12" } },
|
||||
{ id: "forest", name: "Forest", base: "dark", builtin: true, vars: { "--accent": "#35c88a", "--accent-2": "#5fe0a5", "--accent-deep": "#22a06e", "--bg": "#07100b", "--panel": "#0e1a13", "--panel-2": "#12211a", "--panel-3": "#182a20" } },
|
||||
{ id: "ocean", name: "Ocean", base: "dark", builtin: true, vars: { "--accent": "#3aa0ff", "--accent-2": "#6cbaff", "--accent-deep": "#1f7ce0", "--bg": "#060b12", "--panel": "#0c141f", "--panel-2": "#101a28", "--panel-3": "#152234" } },
|
||||
{ id: "rose", name: "Rosé (light)", base: "light", builtin: true, vars: { "--accent": "#e0426a", "--accent-2": "#f06489", "--accent-deep": "#c02b52", "--bg": "#faf3f5" } },
|
||||
];
|
||||
|
||||
const CUSTOM_KEY = "exd-themes-custom";
|
||||
const ACTIVE_KEY = "exd-theme-id";
|
||||
const LEGACY_KEY = "exd-theme"; // old "light"/"dark" flag
|
||||
|
||||
export function loadCustomThemes(): Theme[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(CUSTOM_KEY);
|
||||
if (!raw) return [];
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? (arr as Theme[]).filter((t) => t && t.id && t.vars) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
export function saveCustomThemes(list: Theme[]) {
|
||||
try { localStorage.setItem(CUSTOM_KEY, JSON.stringify(list)); } catch {}
|
||||
}
|
||||
export function allThemes(): Theme[] {
|
||||
return [...BUILTIN_THEMES, ...loadCustomThemes()];
|
||||
}
|
||||
export function loadActiveId(): string {
|
||||
try {
|
||||
const id = localStorage.getItem(ACTIVE_KEY);
|
||||
if (id) return id;
|
||||
// migrate from the old dark/light flag
|
||||
return localStorage.getItem(LEGACY_KEY) === "light" ? "light" : "dark";
|
||||
} catch { return "dark"; }
|
||||
}
|
||||
export function saveActiveId(id: string) {
|
||||
try {
|
||||
localStorage.setItem(ACTIVE_KEY, id);
|
||||
const th = allThemes().find((t) => t.id === id);
|
||||
localStorage.setItem(LEGACY_KEY, th?.base === "light" ? "light" : "dark"); // keep legacy in sync
|
||||
} catch {}
|
||||
}
|
||||
export function resolveTheme(id: string): Theme {
|
||||
return allThemes().find((t) => t.id === id) || BUILTIN_THEMES[0]!;
|
||||
}
|
||||
|
||||
// Apply a theme to the document: set the base class, then overlay its var values
|
||||
// (clearing any previous overlay first).
|
||||
export function applyTheme(theme: Theme) {
|
||||
const body = document.body;
|
||||
for (const v of TOKEN_VARS) body.style.removeProperty(v);
|
||||
body.classList.toggle("light", theme.base === "light");
|
||||
for (const [k, val] of Object.entries(theme.vars)) {
|
||||
if (val) body.style.setProperty(k, val);
|
||||
}
|
||||
}
|
||||
|
||||
// Read the effective value of a token right now (for seeding the editor). Falls
|
||||
// back to the theme's overlay or the computed stylesheet value.
|
||||
export function tokenValue(theme: Theme, varName: string): string {
|
||||
if (theme.vars[varName]) return theme.vars[varName]!;
|
||||
const el = document.createElement("div");
|
||||
el.style.color = `var(${varName})`;
|
||||
// temporarily set base so computed value reflects the right stylesheet
|
||||
const hadLight = document.body.classList.contains("light");
|
||||
document.body.classList.toggle("light", theme.base === "light");
|
||||
document.body.appendChild(el);
|
||||
const rgb = getComputedStyle(el).color;
|
||||
el.remove();
|
||||
document.body.classList.toggle("light", hadLight);
|
||||
return rgbToHex(rgb) || "#7c6ef6";
|
||||
}
|
||||
|
||||
function rgbToHex(rgb: string): string | null {
|
||||
const m = rgb.match(/rgba?\(([^)]+)\)/);
|
||||
if (!m || !m[1]) return null;
|
||||
const parts = m[1].split(",").map((x) => parseInt(x.trim(), 10));
|
||||
const [r, g, b] = parts;
|
||||
if (r == null || g == null || b == null) return null;
|
||||
const h = (n: number) => n.toString(16).padStart(2, "0");
|
||||
return `#${h(r)}${h(g)}${h(b)}`;
|
||||
}
|
||||
|
||||
export function newCustomId(): string {
|
||||
// no Date.now/Math.random dependency worries in the app runtime; keep it simple
|
||||
return "custom-" + Math.random().toString(36).slice(2, 9);
|
||||
}
|
||||
|
||||
export function exportTheme(theme: Theme): string {
|
||||
return JSON.stringify({ name: theme.name, base: theme.base, vars: theme.vars }, null, 2);
|
||||
}
|
||||
export function importTheme(json: string): Theme {
|
||||
const o = JSON.parse(json);
|
||||
if (!o || typeof o !== "object" || !o.vars) throw new Error("Invalid theme file");
|
||||
return { id: newCustomId(), name: String(o.name || "Imported theme"), base: o.base === "light" ? "light" : "dark", vars: o.vars };
|
||||
}
|
||||
18
waitlist-server/.env.example
Normal file
18
waitlist-server/.env.example
Normal file
@ -0,0 +1,18 @@
|
||||
# Copy to .env and fill in. NEVER commit .env.
|
||||
|
||||
# Admin login
|
||||
ADMIN_USER=admin
|
||||
# Generate with: npm run set-password
|
||||
ADMIN_PASS_HASH=
|
||||
|
||||
# Session signing key (>=24 chars). Generate with: npm run gen-secret
|
||||
SESSION_SECRET=
|
||||
|
||||
# Server
|
||||
PORT=8787
|
||||
NODE_ENV=development
|
||||
# Set to 1 (or the hop count) when running behind nginx/Caddy so client IPs are correct
|
||||
TRUST_PROXY=0
|
||||
|
||||
# Path to the landing page folder (default: ../landing)
|
||||
# LANDING_DIR=../landing
|
||||
4
waitlist-server/.gitignore
vendored
Normal file
4
waitlist-server/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/
|
||||
*.log
|
||||
51
waitlist-server/README.md
Normal file
51
waitlist-server/README.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Exbyte Depot — Waitlist server + admin
|
||||
|
||||
Collects waitlist emails from the landing page (deduplicated) and gives you a
|
||||
password-protected admin panel to browse them and export to CSV.
|
||||
|
||||
## Stack
|
||||
- **express** — HTTP + static landing
|
||||
- **better-sqlite3** — storage; `UNIQUE COLLATE NOCASE` on email → no duplicates, ever
|
||||
- **express-rate-limit** — brute-force protection on the login endpoint
|
||||
- **node:crypto** — scrypt password hashing + HMAC-signed session cookies (no extra deps)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd waitlist-server
|
||||
npm install
|
||||
|
||||
cp .env.example .env
|
||||
npm run gen-secret # → paste into SESSION_SECRET in .env
|
||||
npm run set-password # type a password → paste the ADMIN_PASS_HASH line into .env
|
||||
# set ADMIN_USER in .env (default: admin)
|
||||
|
||||
npm start
|
||||
```
|
||||
|
||||
- Landing: http://localhost:8787/
|
||||
- Admin: http://localhost:8787/admin
|
||||
|
||||
## How it works
|
||||
|
||||
- The landing form POSTs `{ email }` to **`POST /api/waitlist`**.
|
||||
- Invalid emails are rejected; a repeat email is silently deduped (no dup row).
|
||||
- A hidden honeypot field (`website`) traps bots.
|
||||
- **`/admin`** requires login. Session is a signed, HttpOnly, SameSite=Strict cookie
|
||||
(Secure in production), valid 8h. Wrong logins are rate-limited (8 / 15 min / IP).
|
||||
- **Export CSV**: `GET /admin/export.csv` streams all signups (UTF-8 + BOM for Excel).
|
||||
|
||||
## Security notes
|
||||
- No plaintext password anywhere — only a scrypt hash in `.env` (git-ignored).
|
||||
- Refuses to start without `ADMIN_USER`, `ADMIN_PASS_HASH`, and a strong `SESSION_SECRET`.
|
||||
- Security headers set (CSP, X-Frame-Options: DENY, nosniff, no-referrer).
|
||||
- Behind a reverse proxy, set `TRUST_PROXY=1` so rate-limiting and stored IPs are correct,
|
||||
and terminate TLS at the proxy (cookies become `Secure` when `NODE_ENV=production`).
|
||||
|
||||
## Deploy (Linux VPS, sketch)
|
||||
1. `NODE_ENV=production`, real `.env`, `npm ci --omit=dev`.
|
||||
2. Run under systemd (or pm2); put nginx/Caddy in front for TLS + `TRUST_PROXY=1`.
|
||||
3. Point your domain at it; the landing is served from `../landing` (or set `LANDING_DIR`).
|
||||
|
||||
## Data
|
||||
SQLite file at `data/waitlist.db` (git-ignored). Back it up to keep your list.
|
||||
129
waitlist-server/lib/auth.js
Normal file
129
waitlist-server/lib/auth.js
Normal file
@ -0,0 +1,129 @@
|
||||
// Authentication for the admin panel — no external crypto deps, all from node:crypto.
|
||||
//
|
||||
// • Passwords are stored as scrypt hashes ("scrypt$salt$hash", both base64url) in
|
||||
// the ADMIN_PASS_HASH env var. Never a plaintext password anywhere.
|
||||
// • Sessions are stateless, signed cookies: base64url(payload).base64url(hmac).
|
||||
// Tamper-proof (HMAC-SHA256 with SESSION_SECRET) and self-expiring. No server
|
||||
// session store to leak or lose on restart.
|
||||
// • Every compare is constant-time (timingSafeEqual) to avoid timing oracles.
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 };
|
||||
|
||||
/** Hash a plaintext password → "scrypt$<salt>$<hash>" (for storing in .env). */
|
||||
export function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16);
|
||||
const hash = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
|
||||
return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`;
|
||||
}
|
||||
|
||||
/** Verify a plaintext password against a stored "scrypt$salt$hash" string. */
|
||||
export function verifyPassword(password, stored) {
|
||||
if (typeof stored !== "string") return false;
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
||||
let salt, expected;
|
||||
try {
|
||||
salt = Buffer.from(parts[1], "base64url");
|
||||
expected = Buffer.from(parts[2], "base64url");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (expected.length !== SCRYPT.keylen) return false;
|
||||
const got = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
|
||||
return crypto.timingSafeEqual(got, expected);
|
||||
}
|
||||
|
||||
/** Constant-time string compare (for the username). */
|
||||
export function safeEqual(a, b) {
|
||||
const ba = Buffer.from(String(a));
|
||||
const bb = Buffer.from(String(b));
|
||||
if (ba.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
// ---- signed session cookies -------------------------------------------------
|
||||
|
||||
const COOKIE_NAME = "exd_admin";
|
||||
|
||||
function sign(data, secret) {
|
||||
return crypto.createHmac("sha256", secret).update(data).digest("base64url");
|
||||
}
|
||||
|
||||
/** Build a signed session token that expires after `ttlMs`. */
|
||||
export function issueToken(username, secret, ttlMs) {
|
||||
const payload = JSON.stringify({ u: username, exp: Date.now() + ttlMs });
|
||||
const body = Buffer.from(payload).toString("base64url");
|
||||
return `${body}.${sign(body, secret)}`;
|
||||
}
|
||||
|
||||
/** Verify a session token → { u } if valid & unexpired, else null. */
|
||||
export function verifyToken(token, secret) {
|
||||
if (typeof token !== "string" || !token.includes(".")) return null;
|
||||
const [body, mac] = token.split(".");
|
||||
if (!body || !mac) return null;
|
||||
const expected = sign(body, secret);
|
||||
const a = Buffer.from(mac);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!payload || typeof payload.exp !== "number" || Date.now() > payload.exp) return null;
|
||||
return { u: payload.u };
|
||||
}
|
||||
|
||||
/** Parse the admin session cookie out of a raw Cookie header. */
|
||||
export function readSessionCookie(req) {
|
||||
const raw = req.headers.cookie || "";
|
||||
for (const part of raw.split(";")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i === -1) continue;
|
||||
if (part.slice(0, i).trim() === COOKIE_NAME) {
|
||||
return decodeURIComponent(part.slice(i + 1).trim());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setSessionCookie(res, token, { secure, ttlMs }) {
|
||||
const attrs = [
|
||||
`${COOKIE_NAME}=${encodeURIComponent(token)}`,
|
||||
"HttpOnly",
|
||||
"SameSite=Strict",
|
||||
"Path=/",
|
||||
`Max-Age=${Math.floor(ttlMs / 1000)}`,
|
||||
];
|
||||
if (secure) attrs.push("Secure");
|
||||
res.append("Set-Cookie", attrs.join("; "));
|
||||
}
|
||||
|
||||
export function clearSessionCookie(res, { secure }) {
|
||||
const attrs = [`${COOKIE_NAME}=`, "HttpOnly", "SameSite=Strict", "Path=/", "Max-Age=0"];
|
||||
if (secure) attrs.push("Secure");
|
||||
res.append("Set-Cookie", attrs.join("; "));
|
||||
}
|
||||
|
||||
/** Express middleware: allow through only requests with a valid session. */
|
||||
export function requireAuth(secret) {
|
||||
return (req, res, next) => {
|
||||
const tok = readSessionCookie(req);
|
||||
const sess = tok && verifyToken(tok, secret);
|
||||
if (!sess) {
|
||||
// req.path is stripped of the mount prefix, so match on originalUrl
|
||||
const url = req.originalUrl || req.url || "";
|
||||
if (url.startsWith("/admin/api") || url.endsWith(".csv")) {
|
||||
return res.status(401).json({ error: "unauthorized" });
|
||||
}
|
||||
return res.redirect("/admin/login");
|
||||
}
|
||||
req.admin = sess;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export { COOKIE_NAME };
|
||||
56
waitlist-server/lib/db.js
Normal file
56
waitlist-server/lib/db.js
Normal file
@ -0,0 +1,56 @@
|
||||
// SQLite storage for waitlist signups. One row per unique email — dedup is
|
||||
// enforced by the DB itself (UNIQUE, case-insensitive), so a repeat signup is a
|
||||
// no-op rather than a duplicate row.
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.join(__dirname, "..", "data");
|
||||
const DB_PATH = process.env.DB_PATH || path.join(DATA_DIR, "waitlist.db");
|
||||
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS signups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
source TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
`);
|
||||
|
||||
const insertStmt = db.prepare(
|
||||
`INSERT INTO signups (email, source, ip, user_agent)
|
||||
VALUES (@email, @source, @ip, @user_agent)
|
||||
ON CONFLICT(email) DO NOTHING`
|
||||
);
|
||||
const countStmt = db.prepare(`SELECT COUNT(*) AS n FROM signups`);
|
||||
const listStmt = db.prepare(
|
||||
`SELECT id, email, source, ip, created_at FROM signups ORDER BY created_at DESC, id DESC`
|
||||
);
|
||||
|
||||
/**
|
||||
* Add an email. Returns { added: true } on a new signup, { added: false } if the
|
||||
* email was already on the list (deduped — no duplicate stored).
|
||||
*/
|
||||
export function addEmail({ email, source = null, ip = null, userAgent = null }) {
|
||||
const info = insertStmt.run({ email, source, ip, user_agent: userAgent });
|
||||
return { added: info.changes > 0 };
|
||||
}
|
||||
|
||||
export function countEmails() {
|
||||
return countStmt.get().n;
|
||||
}
|
||||
|
||||
export function listEmails() {
|
||||
return listStmt.all();
|
||||
}
|
||||
|
||||
export default db;
|
||||
1271
waitlist-server/package-lock.json
generated
Normal file
1271
waitlist-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
waitlist-server/package.json
Normal file
22
waitlist-server/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "exbyte-depot-waitlist",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Waitlist collector + secure admin for the Exbyte Depot landing page",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js",
|
||||
"set-password": "node scripts/set-password.js",
|
||||
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(48).toString('base64url'))\""
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0"
|
||||
}
|
||||
}
|
||||
37
waitlist-server/scripts/set-password.js
Normal file
37
waitlist-server/scripts/set-password.js
Normal file
@ -0,0 +1,37 @@
|
||||
// Generate an ADMIN_PASS_HASH for .env from a password.
|
||||
//
|
||||
// node scripts/set-password.js "my super secret"
|
||||
// node scripts/set-password.js # prompts (hidden input)
|
||||
//
|
||||
// Paste the printed line into .env — the plaintext password is never stored.
|
||||
|
||||
import readline from "node:readline";
|
||||
import { hashPassword } from "../lib/auth.js";
|
||||
|
||||
function ask(question) {
|
||||
return new Promise((resolve) => {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
// hide typed characters
|
||||
const orig = rl._writeToOutput.bind(rl);
|
||||
rl._writeToOutput = (str) => {
|
||||
if (str.includes(question)) orig(str);
|
||||
else orig("*");
|
||||
};
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
process.stdout.write("\n");
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const argPw = process.argv.slice(2).join(" ").trim();
|
||||
const pw = argPw || (await ask("New admin password: "));
|
||||
|
||||
if (!pw || pw.length < 8) {
|
||||
console.error("Password must be at least 8 characters.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\nAdd this line to your .env:\n");
|
||||
console.log(`ADMIN_PASS_HASH=${hashPassword(pw)}\n`);
|
||||
209
waitlist-server/server.js
Normal file
209
waitlist-server/server.js
Normal file
@ -0,0 +1,209 @@
|
||||
// Exbyte Depot — waitlist collector + secure admin panel.
|
||||
//
|
||||
// Public: serves the landing page and accepts POST /api/waitlist (dedup).
|
||||
// Admin: password-gated (scrypt), rate-limited, signed-cookie session.
|
||||
// view signups + export CSV. No plaintext secrets in the codebase.
|
||||
|
||||
import express from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { addEmail, countEmails, listEmails } from "./lib/db.js";
|
||||
import {
|
||||
verifyPassword,
|
||||
safeEqual,
|
||||
issueToken,
|
||||
requireAuth,
|
||||
setSessionCookie,
|
||||
clearSessionCookie,
|
||||
} from "./lib/auth.js";
|
||||
|
||||
// --- load .env (built into Node, no dotenv dependency) ----------------------
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
try {
|
||||
process.loadEnvFile(path.join(__dirname, ".env"));
|
||||
} catch {
|
||||
/* no .env file — rely on real environment variables */
|
||||
}
|
||||
|
||||
const {
|
||||
PORT = 8787,
|
||||
NODE_ENV = "development",
|
||||
ADMIN_USER,
|
||||
ADMIN_PASS_HASH,
|
||||
SESSION_SECRET,
|
||||
TRUST_PROXY = "0",
|
||||
LANDING_DIR = path.join(__dirname, "..", "landing"),
|
||||
} = process.env;
|
||||
|
||||
const IS_PROD = NODE_ENV === "production";
|
||||
const SESSION_TTL_MS = 8 * 60 * 60 * 1000; // 8 hours
|
||||
|
||||
// --- refuse to boot without the security-critical config --------------------
|
||||
const missing = [];
|
||||
if (!ADMIN_USER) missing.push("ADMIN_USER");
|
||||
if (!ADMIN_PASS_HASH) missing.push("ADMIN_PASS_HASH");
|
||||
if (!SESSION_SECRET || SESSION_SECRET.length < 24) missing.push("SESSION_SECRET (>=24 chars)");
|
||||
if (missing.length) {
|
||||
console.error(
|
||||
`\n[waitlist] Refusing to start — missing/weak config: ${missing.join(", ")}\n` +
|
||||
` 1) cp .env.example .env\n` +
|
||||
` 2) npm run gen-secret # paste into SESSION_SECRET\n` +
|
||||
` 3) npm run set-password # paste into ADMIN_PASS_HASH\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
if (TRUST_PROXY !== "0") app.set("trust proxy", Number(TRUST_PROXY) || 1);
|
||||
app.use(express.json({ limit: "16kb" }));
|
||||
app.use(express.urlencoded({ extended: false, limit: "16kb" }));
|
||||
|
||||
// --- security headers (self-contained; no external origins) -----------------
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
res.setHeader("Referrer-Policy", "no-referrer");
|
||||
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||
res.setHeader("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
[
|
||||
"default-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
].join("; ")
|
||||
);
|
||||
next();
|
||||
});
|
||||
|
||||
// --- rate limiters ----------------------------------------------------------
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 8, // per IP per window
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: "Too many attempts. Try again later." },
|
||||
});
|
||||
const waitlistLimiter = rateLimit({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: "Too many requests. Slow down." },
|
||||
});
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
function normalizeEmail(v) {
|
||||
return String(v || "").trim().toLowerCase();
|
||||
}
|
||||
function clientIp(req) {
|
||||
return (req.ip || "").replace(/^::ffff:/, "") || null;
|
||||
}
|
||||
function sendView(res, file, replacements = {}) {
|
||||
let html = fs.readFileSync(path.join(__dirname, "views", file), "utf8");
|
||||
for (const [k, v] of Object.entries(replacements)) {
|
||||
html = html.replaceAll(`{{${k}}}`, v);
|
||||
}
|
||||
res.type("html").send(html);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC — waitlist signup
|
||||
// ============================================================================
|
||||
app.post("/api/waitlist", waitlistLimiter, (req, res) => {
|
||||
// honeypot: bots fill hidden fields; humans leave them blank
|
||||
if (req.body && typeof req.body.website === "string" && req.body.website.trim() !== "") {
|
||||
return res.json({ ok: true, deduped: true }); // silently accept, store nothing
|
||||
}
|
||||
const email = normalizeEmail(req.body?.email);
|
||||
if (!email || email.length > 254 || !EMAIL_RE.test(email)) {
|
||||
return res.status(400).json({ ok: false, error: "Enter a valid email." });
|
||||
}
|
||||
try {
|
||||
const { added } = addEmail({
|
||||
email,
|
||||
source: (req.body?.source || "landing").toString().slice(0, 64),
|
||||
ip: clientIp(req),
|
||||
userAgent: (req.headers["user-agent"] || "").slice(0, 300),
|
||||
});
|
||||
return res.json({ ok: true, added, deduped: !added });
|
||||
} catch (e) {
|
||||
console.error("[waitlist] insert failed:", e.message);
|
||||
return res.status(500).json({ ok: false, error: "Server error." });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ADMIN — auth
|
||||
// ============================================================================
|
||||
app.get("/admin/login", (req, res) => sendView(res, "login.html"));
|
||||
|
||||
app.post("/admin/login", loginLimiter, (req, res) => {
|
||||
const user = String(req.body?.username || "");
|
||||
const pass = String(req.body?.password || "");
|
||||
const ok = safeEqual(user, ADMIN_USER) && verifyPassword(pass, ADMIN_PASS_HASH);
|
||||
if (!ok) {
|
||||
return res.status(401).redirect("/admin/login?error=1");
|
||||
}
|
||||
const token = issueToken(ADMIN_USER, SESSION_SECRET, SESSION_TTL_MS);
|
||||
setSessionCookie(res, token, { secure: IS_PROD, ttlMs: SESSION_TTL_MS });
|
||||
res.redirect("/admin");
|
||||
});
|
||||
|
||||
app.post("/admin/logout", (req, res) => {
|
||||
clearSessionCookie(res, { secure: IS_PROD });
|
||||
res.redirect("/admin/login");
|
||||
});
|
||||
|
||||
// everything below requires a valid session
|
||||
app.use("/admin", requireAuth(SESSION_SECRET));
|
||||
|
||||
app.get("/admin", (req, res) => sendView(res, "admin.html", { USER: req.admin.u }));
|
||||
|
||||
app.get("/admin/api/emails", (req, res) => {
|
||||
res.json({ count: countEmails(), emails: listEmails() });
|
||||
});
|
||||
|
||||
// CSV export
|
||||
app.get("/admin/export.csv", (req, res) => {
|
||||
const rows = listEmails();
|
||||
const esc = (v) => {
|
||||
const s = v == null ? "" : String(v);
|
||||
return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
|
||||
};
|
||||
const header = ["email", "source", "ip", "created_at"];
|
||||
const lines = [header.join(",")];
|
||||
for (const r of rows) {
|
||||
lines.push([r.email, r.source, r.ip, r.created_at].map(esc).join(","));
|
||||
}
|
||||
const csv = "" + lines.join("\r\n"); // BOM so Excel reads UTF-8
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="waitlist-${stamp}.csv"`);
|
||||
res.send(csv);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// LANDING (static) — served last so /api and /admin win
|
||||
// ============================================================================
|
||||
app.use(
|
||||
express.static(LANDING_DIR, {
|
||||
extensions: ["html"],
|
||||
setHeaders: (res) => res.setHeader("Cache-Control", "no-cache"),
|
||||
})
|
||||
);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[waitlist] listening on http://localhost:${PORT}`);
|
||||
console.log(`[waitlist] landing: ${LANDING_DIR}`);
|
||||
console.log(`[waitlist] admin: http://localhost:${PORT}/admin (${IS_PROD ? "prod" : "dev"})`);
|
||||
});
|
||||
155
waitlist-server/views/admin.html
Normal file
155
waitlist-server/views/admin.html
Normal file
@ -0,0 +1,155 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Waitlist · Exbyte Depot Admin</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0a0f; --bg-2:#0d0d14; --panel:#12121b; --panel-2:#16161f; --elevated:#1a1a24;
|
||||
--border:#26263340; --txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
|
||||
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0; --ok:#3ecf8e; --del:#f2637e;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;background:var(--bg);color:var(--txt);
|
||||
font:14.5px/1.5 -apple-system,Segoe UI,Roboto,Inter,system-ui,sans-serif}
|
||||
.wrap{max-width:1000px;margin:0 auto;padding:24px 22px 60px}
|
||||
header{display:flex;align-items:center;gap:12px;padding:6px 0 22px}
|
||||
.logo{width:34px;height:34px;border-radius:10px;flex:0 0 auto;
|
||||
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));
|
||||
box-shadow:0 0 18px rgba(124,110,246,.45);display:grid;place-items:center}
|
||||
.logo svg{width:18px;height:18px}
|
||||
.htitle b{font-size:15px;font-weight:650}
|
||||
.htitle span{display:block;font-size:11.5px;color:var(--faint)}
|
||||
.spacer{flex:1}
|
||||
.who{font-size:12.5px;color:var(--muted);margin-right:4px}
|
||||
.who b{color:var(--txt);font-weight:600}
|
||||
.btn{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:600;cursor:pointer;
|
||||
background:var(--panel-2);border:1px solid var(--border);color:var(--txt);
|
||||
border-radius:10px;padding:8px 13px;transition:.15s;text-decoration:none}
|
||||
.btn:hover{border-color:var(--acc);color:#fff}
|
||||
.btn svg{width:15px;height:15px}
|
||||
.btn.accent{background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));border:0;color:#fff}
|
||||
.btn.accent:hover{filter:brightness(1.08)}
|
||||
.btn.ghost{background:transparent}
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:20px}
|
||||
.stat{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:16px 18px}
|
||||
.stat .n{font-size:28px;font-weight:700;letter-spacing:-.5px;font-variant-numeric:tabular-nums}
|
||||
.stat .l{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-top:2px}
|
||||
.stat.accent .n{color:var(--acc-2)}
|
||||
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
||||
.search{flex:1;min-width:200px;display:flex;align-items:center;gap:8px;
|
||||
background:var(--elevated);border:1px solid var(--border);border-radius:10px;padding:0 12px}
|
||||
.search svg{width:15px;height:15px;color:var(--faint);flex:0 0 auto}
|
||||
.search input{flex:1;background:none;border:0;outline:none;color:var(--txt);font-size:14px;padding:10px 0}
|
||||
.panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th,td{text-align:left;padding:11px 16px;font-size:13.5px}
|
||||
th{background:var(--panel-2);color:var(--muted);font-weight:600;font-size:11.5px;
|
||||
text-transform:uppercase;letter-spacing:.4px;position:sticky;top:0}
|
||||
tbody tr{border-top:1px solid var(--border)}
|
||||
tbody tr:hover{background:#ffffff06}
|
||||
td.email{font-weight:550}
|
||||
td.mono{font-variant-numeric:tabular-nums;color:var(--muted);white-space:nowrap}
|
||||
.tag{display:inline-block;font-size:11px;color:var(--muted);background:var(--panel-2);
|
||||
border:1px solid var(--border);border-radius:6px;padding:1px 8px}
|
||||
.idx{color:var(--faint);font-variant-numeric:tabular-nums;width:42px}
|
||||
.empty{padding:44px;text-align:center;color:var(--faint);font-size:13.5px}
|
||||
.foot{margin-top:16px;font-size:11.5px;color:var(--faint);text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<span class="logo"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7l8-4 8 4v10l-8 4-8-4V7Z" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/><path d="M4 7l8 4 8-4M12 11v10" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||
<div class="htitle"><b>Waitlist</b><span>Exbyte Depot · admin</span></div>
|
||||
<div class="spacer"></div>
|
||||
<span class="who">Signed in as <b>{{USER}}</b></span>
|
||||
<form method="POST" action="/admin/logout" style="display:inline">
|
||||
<button class="btn ghost" type="submit">Log out</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat accent"><div class="n" id="statTotal">—</div><div class="l">Total signups</div></div>
|
||||
<div class="stat"><div class="n" id="statShown">—</div><div class="l">Showing</div></div>
|
||||
<div class="stat"><div class="n" id="statLatest">—</div><div class="l">Latest signup</div></div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.7"/><path d="m20 20-3-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
|
||||
<input id="search" type="search" placeholder="Filter emails…" autocomplete="off" />
|
||||
</div>
|
||||
<button class="btn" id="refresh" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M20 11a8 8 0 1 0-.5 3M20 5v6h-6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Refresh
|
||||
</button>
|
||||
<a class="btn accent" href="/admin/export.csv">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th class="idx">#</th><th>Email</th><th>Source</th><th>Date (UTC)</th></tr>
|
||||
</thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
<div class="empty" id="empty" style="display:none">No signups yet.</div>
|
||||
</div>
|
||||
<div class="foot" id="foot"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let all = [];
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function fmtDate(s){ return (s || "").replace("T"," ").replace("Z",""); }
|
||||
|
||||
function render(){
|
||||
const q = $("search").value.trim().toLowerCase();
|
||||
const list = q ? all.filter(r => r.email.toLowerCase().includes(q)) : all;
|
||||
const tbody = $("rows");
|
||||
tbody.textContent = "";
|
||||
$("empty").style.display = list.length ? "none" : "block";
|
||||
list.forEach((r, i) => {
|
||||
const tr = document.createElement("tr");
|
||||
const idx = document.createElement("td"); idx.className = "idx"; idx.textContent = i + 1;
|
||||
const em = document.createElement("td"); em.className = "email"; em.textContent = r.email;
|
||||
const src = document.createElement("td");
|
||||
const tag = document.createElement("span"); tag.className = "tag"; tag.textContent = r.source || "—";
|
||||
src.appendChild(tag);
|
||||
const dt = document.createElement("td"); dt.className = "mono"; dt.textContent = fmtDate(r.created_at);
|
||||
tr.append(idx, em, src, dt);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
$("statShown").textContent = list.length;
|
||||
$("foot").textContent = list.length
|
||||
? `${list.length} of ${all.length} shown` : "";
|
||||
}
|
||||
|
||||
async function load(){
|
||||
try {
|
||||
const res = await fetch("/admin/api/emails", { headers: { "Accept": "application/json" } });
|
||||
if (res.status === 401) { location.href = "/admin/login"; return; }
|
||||
const data = await res.json();
|
||||
all = data.emails || [];
|
||||
$("statTotal").textContent = data.count ?? all.length;
|
||||
$("statLatest").textContent = all.length ? fmtDate(all[0].created_at).slice(0,10) : "—";
|
||||
render();
|
||||
} catch (e) {
|
||||
$("empty").style.display = "block";
|
||||
$("empty").textContent = "Failed to load. " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
$("search").addEventListener("input", render);
|
||||
$("refresh").addEventListener("click", load);
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
72
waitlist-server/views/login.html
Normal file
72
waitlist-server/views/login.html
Normal file
@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Admin · Exbyte Depot</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0a0f; --panel:#12121b; --elevated:#1a1a24; --border:#26263340;
|
||||
--txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
|
||||
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0; --del:#f2637e;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;
|
||||
background:radial-gradient(1200px 600px at 50% -10%,#171626 0%,var(--bg) 55%);
|
||||
font:15px/1.5 -apple-system,Segoe UI,Roboto,Inter,system-ui,sans-serif;color:var(--txt)}
|
||||
.card{width:100%;max-width:380px;background:var(--panel);border:1px solid var(--border);
|
||||
border-radius:18px;padding:34px 30px;box-shadow:0 30px 80px -30px #000}
|
||||
.brand{display:flex;align-items:center;gap:11px;margin-bottom:24px}
|
||||
.logo{width:38px;height:38px;border-radius:11px;flex:0 0 auto;
|
||||
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));
|
||||
box-shadow:0 0 22px rgba(124,110,246,.5);display:grid;place-items:center}
|
||||
.logo svg{width:20px;height:20px}
|
||||
.brand b{font-size:15px;font-weight:650;letter-spacing:.2px}
|
||||
.brand span{display:block;font-size:11.5px;color:var(--faint);font-weight:500}
|
||||
h1{font-size:19px;font-weight:650;margin:0 0 4px}
|
||||
.sub{color:var(--muted);font-size:12.5px;margin:0 0 22px}
|
||||
label{display:block;font-size:11.5px;color:var(--muted);font-weight:600;margin:0 0 6px;
|
||||
text-transform:uppercase;letter-spacing:.4px}
|
||||
.field{margin-bottom:16px}
|
||||
input{width:100%;background:var(--elevated);border:1px solid var(--border);border-radius:11px;
|
||||
padding:11px 13px;color:var(--txt);font-size:14px;outline:none;transition:.15s}
|
||||
input:focus{border-color:var(--acc);box-shadow:0 0 0 3px rgba(124,110,246,.16)}
|
||||
button{width:100%;margin-top:6px;padding:12px;border:0;border-radius:11px;cursor:pointer;
|
||||
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));color:#fff;
|
||||
font-size:14.5px;font-weight:650;letter-spacing:.2px;transition:.15s}
|
||||
button:hover{filter:brightness(1.08)}
|
||||
.err{display:none;background:rgba(242,99,126,.1);border:1px solid rgba(242,99,126,.35);
|
||||
color:#ff9db0;font-size:12.5px;padding:9px 12px;border-radius:10px;margin-bottom:18px}
|
||||
.err.show{display:block}
|
||||
.foot{margin-top:20px;text-align:center;font-size:11px;color:var(--faint)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form class="card" method="POST" action="/admin/login" autocomplete="off">
|
||||
<div class="brand">
|
||||
<span class="logo"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7l8-4 8 4v10l-8 4-8-4V7Z" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/><path d="M4 7l8 4 8-4M12 11v10" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||
<div><b>Exbyte Depot</b><span>Waitlist admin</span></div>
|
||||
</div>
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">Restricted area — authorized access only.</p>
|
||||
<div class="err" id="err">Invalid username or password.</div>
|
||||
<div class="field">
|
||||
<label for="u">Username</label>
|
||||
<input id="u" name="username" type="text" autocomplete="username" required autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p">Password</label>
|
||||
<input id="p" name="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<button type="submit">Sign in</button>
|
||||
<div class="foot">Protected endpoint · rate-limited</div>
|
||||
</form>
|
||||
<script>
|
||||
if (new URLSearchParams(location.search).get("error")) {
|
||||
document.getElementById("err").classList.add("show");
|
||||
history.replaceState(null, "", "/admin/login");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user