mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
docs: add new design references
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
# План: переезд на новый дизайн (refs)
|
||||
|
||||
> Статус: план к выполнению.
|
||||
>
|
||||
> Источники: `refs/` — Next/React-референс (правда по пикселям), `refs-html/` —
|
||||
> статические HTML-снимки для быстрого просмотра в браузере.
|
||||
|
||||
## 1. Что происходит
|
||||
|
||||
В `refs/` лежит новый визуальный язык проекта: «чертёжный» техно-стиль —
|
||||
сетка на фоне, IBM Plex Sans/Mono, моно-микролейблы в верхнем регистре,
|
||||
острые углы 4px, панели с 1px-бордером, акценты синий/циан/амбер, зелёные
|
||||
статус-точки. Референс написан на Next 16 + Tailwind v4, но **переезд
|
||||
дизайновый, а не фреймворковый**: SvelteKit в `web/` остаётся, переносится
|
||||
только дизайн-слой (токены → примитивы → UI-kit → страницы).
|
||||
|
||||
Чего не делаем: не тащим Tailwind и React в проект. Токены из
|
||||
`refs/app/globals.css` переносятся в наш vanilla-CSS слой почти один в один,
|
||||
классы референса (`step-card`, `setting-group`, …) воспроизводятся как наши
|
||||
глобальные утилиты и стили компонентов.
|
||||
|
||||
## 2. Токены (фундамент)
|
||||
|
||||
Маппинг старых токенов `web/src/app.css` → новые из референса:
|
||||
|
||||
| Старый | Новый | Light | Dark |
|
||||
| -------------- | -------------- | --------- | ------------------------ |
|
||||
| `--bg` | `--background` | `#eef1f4` | `#11171d` |
|
||||
| `--surface` | `--panel` | `#f8fafb` | `#182129` |
|
||||
| `--text` | `--foreground` | `#17212b` | `#e8eef2` |
|
||||
| `--text-muted` | `--muted` | `#6d7883` | `#91a0ac` |
|
||||
| `--border` | `--line` | `#cbd3da` | `#33414c` |
|
||||
| `--accent` | `--blue` | `#1769d2` | `#54a2ff` |
|
||||
| — | `--cyan` | `#00a8c7` | `#00a8c7` |
|
||||
| — | `--amber` | `#bd7411` | `#bd7411` |
|
||||
| — | `--success` | `#25a96a` | `#25a96a` (статус-точки) |
|
||||
| `--danger` | без изменений | `#e5484d` | `#ff7479` |
|
||||
|
||||
Прочее:
|
||||
|
||||
- Радиусы: вместо `--radius-s/m` один `--radius: 4px`. Скруглённая мягкость
|
||||
старой темы уходит.
|
||||
- Шрифты: `IBM Plex Sans` + `IBM Plex Mono`, self-host через `@fontsource`
|
||||
(проект offline-first, CDN нельзя). Mono — рабочий шрифт для лейблов,
|
||||
метаданных, цифр; Sans — для заголовков и текста.
|
||||
- Фон-сетка приложения (32px blueprint-grid) и шахматка канвасов —
|
||||
отдельные утилиты `.app-shell`, `.checker-canvas`, не токены.
|
||||
- Тёмная тема остаётся на `[data-theme='dark']` (инлайн-скрипт в `app.html`
|
||||
уже есть); класс `.dark-mode` из референса не переносим.
|
||||
- На переходный период старые имена живут как алиасы
|
||||
(`--bg: var(--background)` …), чтобы существующие компоненты не ломались
|
||||
до этапа D. В конце алиасы удаляются.
|
||||
|
||||
## 3. Базовые примитивы дизайн-системы
|
||||
|
||||
Повторяющиеся паттерны референса выносим в глобальные классы `app.css`
|
||||
(по одному определению, без копипасты между компонентами):
|
||||
|
||||
- `.mono-label` — микро-лейбл: mono 10px, uppercase, letter-spacing .1em,
|
||||
цвет muted; модификаторы `.mono-label--accent` (синие eyebrow/version).
|
||||
- `.status-dot` / `.status-line` — точка `--success` + подпись
|
||||
(«AUTO PIPELINE», «LIVE PREVIEW»).
|
||||
- `.panel` — панель: фон `--panel`, бордер `--line`; композиция
|
||||
`.panel-heading` (label + strong + правый слот) / `.setting-group` /
|
||||
`.settings-footer` с разделителями.
|
||||
- `.checker-canvas` — рабочая поверхность превью: тёмная подложка +
|
||||
шахматка; размеры через контейнер.
|
||||
- `.meta-row` — пары «CAPTION значение» (dimensions/format/size,
|
||||
result-meta).
|
||||
- `.segmented` — сегмент-переключатель (общий для языка, типа градиента,
|
||||
пресетов).
|
||||
|
||||
## 4. Минимальный UI-kit (`src/lib/components/ui/`)
|
||||
|
||||
API компонентов сохраняем, меняем внутренности. Новые компоненты — только
|
||||
те, для которых нет аналога:
|
||||
|
||||
| Компонент | Статус | Что меняется |
|
||||
| --------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `Button.svelte` | рестайл | варианты `primary` (синий solid) / `secondary` (outline) / `ghost` + `icon`-режим; busy-логика остаётся |
|
||||
| `Field.svelte` | рестайл | обёртка в стиле `.setting-group`: mono-label + output справа |
|
||||
| `SliderField` | рестайл | range + `<output>` значения, опциональная строка подсказок («strict edges … more removal») |
|
||||
| `ColorField` | рестайл | swatch + hex-input + нативный color-picker в одном поле |
|
||||
| `TextField` / `SelectField` / `CheckboxField` | рестайл | чекбокс → switch-toggle 32×18 как в рефе |
|
||||
| `SegmentedControl.svelte` | **новый** | RU/EN, Linear/Radial, пресеты прозрачности |
|
||||
| `Badge.svelte` | **новый** | тег типа шага (BACKGROUND/TRANSFORM/STYLE), чип «✓ AUTO» |
|
||||
| `MetaList.svelte` | **новый** | `.meta-row` для DIMENSIONS/FORMAT/SIZE |
|
||||
| `EmptyState.svelte` | рестайл | под новый стиль панелей |
|
||||
|
||||
Иконки: в рефе lucide-react; берём `lucide-svelte` (те же пути, MIT) —
|
||||
16–17px, stroke, приглушённый цвет.
|
||||
|
||||
## 5. Шелл и страницы
|
||||
|
||||
### 5.1 Layout (`+layout.svelte`)
|
||||
|
||||
- Topbar: brand-mark «EP» (синий квадрат 30px) + `easy-png-tools` + версия
|
||||
суффиксом `/ v0.x`; справа — статус «LOCAL-ONLY», icon-btn темы
|
||||
(Moon/Sun), сегмент RU|EN.
|
||||
- Footer: `easy-png-tools vX` · `pipeline is local-only` · © год.
|
||||
- Фон-сетка на корневом контейнере `.app-shell`.
|
||||
|
||||
### 5.2 `/` — воркспейс
|
||||
|
||||
Текущий hero + ToolSearch переезжает в рампу референса: eyebrow
|
||||
«PNG PROCESSING / WORKSPACE», крупный h1 (clamp 36–64px, tracking −0.06em),
|
||||
lede; поиск — строка в стиле file-chip/pipeline-head. «Открыть последний» —
|
||||
кнопка secondary. Каталог `/list-tools` — сетка карточек в стиле
|
||||
`image-card` (label сверху, canvas-превью).
|
||||
|
||||
### 5.3 `/tools/[id]` — страница инструмента
|
||||
|
||||
Главная перестройка. Целевая раскладка из рефа — двухпанельная:
|
||||
|
||||
```txt
|
||||
eyebrow + tool-title (+ LIVE PREVIEW)
|
||||
┌─ settings-panel ────────┐ ┌─ preview-panel ─────────┐
|
||||
│ panel-heading │ │ preview-toolbar │
|
||||
│ setting-group ×N │ │ checker-canvas / source │
|
||||
│ (Slider/Color/Toggle…) │ │ + result comparison │
|
||||
│ settings-footer (reset) │ │ meta-row │
|
||||
└─────────────────────────┘ └─────────────────────────┘
|
||||
```
|
||||
|
||||
- `ParamsCard`/`ParamForm` → секция настроек: поля становятся
|
||||
`.setting-group` через обновлённый `ui/*`.
|
||||
- `SourceCard`/`ResultCard`/`Preview` → превью-панель: toolbar с именем
|
||||
файла и download-btn, canvas на шахматке, result-meta снизу.
|
||||
- Chain-блоки (`ChainToolBlock`) — нумерованные step-cards из главной
|
||||
рефа: индекс `01`, drag-handle, Badge типа, заголовок, кнопка удаления;
|
||||
параметры этапа — те же setting-groups внутри карточки.
|
||||
- Существующий `ToolStageClassic`/inline-вариант заменяются целевой
|
||||
раскладкой; stage-абстракция из plan-inline-params закрывается этим
|
||||
переездом.
|
||||
|
||||
### 5.4 `/demo`
|
||||
|
||||
Зеркало `refs-html/index.html` — витрина пайплайна, используется как
|
||||
приёмочный стенд нового стиля (делается первым среди страниц).
|
||||
|
||||
## 6. Этапы и проверки
|
||||
|
||||
Каждый этап заканчивается зелёными `test`/`check`/`build` и ревью в браузере
|
||||
(обе темы) против соответствующего HTML из `refs-html/`.
|
||||
|
||||
- **A. Фундамент** — новые токены + алиасы старых, @fontsource IBM Plex,
|
||||
утилиты §3, тёмная тема на `[data-theme]`. Критерий: приложение выглядит
|
||||
по-старому (алиасы дают прежние цвета близко к новым), тесты зелёные,
|
||||
FOUC-скрипт темы работает.
|
||||
- **B. UI-kit** — компоненты §4: сначала рестайл существующих, потом новые
|
||||
(`SegmentedControl`, `Badge`, `MetaList`). Критерий: демо-страница kit'а
|
||||
или `/demo` показывает все состояния; unit-тесты форм не падают.
|
||||
- **C. Шелл** — topbar/footer по §5.1, переключение темы и языка в новом
|
||||
виде. Критерий: i18n/theme тесты зелёные, разметка шапки соответствует
|
||||
рефу.
|
||||
- **D. Страница инструмента** — раскладка §5.3 на `/demo`, затем на всех
|
||||
инструментах; chain-карточки. Критерий: смоук §7, скриншоты против
|
||||
`refs-html/gradient.html` и `background-remover.html`.
|
||||
- **E. Главная и каталог** — §5.2. Критерий: поиск/фильтры работают,
|
||||
покрытие search-coverage/i18n тестов зелёное.
|
||||
- **F. Чистка** — удаление алиасов старых токенов, неиспользуемых классов
|
||||
`app.css` (`.tool-stage`, `.pane*`…), финальный проход по брейкпоинтам
|
||||
1200/1100/800/480 из рефа. Критерий: grep по старым токенам пуст,
|
||||
build без предупреждений.
|
||||
|
||||
Оценка объёма: A+B — фундамент (~полдня), C+D — основная работа (~1–2 дня),
|
||||
E+F — добивка (~полдня).
|
||||
|
||||
## 7. Смоук (после этапа D)
|
||||
|
||||
1. Светлая и тёмная тема: сетка фона, панели, шахматка канвасов читаются,
|
||||
контраст mono-лейблов достаточный.
|
||||
2. Инструмент с параметрами (градиент): slider/color/toggle в стиле
|
||||
setting-groups, автозапуск не регрессировал.
|
||||
3. Удаление фона: сравнение source/result, маска, пипетка работают на новой
|
||||
шахматке.
|
||||
4. Chain из 3+ инструментов: step-cards рендерятся, удаление/сброс работают.
|
||||
5. Узкий экран 800px: настройки над превью, sticky выключается как в рефе.
|
||||
6. RU/EN: новые лейблы локализованы; mono-лейблы рефа либо ключи i18n,
|
||||
либо осознанно английские брендовые элементы (см. §8).
|
||||
|
||||
## 8. Открытые вопросы
|
||||
|
||||
- Mono-лейблы рефа на английском (`PROCESSING PIPELINE`). Предложение:
|
||||
завести i18n-ключи, но для техно-эстетики допустимо оставить часть
|
||||
английскими как «приборную панель». Решить на этапе A.
|
||||
- Версия в топбаре/футере: брать из package.json при сборке (define) или
|
||||
захардкодить? Мелочь, решить на этапе C.
|
||||
- `refs/` оставляем в репо как источник правды; при изменении дизайна —
|
||||
правки там, затем синхронизация вручную. Регенерация refs-html описана в
|
||||
его README.
|
||||
@@ -0,0 +1,37 @@
|
||||
# refs-html — статические снимки нового дизайна
|
||||
|
||||
Отформатированный HTML, вытащенный из Next-референса `refs/`, чтобы смотреть
|
||||
макеты как референс по разметке и классам: открыл файл в браузере — увидел
|
||||
дизайн.
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Маршрут в refs | Что показывает |
|
||||
| ------------------------- | ------------------------------------ | --------------------------------------------------- |
|
||||
| `index.html` | `/` | Главная: пайплайн-воркспейс + панель превью |
|
||||
| `demo.html` | `/easy-png-tools/demo` | То же, что главная (зеркало) |
|
||||
| `gradient.html` | `/easy-png-tools/gradient` | Инструмент «Градиент»: настройки + превью + CSS |
|
||||
| `background-remover.html` | `/easy-png-tools/background-remover` | Инструмент «Удаление фона»: сравнение source/result |
|
||||
|
||||
## Особенности
|
||||
|
||||
- **Это статика без JS**: все `<script>` удалены, интерактивности нет.
|
||||
Тумблеры/кнопки/слайдеры не кликаются — это витрина состояний по умолчанию.
|
||||
- Разметка отформатирована (отступы), CSS темы заинлайнен и причёсан —
|
||||
файлы автономны, работают с `file://`.
|
||||
- Тема по умолчанию: `gradient.html` и `background-remover.html` отрисованы
|
||||
в тёмной (так в коде рефа), `index.html`/`demo.html` — в светлой. Чтобы
|
||||
посмотреть светлый вариант тёмных страниц — убрать класс `dark-mode` у
|
||||
`.app-shell`; для тёмной главной — добавить его же.
|
||||
- Источник правды по пикселям — код в `refs/`; здесь только витрина.
|
||||
|
||||
## Как перегенерировать
|
||||
|
||||
```powershell
|
||||
cd refs
|
||||
pnpm install # если ещё не ставили
|
||||
node node_modules\next\dist\bin\next build # output: 'export' включён в next.config.mjs
|
||||
node extract-static.mjs .next\server\app ..\refs-html # форматирование, инлайн-CSS, без скриптов
|
||||
```
|
||||
|
||||
Скрипт: `refs/extract-static.mjs`.
|
||||
File diff suppressed because it is too large
Load Diff
+2576
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
# v0 sandbox internal files
|
||||
__v0_runtime_loader.js
|
||||
__v0_devtools.tsx
|
||||
__v0_jsx-dev-runtime.ts
|
||||
.snowflake/
|
||||
.v0-trash/
|
||||
.vercel/
|
||||
|
||||
# Environment variables
|
||||
.env*.local
|
||||
|
||||
# Common ignores
|
||||
node_modules
|
||||
.next/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Download, Moon, RotateCcw, Sun, Upload, Check } from 'lucide-react'
|
||||
|
||||
export default function BackgroundRemoverPage() {
|
||||
const [color, setColor] = useState('#E8EEF2')
|
||||
const [similarity, setSimilarity] = useState(72)
|
||||
const [outerOnly, setOuterOnly] = useState(true)
|
||||
const [showMask, setShowMask] = useState(false)
|
||||
const [dark, setDark] = useState(true)
|
||||
const [downloaded, setDownloaded] = useState(false)
|
||||
const previewBackground = showMask ? 'repeating-conic-gradient(#7b8791 0 25%, #cbd3da 0 50%) 50% / 28px 28px' : 'repeating-conic-gradient(#d7dce0 0 25%, #f3f5f6 0 50%) 50% / 28px 28px'
|
||||
const subjectStyle = useMemo(() => ({ background: showMask ? '#596773' : 'linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)', opacity: showMask ? .88 : 1, clipPath: `polygon(23% 11%, 73% 8%, 89% 30%, 79% 81%, 52% 93%, 17% 78%, 8% 39%)` }), [showMask])
|
||||
const reset = () => { setColor('#E8EEF2'); setSimilarity(72); setOuterOnly(true); setShowMask(false) }
|
||||
return <main className={`app-shell ${dark ? 'dark-mode' : ''}`}>
|
||||
<header className="topbar"><div className="brand"><span className="brand-mark">EP</span><span>easy-png-tools</span><span className="version">/ BACKGROUND REMOVER</span></div><div className="top-actions"><span className="status"><i /> AUTO PROCESSING</span><button className="icon-btn" aria-label="Toggle theme" onClick={() => setDark(!dark)}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button></div></header>
|
||||
<div className="tool-page"><div className="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div><div className="tool-title"><div><h1>Remove background.</h1><p className="lede">Select a background color and tune the edge detection. Changes are processed automatically in your browser.</p></div><span className="tool-status"><i /> LIVE PREVIEW</span></div>
|
||||
<div className="remover-layout"><section className="settings-panel"><div className="panel-heading"><div><span className="label">REMOVER SETTINGS</span><strong>Configure detection</strong></div><span className="step-type">TOOL 02</span></div>
|
||||
<div className="setting-group"><label>BACKGROUND COLOR</label><div className="color-field"><span className="swatch" style={{ background: color }} /><input value={color} onChange={e => setColor(e.target.value)} /><input className="native-color" type="color" value={color} onChange={e => setColor(e.target.value)} aria-label="Choose background color" /></div><div className="color-reference"><span style={{ background: color }} /> sampled from image background</div></div>
|
||||
<div className="setting-group"><label>COLOR SIMILARITY <output>{similarity}%</output></label><input type="range" min="0" max="100" value={similarity} onChange={e => setSimilarity(Number(e.target.value))} /><div className="range-hints"><span>strict edges</span><span>more removal</span></div></div>
|
||||
<div className="setting-group toggle-group"><label><span>OUTER COLOR ONLY</span><input type="checkbox" checked={outerOnly} onChange={e => setOuterOnly(e.target.checked)} /><b className="toggle" /></label><p>Only remove connected background pixels from the edges.</p></div>
|
||||
<div className="setting-group toggle-group"><label><span>SHOW MASK</span><input type="checkbox" checked={showMask} onChange={e => setShowMask(e.target.checked)} /><b className="toggle" /></label><p>Preview the detected transparency mask.</p></div>
|
||||
<div className="settings-footer"><button className="reset-btn" onClick={reset}><RotateCcw size={14} /> Reset</button><span className="auto-note"><i /> updates automatically</span></div>
|
||||
</section>
|
||||
<section className="remover-preview"><div className="preview-toolbar"><div><span className="label">SOURCE / RESULT</span><strong>comparison.png</strong></div><div className="preview-actions"><span className="processed"><Check size={14} /> processed</span><button className="download-btn" onClick={() => setDownloaded(true)}><Download size={15} /> {downloaded ? 'Downloaded' : 'Download result'}</button></div></div><div className="comparison-grid"><div className="image-card"><div className="image-label"><span>SOURCE</span><b>original.png</b></div><div className="remover-canvas source-canvas"><div className="subject subject-source" style={subjectStyle}><span>OBJECT</span></div><span className="canvas-size">1200 × 800</span></div></div><div className="image-card"><div className="image-label"><span>RESULT</span><b>{showMask ? 'mask-preview.png' : 'removed-bg.png'}</b></div><div className="remover-canvas" style={{ background: previewBackground }}><div className="subject" style={subjectStyle}><span>{showMask ? 'MASK' : 'PNG'}</span></div><span className="canvas-size">1200 × 800</span></div></div></div><div className="result-meta"><span>FORMAT <b>PNG-24</b></span><span>ALPHA <b>{showMask ? 'MASK' : 'ENABLED'}</b></span><span>SIMILARITY <b>{similarity}%</b></span></div></section></div>
|
||||
</div><footer className="footer"><span>easy-png-tools <b>v2.4.0</b></span><span>background remover · local-only</span><span>© 2024</span></footer>
|
||||
</main>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from '../../page'
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown, Copy, Download, Moon, RotateCcw, Sun } from 'lucide-react'
|
||||
|
||||
const directions = ['0° →', '45° ↗', '90° ↑', '135° ↖', '180° ←', '225° ↙', '270° ↓', '315° ↘']
|
||||
|
||||
export default function GradientPage() {
|
||||
const [start, setStart] = useState('#1769D2')
|
||||
const [end, setEnd] = useState('#00A8C7')
|
||||
const [direction, setDirection] = useState(135)
|
||||
const [opacity, setOpacity] = useState(100)
|
||||
const [type, setType] = useState<'linear' | 'radial'>('linear')
|
||||
const [dark, setDark] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const gradient = useMemo(() => type === 'linear' ? `linear-gradient(${direction}deg, ${start} 0%, ${end} 100%)` : `radial-gradient(circle, ${start} 0%, ${end} 100%)`, [direction, end, start, type])
|
||||
const css = `background: ${gradient};\nopacity: ${opacity / 100};`
|
||||
const reset = () => { setStart('#1769D2'); setEnd('#00A8C7'); setDirection(135); setOpacity(100); setType('linear') }
|
||||
const copyCss = async () => { await navigator.clipboard?.writeText(css); setCopied(true); setTimeout(() => setCopied(false), 1400) }
|
||||
return <main className={`app-shell ${dark ? 'dark-mode' : ''}`}>
|
||||
<header className="topbar"><div className="brand"><span className="brand-mark">EP</span><span>easy-png-tools</span><span className="version">/ GRADIENT</span></div><div className="top-actions"><button className="icon-btn" aria-label="Toggle theme" onClick={() => setDark(!dark)}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button></div></header>
|
||||
<div className="tool-page"><div className="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div><div className="tool-title"><div><h1>Gradient background.</h1><p className="lede">Create a clean, export-ready gradient with precise control over color, direction and transparency.</p></div><span className="tool-status"><i /> LIVE PREVIEW</span></div>
|
||||
<div className="gradient-layout"><section className="settings-panel"><div className="panel-heading"><div><span className="label">GRADIENT SETTINGS</span><strong>Configure output</strong></div><span className="step-type">TOOL 01</span></div>
|
||||
<div className="setting-group"><label>GRADIENT TYPE</label><div className="segmented wide"><button className={type === 'linear' ? 'selected' : ''} onClick={() => setType('linear')}>Linear</button><button className={type === 'radial' ? 'selected' : ''} onClick={() => setType('radial')}>Radial</button></div></div>
|
||||
<div className="setting-group"><label>COLOR STOPS</label><div className="color-row"><div className="color-field"><span className="swatch" style={{ background: start }} /><input value={start} onChange={e => setStart(e.target.value)} /></div><span className="stop-arrow">→</span><div className="color-field"><span className="swatch" style={{ background: end }} /><input value={end} onChange={e => setEnd(e.target.value)} /></div></div><div className="gradient-bar" style={{ background: gradient }} /></div>
|
||||
<div className="setting-group"><label>DIRECTION <output>{direction}°</output></label><input type="range" min="0" max="360" value={direction} onChange={e => setDirection(Number(e.target.value))} /><div className="direction-grid">{directions.map((item, i) => <button key={item} className={direction === i * 45 ? 'selected' : ''} onClick={() => setDirection(i * 45)}>{item}</button>)}</div></div>
|
||||
<div className="setting-group"><label>OPACITY <output>{opacity}%</output></label><input type="range" min="0" max="100" value={opacity} onChange={e => setOpacity(Number(e.target.value))} /></div>
|
||||
<div className="settings-footer"><button className="reset-btn" onClick={reset}><RotateCcw size={14} /> Reset</button><span className="auto-note"><i /> updates automatically</span></div>
|
||||
</section>
|
||||
<section className="gradient-preview"><div className="preview-toolbar"><div><span className="label">OUTPUT PREVIEW</span><strong>gradient.png</strong></div><div className="preview-actions"><button className="secondary-btn" onClick={copyCss}>{copied ? <Check size={15} /> : <Copy size={15} />} {copied ? 'Copied' : 'Copy CSS'}</button><button className="download-btn"><Download size={15} /> Download PNG <ChevronDown size={14} /></button></div></div><div className="large-canvas"><div className="gradient-art" style={{ background: gradient, opacity: opacity / 100 }}><div className="art-mark">PNG</div><span>easy-png-tools</span></div></div><div className="code-block"><div><span className="label">GENERATED CSS</span><button className="icon-btn" onClick={copyCss} aria-label="Copy CSS"><Copy size={14} /></button></div><pre>{css}</pre></div></section></div>
|
||||
</div><footer className="footer"><span>easy-png-tools <b>v2.4.0</b></span><span>gradient tool · local-only</span><span>© 2024</span></footer>
|
||||
</main>
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useMemo, useState } from "react"
|
||||
import { ArrowUpRight, FileImage, Filter, Search, Sparkles } from "lucide-react"
|
||||
|
||||
const groups = [
|
||||
{ name: "CONVERT", tools: [["Convert JPG to PNG", "Re-encode JPEG files as lossless PNG while preserving transparency.", "/easy-png-tools/gradient"], ["Convert WebP to PNG", "Turn WebP images into a universal PNG format for any workflow."], ["PNG to Base64", "Encode an image as a base64 string for embedding in code or styles."], ["PNG to Data URI", "Build a complete data URI ready for HTML and CSS."], ["Convert PNG to JPG", "Composite transparency over a selected backdrop and export JPEG."]] },
|
||||
{ name: "TRANSPARENCY", tools: [["Remove background PNG", "Remove a solid background by color, tolerance, or edge-connected regions.", "/easy-png-tools/background-remover"], ["Extract alpha mask", "Turn the alpha channel into a clean black-and-white mask."], ["Round corners PNG", "Clip the image corners by a precise radius percentage."], ["Outline PNG", "Add a colored ring around opaque content with adjustable thickness."], ["Change PNG opacity", "Multiply the alpha channel while keeping the original colors unchanged."]] },
|
||||
{ name: "COLOR", tools: [["Create gradient PNG", "Generate a smooth transition between two colors with direction controls.", "/easy-png-tools/gradient"], ["Grayscale PNG", "Convert the image to luminance-based shades of gray."], ["Invert colors PNG", "Invert every color channel while leaving alpha untouched."], ["Brightness & contrast", "Adjust brightness and contrast across a controlled range."], ["Temperature PNG", "Make an image warmer or cooler with a single precise control."]] },
|
||||
{ name: "GEOMETRY", tools: [["Resize PNG", "Scale an image with bilinear interpolation and optional aspect lock."], ["Crop PNG", "Cut a rectangular area with exact coordinates and dimensions."], ["Rotate PNG", "Rotate by 90, 180, or 270 degrees without quality loss."], ["Flip PNG", "Mirror the image horizontally or vertically."], ["Add padding to PNG", "Expand the canvas on all sides by a chosen number of pixels."]] },
|
||||
{ name: "FILTERS", tools: [["Blur PNG", "Apply a fast Gaussian-style blur with transparent edge handling."], ["Sharpen PNG", "Emphasize edges with an adjustable sharpening kernel."], ["Vignette PNG", "Smoothly darken the image edges while preserving the center."], ["JPEG artifacts", "Simulate low-quality JPEG recompression for testing."]] },
|
||||
{ name: "ANALYZE", tools: [["PNG info", "Inspect dimensions, alpha presence, and unique color count."], ["Check grayscale", "Report whether the image contains only shades of gray."], ["Check transparency", "Detect transparent and semi-transparent pixels."], ["PNG orientation", "Classify the image as portrait, landscape, or square."]] },
|
||||
]
|
||||
|
||||
export default function ListToolsPage() {
|
||||
const [query, setQuery] = useState("")
|
||||
const [active, setActive] = useState("ALL")
|
||||
const filtered = useMemo(() => groups.map(group => ({ ...group, tools: group.tools.filter(([name, description]) => (active === "ALL" || active === group.name) && `${name} ${description}`.toLowerCase().includes(query.toLowerCase())) })).filter(group => group.tools.length), [query, active])
|
||||
return <main className="catalog-page"><header className="catalog-nav"><Link href="/" className="catalog-brand">easy-png-tools</Link><nav><Link href="/">Workspace</Link><Link className="current" href="/easy-png-tools/list-tools">Catalog</Link></nav><span className="catalog-nav-status">LOCAL MODE / READY</span></header>
|
||||
<div className="catalog-head"><div><div className="eyebrow">EASY-PNG-TOOLS / CATALOG</div><h1>Tool catalog</h1><p>Focused utilities for working with PNG. Inspect, transform, and export — locally in your browser.</p></div><div className="catalog-total"><b>32</b><span>TOOLS<br />AVAILABLE</span></div></div>
|
||||
<div className="catalog-toolbar"><label className="catalog-search"><Search size={16} /><input aria-label="Search tools" placeholder="Search tools..." value={query} onChange={e => setQuery(e.target.value)} /></label><div className="catalog-filters"><Filter size={15} />{["ALL", ...groups.map(g => g.name)].map(name => <button key={name} className={active === name ? "active" : ""} onClick={() => setActive(name)}>{name}</button>)}</div></div>
|
||||
<div className="catalog-groups">{filtered.map(group => <section className="catalog-group" key={group.name}><div className="group-title"><span>{group.name}</span><i>{String(group.tools.length).padStart(2, "0")} TOOLS</i></div><div className="tool-cards">{group.tools.map(([name, description, href], index) => <Link className="tool-card" href={href || "#"} key={name} onClick={e => { if (!href) e.preventDefault() }}><span className="tool-icon"><FileImage size={19} /></span><span className="tool-copy"><strong>{name}</strong><span>{description}</span></span><span className="tool-index">{String(index + 1).padStart(2, "0")}</span><ArrowUpRight size={16} className="tool-arrow" /></Link>)}</div></section>)}</div>
|
||||
{!filtered.length && <div className="catalog-empty"><Sparkles size={18} /> No tools match your search.</div>}
|
||||
<footer className="catalog-footer">ALL OPERATIONS RUN LOCALLY <span>•</span> YOUR FILES NEVER LEAVE THIS DEVICE</footer>
|
||||
</main>
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
||||
import { Analytics } from '@vercel/analytics/next'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'easy-png-tools / Demo',
|
||||
description: 'Technical workspace for building PNG processing pipelines.',
|
||||
generator: 'easy-png-tools',
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
colorScheme: 'light',
|
||||
themeColor: '#eef1f4',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return <html lang="ru" className="bg-background"><body className="antialiased">{children}{process.env.NODE_ENV === 'production' && <Analytics />}</body></html>
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ArrowDownToLine, Check, ChevronDown, CircleHelp, Download, GripVertical, Link2, Moon, MoreHorizontal, Plus, RotateCcw, Settings2, SlidersHorizontal, Sun, Upload, X } from 'lucide-react'
|
||||
|
||||
const initialSteps = [
|
||||
{ id: 1, title: 'Gradient background', type: 'BACKGROUND' },
|
||||
{ id: 2, title: 'Remove background', type: 'TRANSFORM' },
|
||||
{ id: 3, title: 'Add outline', type: 'STYLE' },
|
||||
{ id: 4, title: 'Round corners', type: 'STYLE' },
|
||||
]
|
||||
|
||||
function PreviewTile({ label, sublabel, style, active = false }: { label: string; sublabel: string; style: React.CSSProperties; active?: boolean }) {
|
||||
return <div className={`preview-tile ${active ? 'active' : ''}`}><div className="tile-canvas"><div className="image-preview" style={style}><span className="sample-icon">PNG</span><span>easy-png-tools</span></div></div><div className="tile-label"><span>{label}</span><b>{sublabel}</b></div></div>
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [steps, setSteps] = useState(initialSteps)
|
||||
const [angle, setAngle] = useState(135)
|
||||
const [radius, setRadius] = useState(18)
|
||||
const [outline, setOutline] = useState(2)
|
||||
const [language, setLanguage] = useState('RU')
|
||||
const [dark, setDark] = useState(false)
|
||||
const [showIntermediate, setShowIntermediate] = useState(true)
|
||||
const [gradient, setGradient] = useState('#DCEBFF')
|
||||
const gradientStyle = useMemo(() => ({ background: `linear-gradient(${angle}deg, ${gradient}, #8BC8F5)` }), [angle, gradient])
|
||||
const finalStyle = { ...gradientStyle, borderRadius: radius, boxShadow: `0 0 0 ${outline}px #16202B` }
|
||||
|
||||
return <main className={dark ? 'app-shell dark-mode' : 'app-shell'}>
|
||||
<header className="topbar"><div className="brand"><span className="brand-mark">EP</span><span>easy-png-tools</span><span className="version">/ DEMO</span></div><div className="top-actions"><span className="status"><i /> AUTO PIPELINE</span><button className="icon-btn" aria-label="Help"><CircleHelp size={17} /></button><button className="icon-btn" aria-label="Toggle theme" onClick={() => setDark(!dark)}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button><div className="language"><button className={language === 'RU' ? 'active' : ''} onClick={() => setLanguage('RU')}>RU</button><button className={language === 'EN' ? 'active' : ''} onClick={() => setLanguage('EN')}>EN</button></div></div></header>
|
||||
<div className="page-grid">
|
||||
<section className="workspace"><div className="eyebrow">PNG PROCESSING <span>/</span> WORKSPACE</div><div className="title-row"><div><h1>Build your image pipeline.</h1><p className="lede">Chain simple tools together. Every change is processed automatically and previewed at each stage.</p></div><div className="file-chip"><Upload size={15} /><span>source.png</span><b>1.8 MB</b></div></div>
|
||||
<div className="pipeline-head"><div><span className="label">PROCESSING PIPELINE</span><strong>{steps.length} active steps <em>• LIVE</em></strong></div><button className="add-btn" onClick={() => setSteps([...steps, { id: Date.now(), title: 'New adjustment', type: 'STYLE' }])}><Plus size={15} /> Add tool</button></div>
|
||||
<div className="steps-list">{steps.map((step, index) => <article className="step-card" key={step.id}><div className="step-index">{String(index + 1).padStart(2, '0')}</div><GripVertical className="drag" size={16} /><div className="step-body"><div className="step-heading"><div><span className="step-type">{step.type}</span><h2>{step.title}</h2></div><div className="step-tools"><span className="check"><Check size={12} /> AUTO</span><button aria-label="Remove step" onClick={() => setSteps(steps.filter(item => item.id !== step.id))}><X size={16} /></button><MoreHorizontal size={17} /></div></div>{index === 0 ? <div className="controls"><div className="control-block"><label>COLOR</label><div className="color-field"><span className="swatch" style={{ background: gradient }} /><input value={gradient} onChange={e => setGradient(e.target.value)} aria-label="Gradient color" /><ChevronDown size={14} /></div></div><div className="control-block"><label>DIRECTION <output>{angle}°</output></label><input type="range" min="0" max="360" value={angle} onChange={e => setAngle(Number(e.target.value))} /></div><div className="control-block"><label>OPACITY <output>100%</output></label><div className="segmented"><button className="selected">100</button><button>75</button><button>50</button><button>25</button></div></div></div> : index === 2 ? <div className="controls compact"><div className="control-block"><label>WIDTH <output>{outline}px</output></label><input type="range" min="0" max="8" value={outline} onChange={e => setOutline(Number(e.target.value))} /></div><div className="control-block"><label>COLOR</label><div className="color-field"><span className="swatch dark" /><input value="#16202B" readOnly /></div></div></div> : index === 3 ? <div className="controls compact"><div className="control-block"><label>RADIUS <output>{radius}px</output></label><input type="range" min="0" max="48" value={radius} onChange={e => setRadius(Number(e.target.value))} /></div><div className="toggle-row"><span>Preserve aspect ratio</span><button className="toggle on" aria-label="Preserve aspect ratio"><i /></button></div></div> : <div className="transform-note"><SlidersHorizontal size={15} /> Automatic subject detection enabled</div>}</div></article>)}</div>
|
||||
<div className="pipeline-footer"><button className="reset-btn" onClick={() => setSteps(initialSteps)}><RotateCcw size={14} /> Reset pipeline</button><span className="auto-note"><i /> changes are applied automatically</span></div>
|
||||
</section>
|
||||
<section className="preview-panel"><div className="preview-top"><div><span className="label">PIPELINE OUTPUTS</span><strong>Visual history</strong></div><div className="preview-actions"><button className="history-toggle top-toggle" onClick={() => setShowIntermediate(!showIntermediate)} aria-expanded={showIntermediate}>{showIntermediate ? 'Hide intermediate' : 'Show intermediate'} <ChevronDown size={15} className={showIntermediate ? 'rotated' : ''} /></button><button className="download-btn"><Download size={16} /> Download result <ArrowDownToLine size={14} /></button><div className="preview-meta"><div><span>DIMENSIONS</span><b>1200 × 800 px</b></div><div><span>FORMAT</span><b>PNG-24</b></div><div><span>SIZE</span><b>1.2 MB</b></div></div><button className="icon-btn" aria-label="Preview settings"><Settings2 size={17} /></button></div></div><div className="preview-stack"><PreviewTile label="SOURCE" sublabel="original.png · 1200 × 800" style={{ background: '#8d9aa5' }} />{showIntermediate && <><PreviewTile label="STEP 01" sublabel="gradient applied" style={gradientStyle} /><PreviewTile label="STEP 02" sublabel="background removed" style={{ ...gradientStyle, clipPath: 'inset(10% 8% 10% 8% round 12px)' }} /><PreviewTile label="STEP 03" sublabel="outline added" style={{ ...gradientStyle, boxShadow: `0 0 0 ${outline}px #16202B` }} /></>}<PreviewTile label="FINAL OUTPUT" sublabel="ready · PNG-24" style={finalStyle} active /></div><p className="preview-note">Output is generated in-browser. Your files never leave this device.</p></section>
|
||||
</div><footer className="footer"><span>easy-png-tools <b>v2.4.0</b></span><span><Link2 size={13} /> pipeline is local-only</span><span>© 2024</span></footer>
|
||||
</main>
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from '@base-ui/react/button'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
outline:
|
||||
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost:
|
||||
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
||||
destructive:
|
||||
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
icon: 'size-8',
|
||||
'icon-xs':
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm':
|
||||
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||
'icon-lg': 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,118 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname, basename } from 'node:path';
|
||||
|
||||
// Usage: node extract-static.mjs <source-out-dir> <dest-dir>
|
||||
// e.g. node extract-static.mjs .next\server\app ..\refs-html
|
||||
|
||||
const root = process.argv[2];
|
||||
const dest = process.argv[3];
|
||||
|
||||
// --- CSS: find the compiled chunk and pretty-print it ---
|
||||
const cssChunks = join(root, '..', '..', 'static', 'chunks');
|
||||
const cssFile = readdirSync(cssChunks)
|
||||
.filter(f => f.endsWith('.css'))
|
||||
.sort((a, b) => statSync(join(cssChunks, b)).size - statSync(join(cssChunks, a)).size)[0];
|
||||
const css = readFileSync(join(cssChunks, cssFile), 'utf8');
|
||||
|
||||
const prettyCss = css
|
||||
.replace(/\r/g, '')
|
||||
.replace(/\{/g, ' {\n ')
|
||||
.replace(/;/g, ';\n ')
|
||||
.replace(/\}/g, '\n}\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
// --- conservative html formatter ---
|
||||
|
||||
const VOID = new Set([
|
||||
'area','base','br','col','embed','hr','img','input',
|
||||
'link','meta','param','source','track','wbr',
|
||||
]);
|
||||
const RAW = new Set(['pre','textarea','script','style']);
|
||||
|
||||
function fmtHtml(html) {
|
||||
const tokens = html.match(
|
||||
/<!--[\s\S]*?-->|<!doctype[^>]*>|<\/?[a-zA-Z][^>]*>|[^<]+|</gi
|
||||
) || [];
|
||||
const out = [];
|
||||
let depth = 0;
|
||||
let rawTag = null;
|
||||
|
||||
for (const tk of tokens) {
|
||||
if (rawTag) {
|
||||
out.push(tk);
|
||||
if (new RegExp(`</${rawTag}\\s*>`, 'i').test(tk)) {
|
||||
depth--;
|
||||
rawTag = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const isOpen = /^<[a-zA-Z]/.test(tk);
|
||||
const isClose = /^<\//.test(tk);
|
||||
let name = '';
|
||||
if (isOpen || isClose) {
|
||||
name = (tk.match(/^<\/?([a-zA-Z0-9-]+)/) || [])[1]?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
if (isOpen && RAW.has(name)) {
|
||||
out.push('\n' + ' '.repeat(depth) + tk);
|
||||
if (!/\/>$/.test(tk)) { depth++; rawTag = name; }
|
||||
} else if (isOpen && !RAW.has(name)) {
|
||||
out.push('\n' + ' '.repeat(depth) + tk);
|
||||
if (!/\/>$/.test(tk) && !VOID.has(name)) depth++;
|
||||
} else if (isClose) {
|
||||
depth = Math.max(0, depth - 1);
|
||||
out.push('\n' + ' '.repeat(depth) + tk);
|
||||
} else if (/^</.test(tk)) {
|
||||
out.push('\n' + ' '.repeat(depth) + tk);
|
||||
} else {
|
||||
const t = tk.replace(/\s+/g, ' ');
|
||||
if (t.trim()) out.push(t);
|
||||
}
|
||||
}
|
||||
return out.join('').replace(/^\n/, '').replace(/\n{3,}/g, '\n\n');
|
||||
}
|
||||
|
||||
// --- auto-discover pages by walking the export dir for *.html ---
|
||||
function walk(dir, base = '') {
|
||||
const found = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const rel = base ? join(base, entry) : entry;
|
||||
if (statSync(full).isDirectory()) {
|
||||
found.push(...walk(full, rel));
|
||||
} else if (entry.endsWith('.html')) {
|
||||
found.push(rel);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const pages = walk(root)
|
||||
// skip Next internals
|
||||
.filter((rel) => !basename(rel).startsWith('_') && !basename(rel).startsWith('404'))
|
||||
.map((rel) => [rel, basename(rel)]);
|
||||
|
||||
console.log(`discovered ${pages.length} page(s):`, pages.map((p) => p[1]).join(', '));
|
||||
|
||||
for (const [src, out] of pages) {
|
||||
let html = readFileSync(join(root, src), 'utf8');
|
||||
|
||||
// strip scripts — pure static snapshot
|
||||
html = html.replace(/<script[\s\S]*?<\/script>/g, '');
|
||||
html = html.replace(/<link[^>]+rel="preload"[^>]+as="script"[^>]*>/g, '');
|
||||
|
||||
// inline compiled css instead of linking /_next/...
|
||||
html = html.replace(
|
||||
/<link[^>]+rel="stylesheet"[^>]*>/g,
|
||||
() => '<style>\n' + prettyCss + '\n</style>',
|
||||
);
|
||||
|
||||
html = fmtHtml(html);
|
||||
|
||||
const file = join(dest, out);
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, html);
|
||||
console.log('written:', file);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "my-project",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"html": "node extract-static.mjs .next\\server\\app ..\\refs-html",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@vercel/analytics": "1.6.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"next": "16.3.0",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"shadcn": "^4.8.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/node": "^24",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "5.7.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"hono": "4.12.25"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3931
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
msw: set this to true or false
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"target": "ES6",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user