feat: ConvertGUI v2.0 (Wails + preview/trim + NSIS + Gitea CI)
Build Windows Installer / build (push) Successful in 4m11s

This commit is contained in:
m4xx1m committed 2026-11-12 17:30:44 +03:00
1 parent 02fd1dc817
commit 65387c5dcd
40 files changed
+6084 -266

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ConvertGUI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1173
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "convert-gui-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"test": "node --experimental-strip-types --test src/timecode.test.ts"
},
"devDependencies": {
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}
+100
View File
@@ -0,0 +1,100 @@
export type Lang = 'ru' | 'en';
export const dict = {
ru: {
openVideo: 'Открыть видео',
dropTitle: 'Перетащите видео сюда',
dropHint: 'MP4, MKV, MOV, AVI, WebM — или нажмите «Открыть видео»',
ffmpegOk: 'FFmpeg готов',
ffmpegMissing: 'FFmpeg не найден (указать)',
setStart: 'Начало [I]',
setEnd: 'Конец [O]',
loopTrim: 'Повтор фрагмента',
previewMix: 'Микс дорожек в превью',
proxyLoading: 'Подготовка совместимого превью...',
trimStart: 'Начало (ss)',
trimEnd: 'Конец (to)',
clipDur: 'Длительность',
outDur: 'Итог (со скор.)',
resetTrim: 'Сбросить',
sectionVideo: 'Видео и качество',
encoder: 'Кодек',
hwDecode: 'Аппар. декодинг',
crf: 'Качество (CRF / CQ)',
preset: 'Пресет скорости',
tune: 'Тонкая настройка',
fps: 'FPS',
fpsOriginal: 'Оригинал',
sectionSpeed: 'Скорость воспроизведения',
sectionAudio: 'Аудиодорожки',
audioMerge: 'Объединить (amix)',
audioTrack: 'Одна дорожка',
audioMute: 'Без звука (-an)',
noAudio: 'В видео нет аудиодорожек',
sectionCrop: 'Кадрирование (Crop)',
cropEnable: 'Включить обрезку кадра',
sectionOutput: 'Выходной файл',
browse: 'Обзор...',
showCli: 'Команда FFmpeg',
copy: 'Копировать',
copied: 'Скопировано',
convertBtn: 'Конвертировать',
cancelBtn: 'Отмена',
doneTitle: 'Готово!',
openFolder: 'Открыть папку',
playOutput: 'Открыть файл',
crfHintBest: 'Почти без потерь',
crfHintBal: 'Оптимальный баланс',
crfHintComp: 'Сильное сжатие',
crfHintSmall: 'Минимальный размер',
eta: 'Осталось',
},
en: {
openVideo: 'Open Video',
dropTitle: 'Drop a video file here',
dropHint: 'MP4, MKV, MOV, AVI, WebM — or click "Open Video"',
ffmpegOk: 'FFmpeg ready',
ffmpegMissing: 'FFmpeg missing (locate)',
setStart: 'Set In [I]',
setEnd: 'Set Out [O]',
loopTrim: 'Loop Trim',
previewMix: 'Mix Audio in Preview',
proxyLoading: 'Preparing compatible preview...',
trimStart: 'Start (ss)',
trimEnd: 'End (to)',
clipDur: 'Selection',
outDur: 'Output (w/ speed)',
resetTrim: 'Reset',
sectionVideo: 'Video & Quality',
encoder: 'Encoder',
hwDecode: 'HW Decode',
crf: 'Quality (CRF / CQ)',
preset: 'Preset',
tune: 'Tune',
fps: 'FPS',
fpsOriginal: 'Original',
sectionSpeed: 'Playback Speed',
sectionAudio: 'Audio Streams',
audioMerge: 'Merge Tracks (amix)',
audioTrack: 'Single Track',
audioMute: 'Mute (-an)',
noAudio: 'No audio streams in source',
sectionCrop: 'Frame Crop',
cropEnable: 'Enable spatial crop',
sectionOutput: 'Output File',
browse: 'Browse...',
showCli: 'FFmpeg CLI',
copy: 'Copy',
copied: 'Copied',
convertBtn: 'Convert Video',
cancelBtn: 'Cancel',
doneTitle: 'Conversion Complete!',
openFolder: 'Show in Folder',
playOutput: 'Open File',
crfHintBest: 'Near lossless',
crfHintBal: 'Sweet spot',
crfHintComp: 'Compact size',
crfHintSmall: 'Heavy compression',
eta: 'ETA',
},
} as const;
+1224
View File
File diff suppressed because it is too large. Load diff
+684
View File
@@ -0,0 +1,684 @@
:root {
--bg: #0b0f17;
--panel: #111827;
--panel-2: #161f30;
--border: #243044;
--text: #f1f5f9;
--muted: #94a3b8;
--cyan: #06b6d4;
--cyan-glow: rgba(6, 182, 212, 0.25);
--violet: #8b5cf6;
--green: #10b981;
--red: #ef4444;
--amber: #f59e0b;
--radius: 10px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 13px;
user-select: none;
overflow: hidden;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Top Bar */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-icon {
width: 28px;
height: 28px;
border-radius: 7px;
background: linear-gradient(135deg, var(--cyan), var(--violet));
display: grid;
place-items: center;
font-weight: 800;
color: #fff;
font-size: 14px;
}
.brand-title {
font-weight: 700;
font-size: 15px;
letter-spacing: 0.2px;
}
.badge {
font-size: 11px;
padding: 2px 7px;
border-radius: 999px;
background: var(--panel-2);
border: 1px solid var(--border);
color: var(--muted);
}
.topbar-center {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
justify-content: center;
}
.file-pill {
display: flex;
align-items: center;
gap: 8px;
background: var(--panel-2);
border: 1px solid var(--border);
padding: 5px 12px;
border-radius: 999px;
max-width: 520px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.file-pill strong {
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
color: var(--muted);
font-size: 11px;
font-family: var(--mono);
}
.topbar-right {
display: flex;
align-items: center;
gap: 8px;
}
/* Buttons */
button {
font-family: inherit;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
padding: 6px 12px;
border-radius: 7px;
cursor: pointer;
transition: all 0.14s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
button:hover:not(:disabled) {
border-color: var(--cyan);
background: #1c283d;
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
button.primary {
background: linear-gradient(135deg, #0891b2, #4f46e5);
border-color: transparent;
color: #fff;
}
button.primary:hover:not(:disabled) {
filter: brightness(1.1);
}
button.danger {
background: rgba(239, 68, 68, 0.16);
border-color: rgba(239, 68, 68, 0.5);
color: #fca5a5;
}
button.pill-active {
background: var(--cyan-glow);
border-color: var(--cyan);
color: #cffafe;
}
.status-pill {
font-size: 11px;
padding: 5px 10px;
border-radius: 999px;
}
.status-pill.ok {
background: rgba(16, 185, 129, 0.14);
border-color: rgba(16, 185, 129, 0.4);
color: #6ee7b7;
}
.status-pill.warn {
background: rgba(245, 158, 11, 0.16);
border-color: rgba(245, 158, 11, 0.5);
color: #fcd34d;
}
/* Main Layout */
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 390px;
flex: 1;
min-height: 0;
}
/* Left Stage */
.stage-col {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 14px;
gap: 12px;
}
.player-wrap {
position: relative;
flex: 1;
min-height: 240px;
background: #05070b;
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.video-stage {
position: relative;
display: inline-flex;
max-width: 100%;
max-height: 100%;
}
video {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
background: #000;
}
.drop-overlay {
position: absolute;
inset: 14px;
border: 2px dashed var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
color: var(--muted);
background: rgba(11, 15, 23, 0.85);
cursor: pointer;
}
.drop-overlay.drag-over {
border-color: var(--cyan);
background: rgba(6, 182, 212, 0.08);
color: var(--text);
}
.drop-overlay h2 {
margin: 0;
font-size: 18px;
color: var(--text);
}
.proxy-banner {
position: absolute;
top: 10px;
left: 10px;
background: rgba(15, 23, 42, 0.85);
border: 1px solid var(--cyan);
color: #cffafe;
padding: 5px 10px;
border-radius: 6px;
font-size: 11px;
z-index: 5;
}
/* Crop Overlay */
.crop-overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.crop-box {
position: absolute;
border: 2px solid var(--cyan);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.58);
cursor: move;
pointer-events: auto;
}
.crop-handle {
position: absolute;
width: 11px;
height: 11px;
background: #fff;
border: 2px solid var(--cyan);
border-radius: 2px;
}
.crop-handle.nw { top: -6px; left: -6px; cursor: nwse-resize; }
.crop-handle.ne { top: -6px; right: -6px; cursor: nesw-resize; }
.crop-handle.sw { bottom: -6px; left: -6px; cursor: nesw-resize; }
.crop-handle.se { bottom: -6px; right: -6px; cursor: nwse-resize; }
/* Transport Bar */
.transport {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 12px;
}
.transport-group {
display: flex;
align-items: center;
gap: 6px;
}
.time-readout {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
background: var(--bg);
padding: 4px 8px;
border-radius: 6px;
border: 1px solid var(--border);
}
/* Timeline & Trim */
.timeline-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.timeline-track {
position: relative;
height: 54px;
background: #070a10;
border: 1px solid var(--border);
border-radius: 7px;
overflow: visible;
cursor: pointer;
}
.filmstrip {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(12, 1fr);
overflow: hidden;
border-radius: 6px;
}
.filmstrip img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.72;
pointer-events: none;
}
.trim-dim-left,
.trim-dim-right {
position: absolute;
top: 0;
bottom: 0;
background: rgba(5, 7, 11, 0.72);
pointer-events: none;
}
.trim-dim-left { left: 0; border-radius: 6px 0 0 6px; }
.trim-dim-right { right: 0; border-radius: 0 6px 6px 0; }
.trim-selection {
position: absolute;
top: 0;
bottom: 0;
border-top: 2px solid var(--cyan);
border-bottom: 2px solid var(--cyan);
pointer-events: none;
}
.trim-handle {
position: absolute;
top: -3px;
bottom: -3px;
width: 12px;
transform: translateX(-50%);
cursor: ew-resize;
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
}
.trim-handle::before {
content: '';
width: 6px;
height: 100%;
border-radius: 3px;
}
.trim-handle.start::before {
background: var(--cyan);
box-shadow: 0 0 8px var(--cyan);
}
.trim-handle.end::before {
background: var(--violet);
box-shadow: 0 0 8px var(--violet);
}
.playhead {
position: absolute;
top: -5px;
bottom: -5px;
width: 2px;
background: #fff;
transform: translateX(-50%);
pointer-events: none;
z-index: 5;
box-shadow: 0 0 6px rgba(255, 255, 255, 0.8);
}
.playhead::before {
content: '';
position: absolute;
top: -2px;
left: -4px;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #fff;
}
.trim-inputs {
display: grid;
grid-template-columns: 1fr 1fr auto auto auto;
gap: 8px;
align-items: end;
}
.field-mini {
display: flex;
flex-direction: column;
gap: 3px;
}
.field-mini label {
font-size: 11px;
color: var(--muted);
}
.stat-chip {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 9px;
font-family: var(--mono);
font-size: 12px;
white-space: nowrap;
}
/* Right Settings Panel */
.settings-col {
background: var(--panel);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
}
.settings-scroll {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
.card {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.card-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--muted);
display: flex;
align-items: center;
justify-content: space-between;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field label {
font-size: 11px;
color: var(--muted);
display: flex;
justify-content: space-between;
}
input[type='text'],
input[type='number'],
select {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 8px;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
outline: none;
}
input[type='text']:focus,
input[type='number']:focus,
select:focus {
border-color: var(--cyan);
}
input.mono {
font-family: var(--mono);
}
input[type='range'] {
width: 100%;
accent-color: var(--cyan);
}
.pill-row {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.pill-btn {
padding: 4px 8px;
font-size: 11px;
border-radius: 6px;
}
.track-list {
display: flex;
flex-direction: column;
gap: 5px;
}
.track-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
background: var(--bg);
border: 1px solid var(--border);
padding: 5px 8px;
border-radius: 6px;
}
.input-row {
display: flex;
gap: 6px;
}
.input-row input {
flex: 1;
}
.cli-box {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
font-family: var(--mono);
font-size: 11px;
color: #a5f3fc;
word-break: break-all;
max-height: 90px;
overflow-y: auto;
user-select: text;
}
/* Sticky Bottom Action */
.action-footer {
padding: 12px 14px;
border-top: 1px solid var(--border);
background: var(--panel);
display: flex;
flex-direction: column;
gap: 8px;
}
.progress-wrap {
display: flex;
flex-direction: column;
gap: 5px;
}
.progress-bar-bg {
height: 8px;
background: var(--bg);
border-radius: 999px;
overflow: hidden;
border: 1px solid var(--border);
}
.progress-bar-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--cyan), var(--violet));
transition: width 0.15s linear;
}
.progress-meta {
display: flex;
justify-content: space-between;
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
}
.done-banner {
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.45);
border-radius: 8px;
padding: 9px 10px;
display: flex;
flex-direction: column;
gap: 7px;
}
.error-banner {
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.45);
border-radius: 8px;
padding: 8px 10px;
color: #fca5a5;
font-size: 11px;
word-break: break-word;
user-select: text;
}
.hidden {
display: none !important;
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { formatTimecode, parseTimecode, formatBytes, clampTrimRange } from './timecode.ts';
describe('timecode helpers', () => {
it('formats seconds to HH:MM:SS.mmm', () => {
assert.equal(formatTimecode(0), '00:00:00.000');
assert.equal(formatTimecode(65.25), '00:01:05.250');
assert.equal(formatTimecode(3661.5), '01:01:01.500');
assert.equal(formatTimecode(125, false), '02:05');
});
it('parses numeric seconds and HH:MM:SS.mmm', () => {
assert.equal(parseTimecode('42.5'), 42.5);
assert.equal(parseTimecode('01:05.250'), 65.25);
assert.equal(parseTimecode('01:02:03.5'), 3723.5);
assert.equal(parseTimecode('invalid'), null);
assert.equal(parseTimecode('00:75:00'), null);
});
it('clamps trim range cleanly', () => {
assert.deepEqual(clampTrimRange(-5, 200, 60), { start: 0, end: 60 });
assert.deepEqual(clampTrimRange(30, 30, 60, 0.5), { start: 30, end: 30.5 });
assert.deepEqual(clampTrimRange(59.9, 60, 60, 0.5), { start: 59.5, end: 60 });
});
it('formats bytes', () => {
assert.equal(formatBytes(0), '0 B');
assert.equal(formatBytes(1024 * 1024 * 25.4), '25.4 MB');
});
});
+94
View File
@@ -0,0 +1,94 @@
export function formatTimecode(seconds: number, includeMillis = true): string {
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
const totalMs = Math.round(seconds * 1000);
const ms = totalMs % 1000;
const totalSec = Math.floor(totalMs / 1000);
const s = totalSec % 60;
const totalMin = Math.floor(totalSec / 60);
const m = totalMin % 60;
const h = Math.floor(totalMin / 60);
const hh = String(h).padStart(2, '0');
const mm = String(m).padStart(2, '0');
const ss = String(s).padStart(2, '0');
if (!includeMillis) {
return h > 0 ? `${hh}:${mm}:${ss}` : `${mm}:${ss}`;
}
const mmm = String(ms).padStart(3, '0');
return `${hh}:${mm}:${ss}.${mmm}`;
}
export function parseTimecode(input: string): number | null {
const s = input.trim();
if (!s) return null;
if (/^\d+(\.\d+)?$/.test(s)) {
const val = Number(s);
return Number.isFinite(val) && val >= 0 ? val : null;
}
const parts = s.split(':');
if (parts.length < 2 || parts.length > 3) return null;
let h = 0;
let m = 0;
let sec = 0;
if (parts.length === 2) {
m = Number(parts[0]);
sec = Number(parts[1]);
} else {
h = Number(parts[0]);
m = Number(parts[1]);
sec = Number(parts[2]);
}
if (
!Number.isFinite(h) ||
!Number.isFinite(m) ||
!Number.isFinite(sec) ||
h < 0 ||
m < 0 ||
m >= 60 ||
sec < 0 ||
sec >= 60
) {
return null;
}
return h * 3600 + m * 60 + sec;
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let idx = 0;
let val = bytes;
while (val >= 1024 && idx < units.length - 1) {
val /= 1024;
idx++;
}
return `${val >= 100 || idx === 0 ? val.toFixed(0) : val.toFixed(1)} ${units[idx]}`;
}
export function clampTrimRange(
start: number,
end: number,
duration: number,
minGap = 0.05
): { start: number; end: number } {
const dur = Math.max(0, duration);
let s = Math.max(0, Math.min(start, dur));
let e = Math.max(0, Math.min(end, dur));
if (dur <= minGap) {
return { start: 0, end: dur };
}
if (e - s < minGap) {
if (s + minGap <= dur) {
e = s + minGap;
} else {
s = Math.max(0, dur - minGap);
e = dur;
}
}
return { start: s, end: e };
}
+132
View File
@@ -0,0 +1,132 @@
export interface AudioStream {
index: number;
audioOrder: number;
codec: string;
channels: number;
channelLayout: string;
sampleRate: string;
language: string;
title: string;
}
export interface VideoInfo {
path: string;
fileName: string;
extension: string;
container: string;
sizeBytes: number;
duration: number;
bitRate: number;
width: number;
height: number;
fps: number;
videoCodec: string;
pixFmt: string;
audioStreams: AudioStream[];
browserNative: boolean;
}
export interface Binaries {
ffmpeg: string;
ffprobe: string;
source: string;
}
export interface AppConfig {
language: 'ru' | 'en' | string;
ffmpegPath: string;
ffprobePath: string;
defaultEncoder: string;
defaultHwDecode: string;
defaultPreset: string;
defaultCrf: number;
defaultFps: number;
defaultTune: string;
defaultMergeAudio: boolean;
outputSuffix: string;
}
export interface AppInfo {
version: string;
binaries: Binaries;
config: AppConfig;
initialFile: string;
}
export interface LoadedVideo {
info: VideoInfo;
videoId: string;
previewUrl: string;
proxyUrl: string;
proxyMergedUrl: string;
thumbBaseUrl: string;
defaultOutputPath: string;
}
export interface CropRect {
enabled: boolean;
x: number;
y: number;
w: number;
h: number;
}
export interface ConvertRequest {
inputPath: string;
outputPath: string;
trimStart: number;
trimEnd: number;
encoder: string;
hwDecode: string;
preset: string;
crf: number;
tune: string;
fps: number;
speed: number;
audioMode: 'merge' | 'track' | 'mute';
audioTrack: number;
selectedTracks: number[];
crop: CropRect;
fastStart: boolean;
overwrite: boolean;
}
export interface ProgressEvent {
percent: number;
outTimeSec: number;
totalSec: number;
fps: number;
speed: string;
totalSize: number;
etaSec: number;
done: boolean;
error?: string;
outputPath?: string;
outputBytes?: number;
}
declare global {
interface Window {
go?: {
main?: {
App?: {
GetAppInfo(): Promise<AppInfo>;
RefreshBinaries(): Promise<Binaries>;
SaveConfig(cfg: AppConfig): Promise<AppInfo>;
OpenVideoDialog(): Promise<string>;
SaveVideoDialog(defaultPath: string): Promise<string>;
SelectFFmpegDialog(): Promise<AppInfo>;
LoadVideo(path: string): Promise<LoadedVideo>;
PreviewCommand(req: ConvertRequest): Promise<string>;
StartConvert(req: ConvertRequest): Promise<void>;
CancelConvert(): Promise<boolean>;
OpenInExplorer(path: string): Promise<void>;
OpenOutputFile(path: string): Promise<void>;
};
};
};
runtime?: {
EventsOn(eventName: string, callback: (...args: any[]) => void): () => void;
};
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
build: {
outDir: 'dist',
emptyOutDir: true,
target: 'es2022',
},
});