feat: add ImageFind application and release pipelines

This commit is contained in:
2026-08-11 18:02:40 +08:00
commit 16239d7525
270 changed files with 59163 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist/**", "node_modules/**", "tests/**/*.spec.ts"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/**/*.{ts,tsx}", "*.config.ts"],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
plugins: { "react-hooks": reactHooks },
rules: {
...reactHooks.configs.recommended.rules,
"react-hooks/set-state-in-effect": "off",
"react-hooks/purity": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"no-empty": ["error", { allowEmptyCatch: true }],
},
},
);
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#f7f8fa" />
<meta name="referrer" content="no-referrer" />
<script>
(() => {
const saved = localStorage.getItem("imagefind:theme");
const preference = saved === "light" || saved === "dark" ? saved : "system";
const dark = preference === "dark" || (preference === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.themePreference = preference;
document.documentElement.dataset.theme = dark ? "dark" : "light";
document.documentElement.style.colorScheme = dark ? "dark" : "light";
document.querySelector('meta[name="theme-color"]').content = dark ? "#0f1115" : "#f7f8fa";
})();
</script>
<title>ImageFind · 私有媒体空间</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2775
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "imagefind-web",
"private": true,
"version": "0.5.45",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"test:ui": "playwright test",
"test:ui:fnos": "playwright test --config playwright.fnos.config.ts",
"lint": "eslint ."
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"hls.js": "latest",
"lucide-react": "latest",
"react": "latest",
"react-dom": "latest",
"typescript": "latest",
"vite": "latest"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.62.0",
"@types/react": "latest",
"@types/react-dom": "latest",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.8.0",
"typescript-eslint": "^8.65.0"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from "@playwright/test";
const port = Number(process.env.PLAYWRIGHT_PORT || 4173);
export default defineConfig({
testDir: "./tests",
testIgnore: "**/fnos.spec.ts",
outputDir: "../.playwright-results",
// Chromium can take longer to create a fresh page on the low-memory fnOS
// release runner after visual-audit contexts have been reclaimed.
timeout: 90_000,
expect: { timeout: 30_000, toHaveScreenshot: { animations: "disabled", maxDiffPixelRatio: 0.01 } },
use: {
baseURL: `http://127.0.0.1:${port}`,
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
colorScheme: "light",
},
webServer: {
command: `npm run dev -- --host 127.0.0.1 --port ${port}`,
url: `http://127.0.0.1:${port}`,
// CI and release acceptance must own the server process so a stale Vite
// instance from a previous batch cannot disappear midway through a run.
reuseExistingServer: !process.env.CI,
},
});
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "@playwright/test";
const baseURL = process.env.IMAGEFIND_FNOS_BASE_URL;
const storageState = process.env.IMAGEFIND_FNOS_STORAGE_STATE;
if (!baseURL || !storageState) {
throw new Error("真机测试需要 IMAGEFIND_FNOS_BASE_URL 和 IMAGEFIND_FNOS_STORAGE_STATE");
}
export default defineConfig({
testDir: "./tests",
testMatch: "**/fnos.spec.ts",
outputDir: "../.playwright-results/fnos",
timeout: 120_000,
expect: { timeout: 30_000 },
use: {
baseURL,
storageState,
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
colorScheme: "light",
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
});
+1658
View File
File diff suppressed because one or more lines are too long
+178
View File
@@ -0,0 +1,178 @@
declare global {
interface Window {
__IMAGEFIND_BASE__?: string;
}
}
function normaliseBase(value: string | undefined) {
const base = value?.trim() || "/";
return `/${base.replace(/^\/+|\/+$/g, "")}${base === "/" ? "" : "/"}`;
}
export const appBase = normaliseBase(window.__IMAGEFIND_BASE__);
export function appUrl(value: string | undefined): string {
if (!value) return "";
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(value)) return value;
const target = `${appBase}${value.replace(/^\/+/, "")}`;
const path = `/${value.replace(/^\/+/, "")}`;
const mediaPath = /^\/api\/v1\/(?:frames\/[^/]+\/thumbnail|faces\/[^/]+\/thumbnail|videos\/[^/]+\/(?:stream|download)|previews\/)/.test(path);
if (appBase === "/" || !gatewayMediaToken || !mediaPath) return target;
return `${target}${target.includes("?") ? "&" : "?"}media_token=${encodeURIComponent(gatewayMediaToken)}`;
}
let csrfToken = sessionStorage.getItem("imagefind:csrf") || "";
let gatewaySessionToken = sessionStorage.getItem("imagefind:gateway-session") || "";
let gatewayMediaToken = sessionStorage.getItem("imagefind:gateway-media-token") || "";
let gatewayRenewal: Promise<void> | null = null;
const inflightReads = new Map<string, Promise<unknown>>();
export function appMediaUrl(value: string | undefined): string {
if (!value) return "";
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(value)) return value;
const target = appUrl(value);
if (appBase === "/" || !gatewayMediaToken) return target;
const hashAt = target.indexOf("#");
const body = hashAt >= 0 ? target.slice(0, hashAt) : target;
const hash = hashAt >= 0 ? target.slice(hashAt) : "";
if (/(?:^|[?&])media_token=/.test(body)) return target;
const separator = body.includes("?") ? "&" : "?";
return `${body}${separator}media_token=${encodeURIComponent(gatewayMediaToken)}${hash}`;
}
export function setCsrf(token: string) {
csrfToken = token;
if (token) sessionStorage.setItem("imagefind:csrf", token);
else sessionStorage.removeItem("imagefind:csrf");
}
export function setGatewaySession(token: string) {
gatewaySessionToken = token;
if (token) sessionStorage.setItem("imagefind:gateway-session", token);
else sessionStorage.removeItem("imagefind:gateway-session");
}
export function setGatewayMediaToken(token: string) {
gatewayMediaToken = token;
if (token) sessionStorage.setItem("imagefind:gateway-media-token", token);
else sessionStorage.removeItem("imagefind:gateway-media-token");
}
async function renewGatewaySession(): Promise<void> {
if (appBase === "/") throw new Error("需要管理员登录");
if (!gatewayRenewal) {
gatewayRenewal = fetch(appUrl("/api/v1/auth/gateway"), {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: "{}",
}).then(async response => {
if (!response.ok) throw new Error("飞牛管理员会话需要重新建立");
const value = await response.json() as {
csrf_token: string;
gateway_session_token: string;
gateway_media_token: string;
};
setGatewaySession(value.gateway_session_token);
setGatewayMediaToken(value.gateway_media_token);
setCsrf(value.csrf_token);
window.dispatchEvent(new CustomEvent("imagefind-gateway-renewed"));
}).finally(() => { gatewayRenewal = null; });
}
return gatewayRenewal;
}
async function request(path: string, options: RequestInit = {}): Promise<Response> {
const send = () => {
const headers = new Headers(options.headers);
if (typeof options.body === "string" && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
if (options.method && !["GET", "HEAD"].includes(options.method.toUpperCase()) && csrfToken) {
headers.set("X-CSRF-Token", csrfToken);
}
if (appBase !== "/" && gatewaySessionToken) headers.set("X-ImageFind-Gateway-Session", gatewaySessionToken);
return fetch(appUrl(`/api/v1${path}`), { ...options, headers, credentials: "same-origin" });
};
let response = await send();
if (response.status === 401 && appBase !== "/" && path !== "/auth/gateway") {
await renewGatewaySession();
response = await send();
}
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const payload = await response.json();
message = typeof payload.detail === "string" ? payload.detail : payload.detail?.message || message;
} catch { /* response is not JSON */ }
throw new Error(message);
}
return response;
}
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const method = String(options.method || "GET").toUpperCase();
if (method === "GET" && options.body === undefined) {
const existing = inflightReads.get(path);
if (existing) return existing as Promise<T>;
const pending = request(path, options).then(response =>
response.status === 204 ? undefined : response.json()
).finally(() => inflightReads.delete(path));
inflightReads.set(path, pending);
return pending as Promise<T>;
}
const response = await request(path, options);
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
export async function downloadBackup(scope: "keys" | "full", password: string) {
const response = await request("/backups/export", {
method: "POST",
body: JSON.stringify({ scope, password }),
});
const disposition = response.headers.get("Content-Disposition") || "";
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1];
const filename = encoded ? decodeURIComponent(encoded) : plain || `imagefind-backup-${scope}.ifbackup`;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
return { filename, size_bytes: blob.size };
}
export async function downloadGeneratedBackup(exportId: string) {
const response = await request(`/backups/${encodeURIComponent(exportId)}/download`);
const disposition = response.headers.get("Content-Disposition") || "";
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1];
const filename = encoded ? decodeURIComponent(encoded) : plain || `imagefind-backup-${exportId}.ifbackup`;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
return { filename, size_bytes: blob.size };
}
export async function restoreBackup<T>(file: File, password: string, confirmed: boolean): Promise<T> {
const form = new FormData();
form.append("file", file);
form.append("password", password);
form.append("confirmed", String(confirmed));
return api<T>("/backups/restore", { method: "POST", body: form });
}
export async function uploadImage(file: File): Promise<{ id: string }> {
const form = new FormData();
form.append("file", file);
return api("/query-images", { method: "POST", body: form });
}
+20
View File
@@ -0,0 +1,20 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { appBase } from "./api";
import "./styles.css";
document.documentElement.dataset.imagefindAccess=appBase==="/"?"direct":"gateway";
function syncVisualViewport(){
const height=Math.round(window.visualViewport?.height||window.innerHeight);
document.documentElement.style.setProperty("--imagefind-visual-height",`${height}px`);
document.documentElement.style.setProperty("--imagefind-sheet-height",`${Math.round(height*.82)}px`);
}
syncVisualViewport();
window.addEventListener("resize",syncVisualViewport,{passive:true});
window.visualViewport?.addEventListener("resize",syncVisualViewport,{passive:true});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode><App /></React.StrictMode>
);
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+120
View File
@@ -0,0 +1,120 @@
import { expect, Page, test } from "@playwright/test";
const sourceName=process.env.IMAGEFIND_E2E_SOURCE_NAME;
async function appApi<T>(page:Page,path:string,options:RequestInit={}):Promise<T>{
return page.evaluate(async({path,options})=>{
const base=(window.__IMAGEFIND_BASE__||"/").replace(/\/$/,"");
const headers=new Headers(options.headers);const csrf=sessionStorage.getItem("imagefind:csrf");
if(csrf)headers.set("X-CSRF-Token",csrf);
if(typeof options.body==="string")headers.set("Content-Type","application/json");
const response=await fetch(`${base}/api/v1${path}`,{...options,headers,credentials:"same-origin"});
if(!response.ok)throw new Error(`${response.status} ${await response.text()}`);
return response.status===204?null:response.json();
},{path,options}) as Promise<T>;
}
test("fnOS deployment renders the app shell and system-history navigation",async({page})=>{
await page.setViewportSize({width:390,height:844});
await page.goto("");
await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible();
const homeLayout=await page.evaluate(()=>{const content=document.querySelector<HTMLElement>(".content")!;const shell=document.querySelector<HTMLElement>(".app-shell")!;return {access:document.documentElement.dataset.imagefindAccess,topbarTop:Math.round(document.querySelector<HTMLElement>(".topbar")!.getBoundingClientRect().top),contentOverflow:getComputedStyle(content).overflowY,shellOverflow:getComputedStyle(shell).overflow,documentHeight:document.documentElement.scrollHeight,viewportHeight:innerHeight}});
expect(["direct","gateway"]).toContain(homeLayout.access);
expect(homeLayout).toMatchObject({topbarTop:0,contentOverflow:"auto",shellOverflow:"hidden"});
expect(homeLayout.documentHeight).toBeLessThanOrEqual(homeLayout.viewportHeight+1);
await expect(page.locator(".sidebar nav > button:visible")).toHaveCount(5);
await page.getByRole("button",{name:"更多"}).click();
await expect(page.locator(".mobile-more-menu").getByText("退出登录",{exact:true})).toHaveCount(homeLayout.access==="gateway"?0:1);
await expect(page.getByText("返回飞牛桌面",{exact:true})).toHaveCount(0);
await page.locator(".mobile-more-grid>.mobile-more-item").filter({hasText:"设置"}).click();
await expect(page.getByRole("heading",{name:"设置"})).toBeVisible();
await expect(page.locator(".topbar")).toBeHidden();
await expect(page.locator(".sidebar")).toBeHidden();
await expect(page.locator(".content")).not.toHaveClass(/sheet-locked/);
await expect.poll(()=>page.locator(".content").evaluate(element=>element.scrollHeight-element.clientHeight)).toBeGreaterThan(320);
await page.locator(".content").evaluate(element=>element.scrollTo({top:320,behavior:"instant"}));
await expect.poll(()=>page.locator(".content").evaluate(element=>element.scrollTop)).toBeGreaterThan(100);
await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
await page.goBack();
await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible();
});
test("live transfer, profile, resources and media surfaces remain usable",async({page})=>{
await page.setViewportSize({width:390,height:844});
await page.goto("");
const status=await appApi<{configured:boolean;version:string;access_mode:string}>(page,"/status");
expect(status).toMatchObject({configured:true,version:"0.5.45"});
await page.getByRole("button",{name:"更多"}).click();
await page.locator(".mobile-more-grid>.mobile-more-item").filter({hasText:"上传中心"}).click();
await expect(page.getByRole("heading",{name:"传输中心"})).toBeVisible();
await expect(page.locator(".transfer-tabs>button")).toHaveCount(2);
await page.locator(".transfer-tabs").getByRole("button",{name:/后台下载/}).click();
const aria2=await appApi<{available:boolean;running:boolean}>(page,"/downloads/runtime");
expect(typeof aria2.available).toBe("boolean");
await expect(page.locator(".download-list")).toBeVisible();
await page.goBack();
await page.locator(".mobile-profile-button").click();
await expect(page.getByRole("button",{name:"个人资料"})).toBeVisible();
await expect(page.locator(".profile-stats article")).toHaveCount(4);
await page.getByRole("button",{name:"更多"}).click();
await page.locator(".mobile-more-grid>.mobile-more-item").filter({hasText:"设置"}).click();
await page.getByRole("button",{name:"任务与偏好"}).click();
await expect(page.locator(".resource-panel")).toContainText("前台访问优先");
await expect(page.locator(".resource-lanes article")).toHaveCount(4);
const resources=await appApi<{profile:string;policy:{cpu_pause_percent:number;cpu_resume_percent:number}}>(page,"/system/resources");
expect(["conservative","balanced","turbo"]).toContain(resources.profile);
expect(resources.policy.cpu_resume_percent).toBeLessThan(resources.policy.cpu_pause_percent);
await page.goBack();
await page.locator(".nav-item-1").click();
const cards=page.locator(".video-card");
if(await cards.count()){
await cards.first().click();
await expect(page.locator(".player-stage video")).toBeVisible();
await expect(page.getByRole("button",{name:"删除视频"})).toBeVisible();
const source=await page.locator(".player-stage video").getAttribute("src");
expect(source).toContain("/api/v1/videos/");
}
});
for(const width of [320,390])test(`live mobile layout has no horizontal overflow at ${width}px`,async({page})=>{
await page.setViewportSize({width,height:780});
await page.goto("");
const geometry=await page.evaluate(()=>({viewport:innerWidth,document:document.documentElement.scrollWidth,content:document.querySelector<HTMLElement>(".content")!.scrollWidth}));
expect(geometry.document).toBeLessThanOrEqual(geometry.viewport+1);
expect(geometry.content).toBeLessThanOrEqual(geometry.viewport+1);
await expect(page.locator(".sidebar nav > button:visible")).toHaveCount(5);
});
test("fnOS isolated source supports upload and test-owned cleanup",async({page})=>{
test.skip(!sourceName,"设置 IMAGEFIND_E2E_SOURCE_NAME 后才运行写入测试");
await page.setViewportSize({width:390,height:844});
await page.goto("");
const filename=`E2E-${Date.now()}.mp4`;
let sourceId="";
try{
await page.locator(".sidebar nav > .upload-status").click();
const sourceSelect=page.getByLabel("目标媒体库");
const option=sourceSelect.locator("option").filter({hasText:sourceName!});
await expect(option).toHaveCount(1);
sourceId=await option.getAttribute("value")||"";
await sourceSelect.selectOption(sourceId);
await page.locator('.drop-zone input[type="file"]').setInputFiles({name:filename,mimeType:"video/mp4",buffer:Buffer.from("imagefind isolated e2e fixture")});
await page.getByRole("button",{name:"开始上传"}).click();
await expect(page.getByText(/已进入后台传输队列|1 个文件已排队/)).toBeVisible();
await page.waitForTimeout(1500);
const uploads=await appApi<Array<{id:string;filename:string}>>(page,"/uploads?limit=100");
expect(uploads.some(item=>item.filename===filename)).toBe(true);
}finally{
if(sourceId){
const videos=await appApi<Array<{source_id:string;source_key:string;display_name:string}>>(page,"/videos?limit=500").catch(()=>[]);
const owned=videos.filter(item=>item.source_id===sourceId&&(item.display_name===filename||item.source_key.endsWith(`/${filename}`)||item.source_key===filename));
if(owned.length)await appApi(page,"/files/trash",{method:"POST",body:JSON.stringify({source_id:sourceId,keys:owned.map(item=>item.source_key)})}).catch(()=>null);
const trash=await appApi<Array<{id:string;display_name:string}>>(page,`/trash?source_id=${encodeURIComponent(sourceId)}`).catch(()=>[]);
for(const item of trash.filter(value=>value.display_name===filename))await appApi(page,`/trash/${item.id}`,{method:"DELETE"}).catch(()=>null);
}
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
{
"viewport": {
"width": 1440,
"height": 900
},
"document": {
"width": 1440,
"height": 1464
},
"topbar": {
"x": 0,
"y": 0,
"width": 1440,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 836
},
"content": {
"x": 224,
"y": 0,
"width": 1216,
"height": 1464
},
"page": {
"x": 224,
"y": 64,
"width": 1216,
"height": 1400
},
"videoGrid": {
"x": 256,
"y": 259,
"width": 1152,
"height": 220
},
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

@@ -0,0 +1,42 @@
{
"viewport": {
"width": 1440,
"height": 900
},
"document": {
"width": 1440,
"height": 1094
},
"topbar": {
"x": 0,
"y": 0,
"width": 1440,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 836
},
"content": {
"x": 224,
"y": 0,
"width": 1216,
"height": 1094
},
"page": {
"x": 224,
"y": 64,
"width": 1216,
"height": 1030
},
"videoGrid": null,
"tagGroups": {
"x": 256,
"y": 198,
"width": 1152,
"height": 236
},
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 394
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

@@ -0,0 +1,42 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 390,
"height": 58
},
"sidebar": {
"x": 0,
"y": 780,
"width": 390,
"height": 64
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 58,
"width": 390,
"height": 1210
},
"videoGrid": {
"x": 12,
"y": 268,
"width": 378,
"height": 175
},
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 298
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,47 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 390,
"height": 58
},
"sidebar": {
"x": 0,
"y": 780,
"width": 390,
"height": 64
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 58,
"width": 390,
"height": 1212
},
"videoGrid": {
"x": 12,
"y": 270,
"width": 378,
"height": 175
},
"tagGroups": null,
"mobileMore": {
"x": 0,
"y": 508,
"width": 390,
"height": 336
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -0,0 +1,47 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 390,
"height": 58
},
"sidebar": {
"x": 0,
"y": 780,
"width": 390,
"height": 64
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 58,
"width": 390,
"height": 1210
},
"videoGrid": {
"x": 12,
"y": 268,
"width": 378,
"height": 175
},
"tagGroups": null,
"mobileMore": {
"x": 0,
"y": 508,
"width": 390,
"height": 336
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -0,0 +1,32 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": null,
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 780,
"width": 390,
"height": 64
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 1203
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 780,
"width": 390,
"height": 64
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 96,
"width": 390,
"height": 728
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 341
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,42 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 1297
},
"videoGrid": null,
"tagGroups": {
"x": 12,
"y": 153,
"width": 366,
"height": 369
},
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 844
},
"document": {
"width": 390,
"height": 844
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 844
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 579
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 1024,
"height": 900
},
"document": {
"width": 1024,
"height": 2198
},
"topbar": {
"x": 0,
"y": 0,
"width": 1024,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 836
},
"content": {
"x": 224,
"y": 0,
"width": 800,
"height": 2198
},
"page": {
"x": 224,
"y": 64,
"width": 800,
"height": 2134
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 1440,
"height": 900
},
"document": {
"width": 1440,
"height": 2000
},
"topbar": {
"x": 0,
"y": 0,
"width": 1440,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 836
},
"content": {
"x": 224,
"y": 0,
"width": 1216,
"height": 2000
},
"page": {
"x": 224,
"y": 64,
"width": 1216,
"height": 1936
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 320,
"height": 780
},
"document": {
"width": 320,
"height": 780
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 320,
"height": 780
},
"page": {
"x": 0,
"y": 0,
"width": 320,
"height": 2967
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 390,
"height": 780
},
"document": {
"width": 390,
"height": 780
},
"topbar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"sidebar": {
"x": 0,
"y": 0,
"width": 0,
"height": 0
},
"content": {
"x": 0,
"y": 0,
"width": 390,
"height": 780
},
"page": {
"x": 0,
"y": 0,
"width": 390,
"height": 2908
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 768,
"height": 900
},
"document": {
"width": 768,
"height": 2522
},
"topbar": {
"x": 0,
"y": 0,
"width": 768,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 836
},
"content": {
"x": 224,
"y": 0,
"width": 544,
"height": 2522
},
"page": {
"x": 224,
"y": 64,
"width": 544,
"height": 2458
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

@@ -0,0 +1,37 @@
{
"viewport": {
"width": 1024,
"height": 800
},
"document": {
"width": 1024,
"height": 2049
},
"topbar": {
"x": 0,
"y": 0,
"width": 1024,
"height": 64
},
"sidebar": {
"x": 0,
"y": 64,
"width": 224,
"height": 736
},
"content": {
"x": 224,
"y": 0,
"width": 800,
"height": 2049
},
"page": {
"x": 224,
"y": 64,
"width": 800,
"height": 1985
},
"videoGrid": null,
"tagGroups": null,
"mobileMore": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts"],"version":"6.0.3"}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: "./",
plugins: [react()],
server: {
port: 5173,
proxy: { "/api": "http://127.0.0.1:8765" }
}
});