chore: import existing Endoleap Pages Router project
@@ -0,0 +1,2 @@
|
||||
FOUNDRY_API_ORIGIN=https://api.foundry.entroia.com
|
||||
NEXT_PUBLIC_FOUNDRY_PROJECT=endoleap
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env*
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
## This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,63 @@
|
||||
# 卡顿与首次创建失败检查
|
||||
|
||||
范围:本地 Next.js 前端、Foundry 代理、线上只读健康检查与现有 Go 代码。没有读取浏览器会话令牌,也没有向用户的线上账号写测试数据。
|
||||
|
||||
## 结论与证据
|
||||
|
||||
主要等待位于本地前端到公网 API 的请求链路;原来的前端串行加载进一步累加了这部分延迟。尚无证据表明页面绘制或某条业务 SQL 是这次秒级等待的主要来源。下面的采样数量有限,不是负载测试,也不能代替真实授权请求的逐段追踪。
|
||||
|
||||
| 测量位置 | 本次样本 |
|
||||
| --- | --- |
|
||||
| 本地 /login HTTP 完整响应,3 次 | 1.6、1.8、7.0 ms |
|
||||
| 本地代理调用未登录 /profile,修改前,3 次 | 473、686、1262 ms |
|
||||
| 公网 /healthz,直连、新连接,3 次 | 1.38、2.71、4.09 s |
|
||||
| 公网 /healthz,经当前 Clash 代理,3 次 | 1.19、2.65、3.28 s |
|
||||
| 服务器容器内 /readyz,5 次 | 10–81 ms |
|
||||
| 服务器访问 Google token URL(无有效 code),3 次 | 52、74、309 ms |
|
||||
| 服务器访问 Google userinfo(无会话),3 次 | 55、60、112 ms |
|
||||
|
||||
本地代理新增 Server-Timing 后,再测同一条未登录 /profile:
|
||||
|
||||
| 样本 | 等待上游响应头 | 代理总耗时 | 总耗时减去上游等待 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 5196.4 ms | 5198.3 ms | 1.9 ms |
|
||||
| 2 | 614.6 ms | 615.0 ms | 0.4 ms |
|
||||
| 3 | 1080.6 ms | 1081.5 ms | 0.9 ms |
|
||||
|
||||
这条未登录请求在后端会话格式校验时就返回 401,不需要业务 SQL。上游耗时包含 DNS、连接/TLS、公网传输、边缘入口与后端响应;这次未在边缘入口加分段追踪,不能把它进一步精确归因给某一个网络跳点。直连与代理样本都有波动,目前不支持简单添加代理规则即可解决的结论。
|
||||
|
||||
## 代码中的等待与失败点
|
||||
|
||||
1. 登录回调先兑换会话,再导航到 dashboard;dashboard 原来依次获取 profile、workspaces,最后再请求首个空间的画布。资料与空间没有数据依赖,原来的串行等待是多余的。
|
||||
2. 初次进入的账号可能没有 workspace。原来的创建画布分支直接抛出 workspace_required,错误映射又没有这个编码,所以截图只显示“操作未完成,请稍后重试”。没有发出创建画布 HTTP 请求。
|
||||
3. 切换空间时没有取消旧读取;旧列表还可能在创建完成后返回,从而覆盖刚添加的画布。
|
||||
4. 画布读取期间显示空状态,Google 跳转时缺乏忙碌提示,会把真实等待和“操作没有生效”混在一起。
|
||||
5. 全局 h2 样式覆盖 DialogTitle 的字体工具类,导致截图中的弹窗标题异常偏大。
|
||||
|
||||
## 本地已修改
|
||||
|
||||
- src/lib/workspace.ts:并行加载资料和空间;创建首张画布前建立默认个人空间,空间创建成功后立即保留其 ID,避免画布失败重试时重复创建空间。
|
||||
- src/pages/dashboard.tsx:接入上述流程;取消过期读取,防止旧列表覆盖新画布,增加画布加载状态;读取空间失败时禁止误判为“尚无空间”;限定弹窗标题样式。
|
||||
- src/lib/api.ts:补充空间、物料与超时的明确错误提示。
|
||||
- src/pages/api/foundry/[...path].ts:增加仅含耗时数字的 Server-Timing;取消已断开的上游读取;明确区分超时,保持 HttpOnly 会话处理。
|
||||
- src/pages/login.tsx:Google / Slack 跳转提供忙碌状态,避免重复点击。
|
||||
- src/styles/globals.css:明确限定弹窗标题、描述字体大小。
|
||||
- tests/workspace.test.mjs:覆盖读取并行、首张画布、创建失败后的重试、已有空间直接创建。
|
||||
- tests/api-smoke.mjs:保留会话安全检查并验证耗时标记。
|
||||
|
||||
没有修改后端业务源码、路由安全策略或线上数据。公网链路延迟仍待进一步追踪;本轮没有把它宣称为已解决。
|
||||
|
||||
## 验证
|
||||
|
||||
- 最终 npm run build 成功,包含 TypeScript 检查。
|
||||
- 4 项 workspace 流程回归全部成功。
|
||||
- API 会话、跨站拒绝、白名单与耗时标记 smoke 通过。
|
||||
- 隔离浏览器验收:空账号直接点击新建,创建“个人空间”和“周末的灵感”成功。模拟旧画布列表延迟 650 ms,新建响应 100 ms,画布仍保持可见,没有被旧列表覆盖。
|
||||
- 隔离后端记录 profile 与 workspaces 在相隔 2 ms 时发起,确认已并行。
|
||||
- 本地生产预览已重启到 http://localhost:17321。原有登录 Cookie 保留;刷新页面即可载入新代码。
|
||||
- 首次画布的完整浏览器回归使用本机模拟后端;没有替用户创建线上测试画布。
|
||||
|
||||

|
||||
|
||||
|
||||
后续真实账号验收已完成:创建「个人空间」和「周末的灵感」后,完整刷新页面仍能读取画布。此前的隔离测试限制已由此项验收补齐,详情见 VALIDATION.md。
|
||||
@@ -0,0 +1,57 @@
|
||||
# 啡跃 Endoleap
|
||||
|
||||
本地 Next.js Pages Router + TypeScript + Tailwind CSS + shadcn/ui 项目。首页和账户页参考 qyof 的 PROMPT 文件,工作台结合双侧栏画布、Notion 的留白与 Miro 的空间组织方式。Other Designs 用于首页叙事与版式参考。
|
||||
|
||||
项目目录:`/Users/zzyang/endoleap`。
|
||||
|
||||
## 启动
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认仅监听本机 localhost:17321。生产模式:`npm run build && npm start`。
|
||||
|
||||
- 首页:/
|
||||
- 登录、注册:/login
|
||||
- 登录回调:/auth/callback
|
||||
- 真实工作台:/dashboard
|
||||
- 本地演示:/dashboard?demo=1
|
||||
|
||||
## 配置
|
||||
|
||||
复制 .env.example 为 .env.local,按实际部署填写:
|
||||
|
||||
- FOUNDRY_API_ORIGIN:仅供 Next 服务端请求后端,默认 https://api.foundry.entroia.com。
|
||||
- NEXT_PUBLIC_FOUNDRY_PROJECT:项目标识,默认 endoleap。
|
||||
|
||||
Slack Client Secret、Signing Secret、Verification Token、用户令牌与 app token 均不需要放入此前端。浏览器调用同源 Next API,由服务端转发到 Foundry。Foundry 会话保存在 HttpOnly / SameSite=Lax Cookie,生产 HTTPS 反向代理需传 X-Forwarded-Proto=https。不要将原始会话写入客户端存储。
|
||||
|
||||
Google / Slack 的提供方回调仍是 Foundry 后端地址;后端的项目回调映射必须指向本前端 /auth/callback。协议、主机名、端口必须与启动第三方登录时一致,否则 sessionStorage 中的 verifier 无法恢复。正式账号授权、邮件验证与 OAuth 全流程尚未完成真人验收;HTTP 302 跳转成功不能代表登录成功。
|
||||
|
||||
## 当前功能与范围
|
||||
|
||||
- 响应式首页、锚点导航与 FAQ。
|
||||
- 邮箱注册 / 登录 / 邮件验证兑换,Google 和 Slack 的 PKCE 登录入口及回调处理。
|
||||
- 真实用户资料、空间列表与创建、画布列表与创建。资料和空间并行读取;首次创建画布时自动建立个人空间。
|
||||
- 本地演示有示例画布、搜索、新建、画布界面、笔记浏览器保存、缩放、待办勾选和临时文字节点。
|
||||
- 图形编辑、连接线、分享、Agent、收藏及完整布局持久化尚未接入。画布界面是交互预览,不是完整编辑器;演示数据不写入线上。
|
||||
- 本地演示仅用于查看界面,不提供后端认证与权限。
|
||||
|
||||
Figma 原始 Logo、人物插画与图标已下载到 public/assets,不依赖临时 Figma URL。品牌保持啡跃 Endoleap;shadcn Button / Dialog 使用官方 registry 源码。依赖版本固定,package-lock.json 可用于 npm ci。
|
||||
|
||||
## 验证
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
node tests/api-smoke.mjs
|
||||
```
|
||||
|
||||
API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、令牌不泄漏、同源校验和路径白名单,不访问线上账号。构建需先完成。浏览器验收覆盖登录/注册切换、演示画布创建、搜索空状态、笔记保存、桌面与 390px 手机布局。
|
||||
|
||||
项目尚未创建远程仓库或部署。
|
||||
|
||||
|
||||
性能分析、实测耗时和修复说明见 [PERFORMANCE.md](PERFORMANCE.md)。
|
||||
流程回归可在支持 TypeScript 类型擦除的 Node 版本执行:`node --experimental-strip-types --test tests/workspace.test.mjs`。
|
||||
@@ -0,0 +1,18 @@
|
||||
# 本地验收记录
|
||||
|
||||
- 生产构建:Next.js 16.4.5,TypeScript 检查及 6 个页面静态生成成功。
|
||||
- API 会话测试:HttpOnly / SameSite Cookie、令牌不返回前端、带会话代理、401、跨站拒绝、非法 Origin、路径白名单、OAuth challenge 校验通过。
|
||||
- 线上轻量检查:profile 401;空登录参数 400;Slack 与 Google 启动登录均返回 302,目标分别为 slack.com、accounts.google.com。未执行真人账户登录。
|
||||
- 浏览器:登录/注册切换、演示画布创建、搜索无结果、打开画布、笔记保存、待办勾选,以及未登录访问 /dashboard 返回 /login。
|
||||
- 1440px 首页和 390px 首页/工作台:页面宽度等于视口宽度,未发现横向溢出。首页图片全部加载。
|
||||
- Figma:原为 DIRECT;已添加 figma.com、figmausercontent.com 代理规则并从实际连接确认 DomainSuffix 生效。验证时代理组当前节点为香港 Y03。
|
||||
- 本机生产预览:http://localhost:17321(只监听本机)。
|
||||
- 尚未完成:真人 OAuth / 邮箱验证完整回路、后端项目回调地址匹配、完整编辑器布局持久化、分享/收藏/Agent。
|
||||
- 本项目仅本地创建,未新建 Gitea 远程仓库、未提交或推送。
|
||||
|
||||
|
||||
后续性能检查与首次创建流程修复已完成,最终构建、API smoke、4 项流程回归与隔离浏览器验收通过。详见 PERFORMANCE.md。
|
||||
|
||||
## 真实账号创建验收
|
||||
|
||||
已在用户已登录的 Chrome 中打开 http://localhost:17321/dashboard。初始空间列表为空;使用新建画布流程提交用户原先填写的「周末的灵感」后,创建了「个人空间」和该画布。完整刷新页面后,侧栏和主列表仍显示该画布,确认读取到后端持久化记录。此项为真实账号验收,补充此前只完成隔离模拟测试的限制。
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { NextConfig } from "next";
|
||||
const config: NextConfig = { reactStrictMode: true, poweredByHeader: false };
|
||||
export default config;
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "endoleap",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --hostname localhost --port 17321",
|
||||
"build": "next build",
|
||||
"start": "next start --hostname localhost --port 17321",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.4.5",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"tailwind-merge": "3.7.0",
|
||||
"@radix-ui/react-slot": "1.5.0",
|
||||
"@radix-ui/react-dialog": "1.3.1",
|
||||
"lucide-react": "1.69.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "7.0.2",
|
||||
"@types/node": "26.6.6",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"tailwindcss": "4.3.4",
|
||||
"@tailwindcss/postcss": "4.3.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { "@tailwindcss/postcss": {} } };
|
||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / bell" clip-path="url(#clip0_0_107)">
|
||||
<path id="Vector" d="M6.25 13.125H8.75M11.25 5C11.25 4.00544 10.8549 3.05161 10.1517 2.34835C9.44839 1.64509 8.49456 1.25 7.5 1.25C6.50544 1.25 5.55161 1.64509 4.84835 2.34835C4.14509 3.05161 3.75 4.00544 3.75 5C3.75 9.375 1.875 9.375 1.875 10.625H13.125C13.125 9.375 11.25 9.375 11.25 5Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_107">
|
||||
<rect width="15" height="15" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 703 B |
@@ -0,0 +1,10 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / bot">
|
||||
<path id="Vector" d="M7.5 5V2.5H5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M3.75 5H11.25C11.9404 5 12.5 5.55964 12.5 6.25V11.25C12.5 11.9404 11.9404 12.5 11.25 12.5H3.75C3.05964 12.5 2.5 11.9404 2.5 11.25V6.25C2.5 5.55964 3.05964 5 3.75 5Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M1.25 8.75H2.5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_4" d="M12.5 8.75H13.75" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_5" d="M9.375 8.125V9.375" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_6" d="M5.625 8.125V9.375" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / chevron-down">
|
||||
<path id="Vector" d="M2.5 3.75L5 6.25L7.5 3.75" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 340 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / chevron-down">
|
||||
<path id="Vector" d="M2.25 3.375L4.5 5.625L6.75 3.375" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 343 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / chevron-down">
|
||||
<path id="Vector" d="M2.5 3.75L5 6.25L7.5 3.75" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 340 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / chevron-down">
|
||||
<path id="Vector" d="M3.75 5.625L7.5 9.375L11.25 5.625" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
@@ -0,0 +1,10 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / circle" clip-path="url(#clip0_0_52)">
|
||||
<path id="Vector" d="M12.8333 7C12.8333 10.2217 10.2217 12.8333 7 12.8333C3.77834 12.8333 1.16667 10.2217 1.16667 7C1.16667 3.77834 3.77834 1.16667 7 1.16667C10.2217 1.16667 12.8333 3.77834 12.8333 7Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_52">
|
||||
<rect width="14" height="14" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 615 B |
@@ -0,0 +1,7 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / ellipsis">
|
||||
<path id="Vector" d="M8.125 7.5C8.125 7.84518 7.84518 8.125 7.5 8.125C7.15482 8.125 6.875 7.84518 6.875 7.5C6.875 7.15482 7.15482 6.875 7.5 6.875C7.84518 6.875 8.125 7.15482 8.125 7.5Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M12.5 7.5C12.5 7.84518 12.2202 8.125 11.875 8.125C11.5298 8.125 11.25 7.84518 11.25 7.5C11.25 7.15482 11.5298 6.875 11.875 6.875C12.2202 6.875 12.5 7.15482 12.5 7.5Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M3.75 7.5C3.75 7.84518 3.47018 8.125 3.125 8.125C2.77982 8.125 2.5 7.84518 2.5 7.5C2.5 7.15482 2.77982 6.875 3.125 6.875C3.47018 6.875 3.75 7.15482 3.75 7.5Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1016 B |
@@ -0,0 +1,12 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / external-link" clip-path="url(#clip0_0_21)">
|
||||
<path id="Vector" d="M9.375 1.875H13.125V5.625" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M6.25 8.75L13.125 1.875" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M11.25 8.125V11.875C11.25 12.2065 11.1183 12.5245 10.8839 12.7589C10.6495 12.9933 10.3315 13.125 10 13.125H3.125C2.79348 13.125 2.47554 12.9933 2.24112 12.7589C2.0067 12.5245 1.875 12.2065 1.875 11.875V5C1.875 4.66848 2.0067 4.35054 2.24112 4.11612C2.47554 3.8817 2.79348 3.75 3.125 3.75H6.875" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_21">
|
||||
<rect width="15" height="15" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1004 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / eye">
|
||||
<path id="Vector" d="M1.11692 6.6885C1.07177 6.56689 1.07177 6.43311 1.11692 6.3115C1.55659 5.24542 2.3029 4.3339 3.26125 3.69249C4.2196 3.05108 5.34682 2.70867 6.5 2.70867C7.65319 2.70867 8.7804 3.05108 9.73875 3.69249C10.6971 4.3339 11.4434 5.24542 11.8831 6.3115C11.9282 6.43311 11.9282 6.56689 11.8831 6.6885C11.4434 7.75458 10.6971 8.6661 9.73875 9.30751C8.7804 9.94892 7.65319 10.2913 6.5 10.2913C5.34682 10.2913 4.2196 9.94892 3.26125 9.30751C2.3029 8.6661 1.55659 7.75458 1.11692 6.6885Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M8.125 6.5C8.125 7.39746 7.39746 8.125 6.5 8.125C5.60254 8.125 4.875 7.39746 4.875 6.5C4.875 5.60254 5.60254 4.875 6.5 4.875C7.39746 4.875 8.125 5.60254 8.125 6.5Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / file-text">
|
||||
<path id="Vector" d="M3.5 12.8333C3.19058 12.8333 2.89383 12.7104 2.67504 12.4916C2.45625 12.2728 2.33333 11.9761 2.33333 11.6667V2.33333C2.33333 2.02391 2.45625 1.72717 2.67504 1.50838C2.89383 1.28958 3.19058 1.16667 3.5 1.16667H8.16667C8.35132 1.16637 8.53422 1.2026 8.70481 1.27328C8.8754 1.34396 9.03033 1.44769 9.16067 1.5785L11.2537 3.6715C11.3848 3.80188 11.4889 3.95695 11.5597 4.12776C11.6306 4.29857 11.667 4.48173 11.6667 4.66667V11.6667C11.6667 11.9761 11.5438 12.2728 11.325 12.4916C11.1062 12.7104 10.8094 12.8333 10.5 12.8333H3.5Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M8.16667 1.16667V4.08333C8.16667 4.23804 8.22813 4.38642 8.33752 4.49581C8.44692 4.60521 8.59529 4.66667 8.75 4.66667H11.6667" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M5.83333 5.25H4.66667" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_4" d="M9.33333 7.58333H4.66667" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_5" d="M9.33333 9.91667H4.66667" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / file-text">
|
||||
<path id="Vector" d="M3.75 13.75C3.41848 13.75 3.10054 13.6183 2.86612 13.3839C2.6317 13.1495 2.5 12.8315 2.5 12.5V2.5C2.5 2.16848 2.6317 1.85054 2.86612 1.61612C3.10054 1.3817 3.41848 1.25 3.75 1.25H8.75C8.94785 1.24968 9.1438 1.2885 9.32658 1.36423C9.50936 1.43996 9.67535 1.5511 9.815 1.69125L12.0575 3.93375C12.198 4.07344 12.3095 4.23959 12.3854 4.4226C12.4614 4.60561 12.5003 4.80186 12.5 5V12.5C12.5 12.8315 12.3683 13.1495 12.1339 13.3839C11.8995 13.6183 11.5815 13.75 11.25 13.75H3.75Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M8.75 1.25V4.375C8.75 4.54076 8.81585 4.69973 8.93306 4.81694C9.05027 4.93415 9.20924 5 9.375 5H12.5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M6.25 5.625H5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_4" d="M10 8.125H5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_5" d="M10 10.625H5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / hand" clip-path="url(#clip0_0_54)">
|
||||
<path id="Vector" d="M11.25 6.875V3.75C11.25 3.41848 11.1183 3.10054 10.8839 2.86612C10.6495 2.6317 10.3315 2.5 10 2.5C9.66848 2.5 9.35054 2.6317 9.11612 2.86612C8.8817 3.10054 8.75 3.41848 8.75 3.75" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M8.75 6.25V2.5C8.75 2.16848 8.6183 1.85054 8.38388 1.61612C8.14946 1.3817 7.83152 1.25 7.5 1.25C7.16848 1.25 6.85054 1.3817 6.61612 1.61612C6.3817 1.85054 6.25 2.16848 6.25 2.5V3.75" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M6.25 6.5625V3.75C6.25 3.41848 6.1183 3.10054 5.88388 2.86612C5.64946 2.6317 5.33152 2.5 5 2.5C4.66848 2.5 4.35054 2.6317 4.11612 2.86612C3.8817 3.10054 3.75 3.41848 3.75 3.75V8.75" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_4" d="M11.25 5C11.25 4.66848 11.3817 4.35054 11.6161 4.11612C11.8505 3.8817 12.1685 3.75 12.5 3.75C12.8315 3.75 13.1495 3.8817 13.3839 4.11612C13.6183 4.35054 13.75 4.66848 13.75 5V8.75C13.75 10.0761 13.2232 11.3479 12.2855 12.2855C11.3479 13.2232 10.0761 13.75 8.75 13.75H7.5C5.75 13.75 4.6875 13.2125 3.75625 12.2875L1.50625 10.0375C1.29121 9.79934 1.17599 9.4876 1.18445 9.16684C1.19291 8.84607 1.3244 8.54084 1.5517 8.31435C1.779 8.08786 2.08469 7.95745 2.40548 7.95012C2.72628 7.9428 3.0376 8.05912 3.275 8.275L4.375 9.375" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_54">
|
||||
<rect width="15" height="15" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / image-plus" clip-path="url(#clip0_0_116)">
|
||||
<path id="Vector" d="M10 3.125H13.75" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M11.875 1.25V5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M13.125 7.1875V11.875C13.125 12.2065 12.9933 12.5245 12.7589 12.7589C12.5245 12.9933 12.2065 13.125 11.875 13.125H3.125C2.79348 13.125 2.47554 12.9933 2.24112 12.7589C2.0067 12.5245 1.875 12.2065 1.875 11.875V3.125C1.875 2.79348 2.0067 2.47554 2.24112 2.24112C2.47554 2.0067 2.79348 1.875 3.125 1.875H7.8125" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_4" d="M13.125 9.375L11.1962 7.44625C10.9618 7.21191 10.644 7.08027 10.3125 7.08027C9.98104 7.08027 9.66316 7.21191 9.42875 7.44625L3.75 13.125" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_5" d="M6.875 5.625C6.875 6.31536 6.31536 6.875 5.625 6.875C4.93464 6.875 4.375 6.31536 4.375 5.625C4.375 4.93464 4.93464 4.375 5.625 4.375C6.31536 4.375 6.875 4.93464 6.875 5.625Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_116">
|
||||
<rect width="15" height="15" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / layers" clip-path="url(#clip0_0_80)">
|
||||
<path id="Vector" d="M8.01875 1.3625C7.8559 1.28822 7.67899 1.24978 7.5 1.24978C7.32101 1.24978 7.1441 1.28822 6.98125 1.3625L1.625 3.8C1.51409 3.8489 1.4198 3.929 1.3536 4.03054C1.2874 4.13207 1.25216 4.25067 1.25216 4.37187C1.25216 4.49308 1.2874 4.61168 1.3536 4.71321C1.4198 4.81475 1.51409 4.89485 1.625 4.94375L6.9875 7.3875C7.15035 7.46178 7.32726 7.50022 7.50625 7.50022C7.68524 7.50022 7.86215 7.46178 8.025 7.3875L13.3875 4.95C13.4984 4.9011 13.5927 4.821 13.6589 4.71946C13.7251 4.61793 13.7603 4.49933 13.7603 4.37813C13.7603 4.25692 13.7251 4.13832 13.6589 4.03679C13.5927 3.93525 13.4984 3.85515 13.3875 3.80625L8.01875 1.3625Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#78523F"/>
|
||||
<path id="Vector_2" d="M1.25 7.5C1.2497 7.61954 1.2837 7.73666 1.34795 7.83747C1.4122 7.93828 1.50401 8.01854 1.6125 8.06875L6.9875 10.5125C7.1495 10.5859 7.32529 10.6238 7.50313 10.6238C7.68096 10.6238 7.85675 10.5859 8.01875 10.5125L13.3813 8.075C13.4919 8.02527 13.5857 7.94443 13.6511 7.84233C13.7166 7.74023 13.751 7.62129 13.75 7.5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#78523F"/>
|
||||
<path id="Vector_3" d="M1.25 10.625C1.2497 10.7445 1.2837 10.8617 1.34795 10.9625C1.4122 11.0633 1.50401 11.1435 1.6125 11.1937L6.9875 13.6375C7.1495 13.7109 7.32529 13.7488 7.50313 13.7488C7.68096 13.7488 7.85675 13.7109 8.01875 13.6375L13.3813 11.2C13.4919 11.1503 13.5857 11.0694 13.6511 10.9673C13.7166 10.8652 13.751 10.7463 13.75 10.625" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#78523F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_80">
|
||||
<rect width="15" height="15" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / layers" clip-path="url(#clip0_0_131)">
|
||||
<path id="Vector" d="M7.48417 1.27167C7.33217 1.20234 7.16706 1.16646 7 1.16646C6.83294 1.16646 6.66783 1.20234 6.51583 1.27167L1.51667 3.54667C1.41315 3.59231 1.32515 3.66707 1.26336 3.76183C1.20158 3.8566 1.16868 3.96729 1.16868 4.08042C1.16868 4.19355 1.20158 4.30423 1.26336 4.399C1.32515 4.49377 1.41315 4.56852 1.51667 4.61417L6.52167 6.895C6.67366 6.96433 6.83877 7.00021 7.00583 7.00021C7.17289 7.00021 7.33801 6.96433 7.49 6.895L12.495 4.62C12.5985 4.57436 12.6865 4.4996 12.7483 4.40483C12.8101 4.31007 12.843 4.19938 12.843 4.08625C12.843 3.97312 12.8101 3.86243 12.7483 3.76767C12.6865 3.6729 12.5985 3.59814 12.495 3.5525L7.48417 1.27167Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M1.16666 7C1.16639 7.11157 1.19812 7.22088 1.25809 7.31497C1.31805 7.40906 1.40374 7.48397 1.505 7.53083L6.52167 9.81167C6.67287 9.88013 6.83694 9.91555 7.00292 9.91555C7.1689 9.91555 7.33297 9.88013 7.48417 9.81167L12.4892 7.53667C12.5924 7.49026 12.6799 7.4148 12.7411 7.31951C12.8022 7.22422 12.8342 7.1132 12.8333 7" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M1.16666 9.91667C1.16639 10.0282 1.19812 10.1376 1.25809 10.2316C1.31805 10.3257 1.40374 10.4006 1.505 10.4475L6.52167 12.7283C6.67287 12.7968 6.83694 12.8322 7.00292 12.8322C7.1689 12.8322 7.33297 12.7968 7.48417 12.7283L12.4892 10.4533C12.5924 10.4069 12.6799 10.3315 12.7411 10.2362C12.8022 10.1409 12.8342 10.0299 12.8333 9.91667" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_131">
|
||||
<rect width="14" height="14" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / layout-dashboard" clip-path="url(#clip0_0_135)">
|
||||
<path id="Vector" d="M2.5 1.875H5.625C5.97018 1.875 6.25 2.15482 6.25 2.5V6.875C6.25 7.22018 5.97018 7.5 5.625 7.5H2.5C2.15482 7.5 1.875 7.22018 1.875 6.875V2.5C1.875 2.15482 2.15482 1.875 2.5 1.875Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M9.375 1.875H12.5C12.8452 1.875 13.125 2.15482 13.125 2.5V4.375C13.125 4.72018 12.8452 5 12.5 5H9.375C9.02982 5 8.75 4.72018 8.75 4.375V2.5C8.75 2.15482 9.02982 1.875 9.375 1.875Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M9.375 7.5H12.5C12.8452 7.5 13.125 7.77982 13.125 8.125V12.5C13.125 12.8452 12.8452 13.125 12.5 13.125H9.375C9.02982 13.125 8.75 12.8452 8.75 12.5V8.125C8.75 7.77982 9.02982 7.5 9.375 7.5Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_4" d="M2.5 10H5.625C5.97018 10 6.25 10.2798 6.25 10.625V12.5C6.25 12.8452 5.97018 13.125 5.625 13.125H2.5C2.15482 13.125 1.875 12.8452 1.875 12.5V10.625C1.875 10.2798 2.15482 10 2.5 10Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_135">
|
||||
<rect width="15" height="15" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / layout-dashboard" clip-path="url(#clip0_0_88)">
|
||||
<path id="Vector" d="M2.33333 1.75H5.25C5.57217 1.75 5.83333 2.01117 5.83333 2.33333V6.41667C5.83333 6.73883 5.57217 7 5.25 7H2.33333C2.01117 7 1.75 6.73883 1.75 6.41667V2.33333C1.75 2.01117 2.01117 1.75 2.33333 1.75Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M8.75 1.75H11.6667C11.9888 1.75 12.25 2.01117 12.25 2.33333V4.08333C12.25 4.4055 11.9888 4.66667 11.6667 4.66667H8.75C8.42783 4.66667 8.16667 4.4055 8.16667 4.08333V2.33333C8.16667 2.01117 8.42783 1.75 8.75 1.75Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M8.75 7H11.6667C11.9888 7 12.25 7.26117 12.25 7.58333V11.6667C12.25 11.9888 11.9888 12.25 11.6667 12.25H8.75C8.42783 12.25 8.16667 11.9888 8.16667 11.6667V7.58333C8.16667 7.26117 8.42783 7 8.75 7Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_4" d="M2.33333 9.33333H5.25C5.57217 9.33333 5.83333 9.5945 5.83333 9.91667V11.6667C5.83333 11.9888 5.57217 12.25 5.25 12.25H2.33333C2.01117 12.25 1.75 11.9888 1.75 11.6667V9.91667C1.75 9.5945 2.01117 9.33333 2.33333 9.33333Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_88">
|
||||
<rect width="14" height="14" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / link-2">
|
||||
<path id="Vector" d="M5.625 10.625H4.375C3.5462 10.625 2.75134 10.2958 2.16529 9.70971C1.57924 9.12366 1.25 8.3288 1.25 7.5C1.25 6.6712 1.57924 5.87634 2.16529 5.29029C2.75134 4.70424 3.5462 4.375 4.375 4.375H5.625" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M9.375 4.375H10.625C11.4538 4.375 12.2487 4.70424 12.8347 5.29029C13.4208 5.87634 13.75 6.6712 13.75 7.5C13.75 8.3288 13.4208 9.12366 12.8347 9.70971C12.2487 10.2958 11.4538 10.625 10.625 10.625H9.375" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M5 7.5H10" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 931 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / mouse-pointer-2">
|
||||
<path id="Vector" d="M2.52313 2.93C2.49846 2.87307 2.49148 2.81004 2.50309 2.7491C2.5147 2.68815 2.54437 2.63211 2.58824 2.58824C2.63211 2.54437 2.68815 2.5147 2.7491 2.50309C2.81004 2.49148 2.87307 2.49846 2.93 2.52313L12.93 6.58562C12.9908 6.6104 13.0423 6.65369 13.0771 6.70937C13.1119 6.76504 13.1283 6.83026 13.1239 6.89577C13.1195 6.96128 13.0947 7.02376 13.0528 7.07433C13.0109 7.1249 12.9542 7.161 12.8906 7.1775L9.06313 8.165C8.84687 8.2206 8.64945 8.3331 8.4914 8.49083C8.33334 8.64855 8.22042 8.84573 8.16437 9.06187L7.1775 12.8906C7.161 12.9542 7.1249 13.0109 7.07433 13.0528C7.02376 13.0947 6.96128 13.1195 6.89577 13.1239C6.83026 13.1283 6.76504 13.1119 6.70937 13.0771C6.65369 13.0423 6.6104 12.9908 6.58562 12.93L2.52313 2.93Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#78523F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / panel-left" clip-path="url(#clip0_0_49)">
|
||||
<path id="Vector" d="M3.125 1.875H11.875C12.5654 1.875 13.125 2.43464 13.125 3.125V11.875C13.125 12.5654 12.5654 13.125 11.875 13.125H3.125C2.43464 13.125 1.875 12.5654 1.875 11.875V3.125C1.875 2.43464 2.43464 1.875 3.125 1.875Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M5.625 1.875V13.125" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_49">
|
||||
<rect width="15" height="15" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 776 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / plus">
|
||||
<path id="Vector" d="M3.125 7.5H11.875" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M7.5 3.125V11.875" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / plus">
|
||||
<path id="Vector" d="M2.91667 7H11.0833" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M7 2.91667V11.0833" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 453 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / redo-2">
|
||||
<path id="Vector" d="M9.375 8.75L12.5 5.625L9.375 2.5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M12.5 5.625H5.9375C5.02582 5.625 4.15148 5.98716 3.50682 6.63182C2.86216 7.27648 2.5 8.15082 2.5 9.0625C2.5 9.51392 2.58891 9.96092 2.76166 10.378C2.93441 10.795 3.18762 11.174 3.50682 11.4932C4.15148 12.1378 5.02582 12.5 5.9375 12.5H8.125" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 690 B |
@@ -0,0 +1,8 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / scan">
|
||||
<path id="Vector" d="M2.25 5.25V3.75C2.25 3.35218 2.40804 2.97064 2.68934 2.68934C2.97064 2.40804 3.35218 2.25 3.75 2.25H5.25" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#292623"/>
|
||||
<path id="Vector_2" d="M12.75 2.25H14.25C14.6478 2.25 15.0294 2.40804 15.3107 2.68934C15.592 2.97064 15.75 3.35218 15.75 3.75V5.25" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#292623"/>
|
||||
<path id="Vector_3" d="M15.75 12.75V14.25C15.75 14.6478 15.592 15.0294 15.3107 15.3107C15.0294 15.592 14.6478 15.75 14.25 15.75H12.75" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#292623"/>
|
||||
<path id="Vector_4" d="M5.25 15.75H3.75C3.35218 15.75 2.97064 15.592 2.68934 15.3107C2.40804 15.0294 2.25 14.6478 2.25 14.25V12.75" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#292623"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / scan" clip-path="url(#clip0_0_73)">
|
||||
<path id="Vector" d="M1.75 4.08333V2.91667C1.75 2.60725 1.87292 2.3105 2.09171 2.09171C2.3105 1.87292 2.60725 1.75 2.91667 1.75H4.08333" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#78523F"/>
|
||||
<path id="Vector_2" d="M9.91667 1.75H11.0833C11.3928 1.75 11.6895 1.87292 11.9083 2.09171C12.1271 2.3105 12.25 2.60725 12.25 2.91667V4.08333" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#78523F"/>
|
||||
<path id="Vector_3" d="M12.25 9.91667V11.0833C12.25 11.3928 12.1271 11.6895 11.9083 11.9083C11.6895 12.1271 11.3928 12.25 11.0833 12.25H9.91667" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#78523F"/>
|
||||
<path id="Vector_4" d="M4.08333 12.25H2.91667C2.60725 12.25 2.3105 12.1271 2.09171 11.9083C1.87292 11.6895 1.75 11.3928 1.75 11.0833V9.91667" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#78523F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_73">
|
||||
<rect width="14" height="14" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / search" clip-path="url(#clip0_0_125)">
|
||||
<path id="Vector" d="M13.125 13.125L10.4125 10.4125" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M11.875 6.875C11.875 9.63642 9.63642 11.875 6.875 11.875C4.11358 11.875 1.875 9.63642 1.875 6.875C1.875 4.11358 4.11358 1.875 6.875 1.875C9.63642 1.875 11.875 4.11358 11.875 6.875Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_125">
|
||||
<rect width="15" height="15" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 758 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / settings">
|
||||
<path id="Vector" d="M6.04437 2.585C6.07881 2.22271 6.24708 1.88628 6.51631 1.64142C6.78554 1.39657 7.13639 1.26089 7.50031 1.26089C7.86423 1.26089 8.21508 1.39657 8.48431 1.64142C8.75354 1.88628 8.92181 2.22271 8.95625 2.585C8.97695 2.81903 9.05373 3.04464 9.18008 3.24271C9.30644 3.44079 9.47866 3.60551 9.68216 3.72292C9.88567 3.84034 10.1145 3.907 10.3492 3.91726C10.5839 3.92752 10.8177 3.88108 11.0306 3.78187C11.3613 3.63174 11.736 3.61001 12.0819 3.72093C12.4277 3.83185 12.7199 4.06747 12.9016 4.38194C13.0833 4.69641 13.1414 5.06723 13.0648 5.42223C12.9881 5.77723 12.7821 6.09101 12.4869 6.3025C12.2946 6.4374 12.1377 6.61663 12.0293 6.82502C11.921 7.0334 11.8644 7.26482 11.8644 7.49969C11.8644 7.73456 11.921 7.96597 12.0293 8.17436C12.1377 8.38275 12.2946 8.56197 12.4869 8.69688C12.7821 8.90837 12.9881 9.22214 13.0648 9.57714C13.1414 9.93214 13.0833 10.303 12.9016 10.6174C12.7199 10.9319 12.4277 11.1675 12.0819 11.2784C11.736 11.3894 11.3613 11.3676 11.0306 11.2175C10.8177 11.1183 10.5839 11.0719 10.3492 11.0821C10.1145 11.0924 9.88567 11.159 9.68216 11.2765C9.47866 11.3939 9.30644 11.5586 9.18008 11.7567C9.05373 11.9547 8.97695 12.1803 8.95625 12.4144C8.92181 12.7767 8.75354 13.1131 8.48431 13.3579C8.21508 13.6028 7.86423 13.7385 7.50031 13.7385C7.13639 13.7385 6.78554 13.6028 6.51631 13.3579C6.24708 13.1131 6.07881 12.7767 6.04437 12.4144C6.02371 12.1803 5.94694 11.9546 5.82054 11.7564C5.69415 11.5583 5.52186 11.3935 5.31827 11.2761C5.11468 11.1586 4.88579 11.092 4.65099 11.0818C4.41618 11.0716 4.18238 11.1182 3.96937 11.2175C3.63868 11.3676 3.26395 11.3894 2.91813 11.2784C2.5723 11.1675 2.28011 10.9319 2.09843 10.6174C1.91675 10.303 1.85857 9.93214 1.93523 9.57714C2.01188 9.22214 2.21788 8.90837 2.51312 8.69688C2.70539 8.56197 2.86233 8.38275 2.97068 8.17436C3.07903 7.96597 3.1356 7.73456 3.1356 7.49969C3.1356 7.26482 3.07903 7.0334 2.97068 6.82502C2.86233 6.61663 2.70539 6.4374 2.51312 6.3025C2.21829 6.0909 2.01266 5.77725 1.93619 5.42249C1.85972 5.06773 1.91788 4.69721 2.09937 4.38295C2.28085 4.06868 2.5727 3.83311 2.91818 3.72203C3.26367 3.61094 3.63811 3.63227 3.96875 3.78187C4.18172 3.88108 4.41546 3.92752 4.65019 3.91726C4.88491 3.907 5.11371 3.84034 5.31721 3.72292C5.52071 3.60551 5.69293 3.44079 5.81929 3.24271C5.94565 3.04464 6.02243 2.81903 6.04313 2.585" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M9.375 7.5C9.375 8.53553 8.53553 9.375 7.5 9.375C6.46447 9.375 5.625 8.53553 5.625 7.5C5.625 6.46447 6.46447 5.625 7.5 5.625C8.53553 5.625 9.375 6.46447 9.375 7.5Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / share-2" clip-path="url(#clip0_0_15)">
|
||||
<path id="Vector" d="M13.125 3.125C13.125 4.16053 12.2855 5 11.25 5C10.2145 5 9.375 4.16053 9.375 3.125C9.375 2.08947 10.2145 1.25 11.25 1.25C12.2855 1.25 13.125 2.08947 13.125 3.125Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="white"/>
|
||||
<path id="Vector_2" d="M5.625 7.5C5.625 8.53553 4.78553 9.375 3.75 9.375C2.71447 9.375 1.875 8.53553 1.875 7.5C1.875 6.46447 2.71447 5.625 3.75 5.625C4.78553 5.625 5.625 6.46447 5.625 7.5Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="white"/>
|
||||
<path id="Vector_3" d="M13.125 11.875C13.125 12.9105 12.2855 13.75 11.25 13.75C10.2145 13.75 9.375 12.9105 9.375 11.875C9.375 10.8395 10.2145 10 11.25 10C12.2855 10 13.125 10.8395 13.125 11.875Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="white"/>
|
||||
<path id="Vector_4" d="M5.36875 8.44375L9.6375 10.9313" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="white"/>
|
||||
<path id="Vector_5" d="M9.63125 4.06875L5.36875 6.55625" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_15">
|
||||
<rect width="15" height="15" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / square" clip-path="url(#clip0_0_149)">
|
||||
<path id="Vector" d="M2.91667 1.75H11.0833C11.7277 1.75 12.25 2.27233 12.25 2.91667V11.0833C12.25 11.7277 11.7277 12.25 11.0833 12.25H2.91667C2.27233 12.25 1.75 11.7277 1.75 11.0833V2.91667C1.75 2.27233 2.27233 1.75 2.91667 1.75Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_149">
|
||||
<rect width="14" height="14" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 646 B |
@@ -0,0 +1,10 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / square" clip-path="url(#clip0_0_11)">
|
||||
<path id="Vector" d="M3.125 1.875H11.875C12.5654 1.875 13.125 2.43464 13.125 3.125V11.875C13.125 12.5654 12.5654 13.125 11.875 13.125H3.125C2.43464 13.125 1.875 12.5654 1.875 11.875V3.125C1.875 2.43464 2.43464 1.875 3.125 1.875Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_11">
|
||||
<rect width="15" height="15" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 643 B |
@@ -0,0 +1,14 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / sun-moon" clip-path="url(#clip0_0_43)">
|
||||
<path id="Vector" d="M7.5 1.25V2.5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M9.27312 10.2406C9.11916 10.8901 8.79464 11.4867 8.33306 11.9689C7.87148 12.451 7.28958 12.8012 6.64743 12.9833C6.00529 13.1655 5.32617 13.1729 4.68019 13.0048C4.03421 12.8368 3.44479 12.4994 2.97278 12.0275C2.50078 11.5555 2.16329 10.9661 1.99516 10.3202C1.82703 9.67424 1.83436 8.99512 2.0164 8.35295C2.19843 7.71077 2.54856 7.12882 3.03065 6.66718C3.51273 6.20553 4.1093 5.88093 4.75875 5.72687C5.14875 5.635 5.365 6.13937 5.20563 6.50687C5.00667 6.96681 4.95054 7.47592 5.0445 7.96816C5.13846 8.46039 5.37818 8.91303 5.73256 9.26734C6.08694 9.62165 6.53962 9.86129 7.03187 9.95516C7.52413 10.049 8.03323 9.99279 8.49312 9.79375C8.86125 9.63437 9.36562 9.85 9.27312 10.2406Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M10 7.5C10 6.83696 9.73661 6.20107 9.26777 5.73223C8.79893 5.26339 8.16304 5 7.5 5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_4" d="M11.875 3.125L11.09 3.91" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_5" d="M12.5 7.5H13.75" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_43">
|
||||
<rect width="15" height="15" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / type">
|
||||
<path id="Vector" d="M7 2.33333V11.6667" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M2.33333 4.08333V2.91667C2.33333 2.76196 2.39479 2.61358 2.50419 2.50419C2.61358 2.39479 2.76196 2.33333 2.91667 2.33333H11.0833C11.238 2.33333 11.3864 2.39479 11.4958 2.50419C11.6052 2.61358 11.6667 2.76196 11.6667 2.91667V4.08333" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M5.25 11.6667H8.75" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 794 B |
@@ -0,0 +1,7 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / type">
|
||||
<path id="Vector" d="M7 2.33333V11.6667" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#78523F"/>
|
||||
<path id="Vector_2" d="M2.33333 4.08333V2.91667C2.33333 2.76196 2.39479 2.61358 2.50419 2.50419C2.61358 2.39479 2.76196 2.33333 2.91667 2.33333H11.0833C11.238 2.33333 11.3864 2.39479 11.4958 2.50419C11.6052 2.61358 11.6667 2.76196 11.6667 2.91667V4.08333" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#78523F"/>
|
||||
<path id="Vector_3" d="M5.25 11.6667H8.75" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#78523F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 794 B |
@@ -0,0 +1,7 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / type">
|
||||
<path id="Vector" d="M7.5 2.5V12.5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M2.5 4.375V3.125C2.5 2.95924 2.56585 2.80027 2.68306 2.68306C2.80027 2.56585 2.95924 2.5 3.125 2.5H11.875C12.0408 2.5 12.1997 2.56585 12.3169 2.68306C12.4342 2.80027 12.5 2.95924 12.5 3.125V4.375" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_3" d="M5.625 12.5H9.375" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 752 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / undo-2">
|
||||
<path id="Vector" d="M5.625 8.75L2.5 5.625L5.625 2.5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
<path id="Vector_2" d="M2.5 5.625H9.0625C9.51392 5.625 9.96092 5.71391 10.378 5.88666C10.795 6.05941 11.174 6.31262 11.4932 6.63182C11.8124 6.95102 12.0656 7.32997 12.2383 7.74703C12.4111 8.16408 12.5 8.61108 12.5 9.0625C12.5 9.51392 12.4111 9.96092 12.2383 10.378C12.0656 10.795 11.8124 11.174 11.4932 11.4932C11.174 11.8124 10.795 12.0656 10.378 12.2383C9.96092 12.4111 9.51392 12.5 9.0625 12.5H6.875" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#6D665F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 829 B |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="200" height="220" viewBox="0 0 200 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame">
|
||||
<path id="Vector" d="M17 202C45 200 73.6667 200 103 202M18 210L80 211M13 17L185 14L188 190L14 193L13 17Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_2" d="M42 188C44 157.333 54.3333 137.333 73 128L115 129C133.667 139.667 147.667 159.333 157 188" fill="#EDE9D0" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_3" d="M78 64C64.6667 62 59.6667 55.3333 63 44C66.3333 34 73.3333 30.3333 84 33C91.3333 23 101 21 113 27C126.333 31.6667 130 42 124 58L106 48L91 66L78 64Z" fill="#49372E" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_4" d="M79 63C71.6667 66.3333 70 72 74 80L82 82C82.6667 100 89.6667 109.667 103 111C117 108.333 124 95.3333 124 72L121 57M87 109L82 129M106 111L113 129M96 71L94 84L102 86M93 99C99 102.333 104.667 101.333 110 96M87 73H88M112 72H113" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_5" d="M60 156L75 138L128 145L123 185L61 180L60 156Z" fill="#FFF6EF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_6" d="M76 138L84 127L103 130L110 143M62 162L51 157L43 165L58 180M122 159L137 168L124 179M98 152C96.4241 152.066 94.8766 152.441 93.4459 153.105C92.0151 153.769 90.729 154.708 89.6612 155.868C88.5933 157.029 87.7645 158.389 87.2221 159.87C86.6797 161.351 86.4343 162.924 86.5 164.5C86.5657 166.076 86.9411 167.623 87.6048 169.054C88.2685 170.485 89.2075 171.771 90.3683 172.839C91.529 173.907 92.8887 174.736 94.3697 175.278C95.8508 175.82 97.4241 176.066 99 176C102.183 175.867 105.182 174.476 107.339 172.132C109.496 169.788 110.633 166.683 110.5 163.5C110.367 160.317 108.976 157.318 106.632 155.161C104.288 153.004 101.183 151.867 98 152Z" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_7" d="M150 82L156 76L166 85M158 71H159M143 68L168 61L175 92L150 99L143 68Z" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#78523F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="200" height="220" viewBox="0 0 200 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame">
|
||||
<path id="Vector" d="M17 202C45 200 73.6667 200 103 202M18 210L80 211M13 17L185 14L188 190L14 193L13 17Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_2" d="M42 188C44 157.333 54.3333 137.333 73 128L115 129C133.667 139.667 147.667 159.333 157 188" fill="#EDE9D0" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_3" d="M78 64C64.6667 62 59.6667 55.3333 63 44C66.3333 34 73.3333 30.3333 84 33C91.3333 23 101 21 113 27C126.333 31.6667 130 42 124 58L106 48L91 66L78 64Z" fill="#49372E" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_4" d="M79 63C71.6667 66.3333 70 72 74 80L82 82C82.6667 100 89.6667 109.667 103 111C117 108.333 124 95.3333 124 72L121 57M87 109L82 129M106 111L113 129M96 71L94 84L102 86M93 99C99 102.333 104.667 101.333 110 96M87 73H88M112 72H113" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_5" d="M49 132L98 148L144 128L140 186L99 201L53 186L49 132Z" fill="#FFF6EF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_6" d="M98 148L99 201M64 147L87 155M65 158L85 165M66 168L82 174M110 158L130 150M110 169L128 162M110 179L125 173" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_7" d="M50 162L62 157C67.3333 157.667 69 160.333 67 165L53 178M142 161L130 157C124 157 122 159.333 124 164L139 176" fill="#FFF6EF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="200" height="220" viewBox="0 0 200 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame">
|
||||
<path id="Vector" d="M17 202C45 200 73.6667 200 103 202M18 210L80 211M13 17L185 14L188 190L14 193L13 17Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_2" d="M42 188C44 157.333 54.3333 137.333 73 128L115 129C133.667 139.667 147.667 159.333 157 188" fill="#EDE9D0" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_3" d="M78 64C64.6667 62 59.6667 55.3333 63 44C66.3333 34 73.3333 30.3333 84 33C91.3333 23 101 21 113 27C126.333 31.6667 130 42 124 58L106 48L91 66L78 64Z" fill="#49372E" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_4" d="M79 63C71.6667 66.3333 70 72 74 80L82 82C82.6667 100 89.6667 109.667 103 111C117 108.333 124 95.3333 124 72L121 57M87 109L82 129M106 111L113 129M96 71L94 84L102 86M93 99C99 102.333 104.667 101.333 110 96M87 73H88M112 72H113" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_5" d="M117 135L134 97L147 49L158 52L151 105L137 154M68 143L63 170L100 185" fill="#FFF6EF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_6" d="M141 70L133 39L159 30L170 59L141 70Z" fill="#EDE9D0" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_7" d="M142 42L156 37M145 51L158 46M148 59L160 54M151 22V12M167 26L175 19M134 31L125 27" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="200" height="220" viewBox="0 0 200 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame">
|
||||
<path id="Vector" d="M17 202C45 200 73.6667 200 103 202M18 210L80 211M13 17L185 14L188 190L14 193L13 17Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_2" d="M42 188C44 157.333 54.3333 137.333 73 128L115 129C133.667 139.667 147.667 159.333 157 188" fill="#EDE9D0" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_3" d="M78 64C64.6667 62 59.6667 55.3333 63 44C66.3333 34 73.3333 30.3333 84 33C91.3333 23 101 21 113 27C126.333 31.6667 130 42 124 58L106 48L91 66L78 64Z" fill="#49372E" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_4" d="M79 63C71.6667 66.3333 70 72 74 80L82 82C82.6667 100 89.6667 109.667 103 111C117 108.333 124 95.3333 124 72L121 57M87 109L82 129M106 111L113 129M96 71L94 84L102 86M93 99C99 102.333 104.667 101.333 110 96M87 73H88M112 72H113" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_5" d="M82 136L98 166L124 160L136 124M118 162L136 173L147 138L140 118C137.333 113.333 134.667 112.333 132 115L130 129L124 122M78 148L69 173L97 187L134 174" fill="#FFF6EF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_6" d="M154 35L158 47L171 22L142 32L154 35ZM171 22L154 35M135 47C121 49.6667 116 55.3333 120 64C124 72.6667 132.333 73.3333 145 66" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#78523F"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="200" height="220" viewBox="0 0 200 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame">
|
||||
<path id="Vector" d="M17 202C45 200 73.6667 200 103 202M18 210L80 211M13 17L185 14L188 190L14 193L13 17Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_2" d="M42 188C44 157.333 54.3333 137.333 73 128L115 129C133.667 139.667 147.667 159.333 157 188" fill="#EDE9D0" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_3" d="M78 64C64.6667 62 59.6667 55.3333 63 44C66.3333 34 73.3333 30.3333 84 33C91.3333 23 101 21 113 27C126.333 31.6667 130 42 124 58L106 48L91 66L78 64Z" fill="#49372E" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_4" d="M79 63C71.6667 66.3333 70 72 74 80L82 82C82.6667 100 89.6667 109.667 103 111C117 108.333 124 95.3333 124 72L121 57M87 109L82 129M106 111L113 129M96 71L94 84L102 86M93 99C99 102.333 104.667 101.333 110 96M87 73H88M112 72H113" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_5" d="M99 143L164 136L179 187L111 192L99 143Z" fill="#FFF6EF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_6" d="M101 150L79 153L71 172L100.397 176.593M100.397 176.593L103 177M100.397 176.593L80 178L64 171M100.397 176.593L109 176M130 164H143M115 193H37" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_7" d="M151 46L156 32L164 44L179 46L168 55L169 70L156 62L143 67L147 54L137 44L151 46Z" fill="#EDE9D0" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="140" height="110" viewBox="0 0 140 110" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame">
|
||||
<path id="Vector" d="M43 25L92 20L99 84L46 90L43 25Z" fill="#EDE9D0" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_2" d="M54 39L79 37M55 51L85 48M57 62L78 60M34 18L28 10M47 13L46 3M107 65L115 68M34 70L24 72M98 37L116 17L123 24L103 45L94 48L98 37Z" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 586 B |
@@ -0,0 +1,7 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="140" height="110" viewBox="0 0 140 110" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame">
|
||||
<path id="Vector" d="M42 37C60 16.3333 76.6667 16 92 36M44 44C62 32.6667 77.6667 32.3333 91 43M45 62C60.3333 80.6667 75.6667 81.3333 91 64" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_2" d="M18 32L47 29L50 63L19 65L18 32ZM90 24L122 28L119 61L89 58L90 24ZM52 73L84 72L86 97L54 99L52 73Z" fill="#EDE9D0" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_3" d="M26 43L40 42M27 51L37 50M99 37L112 39M99 46L110 47M62 83H75M63 90H72" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 820 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="140" height="110" viewBox="0 0 140 110" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame">
|
||||
<path id="Vector" d="M20 30C36.6667 24.6667 53 27 69 37C85 25.6667 102 22.3333 120 27L117 83C101 79.6667 85 83 69 93C54.3333 84.3333 38.6667 82 22 86L20 30Z" fill="#EDE9D0" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_2" d="M69 37V93M32 42C40.6667 41.3333 49 43 57 47M34 55L55 60M34 68L54 72M81 45L105 39M82 58L103 52M83 70L101 66M66 22L64 11M81 21L87 11" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 695 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="140" height="110" viewBox="0 0 140 110" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame">
|
||||
<path id="Vector" d="M31 41H102M45 34H46M36 34H37M57 33H58M71 54L98 53M72 65L92 64M73 77L97 76M24 29L109 26L114 89L28 93L24 29ZM40 54L61 52L62 78L40 79V54Z" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
<path id="Vector_2" d="M103 14L110 3L114 15L127 17L117 25L119 36L109 30L99 37L101 25L91 18L103 14Z" fill="#EDE9D0" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" stroke="#49372E"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 639 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="382" height="235" viewBox="0 0 382 235" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="stroke0_0_17" fill="white">
|
||||
<path d="M382 117.5C382 182.393 296.486 235 191 235C85.5136 235 0 182.393 0 117.5C0 52.6065 85.5136 0 191 0C296.486 0 382 52.6065 382 117.5Z"/>
|
||||
</mask>
|
||||
<path id="Ellipse" d="M382 117.5C382 182.393 296.486 235 191 235C85.5136 235 0 182.393 0 117.5C0 52.6065 85.5136 0 191 0C296.486 0 382 52.6065 382 117.5Z" stroke-width="2" stroke="#68594F" stroke-opacity="0.5" mask="url(#stroke0_0_17)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 605 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="310" height="274" viewBox="0 0 310 274" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="stroke0_0_16" fill="white">
|
||||
<path d="M310 137C310 212.663 240.604 274 155 274C69.3959 274 0 212.663 0 137C0 61.337 69.3959 0 155 0C240.604 0 310 61.337 310 137Z"/>
|
||||
</mask>
|
||||
<path id="Ellipse" d="M310 137C310 212.663 240.604 274 155 274C69.3959 274 0 212.663 0 137C0 61.337 69.3959 0 155 0C240.604 0 310 61.337 310 137Z" stroke-width="2" stroke="#68594F" stroke-opacity="0.5" mask="url(#stroke0_0_16)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / arrow-up-right">
|
||||
<path id="Vector" d="M5.25 5.25H12.75V12.75" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#FFFBF6"/>
|
||||
<path id="Vector_2" d="M5.25 12.75L12.75 5.25" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#FFFBF6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 471 B |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon / eye">
|
||||
<path id="Vector" d="M1.63242 9.7755C1.56644 9.59776 1.56644 9.40224 1.63242 9.2245C2.27501 7.66638 3.36578 6.33416 4.76644 5.39671C6.1671 4.45927 7.81458 3.95882 9.5 3.95882C11.1854 3.95882 12.8329 4.45927 14.2336 5.39671C15.6342 6.33416 16.725 7.66638 17.3676 9.2245C17.4336 9.40224 17.4336 9.59776 17.3676 9.7755C16.725 11.3336 15.6342 12.6658 14.2336 13.6033C12.8329 14.5407 11.1854 15.0412 9.5 15.0412C7.81458 15.0412 6.1671 14.5407 4.76644 13.6033C3.36578 12.6658 2.27501 11.3336 1.63242 9.7755Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#716054"/>
|
||||
<path id="Vector_2" d="M11.875 9.5C11.875 10.8117 10.8117 11.875 9.5 11.875C8.18832 11.875 7.125 10.8117 7.125 9.5C7.125 8.18832 8.18832 7.125 9.5 7.125C10.8117 7.125 11.875 8.18832 11.875 9.5Z" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke="#716054"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,20 @@
|
||||
import Link from "next/link";
|
||||
export function Brand({ light = false }: { light?: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
className={"brand " + (light ? "brand-light" : "")}
|
||||
aria-label="啡跃 Endoleap 首页"
|
||||
>
|
||||
<img
|
||||
src="/assets/landingcontext-imgFrame.svg"
|
||||
alt=""
|
||||
width="36"
|
||||
height="36"
|
||||
/>
|
||||
<span>
|
||||
啡跃 <small>Endoleap</small>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function Icon({ name, size = 18 }: { name: string; size?: number }) {
|
||||
return (
|
||||
<img
|
||||
className="ui-icon"
|
||||
src={"/assets/dashboardcontext-imgIcon" + name + ".svg"}
|
||||
alt=""
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
export type Profile = {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const res = await fetch("/api/foundry/" + path, {
|
||||
...options,
|
||||
headers: { "Content-Type": "application/json", ...options.headers },
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok)
|
||||
throw new Error(body?.error?.code || body?.error || "request_failed");
|
||||
return body as T;
|
||||
}
|
||||
export const PROJECT = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
export async function startOAuth(provider: "google" | "slack") {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const verifier = btoa(String.fromCharCode(...bytes))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(verifier),
|
||||
);
|
||||
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
sessionStorage.setItem("foundry_verifier", verifier);
|
||||
window.location.assign("/api/oauth/" + provider + "?challenge=" + challenge);
|
||||
}
|
||||
export const errorText = (error: unknown) => {
|
||||
const code = error instanceof Error ? error.message : "";
|
||||
const messages: Record<string, string> = {
|
||||
invalid_credentials: "邮箱或密码不正确,或邮箱尚未验证。",
|
||||
rate_limited: "请求太频繁,请稍后再试。",
|
||||
invalid_session: "登录已过期,请重新登录。",
|
||||
workspace_not_loaded: "空间列表尚未加载成功,请刷新页面后重试。",
|
||||
workspace_required: "请先创建或选择一个工作空间。",
|
||||
invalid_workspace: "工作空间名称不符合要求,请修改后重试。",
|
||||
invalid_material: "画布名称或内容不符合要求,请修改后重试。",
|
||||
invalid_reference: "工作空间不存在或当前账户无权访问,请刷新空间列表。",
|
||||
upstream_timeout: "连接服务超时,请稍后再试。",
|
||||
upstream_unavailable: "暂时无法连接服务,请稍后重试。",
|
||||
not_found: "接口暂未上线,请稍后重试。",
|
||||
invalid_verification: "验证链接无效或已过期,请重新注册以获取邮件。",
|
||||
slack_not_configured: "Slack 登录尚未配置完成。",
|
||||
};
|
||||
return messages[code] || "操作未完成,请稍后重试。";
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Profile } from "./api";
|
||||
|
||||
export type Workspace = { id: string; name: string };
|
||||
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
|
||||
export async function loadWorkspaceHome(
|
||||
request: Request,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const [profile, result] = await Promise.all([
|
||||
request<Profile>("profile", { signal }),
|
||||
request<Workspace[] | { items: Workspace[] }>("workspaces", { signal }),
|
||||
]);
|
||||
return {
|
||||
profile,
|
||||
workspaces: Array.isArray(result) ? result : result.items || [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function createBoard(
|
||||
request: Request,
|
||||
title: string,
|
||||
workspaceId: string,
|
||||
onWorkspaceCreated: (workspace: Workspace) => void,
|
||||
) {
|
||||
let id = workspaceId;
|
||||
if (!id) {
|
||||
const workspace = await request<Workspace>("workspaces", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "个人空间" }),
|
||||
});
|
||||
id = workspace.id;
|
||||
onWorkspaceCreated(workspace);
|
||||
}
|
||||
return request<{ id: string }>("workspaces/" + id + "/materials", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
kind: "board",
|
||||
title: title.trim(),
|
||||
content: { schema_version: 1 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AppProps } from "next/app";
|
||||
import Head from "next/head";
|
||||
import "@/styles/globals.css";
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>啡跃 Endoleap · 让灵感有形</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
name="description"
|
||||
content="收藏灵感、连接知识,在自己的创作空间让想法慢慢成形。"
|
||||
/>
|
||||
</Head>
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Html, Head, Main, NextScript } from "next/document";
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="zh-CN">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
const allowed =
|
||||
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials|search)(?:\/[a-zA-Z0-9_-]+)?)?)?)$/;
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
if (!["GET", "POST", "PATCH", "DELETE"].includes(req.method || ""))
|
||||
return res.status(405).json({ error: "method_not_allowed" });
|
||||
if (req.method !== "GET" && req.headers.origin) {
|
||||
try {
|
||||
if (new URL(req.headers.origin).host !== req.headers.host)
|
||||
return res.status(403).json({ error: "invalid_origin" });
|
||||
} catch {
|
||||
return res.status(403).json({ error: "invalid_origin" });
|
||||
}
|
||||
}
|
||||
const path = (Array.isArray(req.query.path) ? req.query.path : []).join("/");
|
||||
if (!allowed.test(path)) return res.status(404).json({ error: "not_found" });
|
||||
const origin =
|
||||
process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
|
||||
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (req.cookies.foundry_session)
|
||||
headers.Authorization = "Bearer " + req.cookies.foundry_session;
|
||||
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"])
|
||||
if (typeof req.headers[h] === "string")
|
||||
headers[h] = req.headers[h] as string;
|
||||
const query = new URLSearchParams();
|
||||
for (const key of ["kind", "sort", "q", "limit", "offset"]) {
|
||||
const v = req.query[key];
|
||||
if (typeof v === "string" && v.length < 300) query.set(key, v);
|
||||
}
|
||||
const started = performance.now();
|
||||
const disconnected = new AbortController();
|
||||
const onClose = () => {
|
||||
if (!res.writableEnded) disconnected.abort();
|
||||
};
|
||||
res.once("close", onClose);
|
||||
try {
|
||||
const upstream = await fetch(
|
||||
origin +
|
||||
"/projects/" +
|
||||
encodeURIComponent(project) +
|
||||
"/" +
|
||||
path +
|
||||
(query.size ? "?" + query.toString() : ""),
|
||||
{
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.method === "GET" ? undefined : JSON.stringify(req.body || {}),
|
||||
signal: AbortSignal.any([
|
||||
AbortSignal.timeout(15000),
|
||||
disconnected.signal,
|
||||
]),
|
||||
redirect: "manual",
|
||||
},
|
||||
);
|
||||
const headersReceived = performance.now();
|
||||
const body = upstream.status === 204 ? null : await upstream.json();
|
||||
res.setHeader(
|
||||
"Server-Timing",
|
||||
`upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
|
||||
);
|
||||
const session = body?.access_token;
|
||||
if (session) {
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
`foundry_session=${encodeURIComponent(session)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.min(body.expires_in || 28800, 28800)}${process.env.NODE_ENV === "production" && req.headers["x-forwarded-proto"] === "https" ? "; Secure" : ""}`,
|
||||
);
|
||||
delete body.access_token;
|
||||
}
|
||||
if (path === "auth/logout" && upstream.ok)
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
"foundry_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
|
||||
);
|
||||
if (upstream.status === 204) return res.status(204).end();
|
||||
return res
|
||||
.status(upstream.status)
|
||||
.json(body || { error: "upstream_unavailable" });
|
||||
} catch (error) {
|
||||
if (res.destroyed) return;
|
||||
res.setHeader(
|
||||
"Server-Timing",
|
||||
`total;dur=${(performance.now() - started).toFixed(1)}`,
|
||||
);
|
||||
const timeout = error instanceof Error && error.name === "TimeoutError";
|
||||
return res
|
||||
.status(timeout ? 504 : 502)
|
||||
.json({ error: timeout ? "upstream_timeout" : "upstream_unavailable" });
|
||||
} finally {
|
||||
res.off("close", onClose);
|
||||
}
|
||||
}
|
||||
export const config = { api: { bodyParser: { sizeLimit: "1mb" } } };
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const p = req.query.provider,
|
||||
c = req.query.challenge;
|
||||
if (
|
||||
req.method !== "GET" ||
|
||||
!["google", "slack"].includes(String(p)) ||
|
||||
typeof c !== "string" ||
|
||||
!/^[-_a-zA-Z0-9]{43}$/.test(c)
|
||||
)
|
||||
return res.status(400).json({ error: "invalid_request" });
|
||||
const origin =
|
||||
process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
|
||||
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.redirect(
|
||||
302,
|
||||
origin +
|
||||
"/projects/" +
|
||||
encodeURIComponent(project) +
|
||||
"/auth/" +
|
||||
p +
|
||||
"?challenge=" +
|
||||
c,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../login";
|
||||
@@ -0,0 +1,934 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Plus,
|
||||
Search,
|
||||
Folder,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Check,
|
||||
MoreHorizontal,
|
||||
X,
|
||||
ArrowLeft,
|
||||
Sun,
|
||||
Moon,
|
||||
} from "lucide-react";
|
||||
import { Brand } from "@/components/brand";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api, errorText, type Profile } from "@/lib/api";
|
||||
import {
|
||||
createBoard,
|
||||
loadWorkspaceHome,
|
||||
type Workspace,
|
||||
} from "@/lib/workspace";
|
||||
type Board = {
|
||||
id: string;
|
||||
title: string;
|
||||
updated: string;
|
||||
style: number;
|
||||
text: string;
|
||||
};
|
||||
const initialBoards: Board[] = [
|
||||
{
|
||||
id: "morning",
|
||||
title: "城市清晨",
|
||||
updated: "刚刚",
|
||||
style: 0,
|
||||
text: "让阳光穿过叶片,落在安静的房间里。",
|
||||
},
|
||||
{
|
||||
id: "ideas",
|
||||
title: "灵感收集",
|
||||
updated: "昨天",
|
||||
style: 1,
|
||||
text: "把一闪而过的念头,留给下一次创作。",
|
||||
},
|
||||
{
|
||||
id: "journey",
|
||||
title: "下一站,去哪里",
|
||||
updated: "2 天前",
|
||||
style: 2,
|
||||
text: "为一段还未开始的旅程,收集一些可能。",
|
||||
},
|
||||
];
|
||||
export default function Dashboard() {
|
||||
const router = useRouter();
|
||||
const demo = router.query.demo === "1";
|
||||
const mutationVersion = useRef(0);
|
||||
const [boardsLoading, setBoardsLoading] = useState(false);
|
||||
const [spacesLoaded, setSpacesLoaded] = useState(false);
|
||||
const [ready, setReady] = useState(false),
|
||||
[profile, setProfile] = useState<Profile | null>(null),
|
||||
[workspaces, setWorkspaces] = useState<Workspace[]>([]),
|
||||
[workspace, setWorkspace] = useState(""),
|
||||
[boards, setBoards] = useState<Board[]>([]),
|
||||
[query, setQuery] = useState(""),
|
||||
[view, setView] = useState("全部文件"),
|
||||
[canvas, setCanvas] = useState<Board | null>(null),
|
||||
[sidebar, setSidebar] = useState(true),
|
||||
[dialog, setDialog] = useState<"board" | "workspace" | null>(null),
|
||||
[title, setTitle] = useState(""),
|
||||
[error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false),
|
||||
[workspaceMenu, setWorkspaceMenu] = useState(false),
|
||||
[notice, setNotice] = useState(""),
|
||||
[dark, setDark] = useState(false),
|
||||
[checked, setChecked] = useState(false),
|
||||
[zoom, setZoom] = useState(100),
|
||||
[nodeText, setNodeText] = useState(""),
|
||||
[note, setNote] = useState("");
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
let active = true;
|
||||
if (demo) {
|
||||
setProfile({
|
||||
id: "demo",
|
||||
username: "creator",
|
||||
display_name: "创作者",
|
||||
email: null,
|
||||
});
|
||||
setWorkspaces([{ id: "demo", name: "个人空间" }]);
|
||||
setWorkspace("demo");
|
||||
setSpacesLoaded(true);
|
||||
let stored: Board[] | null = null;
|
||||
try {
|
||||
stored = JSON.parse(
|
||||
localStorage.getItem("endoleap_demo_boards_demo") || "null",
|
||||
);
|
||||
} catch {}
|
||||
setBoards(stored || initialBoards);
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
loadWorkspaceHome(api, controller.signal)
|
||||
.then(({ profile, workspaces: list }) => {
|
||||
if (!active) return;
|
||||
setProfile(profile);
|
||||
setWorkspaces(list);
|
||||
setSpacesLoaded(true);
|
||||
setWorkspace(list[0]?.id || "");
|
||||
setReady(true);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!active) return;
|
||||
if (e.message === "invalid_session") router.replace("/login");
|
||||
else {
|
||||
setError(errorText(e));
|
||||
setReady(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [router.isReady, demo, router]);
|
||||
useEffect(() => {
|
||||
if (demo && ready)
|
||||
localStorage.setItem(
|
||||
"endoleap_demo_boards_" + workspace,
|
||||
JSON.stringify(boards),
|
||||
);
|
||||
}, [boards, demo, ready, workspace]);
|
||||
useEffect(() => {
|
||||
if (!workspace || demo) {
|
||||
setBoardsLoading(false);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const controller = new AbortController();
|
||||
const version = mutationVersion.current;
|
||||
setBoardsLoading(true);
|
||||
setBoards([]);
|
||||
api<{ items: { id: string; title: string }[] }>(
|
||||
"workspaces/" +
|
||||
workspace +
|
||||
"/materials?kind=board&sort=modified&limit=100",
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((r) => {
|
||||
if (active && version === mutationVersion.current)
|
||||
setBoards(
|
||||
r.items.map((b, i) => ({
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
updated: "最近",
|
||||
style: i % 3,
|
||||
text: "",
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError(errorText(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setBoardsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [workspace, demo]);
|
||||
useEffect(() => {
|
||||
if (notice) {
|
||||
const timer = setTimeout(() => setNotice(""), 3500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [notice]);
|
||||
async function create(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (dialog === "workspace") {
|
||||
const w = demo
|
||||
? { id: crypto.randomUUID(), name: title.trim() }
|
||||
: await api<Workspace>("workspaces", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: title.trim() }),
|
||||
});
|
||||
setWorkspaces([...workspaces, w]);
|
||||
setWorkspace(w.id);
|
||||
setBoards([]);
|
||||
} else {
|
||||
if (!workspace && !spacesLoaded)
|
||||
throw new Error("workspace_not_loaded");
|
||||
const id = demo
|
||||
? crypto.randomUUID()
|
||||
: (
|
||||
await createBoard(api, title, workspace, (w) => {
|
||||
setWorkspaces((previous) =>
|
||||
previous.some((x) => x.id === w.id)
|
||||
? previous
|
||||
: [...previous, w],
|
||||
);
|
||||
setWorkspace(w.id);
|
||||
})
|
||||
).id;
|
||||
mutationVersion.current += 1;
|
||||
setBoards((previous) => [
|
||||
{
|
||||
id,
|
||||
title: title.trim(),
|
||||
updated: "刚刚",
|
||||
style: boards.length % 3,
|
||||
text: "一张空白画布,等待你的第一个念头。",
|
||||
},
|
||||
...previous,
|
||||
]);
|
||||
}
|
||||
setDialog(null);
|
||||
setTitle("");
|
||||
setNotice("已创建");
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function logout() {
|
||||
try {
|
||||
if (!demo) await api("auth/logout", { method: "POST" });
|
||||
await router.push("/login");
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
}
|
||||
}
|
||||
function open(b: Board) {
|
||||
setCanvas(b);
|
||||
setNote(b.text);
|
||||
setNodeText("");
|
||||
if (!demo) setNotice("当前为画布界面预览;服务端已保存画布名称。");
|
||||
}
|
||||
function saveNote() {
|
||||
if (!canvas) return;
|
||||
if (demo) {
|
||||
setBoards(
|
||||
boards.map((b) => (b.id === canvas.id ? { ...b, text: note } : b)),
|
||||
);
|
||||
setNotice("已保存到当前浏览器");
|
||||
} else setNotice("画布文字编辑将在布局接口接入后保存。");
|
||||
}
|
||||
const names =
|
||||
workspaces.find((w) => w.id === workspace)?.name || "创建你的空间";
|
||||
const filtered = boards.filter((b) =>
|
||||
b.title.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
if (!ready)
|
||||
return (
|
||||
<div className="loading-page">
|
||||
<Brand />
|
||||
<p>正在打开你的创作空间…</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={"dashboard " + (dark ? "dark-workspace" : "")}>
|
||||
<aside className="icon-rail">
|
||||
<Link href="/" aria-label="首页">
|
||||
<img
|
||||
src="/assets/dashboardcontext-imgFrame.svg"
|
||||
width="25"
|
||||
height="25"
|
||||
alt="啡跃"
|
||||
/>
|
||||
</Link>
|
||||
<div className="rail-nav">
|
||||
{[
|
||||
["LayoutDashboard", "工作台"],
|
||||
["Layers", "图层"],
|
||||
["Search", "搜索"],
|
||||
["ImagePlus", "素材"],
|
||||
["Bot", "Agent"],
|
||||
].map(([icon, label]) => (
|
||||
<button
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={view === label ? "active" : ""}
|
||||
key={label}
|
||||
onClick={() => {
|
||||
if (label === "搜索") {
|
||||
setCanvas(null);
|
||||
setTimeout(
|
||||
() => document.getElementById("board-search")?.focus(),
|
||||
0,
|
||||
);
|
||||
} else if (label === "图层" && canvas) setSidebar(!sidebar);
|
||||
else if (label === "工作台") {
|
||||
setCanvas(null);
|
||||
setView("全部文件");
|
||||
} else setNotice(label + "将在后续版本开放。");
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} size={17} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rail-bottom">
|
||||
<button aria-label="切换亮暗主题" onClick={() => setDark(!dark)}>
|
||||
{dark ? <Sun size={17} /> : <Moon size={17} />}
|
||||
</button>
|
||||
<button aria-label="退出登录" title="退出登录" onClick={logout}>
|
||||
<LogOut size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{sidebar && (
|
||||
<aside className="workspace-sidebar">
|
||||
<div className="workspace-switch">
|
||||
<button
|
||||
onClick={() => setWorkspaceMenu(!workspaceMenu)}
|
||||
aria-expanded={workspaceMenu}
|
||||
>
|
||||
<span className="workspace-avatar">↑</span>
|
||||
<span>{names}</span>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
{workspaceMenu && (
|
||||
<div className="workspace-menu">
|
||||
{workspaces.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
onClick={() => {
|
||||
setWorkspace(w.id);
|
||||
setWorkspaceMenu(false);
|
||||
if (!demo) setBoards([]);
|
||||
else {
|
||||
let stored: Board[] = [];
|
||||
try {
|
||||
stored = JSON.parse(
|
||||
localStorage.getItem(
|
||||
"endoleap_demo_boards_" + w.id,
|
||||
) || "[]",
|
||||
);
|
||||
} catch {}
|
||||
setBoards(stored);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{w.name}
|
||||
{workspace === w.id && <Check size={14} />}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
setDialog("workspace");
|
||||
setWorkspaceMenu(false);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> 新建工作空间
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="profile-row"
|
||||
onClick={() => setNotice(profile?.email || "当前为本地演示账户")}
|
||||
>
|
||||
<span className="user-avatar">
|
||||
{(profile?.display_name || profile?.username || "U")
|
||||
.slice(0, 1)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
<span>
|
||||
{profile?.display_name || profile?.username || "创作者"}
|
||||
</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
<div className="sidebar-label">
|
||||
{canvas ? "当前画布" : "工作空间"}
|
||||
</div>
|
||||
{canvas ? (
|
||||
<>
|
||||
<button
|
||||
className="side-item selected"
|
||||
onClick={() => {
|
||||
setCanvas(null);
|
||||
setView("全部文件");
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={15} />
|
||||
返回全部文件
|
||||
</button>
|
||||
<div className="layer-label">
|
||||
<Icon name="Layers" size={15} /> 图层 <Plus size={14} />
|
||||
</div>
|
||||
{[
|
||||
"灵感片段",
|
||||
"镜头研究",
|
||||
"清晨光线",
|
||||
"镜头节奏",
|
||||
"光的形状",
|
||||
"环境声音",
|
||||
"拍摄待办",
|
||||
"色彩参考",
|
||||
].map((n, i) => (
|
||||
<button
|
||||
key={n}
|
||||
className={"side-item layer " + (i === 1 ? "selected" : "")}
|
||||
onClick={() => {
|
||||
document.getElementById("canvas-note")?.focus();
|
||||
setNotice("已选择「" + n + "」");
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={i === 1 ? "Scan1" : i === 4 ? "Square" : "Type"}
|
||||
size={14}
|
||||
/>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{[
|
||||
["全部文件", LayoutGrid],
|
||||
["最近打开", FileText],
|
||||
["我的收藏", Folder],
|
||||
].map(([label, Comp]) => {
|
||||
const C = Comp as typeof Folder;
|
||||
return (
|
||||
<button
|
||||
key={label as string}
|
||||
className={
|
||||
"side-item " + (view === label ? "selected" : "")
|
||||
}
|
||||
onClick={() => setView(label as string)}
|
||||
>
|
||||
<C size={16} />
|
||||
{label as string}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="sidebar-label sidebar-label-flex">
|
||||
画布{" "}
|
||||
<button
|
||||
aria-label="新建画布"
|
||||
onClick={() => setDialog("board")}
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{boards.map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
className="side-item"
|
||||
onClick={() => open(b)}
|
||||
>
|
||||
<Icon name="Scan1" size={15} />
|
||||
{b.title}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="side-item muted"
|
||||
onClick={() => setDialog("board")}
|
||||
>
|
||||
<Plus size={15} />
|
||||
新建画布
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="sidebar-bottom">
|
||||
<p>给想法留白,给创作空间。</p>
|
||||
<div className="sidebar-bottom-line">
|
||||
<Brand />
|
||||
{demo && <span className="demo-badge">演示</span>}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
<main className={"workspace-main " + (canvas ? "canvas-main" : "")}>
|
||||
<header className="workspace-header">
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label={sidebar ? "收起侧栏" : "展开侧栏"}
|
||||
onClick={() => setSidebar(!sidebar)}
|
||||
>
|
||||
{sidebar ? (
|
||||
<PanelLeftClose size={17} />
|
||||
) : (
|
||||
<PanelLeftOpen size={17} />
|
||||
)}
|
||||
</button>
|
||||
<span>{names}</span>
|
||||
<span className="breadcrumb">/</span>
|
||||
<span>{canvas ? canvas.title : "工作台"}</span>
|
||||
<div className="header-right">
|
||||
{demo && (
|
||||
<span className="demo-label">
|
||||
本地演示 · 内容保存在此浏览器{" "}
|
||||
<button
|
||||
onClick={() => {
|
||||
setBoards(initialBoards);
|
||||
setWorkspace("demo");
|
||||
setWorkspaces([{ id: "demo", name: "个人空间" }]);
|
||||
}}
|
||||
>
|
||||
重置示例
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="切换主题"
|
||||
onClick={() => setDark(!dark)}
|
||||
>
|
||||
{dark ? <Sun size={17} /> : <Moon size={17} />}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{error && (
|
||||
<div className="workspace-error" role="alert">
|
||||
{error}
|
||||
<button onClick={() => setError("")} aria-label="关闭错误">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{canvas ? (
|
||||
<div className="canvas-viewport">
|
||||
<div className="canvas-floating-title">
|
||||
<Icon name="SunMoon" size={17} />
|
||||
{canvas.title}
|
||||
<span>已打开</span>
|
||||
</div>
|
||||
<div className="canvas-zoom">
|
||||
<button onClick={() => setZoom(Math.max(50, zoom - 10))}>
|
||||
−
|
||||
</button>
|
||||
{zoom}%
|
||||
<button onClick={() => setZoom(Math.min(150, zoom + 10))}>
|
||||
+
|
||||
</button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setNotice("分享需要后端空间权限与链接配置。")}
|
||||
>
|
||||
分享 <ArrowUpRight size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="canvas-stage"
|
||||
style={{ transform: "scale(" + zoom / 100 + ")" }}
|
||||
>
|
||||
<div className="loose-note">
|
||||
<small>灵感片段</small>
|
||||
<p>
|
||||
把日常,
|
||||
<br />
|
||||
拍成一场电影。
|
||||
</p>
|
||||
<span>一闪而过的想法。</span>
|
||||
</div>
|
||||
<section className="study-board">
|
||||
<div className="board-caption">
|
||||
镜头研究 <span>子画布 · 740 × 600</span>
|
||||
</div>
|
||||
<div className="study-text">
|
||||
<h3>清晨光线</h3>
|
||||
<textarea
|
||||
id="canvas-note"
|
||||
aria-label="清晨光线笔记"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onBlur={saveNote}
|
||||
/>
|
||||
</div>
|
||||
<div className="study-text">
|
||||
<h3>镜头节奏</h3>
|
||||
<p>
|
||||
缓慢推进,保留停顿。
|
||||
<br />
|
||||
35mm · 温暖柔和的光线。
|
||||
</p>
|
||||
</div>
|
||||
<div className="study-sound">
|
||||
<h3>环境声音</h3>
|
||||
<p>
|
||||
窗外车流、杯子落桌、翻书。
|
||||
<br />
|
||||
保留房间里自然的声音。
|
||||
</p>
|
||||
</div>
|
||||
<div className="study-art">
|
||||
<h3>光的形状</h3>
|
||||
<div className="light-shape" />
|
||||
</div>
|
||||
<div className="study-checks">
|
||||
<h3>拍摄待办</h3>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => setChecked(e.target.checked)}
|
||||
/>{" "}
|
||||
窗边植物
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" /> 城市空镜
|
||||
</label>
|
||||
</div>
|
||||
<div className="study-palette">
|
||||
<small>色彩参考</small>
|
||||
<div>
|
||||
{[
|
||||
"#49372e",
|
||||
"#a47764",
|
||||
"#d1aa92",
|
||||
"#ede9d0",
|
||||
"#fff6ef",
|
||||
].map((c) => (
|
||||
<span key={c} style={{ background: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{nodeText && (
|
||||
<div className="new-canvas-node">
|
||||
<textarea
|
||||
aria-label="新文字节点"
|
||||
value={nodeText}
|
||||
onChange={(e) => setNodeText(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
aria-label="移除临时节点"
|
||||
onClick={() => setNodeText("")}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="canvas-toolbar">
|
||||
{[
|
||||
["MousePointer2", "选择"],
|
||||
["Hand", "平移"],
|
||||
["Type2", "文字节点"],
|
||||
["FileText1", "文章节点"],
|
||||
["ImagePlus", "图片节点"],
|
||||
["Square1", "图形节点"],
|
||||
["Link2", "连接"],
|
||||
].map(([icon, label]) => (
|
||||
<button
|
||||
key={icon}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => {
|
||||
if (label === "文字节点")
|
||||
setNodeText("在这里写下新的念头…");
|
||||
else setNotice("已选择" + label + "工具");
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} />
|
||||
</button>
|
||||
))}
|
||||
<button aria-label="返回文件" onClick={() => setCanvas(null)}>
|
||||
<LayoutGrid size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspace-content">
|
||||
<div className="welcome-top">
|
||||
<div className="eyebrow">
|
||||
A LITTLE SPACE, A LOT OF POSSIBILITY
|
||||
</div>
|
||||
<span className="welcome-spark">✳</span>
|
||||
<h1>让灵感,从这里开始。</h1>
|
||||
<p>
|
||||
欢迎回来,
|
||||
{profile?.display_name || profile?.username || "创作者"}
|
||||
。今天有什么新想法?
|
||||
</p>
|
||||
</div>
|
||||
<div className="quick-create">
|
||||
<button onClick={() => setDialog("board")}>
|
||||
<span className="quick-icon peach">
|
||||
<Icon name="LayoutDashboard" size={24} />
|
||||
</span>
|
||||
<div>
|
||||
<h3>空白画布</h3>
|
||||
<p>让思路,自由生长。</p>
|
||||
</div>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDialog("board");
|
||||
setTitle("未命名灵感");
|
||||
}}
|
||||
>
|
||||
<span className="quick-icon olive">
|
||||
<Icon name="FileText" size={24} />
|
||||
</span>
|
||||
<div>
|
||||
<h3>记录灵感</h3>
|
||||
<p>先留下,再慢慢展开。</p>
|
||||
</div>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<section className="file-section">
|
||||
<div className="files-heading">
|
||||
<h2>{view}</h2>
|
||||
<div>
|
||||
<div className="board-search">
|
||||
<Search size={15} />
|
||||
<input
|
||||
id="board-search"
|
||||
placeholder="搜索画布…"
|
||||
aria-label="搜索画布"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDialog("board")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="file-tabs">
|
||||
{["全部文件", "最近打开", "我的收藏"].map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={view === t ? "active" : ""}
|
||||
onClick={() => setView(t)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{boardsLoading ? (
|
||||
<div className="empty-state" role="status">
|
||||
<p>正在读取画布…</p>
|
||||
</div>
|
||||
) : view === "我的收藏" ? (
|
||||
<div className="empty-state">
|
||||
<Folder size={32} />
|
||||
<h3>给喜欢的想法,留个位置。</h3>
|
||||
<p>收藏功能将在后续版本接入。</p>
|
||||
</div>
|
||||
) : filtered.length ? (
|
||||
<div className="board-grid">
|
||||
{filtered.map((b) => (
|
||||
<button
|
||||
className="board-card"
|
||||
key={b.id}
|
||||
onClick={() => open(b)}
|
||||
>
|
||||
<div className={"board-cover cover-" + b.style}>
|
||||
{b.style === 0 ? (
|
||||
<>
|
||||
<span className="cover-title">光的形状</span>
|
||||
<div className="light-shape" />
|
||||
<span className="cover-small">
|
||||
城市清晨 / A MORNING STUDY
|
||||
</span>
|
||||
</>
|
||||
) : b.style === 1 ? (
|
||||
<>
|
||||
<span className="cover-serif">
|
||||
a little
|
||||
<br />
|
||||
room for
|
||||
<br />
|
||||
<i>ideas.</i>
|
||||
</span>
|
||||
<b>✳</b>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="cover-circles">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<span className="cover-serif">
|
||||
去看看
|
||||
<br />
|
||||
不一样的日常。
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="board-card-info">
|
||||
<Icon name="Scan1" size={18} />
|
||||
<div>
|
||||
<h3>{b.title}</h3>
|
||||
<p>{b.updated}编辑</p>
|
||||
</div>
|
||||
<MoreHorizontal size={17} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<LayoutGrid size={32} />
|
||||
<h3>{query ? "没有找到相关画布" : "这里还留着一片空白。"}</h3>
|
||||
<p>
|
||||
{query
|
||||
? "换个关键词试试。"
|
||||
: "从一个小小的念头开始,创建第一张画布。"}
|
||||
</p>
|
||||
{!query && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
setDialog(workspace ? "board" : "workspace")
|
||||
}
|
||||
>
|
||||
<Plus size={15} />
|
||||
创建{workspace ? "画布" : "空间"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className="workspace-tip">
|
||||
<span>✦</span>
|
||||
<p>
|
||||
不用想好全部。
|
||||
<br />
|
||||
<strong>带着一点好奇,先开始。</strong>
|
||||
</p>
|
||||
<img
|
||||
src="/assets/landingcontext-imgFrame3.svg"
|
||||
width="100"
|
||||
height="110"
|
||||
alt="记下突然出现的灵感"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{notice && (
|
||||
<div className="toast" role="status">
|
||||
<Check size={16} />
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
<Dialog
|
||||
open={dialog !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setDialog(null);
|
||||
setTitle("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="dialog-title">
|
||||
{dialog === "workspace"
|
||||
? "给你的空间起个名字"
|
||||
: "一个新想法,从这里开始。"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="dialog-description">
|
||||
{dialog === "workspace"
|
||||
? "把相关的创作和资料放在同一个空间。"
|
||||
: "创建一张空白画布,慢慢让灵感成形。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={create}>
|
||||
<label htmlFor="new-title">
|
||||
{dialog === "workspace" ? "空间名称" : "画布名称"}
|
||||
</label>
|
||||
<input
|
||||
id="new-title"
|
||||
className="dialog-input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={
|
||||
dialog === "workspace"
|
||||
? "例如:我的创作空间"
|
||||
: "例如:周末的灵感"
|
||||
}
|
||||
maxLength={80}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
{error && (
|
||||
<p className="form-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialog(null)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={busy || !title.trim()}>
|
||||
{busy
|
||||
? "创建中…"
|
||||
: "创建" + (dialog === "workspace" ? "空间" : "画布")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ArrowUpRight, ArrowRight, Plus, Minus } from "lucide-react";
|
||||
import { Brand } from "@/components/brand";
|
||||
import { Button } from "@/components/ui/button";
|
||||
const scenes = [
|
||||
"收藏生活的切片",
|
||||
"遇见新的工作方法",
|
||||
"记下突然出现的灵感",
|
||||
"给想法一点自由",
|
||||
"做一件属于你的作品",
|
||||
];
|
||||
export default function Home() {
|
||||
const [faq, setFaq] = useState<number | null>(0);
|
||||
return (
|
||||
<div className="landing">
|
||||
<header className="site-nav">
|
||||
<Brand />
|
||||
<nav aria-label="主导航">
|
||||
<a href="#tools">创作工具</a>
|
||||
<a href="#ideas">知识与灵感</a>
|
||||
<a href="#workflow">工作方式</a>
|
||||
</nav>
|
||||
<div className="nav-actions">
|
||||
<Link href="/login">登录</Link>
|
||||
<Button asChild>
|
||||
<Link href="/login">
|
||||
进入工作台 <ArrowUpRight size={15} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section className="hero container">
|
||||
<div className="eyebrow">
|
||||
<span className="status-dot" /> A SPACE FOR HUMAN IDEAS & AGENTS
|
||||
</div>
|
||||
<h1>
|
||||
想法不该停在对话里。
|
||||
<br />
|
||||
让灵感,<span>有形。</span>
|
||||
<svg
|
||||
className="hero-stroke"
|
||||
viewBox="0 0 230 18"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M5 13 Q100 0 225 9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</h1>
|
||||
<p className="hero-copy">
|
||||
从一闪而过的念头,到值得分享的作品。
|
||||
<br />
|
||||
在一张画布上,把你的创作继续下去。
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/login">
|
||||
开始创作 <ArrowUpRight size={18} />
|
||||
</Link>
|
||||
</Button>
|
||||
<a className="text-link" href="#space">
|
||||
探索你的创作空间 <ArrowRight size={16} />
|
||||
</a>
|
||||
</div>
|
||||
<div className="people-row">
|
||||
{scenes.map((s, i) => (
|
||||
<figure key={s}>
|
||||
<div className="person-slot">
|
||||
<img
|
||||
src={"/assets/landingcontext-imgFrame" + (i + 1) + ".svg"}
|
||||
width="200"
|
||||
height="220"
|
||||
alt={s}
|
||||
/>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span>0{i + 1}</span>
|
||||
{s}
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
<div className="hero-footnote">
|
||||
<span>COLLECT. CONNECT. CREATE.</span>
|
||||
<span>给好奇心一点空间 ↓</span>
|
||||
</div>
|
||||
</section>
|
||||
<section id="space" className="space-section">
|
||||
<div className="container">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<div className="eyebrow">YOUR IDEAS, ALL TOGETHER</div>
|
||||
<h2>
|
||||
一个念头,
|
||||
<br />
|
||||
可以去很远。
|
||||
</h2>
|
||||
</div>
|
||||
<p>
|
||||
笔记、参考、还没想好的方向。
|
||||
<br />
|
||||
放在一起,看见它们之间的新可能。
|
||||
</p>
|
||||
</div>
|
||||
<div className="product-preview">
|
||||
<div className="preview-sidebar">
|
||||
<Brand />
|
||||
<p>
|
||||
个人空间 <span>⌄</span>
|
||||
</p>
|
||||
<div className="preview-active">▦ 城市清晨</div>
|
||||
<div>▧ 灵感收集</div>
|
||||
<div>+ 新建画布</div>
|
||||
<small>你的下一件作品,从这里开始。</small>
|
||||
</div>
|
||||
<div className="preview-canvas">
|
||||
<div className="preview-bar">
|
||||
☀ 城市清晨 <span>↗ 分享</span>
|
||||
</div>
|
||||
<div className="preview-note">
|
||||
<small>01 / THE IDEA</small>
|
||||
<h3>
|
||||
把日常,
|
||||
<br />
|
||||
拍成一场电影。
|
||||
</h3>
|
||||
<p>
|
||||
一束光、一段声音、
|
||||
<br />
|
||||
一个值得留下的瞬间。
|
||||
</p>
|
||||
</div>
|
||||
<div className="preview-art">
|
||||
<div className="light-shape" />
|
||||
<span>光的形状</span>
|
||||
</div>
|
||||
<div className="preview-sticky">
|
||||
慢一点。
|
||||
<br />
|
||||
让生活里的细节
|
||||
<br />
|
||||
成为下一次灵感。<span>✳</span>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 800 500"
|
||||
preserveAspectRatio="none"
|
||||
className="preview-connect"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M290 235 C420 235 310 360 480 360"
|
||||
fill="none"
|
||||
stroke="#a98b72"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
</svg>
|
||||
<div className="preview-tools">↖ ✋ T ▧ □ ↗ +</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="tools" className="tools-section container">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<div className="eyebrow">TWO TOOLS. ONE CREATIVE FLOW.</div>
|
||||
<h2>
|
||||
给不同的想法,
|
||||
<br />
|
||||
恰好的空间。
|
||||
</h2>
|
||||
</div>
|
||||
<p>
|
||||
从整理思路,到亲手完成作品。
|
||||
<br />
|
||||
每一步,都有你的空间。
|
||||
</p>
|
||||
</div>
|
||||
<div className="tool-grid">
|
||||
<article className="tool-card">
|
||||
<div className="tool-meta">
|
||||
01 / GRAPHIC <ArrowUpRight size={20} />
|
||||
</div>
|
||||
<div className="type-art">
|
||||
MAKE
|
||||
<br />
|
||||
ROOM<span>for ideas.</span>
|
||||
<i>✳</i>
|
||||
</div>
|
||||
<h3>把灵感排成作品。</h3>
|
||||
<p>
|
||||
让文字、图形与图片自由编排。打磨每个细节,留下属于你的表达。
|
||||
</p>
|
||||
<Link href="/login" className="text-link">
|
||||
打开图形编辑器 <ArrowRight size={16} />
|
||||
</Link>
|
||||
</article>
|
||||
<article className="tool-card">
|
||||
<div className="tool-meta">
|
||||
02 / INFINITE CANVAS <ArrowUpRight size={20} />
|
||||
</div>
|
||||
<div className="canvas-art">
|
||||
<span>一个念头</span>
|
||||
<i>⤳</i>
|
||||
<span>一张参考</span>
|
||||
<b>新的可能 ✧</b>
|
||||
</div>
|
||||
<h3>让思路,自由生长。</h3>
|
||||
<p>
|
||||
把笔记、参考图与提示词连成线索。在无限画布上,找到下一次创作的方向。
|
||||
</p>
|
||||
<Link href="/login" className="text-link">
|
||||
打开无限画布 <ArrowRight size={16} />
|
||||
</Link>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<section id="ideas" className="ideas-section">
|
||||
<div className="container">
|
||||
<div className="eyebrow">GOOD IDEAS DESERVE GOOD COMPANY</div>
|
||||
<h2>
|
||||
不必每一次,
|
||||
<br />
|
||||
都从空白开始。
|
||||
</h2>
|
||||
<div className="idea-grid">
|
||||
{[
|
||||
["留住一闪而过", "一句念头、一张参考图。先留下,再慢慢展开。"],
|
||||
["让线索相遇", "把不同的灵感放上画布,用连接看见新的可能。"],
|
||||
["把创作继续下去", "用熟悉的方法探索,再亲手调整细节。"],
|
||||
].map(([title, copy], i) => (
|
||||
<article key={title}>
|
||||
<img
|
||||
src={"/assets/landingcontext-imgFrame" + (i + 6) + ".svg"}
|
||||
width="140"
|
||||
height="110"
|
||||
alt=""
|
||||
/>
|
||||
<h3>{title}</h3>
|
||||
<p>{copy}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="workflow" className="workflow container">
|
||||
<div>
|
||||
<div className="eyebrow">YOUR AGENT. YOUR WORKFLOW.</div>
|
||||
<h2>
|
||||
熟悉的工作方式。
|
||||
<br />
|
||||
新的创作可能。
|
||||
</h2>
|
||||
<p>
|
||||
从一个小小的起点出发,让工具跟上你的想法。
|
||||
<br />
|
||||
整理、连接、亲手打磨,把创作继续下去。
|
||||
</p>
|
||||
<Link href="/login" className="text-link">
|
||||
进入创作空间 <ArrowUpRight size={17} />
|
||||
</Link>
|
||||
</div>
|
||||
<ol>
|
||||
{[
|
||||
["找到起点", "留住一个念头,或者从一张空白画布开始。"],
|
||||
["连接工作方式", "把相关的笔记与参考放在一起。"],
|
||||
["亲手打磨,保存作品", "调整细节,让想法成为自己的表达。"],
|
||||
].map(([t, c], i) => (
|
||||
<li key={t}>
|
||||
<span>0{i + 1}</span>
|
||||
<div>
|
||||
<h3>{t}</h3>
|
||||
<p>{c}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
<section className="faq container">
|
||||
<div className="eyebrow">A FEW THINGS TO KNOW</div>
|
||||
<h2>开始之前,你也许想知道。</h2>
|
||||
{[
|
||||
[
|
||||
"我可以在这里做什么?",
|
||||
"收集灵感、整理笔记、在画布上连接想法,逐步把零散的线索变成作品。",
|
||||
],
|
||||
[
|
||||
"需要先想好完整的作品吗?",
|
||||
"不用。可以从一段文字、一张参考或一个还没有想清楚的念头开始。",
|
||||
],
|
||||
[
|
||||
"我的内容会自动公开吗?",
|
||||
"你的工作空间需要登录访问。分享内容前,请确认工作空间的成员与权限设置。",
|
||||
],
|
||||
].map(([q, a], i) => (
|
||||
<div className="faq-item" key={q}>
|
||||
<button
|
||||
aria-expanded={faq === i}
|
||||
onClick={() => setFaq(faq === i ? null : i)}
|
||||
>
|
||||
{q}
|
||||
{faq === i ? <Minus size={17} /> : <Plus size={17} />}
|
||||
</button>
|
||||
{faq === i && <p>{a}</p>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<section className="closing">
|
||||
<div className="eyebrow">YOUR NEXT IDEA STARTS HERE</div>
|
||||
<h2>
|
||||
给下一个念头,
|
||||
<br />
|
||||
留一个起点。
|
||||
</h2>
|
||||
<p>不用想好全部。带着一点好奇,先开始。</p>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/login">
|
||||
进入你的工作台 <ArrowUpRight size={18} />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="closing-star">✳</span>
|
||||
</section>
|
||||
</main>
|
||||
<footer className="site-footer">
|
||||
<Brand />
|
||||
<p>给想法留白,给创作空间。</p>
|
||||
<a href="#workflow">
|
||||
工作方式 <ArrowUpRight size={14} />
|
||||
</a>
|
||||
<span>© {new Date().getFullYear()} Endoleap</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
ArrowRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowUpRight,
|
||||
LoaderCircle,
|
||||
} from "lucide-react";
|
||||
import { Brand } from "@/components/brand";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api, errorText, startOAuth } from "@/lib/api";
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const [register, setRegister] = useState(false),
|
||||
[email, setEmail] = useState(""),
|
||||
[password, setPassword] = useState(""),
|
||||
[show, setShow] = useState(false),
|
||||
[busy, setBusy] = useState(false),
|
||||
[message, setMessage] = useState(""),
|
||||
[error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
const hash = new URLSearchParams(location.hash.slice(1));
|
||||
const code = hash.get("code"),
|
||||
verify = hash.get("verify_email"),
|
||||
failure = hash.get("error");
|
||||
if (!code && !verify && !failure) return;
|
||||
history.replaceState(null, "", location.pathname);
|
||||
if (failure) {
|
||||
setError("第三方登录未完成,请重新尝试。");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
const verifier = sessionStorage.getItem("foundry_verifier");
|
||||
sessionStorage.removeItem("foundry_verifier");
|
||||
(verify
|
||||
? api("auth/verify-email", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token: verify }),
|
||||
})
|
||||
: verifier
|
||||
? api("auth/exchange", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Foundry-Code": code!,
|
||||
"X-Foundry-Verifier": verifier,
|
||||
},
|
||||
})
|
||||
: Promise.reject(new Error("invalid_session"))
|
||||
)
|
||||
.then(() => router.replace("/dashboard"))
|
||||
.catch((e) => setError(errorText(e)))
|
||||
.finally(() => setBusy(false));
|
||||
}, [router]);
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
await api("auth/" + (register ? "register" : "login"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (register)
|
||||
setMessage("验证邮件已请求发送,请检查邮箱并点击验证链接。");
|
||||
else await router.push("/dashboard");
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function oauth(p: "google" | "slack") {
|
||||
setError("");
|
||||
setMessage("正在前往 " + (p === "google" ? "Google" : "Slack") + "…");
|
||||
setBusy(true);
|
||||
try {
|
||||
await startOAuth(p);
|
||||
} catch {
|
||||
setBusy(false);
|
||||
setMessage("");
|
||||
setError("无法启动登录,请重新尝试。");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<aside className="auth-story">
|
||||
<Brand light />
|
||||
<div className="auth-story-copy">
|
||||
<div className="eyebrow">A SPACE FOR YOUR NEXT IDEA</div>
|
||||
<h1>
|
||||
给想法留白,
|
||||
<br />
|
||||
让灵感有形。
|
||||
</h1>
|
||||
<p>
|
||||
收集一闪而过的灵感,把有用的知识连接起来。
|
||||
<br />
|
||||
下一件作品,从这里开始。
|
||||
</p>
|
||||
</div>
|
||||
<div className="auth-collage">
|
||||
<div className="auth-orbit" />
|
||||
<article className="auth-note">
|
||||
<small>01 / THE IDEA</small>
|
||||
<h2>
|
||||
留住清晨
|
||||
<br />
|
||||
的一束光。
|
||||
</h2>
|
||||
<p>灵感片段 · 随手记下</p>
|
||||
</article>
|
||||
<article className="auth-art">
|
||||
<small>02 / IN THE MAKING</small>
|
||||
<div className="light-shape" />
|
||||
<h3>光的形状</h3>
|
||||
<p>让一个念头,慢慢成形。</p>
|
||||
</article>
|
||||
</div>
|
||||
<span className="auth-caption">COLLECT. CONNECT. CREATE.</span>
|
||||
<footer>啡跃 Endoleap 为下一次创作,留一个起点。</footer>
|
||||
</aside>
|
||||
<main className="auth-main">
|
||||
<Link href="/" className="auth-back">
|
||||
返回首页 <ArrowUpRight size={14} />
|
||||
</Link>
|
||||
<div className="auth-form-wrap">
|
||||
<div className="eyebrow">WELCOME TO YOUR CREATIVE SPACE</div>
|
||||
<h1>{register ? "创建你的账户" : "欢迎回来"}</h1>
|
||||
<p>
|
||||
{register ? "为下一次创作,留一个起点。" : "登录,继续你的创作。"}
|
||||
</p>
|
||||
<div className="auth-tabs" role="tablist" aria-label="登录方式">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={!register}
|
||||
onClick={() => {
|
||||
setRegister(false);
|
||||
setError("");
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={register}
|
||||
onClick={() => {
|
||||
setRegister(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
创建账户
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={submit}>
|
||||
<label htmlFor="email">邮箱地址</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="password">密码</label>
|
||||
<div className="password-field">
|
||||
<input
|
||||
id="password"
|
||||
type={show ? "text" : "password"}
|
||||
autoComplete={register ? "new-password" : "current-password"}
|
||||
placeholder={register ? "至少 12 个字符" : "输入你的密码"}
|
||||
required
|
||||
minLength={register ? 12 : 1}
|
||||
maxLength={72}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={show ? "隐藏密码" : "显示密码"}
|
||||
onClick={() => setShow(!show)}
|
||||
>
|
||||
{show ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="form-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p role="status" className="form-success">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
<Button className="auth-submit" disabled={busy} type="submit">
|
||||
{busy ? (
|
||||
<LoaderCircle className="animate-spin" size={18} />
|
||||
) : (
|
||||
<>
|
||||
{register ? "创建账户" : "登录"}
|
||||
<ArrowRight size={17} />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="auth-divider">
|
||||
<span>或使用以下方式继续</span>
|
||||
</div>
|
||||
<div className="social-buttons">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => oauth("google")}
|
||||
disabled={busy}
|
||||
>
|
||||
<strong className="google-mark">G</strong> Google
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => oauth("slack")}
|
||||
disabled={busy}
|
||||
>
|
||||
<strong className="slack-mark">#</strong> Slack
|
||||
</Button>
|
||||
</div>
|
||||
<p className="auth-notice">
|
||||
首次注册需要验证邮箱。
|
||||
<br />
|
||||
验证成功后,即可进入你的创作空间。
|
||||
</p>
|
||||
<Link href="/dashboard?demo=1" className="demo-link">
|
||||
先看看工作台 <ArrowUpRight size={13} />
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const host = "http://127.0.0.1:3005";
|
||||
const upstream = createServer((req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (req.url.endsWith("/auth/login")) {
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: "test-only-session",
|
||||
expires_in: 28800,
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
} else if (req.url.endsWith("/profile")) {
|
||||
res.statusCode =
|
||||
req.headers.authorization === "Bearer test-only-session" ? 200 : 401;
|
||||
res.end(
|
||||
JSON.stringify(
|
||||
res.statusCode === 200
|
||||
? { username: "test" }
|
||||
: { error: "invalid_session" },
|
||||
),
|
||||
);
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end("{}");
|
||||
}
|
||||
});
|
||||
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
|
||||
const port = upstream.address().port;
|
||||
const app = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"node_modules/next/dist/bin/next",
|
||||
"start",
|
||||
"--hostname",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"3005",
|
||||
],
|
||||
{
|
||||
cwd: fileURLToPath(new URL("../", import.meta.url)),
|
||||
env: { ...process.env, FOUNDRY_API_ORIGIN: "http://127.0.0.1:" + port },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
let startup = "";
|
||||
app.stdout.on("data", (x) => (startup += x));
|
||||
app.stderr.on("data", (x) => (startup += x));
|
||||
try {
|
||||
let ready = false;
|
||||
for (let n = 0; n < 100; n++) {
|
||||
if (app.exitCode !== null)
|
||||
throw new Error("Frontend failed to start: " + startup);
|
||||
try {
|
||||
if ((await fetch(host)).ok) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
assert(ready, "Frontend did not start");
|
||||
const res = await fetch(host + "/api/foundry/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Origin: host },
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get("server-timing") || "", /upstream;dur=\d/);
|
||||
const body = await res.json();
|
||||
assert.equal(body.access_token, undefined);
|
||||
const cookie = res.headers.get("set-cookie");
|
||||
assert(cookie?.includes("HttpOnly"));
|
||||
assert(cookie.includes("SameSite=Lax"));
|
||||
assert(cookie.includes("Max-Age=28800"));
|
||||
const session = cookie.split(";")[0];
|
||||
assert.equal(
|
||||
(
|
||||
await fetch(host + "/api/foundry/profile", {
|
||||
headers: { Cookie: session },
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await fetch(host + "/api/foundry/profile")).status, 401);
|
||||
for (const origin of ["https://example.com", "null"])
|
||||
assert.equal(
|
||||
(
|
||||
await fetch(host + "/api/foundry/auth/login", {
|
||||
method: "POST",
|
||||
headers: { Origin: origin, "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
})
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
assert.equal((await fetch(host + "/api/foundry/unlisted")).status, 404);
|
||||
assert.equal(
|
||||
(await fetch(host + "/api/oauth/slack?challenge=bad")).status,
|
||||
400,
|
||||
);
|
||||
console.log(
|
||||
"PASS: session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
|
||||
);
|
||||
} finally {
|
||||
const exited = once(app, "exit");
|
||||
app.kill("SIGTERM");
|
||||
await exited;
|
||||
await new Promise((r) => upstream.close(r));
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createBoard, loadWorkspaceHome } from "../src/lib/workspace.ts";
|
||||
|
||||
test("profile and workspace reads both start before either completes", async () => {
|
||||
const calls = [];
|
||||
const resolve = {};
|
||||
const pending = loadWorkspaceHome((path) => {
|
||||
calls.push(path);
|
||||
return new Promise((done) => {
|
||||
resolve[path] = done;
|
||||
});
|
||||
});
|
||||
assert.deepEqual(calls, ["profile", "workspaces"]);
|
||||
resolve.profile({ id: "user" });
|
||||
resolve.workspaces({ items: [{ id: "space", name: "个人空间" }] });
|
||||
const result = await pending;
|
||||
assert.equal(result.profile.id, "user");
|
||||
assert.equal(result.workspaces[0].id, "space");
|
||||
});
|
||||
|
||||
test("a first board creates its personal workspace before posting the board", async () => {
|
||||
const calls = [];
|
||||
let selected = "";
|
||||
const result = await createBoard(
|
||||
async (path, options) => {
|
||||
calls.push([path, JSON.parse(options.body)]);
|
||||
if (path === "workspaces") return { id: "space", name: "个人空间" };
|
||||
assert.equal(selected, "space");
|
||||
return { id: "board" };
|
||||
},
|
||||
" 周末的灵感 ",
|
||||
"",
|
||||
(w) => {
|
||||
selected = w.id;
|
||||
},
|
||||
);
|
||||
assert.equal(result.id, "board");
|
||||
assert.deepEqual(
|
||||
calls.map(([p]) => p),
|
||||
["workspaces", "workspaces/space/materials"],
|
||||
);
|
||||
assert.equal(calls[1][1].title, "周末的灵感");
|
||||
});
|
||||
|
||||
test("a board failure retains the new workspace, so retry creates no duplicate space", async () => {
|
||||
let selected = "";
|
||||
let spaces = 0;
|
||||
let fail = true;
|
||||
const request = async (path) => {
|
||||
if (path === "workspaces") {
|
||||
spaces++;
|
||||
return { id: "space", name: "个人空间" };
|
||||
}
|
||||
if (fail) throw new Error("upstream_unavailable");
|
||||
return { id: "board" };
|
||||
};
|
||||
await assert.rejects(
|
||||
createBoard(request, "作品", selected, (w) => {
|
||||
selected = w.id;
|
||||
}),
|
||||
);
|
||||
fail = false;
|
||||
await createBoard(request, "作品", selected, () =>
|
||||
assert.fail("duplicate workspace"),
|
||||
);
|
||||
assert.equal(spaces, 1);
|
||||
});
|
||||
|
||||
test("an existing workspace posts a board directly", async () => {
|
||||
const calls = [];
|
||||
await createBoard(
|
||||
async (path) => {
|
||||
calls.push(path);
|
||||
return { id: "board" };
|
||||
},
|
||||
"作品",
|
||||
"existing",
|
||||
() => assert.fail("unexpected workspace"),
|
||||
);
|
||||
assert.deepEqual(calls, ["workspaces/existing/materials"]);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||