feat(prototype): add upload manager and docker ci

- add prototype upload, list, view, and delete api routes
- add prototype manager ui with search, modal, and theme toggle
- add filesystem-backed zip extraction and serving logic
- switch project to pnpm with updated lockfile and tooling
- add multi-stage dockerfile with standalone next.js output
- add github actions to lint, build, and publish docker images
This commit is contained in:
siujamo committed 2026-08-31 13:43:59 +08:00
1 parent 0c592225e4
commit ed72045f5e
30 files changed
+5369 -6899

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
name: Docker Build and Publish
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write
env:
IMAGE_NAME: prototype-manager
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
if: github.event_name != 'pull_request'
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Enable Corepack
if: github.event_name != 'pull_request'
run: corepack enable
- name: Install dependencies
if: github.event_name != 'pull_request'
run: pnpm install --frozen-lockfile
- name: Run lint
if: github.event_name != 'pull_request'
run: pnpm lint
- name: Set up QEMU
if: github.event_name != 'pull_request'
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image (PR only, quick validation, no push)
if: github.event_name == 'pull_request'
uses: docker/build-push-action@v6
with:
context: .
push: false
platforms: linux/amd64
cache-from: type=gha,scope=${{ github.ref_name }}
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract Docker metadata
if: github.event_name != 'pull_request'
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push image (multi-arch)
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ github.ref_name }}
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}
+3
View File
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# runtime data
data/
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+24
View File
@@ -0,0 +1,24 @@
printWidth: 100
tabWidth: 2
useTabs: false
semi: false
singleQuote: false
quoteProps: as-needed
jsxSingleQuote: false
trailingComma: es5
bracketSpacing: true
bracketSameLine: true
arrowParens: always
#rangeStart: 0
#rangeEnd: n
#parser:
#filepath:
#requirePragma: false
#insertPragma: false
#proseWrap: preserve
htmlWhitespaceSensitivity: css
#vueIndentScriptAndStyle: false
endOfLine: lf
#embeddedLanguageFormatting: auto
# Enforce single attribute per line in HTML, Vue and JSX
#singleAttributePerLine: false
+41
View File
@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Runtime write path for uploads/index
RUN mkdir -p /app/data/prototypes \
&& chown -R nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+72 -22
View File
@@ -1,36 +1,86 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
This is a Next.js + Tailwind CSS based Axure prototype manager.
## Getting Started
## Tech Stack
First, run the development server:
- Next.js (App Router, TypeScript)
- Tailwind CSS (atomic utility classes)
- Node runtime API Routes
- Local filesystem persistence (`data/prototypes`)
- Package manager: **pnpm**
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
## Features
- Prototype card navigation homepage
- Upload Axure prototype ZIP package manually
- Auto-parse and register prototype (ZIP must contain `start.html`)
- Open prototype in **new tab**
- Dark mode toggle (stored in `localStorage`)
- Delete uploaded prototype
## Directory Structure
```text
app/
api/prototypes/
route.ts # list + upload
[id]/route.ts # delete
[id]/[...path]/route.ts # serve prototype files
layout.tsx
page.tsx
components/
AddPrototypeModal.tsx
PrototypeCard.tsx
ThemeToggle.tsx
lib/
prototype.ts
store.ts
data/
prototypes/
index.json # runtime metadata (auto-generated)
<prototype-id>/... # unzipped Axure files
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Start Development
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
```bash
pnpm install
pnpm dev
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
Open `http://localhost:3000`.
## Learn More
## Build & Run Production
To learn more about Next.js, take a look at the following resources:
```bash
pnpm build
pnpm start
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## Docker (multi-stage, pnpm)
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
Build image:
## Deploy on Vercel
```bash
docker build -t prototype-manager:latest .
```
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Run container:
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
```bash
docker run --rm -p 3000:3000 -v prototype-manager-data:/app/data prototype-manager:latest
```
Notes:
- Image uses Next.js `output: "standalone"` and `pnpm` in multi-stage build.
- Runtime data is written to `/app/data/prototypes`; mount a volume to persist uploads.
## Upload Requirements
- Only `.zip` is accepted
- ZIP must include `start.html`
- Keep Axure relative assets (`resources`, `data`, `images`, etc.) in original structure
## Notes on Deployment
This project writes files at runtime (upload/unzip), so it must run in a writable Node environment (self-hosted server/VM/container with persistent volume).
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server"
import { readPrototypeFile } from "@/lib/store"
export const runtime = "nodejs"
interface RouteContext {
params: Promise<{
id: string
path?: string[]
}>
}
function decodePathSegments(segments: string[] = []) {
return segments.map((segment) => decodeURIComponent(segment)).join("/")
}
export async function GET(_request: Request, context: RouteContext) {
const { id, path } = await context.params
const relativePath = decodePathSegments(path)
const file = await readPrototypeFile(id, relativePath || undefined)
if (!file) {
return NextResponse.json({ error: "文件不存在" }, { status: 404 })
}
return new NextResponse(file.content, {
headers: {
"content-type": file.mimeType,
"content-length": String(file.size),
"last-modified": file.lastModified,
"cache-control": "no-store",
"x-content-type-options": "nosniff",
},
})
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server"
import { deletePrototype } from "@/lib/store"
export const runtime = "nodejs"
interface RouteContext {
params: Promise<{
id: string
}>
}
export async function DELETE(_request: Request, context: RouteContext) {
const { id } = await context.params
const deleted = await deletePrototype(id)
if (!deleted) {
return NextResponse.json({ error: "原型不存在" }, { status: 404 })
}
return NextResponse.json({ ok: true })
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server"
import { createPrototypeFromZip, listPrototypes } from "@/lib/store"
export const runtime = "nodejs"
export async function GET() {
const prototypes = await listPrototypes()
return NextResponse.json({ data: prototypes })
}
export async function POST(request: Request) {
try {
const formData = await request.formData()
const name = `${formData.get("name") ?? ""}`
const description = `${formData.get("description") ?? ""}`
const file = formData.get("file")
if (!(file instanceof File)) {
return NextResponse.json({ error: "请上传 ZIP 文件" }, { status: 400 })
}
const buffer = Buffer.from(await file.arrayBuffer())
const created = await createPrototypeFromZip({
name,
description,
zipBuffer: buffer,
zipFileName: file.name,
})
return NextResponse.json({ data: created }, { status: 201 })
} catch (error) {
const message = error instanceof Error ? error.message : "上传失败"
const status =
message.includes("为空") ||
message.includes("不") ||
message.includes("非法") ||
message.includes("过大") ||
message.includes("未找到")
? 400
: 500
return NextResponse.json({ error: message }, { status })
}
}
+7 -9
View File
@@ -1,8 +1,8 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #f8fafc;
--foreground: #1e293b;
}
@theme inline {
@@ -12,15 +12,13 @@
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
.dark {
--background: #020617;
--foreground: #f8fafc;
}
body {
background: var(--background);
background-color: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-geist-sans), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
+10 -13
View File
@@ -1,29 +1,26 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import "./globals.css"
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
})
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
})
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
title: "原型导航 | Prototype Navigation",
description: "Axure 原型管理系统",
}
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<html lang="zh-CN" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
)
}
+8 -67
View File
@@ -1,69 +1,10 @@
import Image from "next/image";
import { PrototypeManagerPage } from "@/components/PrototypeManagerPage";
import { listPrototypes } from "@/lib/store";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
export const dynamic = "force-dynamic";
export default async function Home() {
const initialPrototypes = await listPrototypes();
return <PrototypeManagerPage initialPrototypes={initialPrototypes} />;
}
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { PrototypeSummary } from "@/lib/prototype";
interface AddPrototypeModalProps {
onCreated: (item: PrototypeSummary) => void;
}
type UploadState = "idle" | "submitting";
export function AddPrototypeModal({ onCreated }: AddPrototypeModalProps) {
const [open, setOpen] = useState(false);
const [uploadState, setUploadState] = useState<UploadState>("idle");
const [error, setError] = useState<string>("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [file, setFile] = useState<File | null>(null);
const canSubmit = useMemo(() => {
return uploadState !== "submitting" && name.trim().length > 0 && Boolean(file);
}, [uploadState, name, file]);
const reset = () => {
setName("");
setDescription("");
setFile(null);
setError("");
setUploadState("idle");
};
const close = () => {
setOpen(false);
reset();
};
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!file) {
setError("请选择 ZIP 文件");
return;
}
try {
setUploadState("submitting");
setError("");
const formData = new FormData();
formData.append("name", name.trim());
formData.append("description", description.trim());
formData.append("file", file);
const response = await fetch("/api/prototypes", {
method: "POST",
body: formData,
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "上传失败");
}
onCreated(payload.data as PrototypeSummary);
close();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "上传失败");
setUploadState("idle");
}
};
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-xl bg-sky-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-sky-700"
>
添加原型
</button>
{open ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4">
<div className="w-full max-w-xl rounded-2xl border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-700 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">上传 Axure 原型</h2>
<button
type="button"
onClick={close}
className="rounded-md px-2 py-1 text-slate-500 transition hover:bg-slate-100 dark:hover:bg-slate-800"
>
✕
</button>
</div>
<form className="space-y-4" onSubmit={onSubmit}>
<label className="block space-y-1">
<span className="text-sm text-slate-700 dark:text-slate-300">原型名称 *</span>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-50"
placeholder="例如:CRM 完整版"
required
/>
</label>
<label className="block space-y-1">
<span className="text-sm text-slate-700 dark:text-slate-300">原型描述</span>
<textarea
rows={3}
value={description}
onChange={(event) => setDescription(event.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-50"
placeholder="例如:CRM 项目开发原型"
/>
</label>
<label className="block space-y-1">
<span className="text-sm text-slate-700 dark:text-slate-300">ZIP 文件 *</span>
<input
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
className="block w-full text-sm text-slate-600 file:mr-4 file:rounded-md file:border-0 file:bg-sky-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-sky-700 hover:file:bg-sky-100 dark:text-slate-300 dark:file:bg-sky-500/20 dark:file:text-sky-200"
required
/>
<p className="text-xs text-slate-500 dark:text-slate-400">ZIP 内必须包含 start.html 与 Axure 资源文件。</p>
</label>
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
{error}
</div>
) : null}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={close}
className="rounded-lg border border-slate-300 px-4 py-2 text-sm text-slate-700 transition hover:bg-slate-100 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800"
>
取消
</button>
<button
type="submit"
disabled={!canSubmit}
className="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{uploadState === "submitting" ? "上传中..." : "确认上传"}
</button>
</div>
</form>
</div>
</div>
) : null}
</>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { PrototypeSummary } from "@/lib/prototype";
interface PrototypeCardProps {
prototype: PrototypeSummary;
onDelete?: (id: string) => Promise<void>;
}
export function PrototypeCard({ prototype, onDelete }: PrototypeCardProps) {
return (
<article className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-lg dark:border-slate-700 dark:bg-slate-900">
<div className="flex h-40 items-center justify-center bg-slate-100 text-6xl text-slate-400 dark:bg-slate-800 dark:text-slate-500">
<span aria-hidden>🧭</span>
</div>
<div className="space-y-4 p-6">
<div>
<h2 className="line-clamp-2 text-2xl font-semibold text-slate-900 dark:text-slate-50">
{prototype.name}
</h2>
{prototype.description ? (
<p className="mt-2 line-clamp-2 text-sm text-slate-600 dark:text-slate-300">
{prototype.description}
</p>
) : (
<p className="mt-2 text-sm text-slate-400 dark:text-slate-500">暂无描述</p>
)}
</div>
<div className="rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-700 dark:bg-sky-500/20 dark:text-sky-200">
上传时间:{new Date(prototype.createdAt).toLocaleString()}
</div>
<div className="flex gap-3">
<a
href={prototype.viewUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex flex-1 items-center justify-center rounded-lg bg-sky-600 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-700"
>
查看原型
</a>
{onDelete ? (
<button
type="button"
onClick={() => onDelete(prototype.id)}
className="inline-flex items-center justify-center rounded-lg border border-red-200 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-50 dark:border-red-900 dark:text-red-300 dark:hover:bg-red-950/40"
>
删除
</button>
) : null}
</div>
</div>
</article>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { AddPrototypeModal } from "@/components/AddPrototypeModal";
import { PrototypeCard } from "@/components/PrototypeCard";
import { ThemeToggle } from "@/components/ThemeToggle";
import { PrototypeSummary } from "@/lib/prototype";
import dayjs from "@/lib/dayjs"
interface PrototypeManagerPageProps {
initialPrototypes: PrototypeSummary[];
}
export function PrototypeManagerPage({ initialPrototypes }: PrototypeManagerPageProps) {
const [prototypes, setPrototypes] = useState<PrototypeSummary[]>(initialPrototypes);
const [keyword, setKeyword] = useState("");
const onCreated = useCallback((item: PrototypeSummary) => {
setPrototypes((prev) => [item, ...prev.filter((current) => current.id !== item.id)]);
}, []);
const onDelete = useCallback(async (id: string) => {
const confirmed = window.confirm("确定删除这个原型吗?删除后不可恢复。");
if (!confirmed) {
return;
}
const response = await fetch(`/api/prototypes/${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ error: "删除失败" }));
throw new Error(payload.error || "删除失败");
}
setPrototypes((prev) => prev.filter((item) => item.id !== id));
}, []);
const filteredPrototypes = useMemo(() => {
const query = keyword.trim().toLowerCase();
if (!query) {
return prototypes;
}
return prototypes.filter((item) => item.name.toLowerCase().includes(query));
}, [keyword, prototypes]);
const content = useMemo(() => {
if (!prototypes.length) {
return (
<div className="rounded-xl border border-dashed border-slate-300 bg-white px-6 py-16 text-center dark:border-slate-700 dark:bg-slate-900">
<p className="text-base text-slate-600 dark:text-slate-300">暂无原型,请点击右上角“添加原型”进行上传。</p>
</div>
);
}
if (!filteredPrototypes.length) {
return (
<div className="rounded-xl border border-dashed border-slate-300 bg-white px-6 py-16 text-center dark:border-slate-700 dark:bg-slate-900">
<p className="text-base text-slate-600 dark:text-slate-300">
未找到名称包含“{keyword}”的原型。
</p>
</div>
);
}
return (
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-3">
{filteredPrototypes.map((item) => (
<PrototypeCard
key={item.id}
prototype={item}
onDelete={async (id) => {
try {
await onDelete(id);
} catch (deleteError) {
const message =
deleteError instanceof Error ? deleteError.message : "删除失败,请稍后重试";
window.alert(message);
}
}}
/>
))}
</div>
);
}, [filteredPrototypes, keyword, onDelete, prototypes]);
return (
<div className="min-h-screen bg-slate-50 text-slate-800 dark:bg-slate-950 dark:text-slate-100">
<div className="mx-auto flex w-full max-w-6xl flex-col px-4 py-8 md:px-8 md:py-10">
<header className="mb-10 flex flex-wrap items-end justify-between gap-4 border-b border-slate-200 pb-8 dark:border-slate-800">
<div>
<h1 className="text-4xl font-bold tracking-tight text-sky-600 dark:text-sky-400">原型导航</h1>
<p className="mt-2 text-slate-600 dark:text-slate-300">Axure 原型集合 · Prototype Collection</p>
</div>
<AddPrototypeModal onCreated={onCreated} />
</header>
<main className="flex-1 space-y-6">
<div className="flex flex-wrap items-center gap-3">
<input
type="text"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="根据名称筛选原型..."
className="w-full max-w-md rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm outline-none transition focus:ring-2 focus:ring-sky-200 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
{keyword ? (
<button
type="button"
onClick={() => setKeyword("")}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
清空
</button>
) : null}
</div>
{content}
</main>
<footer className="mt-16 border-t border-slate-200 pt-8 text-center text-sm text-slate-500 dark:border-slate-800 dark:text-slate-400">
&copy; {dayjs().format("YYYY")} 原型导航 | Powered by OnixByte
</footer>
</div>
<ThemeToggle />
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useState } from "react";
import { THEME_STORAGE_KEY } from "@/lib/prototype";
function getInitialTheme() {
if (typeof window === "undefined") {
return false;
}
const saved = localStorage.getItem(THEME_STORAGE_KEY);
if (saved === "true") {
return true;
}
if (saved === "false") {
return false;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function applyTheme(isDark: boolean) {
document.documentElement.classList.toggle("dark", isDark);
}
export function ThemeToggle() {
const [darkMode, setDarkMode] = useState(getInitialTheme);
useEffect(() => {
applyTheme(darkMode);
localStorage.setItem(THEME_STORAGE_KEY, String(darkMode));
}, [darkMode]);
const toggle = () => {
setDarkMode((prev) => !prev);
};
return (
<button
type="button"
onClick={toggle}
className="fixed right-8 bottom-8 z-50 flex h-12 w-12 items-center justify-center rounded-full border border-slate-200 bg-white text-xl shadow-md transition hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-900"
aria-label={darkMode ? "切换到浅色模式" : "切换到深色模式"}
title={darkMode ? "浅色模式" : "深色模式"}
>
{darkMode ? "☀️" : "🌙"}
</button>
);
}
+1
View File
@@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
"data/**",
]),
]);
+6
View File
@@ -0,0 +1,6 @@
import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
dayjs.extend(duration)
export default dayjs
+10
View File
@@ -0,0 +1,10 @@
export interface PrototypeSummary {
id: string;
name: string;
description: string;
entryFile: string;
createdAt: string;
viewUrl: string;
}
export const THEME_STORAGE_KEY = "prototype-dark-mode";
+331
View File
@@ -0,0 +1,331 @@
import AdmZip from "adm-zip";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { PrototypeSummary } from "@/lib/prototype";
interface PrototypeRecord {
id: string;
name: string;
description: string;
entryFile: string;
createdAt: string;
}
const DATA_ROOT = path.join(process.cwd(), "data", "prototypes");
const INDEX_FILE = path.join(DATA_ROOT, "index.json");
const MAX_UPLOAD_BYTES = 200 * 1024 * 1024;
const MAX_FILES = 20_000;
const MAX_UNCOMPRESSED_BYTES = 600 * 1024 * 1024;
let writeQueue: Promise<void> = Promise.resolve();
function queueWrite<T>(work: () => Promise<T>): Promise<T> {
const next = writeQueue.then(work, work);
writeQueue = next.then(
() => undefined,
() => undefined,
);
return next;
}
async function ensureDataRoot() {
await fs.mkdir(DATA_ROOT, { recursive: true });
}
async function readIndex(): Promise<PrototypeRecord[]> {
await ensureDataRoot();
try {
const raw = await fs.readFile(INDEX_FILE, "utf-8");
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((item): item is PrototypeRecord => {
return (
typeof item?.id === "string" &&
typeof item?.name === "string" &&
typeof item?.description === "string" &&
typeof item?.entryFile === "string" &&
typeof item?.createdAt === "string"
);
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
}
async function writeIndex(records: PrototypeRecord[]) {
await ensureDataRoot();
const tempFile = `${INDEX_FILE}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
await fs.writeFile(tempFile, JSON.stringify(records, null, 2), "utf-8");
await fs.rename(tempFile, INDEX_FILE);
}
function toSummary(record: PrototypeRecord): PrototypeSummary {
return {
...record,
viewUrl: `/api/prototypes/${encodeURIComponent(record.id)}/${encodePathSegments(record.entryFile)}`,
};
}
function generateId() {
return `proto-${Date.now()}-${randomUUID().slice(0, 8)}`;
}
function isPathInside(rootDir: string, candidatePath: string) {
const root = path.resolve(rootDir);
const candidate = path.resolve(candidatePath);
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
}
function sanitizeZipEntry(entryName: string) {
const normalized = entryName.replace(/\\/g, "/");
const cleaned = path.posix.normalize(normalized);
if (!cleaned || cleaned === ".") {
return null;
}
if (
cleaned.startsWith("../") ||
cleaned === ".." ||
cleaned.startsWith("/") ||
/^[A-Za-z]:/.test(cleaned)
) {
throw new Error(`ZIP 中包含非法路径:${entryName}`);
}
return cleaned;
}
function detectMimeType(filePath: string) {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case ".html":
case ".htm":
return "text/html; charset=utf-8";
case ".css":
return "text/css; charset=utf-8";
case ".js":
return "application/javascript; charset=utf-8";
case ".json":
return "application/json; charset=utf-8";
case ".svg":
return "image/svg+xml";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".gif":
return "image/gif";
case ".webp":
return "image/webp";
case ".ico":
return "image/x-icon";
case ".woff":
return "font/woff";
case ".woff2":
return "font/woff2";
case ".ttf":
return "font/ttf";
default:
return "application/octet-stream";
}
}
function encodePathSegments(relativePath: string) {
return relativePath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
}
export async function listPrototypes(): Promise<PrototypeSummary[]> {
const records = await readIndex();
return records
.slice()
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map(toSummary);
}
export async function findPrototypeById(id: string): Promise<PrototypeSummary | null> {
const records = await readIndex();
const found = records.find((item) => item.id === id);
return found ? toSummary(found) : null;
}
export async function createPrototypeFromZip(input: {
name: string;
description?: string;
zipFileName: string;
zipBuffer: Buffer;
}): Promise<PrototypeSummary> {
return queueWrite(async () => {
const name = input.name.trim();
if (!name) {
throw new Error("原型名称不能为空");
}
if (!input.zipFileName.toLowerCase().endsWith(".zip")) {
throw new Error("仅支持上传 ZIP 文件");
}
if (input.zipBuffer.byteLength > MAX_UPLOAD_BYTES) {
throw new Error("上传文件过大,请控制在 200MB 以内");
}
const zip = new AdmZip(input.zipBuffer);
const entries = zip.getEntries();
if (!entries.length) {
throw new Error("ZIP 文件为空");
}
if (entries.length > MAX_FILES) {
throw new Error(`ZIP 文件数量过多(>${MAX_FILES})`);
}
await ensureDataRoot();
const id = generateId();
const tempDir = path.join(DATA_ROOT, `.tmp-${id}`);
const finalDir = path.join(DATA_ROOT, id);
const extractedFiles: string[] = [];
let totalUncompressed = 0;
await fs.mkdir(tempDir, { recursive: true });
try {
for (const entry of entries) {
const safePath = sanitizeZipEntry(entry.entryName);
if (!safePath) {
continue;
}
const target = path.join(tempDir, ...safePath.split("/"));
if (!isPathInside(tempDir, target)) {
throw new Error(`ZIP 中包含非法路径:${entry.entryName}`);
}
if (entry.isDirectory) {
await fs.mkdir(target, { recursive: true });
continue;
}
totalUncompressed += entry.header.size;
if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) {
throw new Error("解压后文件总体积过大");
}
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, entry.getData());
extractedFiles.push(safePath);
}
const startCandidates = extractedFiles
.filter((item) => item.toLowerCase().endsWith("/start.html") || item.toLowerCase() === "start.html")
.sort((a, b) => a.length - b.length);
if (!startCandidates.length) {
throw new Error("ZIP 中未找到 start.html");
}
const entryFile = startCandidates[0];
await fs.rm(finalDir, { recursive: true, force: true });
await fs.rename(tempDir, finalDir);
const records = await readIndex();
const record: PrototypeRecord = {
id,
name,
description: input.description?.trim() ?? "",
createdAt: new Date().toISOString(),
entryFile,
};
records.push(record);
await writeIndex(records);
return toSummary(record);
} catch (error) {
await fs.rm(tempDir, { recursive: true, force: true });
throw error;
}
});
}
export async function deletePrototype(id: string): Promise<boolean> {
return queueWrite(async () => {
const records = await readIndex();
const target = records.find((item) => item.id === id);
if (!target) {
return false;
}
const remained = records.filter((item) => item.id !== id);
await writeIndex(remained);
const targetDir = path.join(DATA_ROOT, id);
if (isPathInside(DATA_ROOT, targetDir)) {
await fs.rm(targetDir, { recursive: true, force: true });
}
return true;
});
}
export async function readPrototypeFile(id: string, relativePath?: string) {
const records = await readIndex();
const target = records.find((item) => item.id === id);
if (!target) {
return null;
}
const requested = relativePath?.trim() ? relativePath : target.entryFile;
const normalized = sanitizeZipEntry(requested);
if (!normalized) {
return null;
}
const filePath = path.join(DATA_ROOT, id, ...normalized.split("/"));
if (!isPathInside(path.join(DATA_ROOT, id), filePath)) {
return null;
}
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
return null;
}
const content = await fs.readFile(filePath);
return {
content,
mimeType: detectMimeType(filePath),
size: stat.size,
lastModified: stat.mtime.toUTCString(),
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;
-6782
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -9,6 +9,8 @@
"lint": "eslint"
},
"dependencies": {
"adm-zip": "^0.6.0",
"dayjs": "^1.11.23",
"next": "16.3.3",
"react": "19.2.8",
"react-dom": "19.2.8"
@@ -20,6 +22,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.3",
"prettier": "^3.9.6",
"tailwindcss": "^4",
"typescript": "^5"
}
+4211
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
unrs-resolver: true
+4 -1
View File
@@ -1 +1,4 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z"
clip-rule="evenodd" fill="#666" fill-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 408 B

+12 -1
View File
@@ -1 +1,12 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<g clip-path="url(#a)">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1"
fill="#666"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h16v16H0z"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+6 -1
View File
@@ -1 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80">
<path fill="#000"
d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/>
<path fill="#000"
d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+3 -1
View File
@@ -1 +1,3 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000">
<path d="m577.3 0 577.4 1000H0z" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 135 B

+5 -1
View File
@@ -1 +1,5 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5"
fill="#666"/>
</svg>

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 412 B