From 6f0ce293d77462fd1785ca4c66f49c2791a0f381 Mon Sep 17 00:00:00 2001 From: anyone Date: Tue, 21 Apr 2026 14:37:19 +0800 Subject: [PATCH 01/18] new input --- .../components_next/button/button.stories.tsx | 18 +- .../src/components_next/icon/icon.stories.tsx | 26 +-- .../components_next/icon/icons/play-queue.tsx | 9 +- apps/pwa/src/components_next/index.ts | 3 + apps/pwa/src/components_next/input/index.tsx | 210 ++++++++++++++++++ .../components_next/input/input.stories.tsx | 99 +++++++++ .../components_next/slider/slider.stories.tsx | 32 +-- 7 files changed, 346 insertions(+), 51 deletions(-) create mode 100644 apps/pwa/src/components_next/input/index.tsx create mode 100644 apps/pwa/src/components_next/input/input.stories.tsx diff --git a/apps/pwa/src/components_next/button/button.stories.tsx b/apps/pwa/src/components_next/button/button.stories.tsx index 753b5db8..36d96dc9 100644 --- a/apps/pwa/src/components_next/button/button.stories.tsx +++ b/apps/pwa/src/components_next/button/button.stories.tsx @@ -10,7 +10,7 @@ const meta = { docs: { description: { component: - '基础按钮组件,支持 4 种变体、3 种尺寸,内置加载和禁用状态,移动端触摸友好。', + 'Duolingo-style button with a hard bottom shadow and a satisfying press-down animation. Supports 4 variants, 3 sizes, loading and disabled states.', }, }, }, @@ -18,30 +18,30 @@ const meta = { variant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'danger'], - description: '视觉变体', + description: 'Visual variant', table: { defaultValue: { summary: 'primary' } }, }, size: { control: 'select', options: ['sm', 'md', 'lg'], - description: '尺寸', + description: 'Size', table: { defaultValue: { summary: 'md' } }, }, loading: { control: 'boolean', - description: '加载中(自动禁用交互)', + description: 'Loading state — disables interaction and shows a spinner', }, disabled: { control: 'boolean', - description: '禁用', + description: 'Disabled state', }, block: { control: 'boolean', - description: '宽度撑满父容器', + description: 'Stretch to full container width', }, children: { control: 'text', - description: '按钮文字', + description: 'Button label', }, onClick: { action: 'clicked' }, }, @@ -50,8 +50,6 @@ const meta = { export default meta; type Story = StoryObj; -// ── Single stories (用于 Controls 面板交互调试) ── - export const Primary: Story = { args: { children: 'Primary', variant: 'primary' }, }; @@ -87,8 +85,6 @@ export const Block: Story = { ], }; -// ── Showcase stories (静态对比展示) ── - export const AllVariants: Story = { name: 'All Variants', render: () => ( diff --git a/apps/pwa/src/components_next/icon/icon.stories.tsx b/apps/pwa/src/components_next/icon/icon.stories.tsx index 6557f30c..2d268d89 100644 --- a/apps/pwa/src/components_next/icon/icon.stories.tsx +++ b/apps/pwa/src/components_next/icon/icon.stories.tsx @@ -1,41 +1,42 @@ +import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { IconList, IconPlayQueue } from '.'; import type { IconProps } from '.'; -// ── Gallery 数据:新增 icon 后在这里加一行 ───────────────────────────────────── -const ALL_ICONS: { name: string; Component: (p: Omit) => JSX.Element }[] = [ +const ALL_ICONS: { name: string; Component: (p: Omit) => React.ReactElement }[] = [ { name: 'IconList', Component: IconList }, { name: 'IconPlayQueue', Component: IconPlayQueue }, ]; -// ── Meta ────────────────────────────────────────────────────────────────────── - const meta = { title: 'Basic/Icon', - component: IconList, // 用于 autodocs 生成 props 表格 + component: IconList, tags: ['autodocs'], parameters: { layout: 'centered', docs: { description: { component: - '描边式 SVG 图标。每个 icon 是独立文件,支持 tree-shaking。\n\n' + - '**使用方式**\n```tsx\nimport { IconPlayQueue } from \'@/components_next/icon\';\n\n```\n\n' + - '**新增 icon**:在 `icons/` 目录新建文件,在 `index.ts` 加一行 export。', + 'Stroke-based SVG icons. Each icon is an independent file — unused icons are tree-shaken out of the bundle. ' + + 'Import named icons directly: `import { IconPlayQueue } from "@/components_next/icon"`. ' + + 'To add a new icon, create a file under `icons/` and add one export line to `index.ts`.', }, }, }, argTypes: { size: { control: { type: 'range', min: 12, max: 64, step: 2 }, + description: 'Icon size in px', table: { defaultValue: { summary: '24' } }, }, strokeWidth: { control: { type: 'range', min: 1, max: 4, step: 0.5 }, + description: 'Stroke width', table: { defaultValue: { summary: '2' } }, }, color: { control: 'color', + description: 'Icon color (maps to CSS currentColor)', table: { defaultValue: { summary: 'currentColor' } }, }, }, @@ -44,8 +45,6 @@ const meta = { export default meta; type Story = StoryObj; -// ── Gallery(文档首屏展示) ──────────────────────────────────────────────────── - export const Gallery: Story = { name: 'Gallery', parameters: { controls: { disable: true } }, @@ -82,15 +81,12 @@ export const Gallery: Story = { ), }; -// ── Playground ──────────────────────────────────────────────────────────────── - export const Playground: Story = { args: { size: 24, strokeWidth: 2 }, }; -// ── Sizes ───────────────────────────────────────────────────────────────────── - export const Sizes: Story = { + name: 'Sizes', parameters: { controls: { disable: true } }, render: () => (
@@ -104,8 +100,6 @@ export const Sizes: Story = { ), }; -// ── Stroke Weights ──────────────────────────────────────────────────────────── - export const StrokeWeights: Story = { name: 'Stroke Weights', parameters: { controls: { disable: true } }, diff --git a/apps/pwa/src/components_next/icon/icons/play-queue.tsx b/apps/pwa/src/components_next/icon/icons/play-queue.tsx index 8d64634c..ed10860f 100644 --- a/apps/pwa/src/components_next/icon/icons/play-queue.tsx +++ b/apps/pwa/src/components_next/icon/icons/play-queue.tsx @@ -3,10 +3,11 @@ import Icon, { IconProps } from '../base'; function IconPlayQueue(props: Omit) { return ( - - - - + {/* triangle centred at y=7, matching the top line of IconList */} + + + + ); } diff --git a/apps/pwa/src/components_next/index.ts b/apps/pwa/src/components_next/index.ts index a0b42a9f..5044150f 100644 --- a/apps/pwa/src/components_next/index.ts +++ b/apps/pwa/src/components_next/index.ts @@ -1,6 +1,9 @@ export { default as Button } from './button'; export type { ButtonProps, Variant as ButtonVariant, Size as ButtonSize } from './button'; +export { default as Input } from './input'; +export type { InputProps, InputSize } from './input'; + export { default as Slider } from './slider'; export type { SliderProps, SliderEdge } from './slider'; diff --git a/apps/pwa/src/components_next/input/index.tsx b/apps/pwa/src/components_next/input/index.tsx new file mode 100644 index 00000000..9bc74a70 --- /dev/null +++ b/apps/pwa/src/components_next/input/index.tsx @@ -0,0 +1,210 @@ +import { + forwardRef, + InputHTMLAttributes, + ReactNode, + useId, +} from 'react'; +import styled, { css } from 'styled-components'; +import { CSS_VAR } from '../theme'; + +export type InputSize = 'sm' | 'md' | 'lg'; + +// ─── Size tokens(与 Button 对齐) ──────────────────────────────────────────── + +const SIZE: Record< + InputSize, + { height: number; font: number; radius: number; shadow: number; padding: string } +> = { + sm: { height: 34, font: 13, radius: 10, shadow: 3, padding: '0 12px' }, + md: { height: 44, font: 15, radius: 13, shadow: 4, padding: '0 14px' }, + lg: { height: 54, font: 17, radius: 16, shadow: 5, padding: '0 18px' }, +}; + +const FONT = `'Nunito', 'Varela Round', system-ui, sans-serif`; + +// ─── Styled ─────────────────────────────────────────────────────────────────── + +const Root = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +`; + +const Label = styled.label` + font-family: ${FONT}; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.2px; + color: rgb(66 66 66); + user-select: none; +`; + +const Wrapper = styled.div<{ + $size: InputSize; + $error: boolean; + $disabled: boolean; +}>` + position: relative; + display: flex; + align-items: center; + gap: 8px; + background: #fff; + border-style: solid; + border-width: 2px; + cursor: text; + + transition: + border-color 150ms ease-out, + box-shadow 150ms ease-out; + + /* 尺寸 */ + ${({ $size }) => { + const s = SIZE[$size]; + return css` + height: ${s.height}px; + padding: ${s.padding}; + border-radius: ${s.radius}px; + box-shadow: 0 ${s.shadow}px 0 rgb(185 185 185); + `; + }} + + /* 默认状态 */ + border-color: rgb(220 220 220); + + /* 聚焦 */ + &:focus-within { + border-color: var(${CSS_VAR.colorPrimary}); + box-shadow: ${({ $size }) => + `0 ${SIZE[$size].shadow}px 0 var(${CSS_VAR.colorPrimaryShadow})`}; + } + + /* 错误 */ + ${({ $error, $size }) => + $error && + css` + border-color: rgb(242 80 66); + box-shadow: 0 ${SIZE[$size].shadow}px 0 rgb(190 46 34); + + &:focus-within { + border-color: rgb(242 80 66); + box-shadow: 0 ${SIZE[$size].shadow}px 0 rgb(190 46 34); + } + `} + + /* 禁用 */ + ${({ $disabled }) => + $disabled && + css` + opacity: 0.5; + box-shadow: none; + cursor: not-allowed; + `} +`; + +const Affix = styled.span` + display: flex; + align-items: center; + flex-shrink: 0; + color: rgb(175 175 175); + + /* 聚焦时前后缀也跟着变色 */ + ${Wrapper}:focus-within & { + color: var(${CSS_VAR.colorPrimary}); + } +`; + +const NativeInput = styled.input<{ $size: InputSize }>` + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + font-family: ${FONT}; + font-weight: 600; + letter-spacing: 0.2px; + color: rgb(55 55 55); + + font-size: ${({ $size }) => SIZE[$size].font}px; + + &::placeholder { + color: rgb(205 205 205); + font-weight: 500; + } + + &:disabled { + cursor: not-allowed; + } +`; + +const Bottom = styled.p<{ $error: boolean }>` + margin: 0; + font-family: ${FONT}; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.1px; + color: ${({ $error }) => ($error ? 'rgb(242 80 66)' : 'rgb(160 160 160)')}; +`; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +export interface InputProps + extends Omit, 'size' | 'prefix'> { + /** 输入框尺寸,默认 md */ + size?: InputSize; + /** 标签文字 */ + label?: string; + /** 输入框前置内容(图标等) */ + prefix?: ReactNode; + /** 输入框后置内容(图标、按钮等) */ + suffix?: ReactNode; + /** 错误提示(非空时触发错误样式) */ + error?: string; + /** 辅助说明文字(有 error 时被 error 替代) */ + hint?: string; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +const Input = forwardRef( + ( + { + size = 'md', + label, + prefix, + suffix, + error, + hint, + disabled, + id: idProp, + ...rest + }, + ref, + ) => { + const generatedId = useId(); + const id = idProp ?? generatedId; + const bottom = error || hint; + + return ( + + {label && } + + {prefix && {prefix}} + + {suffix && {suffix}} + + {bottom && {bottom}} + + ); + }, +); + +Input.displayName = 'Input'; + +export default Input; diff --git a/apps/pwa/src/components_next/input/input.stories.tsx b/apps/pwa/src/components_next/input/input.stories.tsx new file mode 100644 index 00000000..a7bb0bb9 --- /dev/null +++ b/apps/pwa/src/components_next/input/input.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Input from '.'; + +const meta = { + title: 'Basic/Input', + component: Input, + tags: ['autodocs'], + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Text input field with Duolingo-style hard shadow. Supports prefix/suffix slots, label, hint and error messages. Built with `forwardRef` for compatibility with form libraries like React Hook Form.', + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + size: { + control: 'select', + options: ['sm', 'md', 'lg'], + description: 'Input size — aligns with Button sizes', + table: { defaultValue: { summary: 'md' } }, + }, + label: { control: 'text', description: 'Label rendered above the input' }, + placeholder: { control: 'text', description: 'Placeholder text' }, + hint: { control: 'text', description: 'Helper text shown below (hidden when error is set)' }, + error: { control: 'text', description: 'Error message — also triggers the error visual state' }, + disabled: { control: 'boolean', description: 'Disabled state' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + args: { + label: 'Username', + placeholder: 'Enter username...', + hint: 'Letters and numbers only', + }, +}; + +export const States: Story = { + name: 'States', + parameters: { controls: { disable: true } }, + render: () => ( +
+ + + + +
+ ), +}; + +export const Sizes: Story = { + name: 'Sizes', + parameters: { controls: { disable: true } }, + render: () => ( +
+ + + +
+ ), +}; + +export const Affixes: Story = { + name: 'Prefix & Suffix', + parameters: { controls: { disable: true } }, + render: () => ( +
+ 🔍} + /> + 👁} + /> + ¥} + suffix={CNY} + /> +
+ ), +}; diff --git a/apps/pwa/src/components_next/slider/slider.stories.tsx b/apps/pwa/src/components_next/slider/slider.stories.tsx index 25c5a50b..5508e5f5 100644 --- a/apps/pwa/src/components_next/slider/slider.stories.tsx +++ b/apps/pwa/src/components_next/slider/slider.stories.tsx @@ -11,33 +11,33 @@ const meta = { docs: { description: { component: - 'Duolingo 漫画风格滑块:轨道带硬阴影描边,拇指(触摸设备)按下时下沉弹回,与 Button 使用同一套视觉公式。', + 'Duolingo-style slider: track with hard shadow outline, thumb presses down on interaction — same visual language as Button.', }, }, }, argTypes: { value: { control: { type: 'range', min: 0, max: 1, step: 0.01 }, - description: '当前值(0 ~ max)', + description: 'Current value (0 ~ max)', }, max: { control: { type: 'number', min: 0.01 }, - description: '最大值', + description: 'Maximum value', table: { defaultValue: { summary: '1' } }, }, edge: { control: 'select', options: ['rounded', 'square'], - description: '轨道边缘风格', + description: 'Track end style', table: { defaultValue: { summary: 'rounded' } }, }, secondValue: { control: { type: 'range', min: 0, max: 1, step: 0.01 }, - description: '副轨道值(0–1),用于缓冲进度等场景', + description: 'Secondary track value (0–1), e.g. buffer progress', }, disabled: { control: 'boolean', - description: '禁用', + description: 'Disabled state', }, onChange: { action: 'changed' }, }, @@ -53,8 +53,6 @@ const meta = { export default meta; type Story = StoryObj; -// ─── Controlled wrapper ─────────────────────────────────────────────────────── - function Controlled({ initialValue = 0.4, secondValue, @@ -75,20 +73,16 @@ function Controlled({ ); } -// ─── Stories ───────────────────────────────────────────────────────────────── - export const Rounded: Story = { - name: 'Rounded(默认)', args: { value: 0.45, edge: 'rounded' }, }; export const Square: Story = { - name: 'Square', args: { value: 0.45, edge: 'square' }, }; export const WithBuffer: Story = { - name: 'With Buffer(缓冲副轨)', + name: 'With Buffer', args: { value: 0.3, secondValue: 0.65 }, }; @@ -97,7 +91,7 @@ export const Disabled: Story = { }; export const Interactive: Story = { - name: 'Interactive(可拖拽)', + name: 'Interactive', render: () => , }; @@ -106,8 +100,6 @@ export const InteractiveWithBuffer: Story = { render: () => , }; -// ─── Showcase ───────────────────────────────────────────────────────────────── - export const AllEdges: Story = { name: 'All Edges', render: () => ( @@ -125,24 +117,24 @@ export const AllEdges: Story = { }; export const Scenarios: Story = { - name: 'Scenarios(使用场景)', + name: 'Scenarios', render: () => (
- 音量 + Volume
- 播放进度(含缓冲) + Playback (with buffer)
- 禁用 + Disabled
From 0ce9222d0231f776b781274baa8f8947179cccb2 Mon Sep 17 00:00:00 2001 From: anyone Date: Tue, 21 Apr 2026 14:37:29 +0800 Subject: [PATCH 02/18] pull request checks --- .../workflows/beta_docker_build_and_push.yaml | 35 ------------- .github/workflows/build_and_release.yaml | 27 ---------- .github/workflows/docker_build_and_push.yaml | 38 -------------- .github/workflows/pull_request_checks.yaml | 50 +++++++++++++++++++ .gitignore | 2 +- apps/cli/internal/api/handler/lyric_test.go | 14 ++++++ apps/cli/internal/api/handler/singer_test.go | 8 +++ apps/cli/internal/store/db.go | 13 +++++ apps/pwa/.gitignore | 1 + apps/pwa/package.json | 4 ++ apps/pwa/test/shared_utils.test.ts | 27 ++++++++++ apps/pwa/tsconfig.test.json | 11 ++++ 12 files changed, 129 insertions(+), 101 deletions(-) delete mode 100644 .github/workflows/beta_docker_build_and_push.yaml delete mode 100644 .github/workflows/build_and_release.yaml delete mode 100644 .github/workflows/docker_build_and_push.yaml create mode 100644 .github/workflows/pull_request_checks.yaml create mode 100644 apps/pwa/test/shared_utils.test.ts create mode 100644 apps/pwa/tsconfig.test.json diff --git a/.github/workflows/beta_docker_build_and_push.yaml b/.github/workflows/beta_docker_build_and_push.yaml deleted file mode 100644 index ad9ab133..00000000 --- a/.github/workflows/beta_docker_build_and_push.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: beta docker build and push - -on: - push: - branches: - - beta - -jobs: - beta_build_and_push: - runs-on: ubuntu-latest - env: - TZ: Asia/Shanghai - steps: - - uses: docker/setup-qemu-action@v2 - - uses: docker/setup-buildx-action@v2 - - - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: apps/pwa/package-lock.json - - uses: actions/setup-go@v5 - with: - go-version-file: apps/cli/go.mod - - - run: git tag beta.$(date +\%y\%m\%d\%H\%M) - - run: ./docker/docker_build_and_push.sh beta diff --git a/.github/workflows/build_and_release.yaml b/.github/workflows/build_and_release.yaml deleted file mode 100644 index e78c2276..00000000 --- a/.github/workflows/build_and_release.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: build and release - -on: - push: - tags: - - "*" - -jobs: - build_and_release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: apps/pwa/package-lock.json - - uses: actions/setup-go@v5 - with: - go-version-file: apps/cli/go.mod - - - run: ./build.sh - - - uses: ncipollo/release-action@v1 - with: - artifacts: "build/*.tar.gz" - token: ${{ secrets.TOKEN }} diff --git a/.github/workflows/docker_build_and_push.yaml b/.github/workflows/docker_build_and_push.yaml deleted file mode 100644 index 758ad689..00000000 --- a/.github/workflows/docker_build_and_push.yaml +++ /dev/null @@ -1,38 +0,0 @@ -name: docker build and push - -on: - push: - tags: - - "*" - -jobs: - build_and_push: - runs-on: ubuntu-latest - steps: - - uses: docker/setup-qemu-action@v2 - - uses: docker/setup-buildx-action@v2 - - - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: apps/pwa/package-lock.json - - uses: actions/setup-go@v5 - with: - go-version-file: apps/cli/go.mod - - - uses: peter-evans/dockerhub-description@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: mebtte/cicada - short-description: "A multi-user music service for self-hosting." - readme-filepath: "./docker/docker.md" - - - run: ./docker/docker_build_and_push.sh v2 diff --git a/.github/workflows/pull_request_checks.yaml b/.github/workflows/pull_request_checks.yaml new file mode 100644 index 00000000..16917530 --- /dev/null +++ b/.github/workflows/pull_request_checks.yaml @@ -0,0 +1,50 @@ +name: pull request checks + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + +jobs: + cli-test: + name: CLI Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/cli + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: apps/cli/go.mod + + - name: Run CLI tests + run: go test ./... + + pwa-test: + name: PWA Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/pwa + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: apps/pwa/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build PWA + run: npm run build + + - name: Run PWA tests + run: npm run test diff --git a/.gitignore b/.gitignore index 7f548e1e..22bf2186 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ tmp/ build/ # claude -.claude/ \ No newline at end of file +.claude/ diff --git a/apps/cli/internal/api/handler/lyric_test.go b/apps/cli/internal/api/handler/lyric_test.go index 260d3921..83282e0d 100644 --- a/apps/cli/internal/api/handler/lyric_test.go +++ b/apps/cli/internal/api/handler/lyric_test.go @@ -14,6 +14,14 @@ import ( func TestGetLyricList(t *testing.T) { gin.SetMode(gin.TestMode) + if err := store.ResetForTests(); err != nil { + t.Fatalf("reset store: %v", err) + } + t.Cleanup(func() { + if err := store.ResetForTests(); err != nil { + t.Fatalf("cleanup store: %v", err) + } + }) dataDir := t.TempDir() config.Set(config.Config{ @@ -27,6 +35,12 @@ func TestGetLyricList(t *testing.T) { } now := time.Now().UnixMilli() + if _, err := store.DB().Exec( + `INSERT INTO user (id,username,password,nickname,joinTimestamp) VALUES (?,?,?,?,?)`, + "1", "tester", store.DoubleMD5("password"), "Tester", now, + ); err != nil { + t.Fatalf("insert user: %v", err) + } if _, err := store.DB().Exec( `INSERT INTO music (id,type,name,asset,createUserId,createTimestamp) VALUES (?,?,?,?,?,?)`, "song-1", int(store.MusicTypeSong), "Song", "song.mp3", "1", now, diff --git a/apps/cli/internal/api/handler/singer_test.go b/apps/cli/internal/api/handler/singer_test.go index 84797224..b2cb57e2 100644 --- a/apps/cli/internal/api/handler/singer_test.go +++ b/apps/cli/internal/api/handler/singer_test.go @@ -14,6 +14,14 @@ import ( func TestGetSinger(t *testing.T) { gin.SetMode(gin.TestMode) + if err := store.ResetForTests(); err != nil { + t.Fatalf("reset store: %v", err) + } + t.Cleanup(func() { + if err := store.ResetForTests(); err != nil { + t.Fatalf("cleanup store: %v", err) + } + }) dataDir := t.TempDir() config.Set(config.Config{ diff --git a/apps/cli/internal/store/db.go b/apps/cli/internal/store/db.go index b3285b4d..aa38fd63 100644 --- a/apps/cli/internal/store/db.go +++ b/apps/cli/internal/store/db.go @@ -28,3 +28,16 @@ func Open(path string) error { // DB returns the shared *sql.DB instance. func DB() *sql.DB { return db } + +// ResetForTests closes the shared DB and clears the one-time initializer. +// It is intended for tests that need an isolated temporary database. +func ResetForTests() error { + if db != nil { + if err := db.Close(); err != nil { + return err + } + } + db = nil + once = sync.Once{} + return nil +} diff --git a/apps/pwa/.gitignore b/apps/pwa/.gitignore index d0ae25a9..5f93525e 100644 --- a/apps/pwa/.gitignore +++ b/apps/pwa/.gitignore @@ -3,3 +3,4 @@ node_modules/ dist/ dist-sw/ +.test-dist/ diff --git a/apps/pwa/package.json b/apps/pwa/package.json index b0de9966..ead79b9d 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -1,10 +1,14 @@ { "name": "pwa", "type": "module", + "engines": { + "node": "24.x" + }, "scripts": { "dev": "vite", "dev-with-sw": "WITH_SW=true vite", "build": "vite build", + "test": "node -e \"require('node:fs').rmSync('.test-dist', { recursive: true, force: true })\" && tsc -p tsconfig.test.json && cd .test-dist && node --test", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, diff --git a/apps/pwa/test/shared_utils.test.ts b/apps/pwa/test/shared_utils.test.ts new file mode 100644 index 00000000..7591a491 --- /dev/null +++ b/apps/pwa/test/shared_utils.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import capitalize from "../src/shared/utils/capitalize.js"; +import stringArrayEqual from "../src/shared/utils/string_array_equal.js"; +import parseSearch from "../src/utils/parse_search.js"; + +test("capitalize uppercases the first letter of each word", () => { + assert.equal(capitalize("hello world"), "Hello World"); + assert.equal(capitalize("cicada"), "Cicada"); +}); + +test("stringArrayEqual compares array length and item order", () => { + assert.equal(stringArrayEqual(["a", "b"], ["a", "b"]), true); + assert.equal(stringArrayEqual(["a", "b"], ["b", "a"]), false); + assert.equal(stringArrayEqual(["a"], ["a", "b"]), false); +}); + +test("parseSearch decodes the search string into key-value pairs", () => { + assert.deepEqual( + parseSearch<"keyword" | "page">("?keyword=lofi%20mix&page=2"), + { + keyword: "lofi mix", + page: "2", + }, + ); +}); diff --git a/apps/pwa/tsconfig.test.json b/apps/pwa/tsconfig.test.json new file mode 100644 index 00000000..d33e7ae5 --- /dev/null +++ b/apps/pwa/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "outDir": ".test-dist", + "types": ["node"] + }, + "include": ["test/**/*.ts"] +} From 39b34f63334cccfc1f4c328b87afa621101ac67b Mon Sep 17 00:00:00 2001 From: anyone Date: Tue, 21 Apr 2026 14:59:26 +0800 Subject: [PATCH 03/18] apply new input --- apps/pwa/src/components/input.tsx | 43 --------- .../src/components/pagination/custom_page.tsx | 18 ++-- apps/pwa/src/components_next/input/index.tsx | 4 +- .../admin/music_management/music_list.tsx | 2 +- apps/pwa/src/pages/login/first_step/index.tsx | 22 +++-- .../pwa/src/pages/login/second_step/index.tsx | 51 +++++------ .../pwa/src/pages/player/2fa_dialog/index.tsx | 16 ++-- apps/pwa/src/pages/player/header/search.tsx | 2 +- .../music_play_record/toolbar/filter.tsx | 2 +- .../pages/player/pages/musicbill/filter.tsx | 2 +- .../my_music/create_music_dialog/index.tsx | 17 ++-- .../player/pages/my_music/toolbar/filter.tsx | 2 +- .../toolbar/filter.tsx | 2 +- .../src/pages/player/pages/search/input.tsx | 2 +- .../pages/player/pages/setting/stop_timer.tsx | 2 +- .../pages/user_manage/create_user_dialog.tsx | 37 ++++---- .../pages/user_manage/toolbar/filter.tsx | 2 +- .../user_edit_drawer/user_edit.tsx | 90 ++++++++----------- .../playlist/toolbar.tsx | 16 ++-- apps/pwa/src/utils/dialog/captcha/index.tsx | 18 ++-- apps/pwa/src/utils/dialog/input.tsx | 32 ++++--- apps/pwa/src/utils/dialog/input_list.tsx | 2 +- apps/pwa/src/utils/dialog/password.tsx | 35 ++++---- 23 files changed, 173 insertions(+), 246 deletions(-) delete mode 100644 apps/pwa/src/components/input.tsx diff --git a/apps/pwa/src/components/input.tsx b/apps/pwa/src/components/input.tsx deleted file mode 100644 index 7dcb1f15..00000000 --- a/apps/pwa/src/components/input.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { ForwardedRef, forwardRef, InputHTMLAttributes } from 'react'; -import styled from 'styled-components'; -import { ComponentSize } from '../constants/style'; -import { CSSVariable } from '../global_style'; - -const Input = styled.input` - padding: 0 10px; - width: 100%; - height: ${ComponentSize.NORMAL}px; - - background-color: #fff; - border-radius: ${CSSVariable.BORDER_RADIUS_NORMAL}; - border: 1px solid ${CSSVariable.COLOR_BORDER}; - color: ${CSSVariable.TEXT_COLOR_PRIMARY}; - font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; - outline: none; - transition: inherit; - -webkit-tap-highlight-color: transparent; - - &:focus { - border-color: ${CSSVariable.COLOR_PRIMARY}; - } - - &:disabled { - border-color: ${CSSVariable.TEXT_COLOR_DISABLED}; - background: ${CSSVariable.BACKGROUND_DISABLED}; - cursor: not-allowed; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - } -`; - -type Props = { - disabled?: boolean; -} & InputHTMLAttributes; - -function Wrapper( - { disabled = false, ...props }: Props, - ref: ForwardedRef, -) { - return ; -} - -export default forwardRef(Wrapper); diff --git a/apps/pwa/src/components/pagination/custom_page.tsx b/apps/pwa/src/components/pagination/custom_page.tsx index 7cf94243..04bff05f 100644 --- a/apps/pwa/src/components/pagination/custom_page.tsx +++ b/apps/pwa/src/components/pagination/custom_page.tsx @@ -8,8 +8,7 @@ import { import Popup from '@/components/popup'; import { UtilZIndex } from '@/constants/style'; import { t } from '@/i18n'; -import Input from '../input'; -import Label from '../label'; +import Input from '@/components_next/input'; import e, { EventType } from './eventemitter'; import { IS_TOUCHABLE } from '../../constants/browser'; @@ -74,14 +73,13 @@ function CustomPage({ maskProps={maskProps} bodyProps={bodyProps} > - + ); } diff --git a/apps/pwa/src/components_next/input/index.tsx b/apps/pwa/src/components_next/input/index.tsx index 9bc74a70..caa1c6bd 100644 --- a/apps/pwa/src/components_next/input/index.tsx +++ b/apps/pwa/src/components_next/input/index.tsx @@ -177,6 +177,8 @@ const Input = forwardRef( hint, disabled, id: idProp, + className, + style, ...rest }, ref, @@ -186,7 +188,7 @@ const Input = forwardRef( const bottom = error || hint; return ( - + {label && } {prefix && {prefix}} diff --git a/apps/pwa/src/pages/admin/music_management/music_list.tsx b/apps/pwa/src/pages/admin/music_management/music_list.tsx index ffc065db..24fc44a1 100644 --- a/apps/pwa/src/pages/admin/music_management/music_list.tsx +++ b/apps/pwa/src/pages/admin/music_management/music_list.tsx @@ -2,7 +2,7 @@ import { ChangeEventHandler, useEffect, useState } from 'react'; import styled from 'styled-components'; import searchMusicRequest from '@/server/api/search_music'; import { CSSVariable } from '@/global_style'; -import Input from '@/components/input'; +import Input from '@/components_next/input'; import Spinner from '@/components/spinner'; import { t } from '@/i18n'; import { MdOutlineEdit, MdMusicNote } from 'react-icons/md'; diff --git a/apps/pwa/src/pages/login/first_step/index.tsx b/apps/pwa/src/pages/login/first_step/index.tsx index d923497d..704842ec 100644 --- a/apps/pwa/src/pages/login/first_step/index.tsx +++ b/apps/pwa/src/pages/login/first_step/index.tsx @@ -1,8 +1,7 @@ import { ChangeEventHandler, KeyboardEventHandler, useState } from 'react'; import styled from 'styled-components'; import notice from '@/utils/notice'; -import Input from '@/components/input'; -import Label from '@/components/label'; +import Input from '@/components_next/input'; import logger from '@/utils/logger'; import Button from '@/components_next/button'; import { t } from '@/i18n'; @@ -82,16 +81,15 @@ function FirstStep({ toNext }: { toNext: () => void }) {
- + @@ -113,8 +103,8 @@ function CaptchaContent({ > {options.confirmText || t('confirm')} - - + + ); } diff --git a/apps/pwa/src/utils/dialog/confirm.tsx b/apps/pwa/src/utils/dialog/confirm.tsx index 167cfa0b..415c1c2f 100644 --- a/apps/pwa/src/utils/dialog/confirm.tsx +++ b/apps/pwa/src/utils/dialog/confirm.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { t } from '@/i18n'; import { Confirm as ConfirmShape } from './constants'; -import { Container, Content, Title, Action } from '../../components/dialog'; +import { DialogHeader, DialogTitle, DialogBody, DialogFooter } from '@/components_next'; import Button from '@/components_next/button'; import useEvent from '../use_event'; import DialogBase from './dialog_base'; @@ -37,10 +37,14 @@ function ConfirmContent({ .finally(() => setConfirming(false)); }); return ( - - {options.title ? {options.title} : null} - {options.content ? {options.content} : null} - + <> + {options.title && ( + + {options.title} + + )} + {options.content && {options.content}} + @@ -52,8 +56,8 @@ function ConfirmContent({ > {options.confirmText || t('confirm')} - - + + ); } diff --git a/apps/pwa/src/utils/dialog/dialog_base.tsx b/apps/pwa/src/utils/dialog/dialog_base.tsx index 6964d96f..6e323f39 100644 --- a/apps/pwa/src/utils/dialog/dialog_base.tsx +++ b/apps/pwa/src/utils/dialog/dialog_base.tsx @@ -1,30 +1,28 @@ -import { - CSSProperties, - HtmlHTMLAttributes, - ReactNode, - useCallback, - useEffect, - useState, -} from 'react'; +import { CSSProperties, ReactNode, useCallback, useEffect, useState } from 'react'; +import { Dialog, DialogContent, DialogTitle } from '@/components_next'; import { DialogOptions } from './constants'; -import Dialog from '../../components/dialog'; -import { UtilZIndex } from '../../constants/style'; import e, { EventType } from './eventemitter'; -const maskProps: { style: CSSProperties } = { - style: { zIndex: UtilZIndex.DIALOG }, +const srOnly: CSSProperties = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0,0,0,0)', + whiteSpace: 'nowrap', + border: 0, }; function DialogBase({ options, onDestroy, children, - bodyProps, }: { options: DialogOptions; onDestroy: (id: string) => void; children: ({ onClose }: { onClose: () => void }) => ReactNode; - bodyProps?: HtmlHTMLAttributes; }) { const [open, setOpen] = useState(false); const onClose = useCallback(() => setOpen(false), []); @@ -35,9 +33,7 @@ function DialogBase({ useEffect(() => { const unlistenClose = e.listen(EventType.CLOSE, ({ id }) => { - if (options.id === id) { - setOpen(false); - } + if (options.id === id) setOpen(false); }); return unlistenClose; }, [options.id]); @@ -50,8 +46,11 @@ function DialogBase({ }, [options.id, onDestroy, open]); return ( - - {children({ onClose })} + { if (!v) onClose(); }}> + + Dialog + {children({ onClose })} + ); } diff --git a/apps/pwa/src/utils/dialog/file_select.tsx b/apps/pwa/src/utils/dialog/file_select.tsx index 3e9f79b7..d32e37ef 100644 --- a/apps/pwa/src/utils/dialog/file_select.tsx +++ b/apps/pwa/src/utils/dialog/file_select.tsx @@ -1,6 +1,6 @@ -import { Container, Title, Content, Action } from '@/components/dialog'; +import { DialogHeader, DialogTitle, DialogBody, DialogFooter } from '@/components_next'; import Button from '@/components_next/button'; -import { CSSProperties, useState } from 'react'; +import { useState } from 'react'; import Label from '@/components/label'; import FileSelect from '@/components/file_select'; import { t } from '@/i18n'; @@ -8,8 +8,6 @@ import DialogBase from './dialog_base'; import { FileSelect as FileSelectShape } from './constants'; import useEvent from '../use_event'; -const contentStyle: CSSProperties = { overflow: 'hidden' }; - function FileSelectContent({ onClose, options, @@ -46,9 +44,13 @@ function FileSelectContent({ }; return ( - - {options.title ? {options.title} : null} - + <> + {options.title && ( + + {options.title} + + )} + - - + + @@ -71,8 +73,8 @@ function FileSelectContent({ > {options.confirmText || t('confirm')} - - + + ); } diff --git a/apps/pwa/src/utils/dialog/image_cut.tsx b/apps/pwa/src/utils/dialog/image_cut.tsx index e1d61731..7dd9455b 100644 --- a/apps/pwa/src/utils/dialog/image_cut.tsx +++ b/apps/pwa/src/utils/dialog/image_cut.tsx @@ -1,7 +1,6 @@ -import { Container, Title, Content, Action } from '@/components/dialog'; +import { DialogHeader, DialogTitle, DialogBody, DialogFooter } from '@/components_next'; import Button from '@/components_next/button'; import { - CSSProperties, useEffect, useLayoutEffect, useRef, @@ -19,11 +18,6 @@ import loadImage from '../load_image'; import upperCaseFirstLetter from '#/utils/upper_case_first_letter'; const ACCEPT_TYPES = ['image/jpeg', 'image/png']; -const contentStyle: CSSProperties = { - display: 'flex', - flexDirection: 'column', - gap: 10, -}; const ImgBox = styled.div` img { display: block; @@ -131,9 +125,13 @@ function ImageCutContent({ }, [confirming, canceling]); return ( - - {options.title ? {options.title} : null} - + <> + {options.title && ( + + {options.title} + + )} + {url ? ( @@ -146,8 +144,8 @@ function ImageCutContent({ acceptTypes={ACCEPT_TYPES} disabled={confirming || canceling} /> - - + + @@ -159,8 +157,8 @@ function ImageCutContent({ > {options.confirmText || t('confirm')} - - + + ); } diff --git a/apps/pwa/src/utils/dialog/input.tsx b/apps/pwa/src/utils/dialog/input.tsx index 3e5b9687..1990c8ea 100644 --- a/apps/pwa/src/utils/dialog/input.tsx +++ b/apps/pwa/src/utils/dialog/input.tsx @@ -1,14 +1,12 @@ -import { Container, Title, Content, Action } from '@/components/dialog'; +import { DialogHeader, DialogTitle, DialogBody, DialogFooter } from '@/components_next'; import Button from '@/components_next/button'; import Input from '@/components_next/input'; -import { CSSProperties, ChangeEventHandler, useState } from 'react'; +import { ChangeEventHandler, useState } from 'react'; import { t } from '@/i18n'; import DialogBase from './dialog_base'; import { Input as InputShape } from './constants'; import useEvent from '../use_event'; -const contentStyle: CSSProperties = { overflow: 'hidden' }; - function InputContent({ onClose, options, @@ -47,9 +45,13 @@ function InputContent({ }; return ( - - {options.title ? {options.title} : null} - + <> + {options.title && ( + + {options.title} + + )} + - - + + @@ -77,8 +79,8 @@ function InputContent({ > {options.confirmText || t('confirm')} - - + + ); } diff --git a/apps/pwa/src/utils/dialog/input_list.tsx b/apps/pwa/src/utils/dialog/input_list.tsx index 606bc7b3..d00b4a0e 100644 --- a/apps/pwa/src/utils/dialog/input_list.tsx +++ b/apps/pwa/src/utils/dialog/input_list.tsx @@ -1,4 +1,4 @@ -import { Container, Title, Content, Action } from '@/components/dialog'; +import { DialogHeader, DialogTitle, DialogBody, DialogFooter } from '@/components_next'; import Button from '@/components_next/button'; import Label from '@/components/label'; import Input from '@/components_next/input'; @@ -6,22 +6,11 @@ import { useState } from 'react'; import IconButton from '@/components/icon_button'; import { ComponentSize } from '@/constants/style'; import { MdDelete } from 'react-icons/md'; -import styled from 'styled-components'; import { t } from '@/i18n'; import DialogBase from './dialog_base'; import { InputList as InputListShape } from './constants'; import useEvent from '../use_event'; -const StyledContent = styled(Content)` - display: flex; - flex-direction: column; - gap: 10px; - - > .action { - flex-shrink: 0; - } -`; - function InputListContent({ onClose, options, @@ -81,9 +70,13 @@ function InputListContent({ }; return ( - - {options.title ? {options.title} : null} - + <> + {options.title && ( + + {options.title} + + )} + {values.map((value, index) => ( - + + @@ -136,8 +128,8 @@ function InputListContent({ > {options.confirmText || t('confirm')} - - + + ); } diff --git a/apps/pwa/src/utils/dialog/multiple_select.tsx b/apps/pwa/src/utils/dialog/multiple_select.tsx index 162df919..dceb12cc 100644 --- a/apps/pwa/src/utils/dialog/multiple_select.tsx +++ b/apps/pwa/src/utils/dialog/multiple_select.tsx @@ -1,15 +1,12 @@ -import { Container, Title, Content, Action } from '@/components/dialog'; +import { DialogHeader, DialogTitle, DialogBody, DialogFooter, MultiSelect, SelectOption } from '@/components_next'; import Button from '@/components_next/button'; import Label from '@/components/label'; -import { CSSProperties, useState } from 'react'; +import { useState } from 'react'; import { t } from '@/i18n'; -import { MultiSelect, SelectOption } from '@/components_next'; import DialogBase from './dialog_base'; import { MultipleSelect as MultipleSelectShape } from './constants'; import useEvent from '../use_event'; -const contentStyle: CSSProperties = { overflow: 'hidden' }; - function MultipleSelectContent({ onClose, options: multipleSelectOptions, @@ -54,11 +51,13 @@ function MultipleSelectContent({ }; return ( - - {multipleSelectOptions.title ? ( - {multipleSelectOptions.title} - ) : null} - + <> + {multipleSelectOptions.title && ( + + {multipleSelectOptions.title} + + )} + - - + + @@ -83,8 +82,8 @@ function MultipleSelectContent({ > {multipleSelectOptions.confirmText || t('confirm')} - - + + ); } diff --git a/apps/pwa/src/utils/dialog/password.tsx b/apps/pwa/src/utils/dialog/password.tsx index 8909902a..424b99d6 100644 --- a/apps/pwa/src/utils/dialog/password.tsx +++ b/apps/pwa/src/utils/dialog/password.tsx @@ -1,8 +1,7 @@ -import { Container, Content, Action } from '@/components/dialog'; +import { DialogBody, DialogFooter } from '@/components_next'; import Button from '@/components_next/button'; import Input from '@/components_next/input'; import { ChangeEventHandler, useState } from 'react'; -import styled from 'styled-components'; import { t } from '@/i18n'; import { PASSWORD_MAX_LENGTH } from '#/constants/user'; import DialogBase from './dialog_base'; @@ -10,15 +9,6 @@ import { Password as PasswordShape } from './constants'; import useEvent from '../use_event'; import notice from '../notice'; -const StyledContent = styled(Content)` - display: flex; - flex-direction: column; - gap: 20px; - - > .action { - flex-shrink: 0; - } -`; function PasswordContent({ onClose, @@ -67,8 +57,8 @@ function PasswordContent({ }; return ( - - + <> + - - + + @@ -97,8 +87,8 @@ function PasswordContent({ > {options.confirmText || t('confirm')} - - + + ); } diff --git a/apps/pwa/src/utils/dialog/textarea_list.tsx b/apps/pwa/src/utils/dialog/textarea_list.tsx index 3bd1c275..370631ab 100644 --- a/apps/pwa/src/utils/dialog/textarea_list.tsx +++ b/apps/pwa/src/utils/dialog/textarea_list.tsx @@ -1,8 +1,8 @@ -import { Container, Title, Content, Action } from '@/components/dialog'; +import { DialogHeader, DialogTitle, DialogBody, DialogFooter } from '@/components_next'; import Button from '@/components_next/button'; import Textarea from '@/components/textarea'; import Label from '@/components/label'; -import { CSSProperties, useState } from 'react'; +import { useState } from 'react'; import IconButton from '@/components/icon_button'; import { ComponentSize } from '@/constants/style'; import { MdDelete, MdUploadFile } from 'react-icons/md'; @@ -13,18 +13,6 @@ import { TextareaList as TextareaListShape } from './constants'; import useEvent from '../use_event'; import selectFile from '../select_file'; -const bodyProps: { style: CSSProperties } = { - style: { width: 'min(750px, 80%)' }, -}; -const StyledContent = styled(Content)` - display: flex; - flex-direction: column; - gap: 10px; - - > .action { - flex-shrink: 0; - } -`; const Addon = styled.div` display: flex; align-items: center; @@ -112,9 +100,13 @@ function TextareaListContent({ }; return ( - - {options.title ? {options.title} : null} - + <> + {options.title && ( + + {options.title} + + )} + {values.map((value, index) => ( - + + @@ -178,8 +169,8 @@ function TextareaListContent({ > {options.confirmText || t('confirm')} - - + + ); } @@ -191,7 +182,7 @@ function Wrapper({ options: TextareaListShape; }) { return ( - + {({ onClose }) => ( )} diff --git a/apps/pwa/vite.config.ts b/apps/pwa/vite.config.ts index af0e902e..a0e18347 100644 --- a/apps/pwa/vite.config.ts +++ b/apps/pwa/vite.config.ts @@ -1,25 +1,18 @@ import path from 'path'; import { fileURLToPath } from 'url'; -import cp from 'child_process'; import fs from 'fs'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { VitePWA } from 'vite-plugin-pwa'; +import { resolveVersion } from '../../scripts/build_version.mjs'; const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const STATIC_DIR = path.join(CURRENT_DIR, 'src/static'); const INVALID_FILES = ['.DS_Store']; -function getVersion() { - try { - return cp.execSync('git describe --abbrev=0 --tags', { stdio: 'pipe' }).toString().trim(); - } catch { - return 'unknown'; - } -} - export default defineConfig(({ command }) => { const withSW = command === 'build' || process.env.WITH_SW === 'true'; + const version = resolveVersion({ command }); return { publicDir: STATIC_DIR, @@ -42,7 +35,7 @@ export default defineConfig(({ command }) => { define: { global: 'globalThis', __DEFINE__: JSON.stringify({ - VERSION: getVersion(), + VERSION: version, BUILD_TIME: new Date(), EMPTY_IMAGE_LIST: fs .readdirSync(`${STATIC_DIR}/empty_image`) diff --git a/docs/development/cli/index.md b/docs/development/cli/index.md index 5e2c2e23..c61b049e 100644 --- a/docs/development/cli/index.md +++ b/docs/development/cli/index.md @@ -23,6 +23,10 @@ All of `cicada` data is under a directory, here is its structure: |- jwt_secret # its content is secret of jwt ``` +## Data Versioning + +The most important thing in `cicada` is data and the data has its version. Generally, data version needs to equal to `cicada` version. When the major version changes, `cicada` can upgrade data of **last version**. For example, cicada changes its version to `v3` from `v2`, `v3` cicda can upgrade `v2` data to `v3`. + ## Database structure Cicada use SQLite as database. diff --git a/docs/development/index.md b/docs/development/index.md index f62389c7..174c79eb 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -16,3 +16,8 @@ Cicada is monorepo which has multiple apps: ## Rules - Variabls prefer lower-camel case +- Uses [semver](https://semver.org) as version norm + +--- + +If you have other questions, you can make a [issue](https://github.com/mebtte/cicada/issues). \ No newline at end of file diff --git a/docs/docker_deployment/index.md b/docs/docker_deployment/index.md new file mode 100644 index 00000000..db0804e9 --- /dev/null +++ b/docs/docker_deployment/index.md @@ -0,0 +1,3 @@ +# Docker Deployment + +todo \ No newline at end of file diff --git a/readme.md b/readme.md index a07f8960..146a33de 100644 --- a/readme.md +++ b/readme.md @@ -9,9 +9,23 @@ A multi-user music service for self-hosting. todo: screenshot +## Features + +todo + +## Demo + +todo + +## Deploy + +> If you use docker, see this [docs](./docs/docker_deployment/index.md). + +todo + ## Development -If you are interested in developing `cicada`, see the [docs](./docs/development/index.md). +If you are interested in developing `cicada`, see the development [docs](./docs/development/index.md). ## License diff --git a/scripts/build_version.mjs b/scripts/build_version.mjs new file mode 100644 index 00000000..26363fe0 --- /dev/null +++ b/scripts/build_version.mjs @@ -0,0 +1,99 @@ +import cp from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const BETA_BRANCH = 'beta'; + +function runGit(args) { + try { + return cp.execFileSync('git', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +export function getLatestTag() { + return ( + runGit([ + 'for-each-ref', + '--sort=-creatordate', + '--count=1', + '--format=%(refname:short)', + 'refs/tags', + ]) || 'unknown' + ); +} + +export function getCurrentBranch() { + for (const key of ['GITHUB_REF_NAME', 'CI_COMMIT_REF_NAME', 'BRANCH_NAME']) { + const value = process.env[key]?.trim(); + if (value) { + return value; + } + } + + return runGit(['branch', '--show-current']); +} + +export function formatVersionTimestamp(date = new Date()) { + const pad = (value) => String(value).padStart(2, '0'); + + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + pad(date.getHours()), + pad(date.getMinutes()), + ].join(''); +} + +export function resolveBuildProfile({ + command, + buildProfile = process.env.CICADA_BUILD_PROFILE?.trim(), + branch = getCurrentBranch(), +} = {}) { + if (buildProfile === 'development' || command === 'serve') { + return 'development'; + } + + if (buildProfile === 'beta') { + return 'beta'; + } + + if (buildProfile === 'production') { + return 'production'; + } + + if (branch === BETA_BRANCH) { + return 'beta'; + } + + return 'production'; +} + +export function resolveVersion(options = {}) { + const overriddenVersion = process.env.CICADA_VERSION?.trim(); + if (overriddenVersion) { + return overriddenVersion; + } + + const latestTag = options.latestTag || getLatestTag(); + const buildProfile = options.buildProfile || resolveBuildProfile(options); + + if (buildProfile === 'development') { + return `${latestTag}-local`; + } + + if (buildProfile === 'beta') { + return `${latestTag}-beta-${formatVersionTimestamp(options.now ?? new Date())}`; + } + + return latestTag; +} + +if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || '')) { + process.stdout.write(`${resolveVersion()}\n`); +} From 1bf779553470b0e0757668256614c3fc4930ca94 Mon Sep 17 00:00:00 2001 From: anyone Date: Wed, 22 Apr 2026 14:31:59 +0800 Subject: [PATCH 06/18] replace to button from icon button --- apps/pwa/src/components/error_card.tsx | 6 +- apps/pwa/src/components/icon_button.tsx | 88 --------------- .../components_next/button/button.stories.tsx | 29 ++++- apps/pwa/src/components_next/button/index.tsx | 32 +++++- apps/pwa/src/constants/route.ts | 2 + .../pages/login/first_step/manage_drawer.tsx | 10 +- .../pages/login/first_step/server_list.tsx | 75 +++++++++---- .../pwa/src/pages/player/components/music.tsx | 28 ++--- .../src/pages/player/controller/operation.tsx | 39 ++++--- apps/pwa/src/pages/player/header/index.tsx | 53 +++++++-- apps/pwa/src/pages/player/header/use_title.ts | 22 +++- .../lyric_panel/controller/operation.tsx | 40 ++++--- .../src/pages/player/music_drawer/content.tsx | 101 ++++++++++++++++++ .../src/pages/player/music_drawer/index.tsx | 4 +- .../player/music_drawer/music_drawer.tsx | 97 +---------------- .../src/pages/player/music_drawer/toolbar.tsx | 37 +++++-- .../src/pages/player/music_drawer/use_open.ts | 47 +++++++- .../player/musicbill_music_drawer/top.tsx | 6 +- .../musicbill_shared_user_drawer/user.tsx | 13 +-- .../downloading_music/music_list/index.tsx | 9 +- .../pages/downloading_music/toolbar/index.tsx | 16 ++- .../src/pages/player/pages/music/index.tsx | 28 +++++ .../music_play_record.tsx | 10 +- .../pages/music_play_record/toolbar/index.tsx | 9 +- .../player/pages/musicbill/operation.tsx | 34 ++++-- .../src/pages/player/pages/singer/index.tsx | 28 +++++ .../pages/user_manage/toolbar/index.tsx | 6 +- .../pages/user_manage/user_list/user.tsx | 11 +- .../playlist/index.tsx | 27 +++-- .../playlist/toolbar.tsx | 9 +- .../playlist_playqueue_drawer/playqueue.tsx | 37 ++++--- .../public_musicbill_drawer/toolbar.tsx | 16 ++- apps/pwa/src/pages/player/route.tsx | 4 + .../player/sidebar/musicbill_list/top.tsx | 43 +++++--- .../pages/player/singer_drawer/content.tsx | 93 ++++++++++++++++ .../src/pages/player/singer_drawer/index.tsx | 4 +- .../player/singer_drawer/singer_drawer.tsx | 89 +-------------- .../pages/player/singer_drawer/toolbar.tsx | 20 ++-- .../pages/player/singer_drawer/use_open.ts | 28 ++++- .../src/pages/player/stop_timer/content.tsx | 6 +- .../use_playlist/use_playlist_restore.tsx | 16 ++- apps/pwa/src/updater.tsx | 16 ++- apps/pwa/src/utils/dialog/input_list.tsx | 10 +- apps/pwa/src/utils/dialog/textarea_list.tsx | 18 ++-- apps/pwa/src/utils/notice/notice_item.tsx | 6 +- docs/ui_designment/index.md | 3 + 46 files changed, 820 insertions(+), 505 deletions(-) delete mode 100644 apps/pwa/src/components/icon_button.tsx create mode 100644 apps/pwa/src/pages/player/music_drawer/content.tsx create mode 100644 apps/pwa/src/pages/player/pages/music/index.tsx create mode 100644 apps/pwa/src/pages/player/pages/singer/index.tsx create mode 100644 apps/pwa/src/pages/player/singer_drawer/content.tsx create mode 100644 docs/ui_designment/index.md diff --git a/apps/pwa/src/components/error_card.tsx b/apps/pwa/src/components/error_card.tsx index afea6aff..0f38793d 100644 --- a/apps/pwa/src/components/error_card.tsx +++ b/apps/pwa/src/components/error_card.tsx @@ -1,6 +1,6 @@ import { memo, useMemo } from 'react'; import styled from 'styled-components'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import definition from '@/definition'; import getRandomInteger from '#/utils/generate_random_integer'; import { MdRefresh } from 'react-icons/md'; @@ -63,9 +63,9 @@ function ErrorCard({ crossOrigin="anonymous" />
{errorMessage}
- + ); } diff --git a/apps/pwa/src/components/icon_button.tsx b/apps/pwa/src/components/icon_button.tsx deleted file mode 100644 index 30cfffc7..00000000 --- a/apps/pwa/src/components/icon_button.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { ButtonHTMLAttributes, CSSProperties, forwardRef } from 'react'; -import styled, { css } from 'styled-components'; -import { ComponentSize } from '../constants/style'; -import { CSSVariable } from '../global_style'; -import Spinner from './spinner'; - -const SVG_PERCENTAGE = 0.75; -const Style = styled.button<{ size: ComponentSize }>` - position: relative; - -webkit-app-region: no-drag; - - padding: 0; - - display: inline-flex; - align-items: center; - justify-content: center; - - border-radius: ${CSSVariable.BORDER_RADIUS_NORMAL}; - outline: none; - color: ${CSSVariable.TEXT_COLOR_PRIMARY}; - border: none; - background-color: transparent; - cursor: pointer; - transition: all 250ms; - user-select: none; - -webkit-tap-highlight-color: transparent; - - > svg { - width: ${SVG_PERCENTAGE * 100}%; - height: ${SVG_PERCENTAGE * 100}%; - } - - &:hover { - background-color: ${CSSVariable.BACKGROUND_COLOR_LEVEL_ONE}; - } - - &:active { - background-color: ${CSSVariable.BACKGROUND_COLOR_LEVEL_TWO}; - } - - &:disabled { - cursor: not-allowed; - background-color: rgb(0 0 0 / 0.15); - } - - ${({ size = ComponentSize.NORMAL }) => css` - width: ${size}px; - height: ${size}px; - `} -`; -const spinnerStyle: CSSProperties = { - position: 'absolute', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', -}; - -const IconButton = forwardRef< - HTMLButtonElement, - ButtonHTMLAttributes & { - size?: number; - loading?: boolean; - } ->( - ( - { - size = ComponentSize.NORMAL, - loading = false, - disabled = false, - children, - ...props - }, - ref, - ) => { - return ( - - ); - }, -); -IconButton.displayName = 'IconButton'; - -export default IconButton; diff --git a/apps/pwa/src/components_next/button/button.stories.tsx b/apps/pwa/src/components_next/button/button.stories.tsx index 36d96dc9..125b2f87 100644 --- a/apps/pwa/src/components_next/button/button.stories.tsx +++ b/apps/pwa/src/components_next/button/button.stories.tsx @@ -10,15 +10,15 @@ const meta = { docs: { description: { component: - 'Duolingo-style button with a hard bottom shadow and a satisfying press-down animation. Supports 4 variants, 3 sizes, loading and disabled states.', + 'Duolingo-style button with a hard bottom shadow and a satisfying press-down animation. Supports 5 variants (`primary`, `secondary`, `ghost`, `danger`, `plain`), 3 sizes, loading and disabled states. Use `square` prop for icon-only buttons (replaces the old icon_button component).', }, }, }, argTypes: { variant: { control: 'select', - options: ['primary', 'secondary', 'ghost', 'danger'], - description: 'Visual variant', + options: ['primary', 'secondary', 'ghost', 'danger', 'plain'], + description: 'Visual variant. `plain` is transparent with no border — suitable for icon-only buttons.', table: { defaultValue: { summary: 'primary' } }, }, size: { @@ -39,6 +39,10 @@ const meta = { control: 'boolean', description: 'Stretch to full container width', }, + square: { + control: 'boolean', + description: 'Icon-only mode — forces aspect-ratio 1:1 and removes padding. Use with `variant="plain"` to replicate the old icon button style.', + }, children: { control: 'text', description: 'Button label', @@ -66,6 +70,10 @@ export const Danger: Story = { args: { children: 'Delete', variant: 'danger' }, }; +export const Plain: Story = { + args: { children: 'Plain', variant: 'plain' }, +}; + export const Loading: Story = { args: { children: 'Loading...', variant: 'primary', loading: true }, }; @@ -93,6 +101,7 @@ export const AllVariants: Story = { +
), }; @@ -137,3 +146,17 @@ export const WithIcon: Story = {
), }; + +export const IconOnly: Story = { + name: 'Icon Only (square)', + render: () => ( +
+ + + + + + +
+ ), +}; diff --git a/apps/pwa/src/components_next/button/index.tsx b/apps/pwa/src/components_next/button/index.tsx index eb4c4b1e..b84d18ed 100644 --- a/apps/pwa/src/components_next/button/index.tsx +++ b/apps/pwa/src/components_next/button/index.tsx @@ -2,7 +2,7 @@ import { ButtonHTMLAttributes, ReactNode } from 'react'; import styled, { css, keyframes } from 'styled-components'; import { CSS_VAR } from '../theme'; -export type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'; +export type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'plain'; export type Size = 'sm' | 'md' | 'lg'; const cn = (v: string) => `var(${v})`; @@ -82,11 +82,33 @@ const makeVariant = ( } `; +const plainVariant = css<{ $offset: number }>` + color: inherit; + background: transparent; + border-color: transparent; + box-shadow: none; + transition: background 120ms; + + &:not(:disabled):hover { + background: rgb(0 0 0 / 0.06); + } + + &:not(:disabled):active { + background: rgb(0 0 0 / 0.12); + } + + &:disabled { + box-shadow: none; + opacity: 0.5; + } +`; + const VARIANT_MAP: Record> = { primary: makeVariant(PRIMARY, PRIMARY_SHADOW), secondary: makeVariant('#ffffff', PRIMARY, PRIMARY), ghost: makeVariant('#ffffff', 'rgb(180 180 180)', 'rgb(88 88 88)'), danger: makeVariant('rgb(242 80 66)', 'rgb(190 46 34)'), + plain: plainVariant, }; // ─── Loader ─────────────────────────────────────────────────────────────────── @@ -123,6 +145,7 @@ const StyledButton = styled.button<{ $block: boolean; $loading: boolean; $offset: number; + $square: boolean; }>` position: relative; display: inline-flex; @@ -159,6 +182,10 @@ const StyledButton = styled.button<{ } ${({ $size }) => SIZE_MAP[$size]} + ${({ $square }) => $square && css` + aspect-ratio: 1; + padding: 0; + `} ${({ $variant, $offset }) => css` ${VARIANT_MAP[$variant]} --offset: ${$offset}px; @@ -172,6 +199,7 @@ export interface ButtonProps extends ButtonHTMLAttributes { size?: Size; loading?: boolean; block?: boolean; + square?: boolean; icon?: ReactNode; } @@ -180,6 +208,7 @@ function Button({ size = 'md', loading = false, block = false, + square = false, disabled = false, icon, children, @@ -192,6 +221,7 @@ function Button({ $variant={variant} $size={size} $block={block} + $square={square} $loading={loading} $offset={offset} disabled={loading || disabled} diff --git a/apps/pwa/src/constants/route.ts b/apps/pwa/src/constants/route.ts index 77d718b0..3cdc1703 100644 --- a/apps/pwa/src/constants/route.ts +++ b/apps/pwa/src/constants/route.ts @@ -7,7 +7,9 @@ export const ROOT_PATH = { export const PLAYER_PATH = { EXPLORATION: '/', MY_MUSIC: '/my_music', + MUSIC: '/music/:id', MUSICBILL: '/musicbill/:id', + SINGER: '/singer/:id', SETTING: '/setting', SHARED_MUSICBILL_INVITATION: '/shared_musicbill_invitation', SEARCH: '/search', diff --git a/apps/pwa/src/pages/login/first_step/manage_drawer.tsx b/apps/pwa/src/pages/login/first_step/manage_drawer.tsx index e837a6a0..ff631233 100644 --- a/apps/pwa/src/pages/login/first_step/manage_drawer.tsx +++ b/apps/pwa/src/pages/login/first_step/manage_drawer.tsx @@ -5,7 +5,7 @@ import absoluteFullSize from '@/style/absolute_full_size'; import scrollbar from '@/style/scrollbar'; import { CSSProperties, useEffect } from 'react'; import styled from 'styled-components'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdDeleteOutline } from 'react-icons/md'; import ellipsis from '@/style/ellipsis'; import upperCaseFirstLetter from '@/style/upper_case_first_letter'; @@ -101,9 +101,11 @@ function ManageDrawer({ {t('origin_users_count', s.users.length.toString())}
- dialog.confirm({ content: t('delete_origin_question'), @@ -117,7 +119,7 @@ function ManageDrawer({ } > - + ))} diff --git a/apps/pwa/src/pages/login/first_step/server_list.tsx b/apps/pwa/src/pages/login/first_step/server_list.tsx index 15720424..c588e0f4 100644 --- a/apps/pwa/src/pages/login/first_step/server_list.tsx +++ b/apps/pwa/src/pages/login/first_step/server_list.tsx @@ -8,6 +8,29 @@ import ManageDrawer from './manage_drawer'; import { useServer } from '@/global_states/server'; const Style = styled.div` + > .select-wrapper { + position: relative; + + > .server-select { + > label { + display: flex; + align-items: center; + min-height: 20px; + padding-right: 48px; + } + } + + > .manage-button { + position: absolute; + top: 0; + right: 0; + display: inline-flex; + align-items: center; + height: 20px; + z-index: 1; + } + } + > .divider { margin-top: 20px; @@ -31,7 +54,11 @@ const Style = styled.div` } } `; -const Addon = styled.span` +const Addon = styled.button` + padding: 0; + border: none; + background: transparent; + font-size: ${CSSVariable.TEXT_SIZE_SMALL}; color: ${CSSVariable.TEXT_COLOR_SECONDARY}; cursor: pointer; @@ -40,6 +67,12 @@ const Addon = styled.span` &:hover { color: ${CSSVariable.TEXT_COLOR_PRIMARY}; } + + &:disabled { + cursor: not-allowed; + color: ${CSSVariable.TEXT_COLOR_SECONDARY}; + opacity: 0.5; + } `; const getServerList = () => useServer.getState().serverList; @@ -61,24 +94,28 @@ function ServerList({ return ( <> ); } diff --git a/apps/pwa/src/pages/player/header/index.tsx b/apps/pwa/src/pages/player/header/index.tsx index b8cf5c44..d54c4de9 100644 --- a/apps/pwa/src/pages/player/header/index.tsx +++ b/apps/pwa/src/pages/player/header/index.tsx @@ -1,8 +1,13 @@ import { memo } from 'react'; import styled from 'styled-components'; import Cover from '@/components/cover'; -import IconButton from '@/components/icon_button'; -import { MdMenu, MdSearch } from 'react-icons/md'; +import Button from '@/components_next/button'; +import { MdArrowBack, MdMenu, MdSearch } from 'react-icons/md'; +import { + matchPath, + useLocation, + useNavigate as useRouterNavigate, +} from 'react-router-dom'; import useNavigate from '@/utils/use_navigate'; import { PLAYER_PATH, ROOT_PATH } from '@/constants/route'; import Search from './search'; @@ -33,24 +38,58 @@ const Style = styled.div` function Header() { const navigate = useNavigate(); + const routerNavigate = useRouterNavigate(); + const { pathname } = useLocation(); const { miniMode } = useTheme(); const title = useTitle(); const { left, right } = useTitlebar(); + const musicbillMatch = matchPath( + `${ROOT_PATH.PLAYER}${PLAYER_PATH.MUSICBILL}`, + pathname, + ); + const musicMatch = matchPath( + `${ROOT_PATH.PLAYER}${PLAYER_PATH.MUSIC}`, + pathname, + ); + const singerMatch = matchPath( + `${ROOT_PATH.PLAYER}${PLAYER_PATH.SINGER}`, + pathname, + ); + const showBackButton = + miniMode && !!(musicMatch || musicbillMatch || singerMatch); return ( ); } diff --git a/apps/pwa/src/pages/player/music_drawer/content.tsx b/apps/pwa/src/pages/player/music_drawer/content.tsx new file mode 100644 index 00000000..7bac2254 --- /dev/null +++ b/apps/pwa/src/pages/player/music_drawer/content.tsx @@ -0,0 +1,101 @@ +import { CSSProperties } from 'react'; +import { animated, useTransition } from 'react-spring'; +import styled from 'styled-components'; +import ErrorCard from '@/components/error_card'; +import Cover, { Shape } from '@/components/cover'; +import Spinner from '@/components/spinner'; +import absoluteFullSize from '@/style/absolute_full_size'; +import autoScrollbar from '@/style/auto_scrollbar'; +import { flexCenter } from '@/style/flexbox'; +import { t } from '@/i18n'; +import CreateUser from '../components/create_user'; +import Info from './info'; +import { MusicDetail } from './constants'; +import Lyric from './lyric'; +import SingerList from './singer_list'; +import SubMusicList from './sub_music_list'; +import Toolbar from './toolbar'; +import useData from './use_data'; + +const Container = styled(animated.div)` + ${absoluteFullSize} +`; +const StatusBox = styled(Container)` + ${flexCenter} +`; +const DetailBox = styled(Container)` + > .scrollable { + ${absoluteFullSize} + + overflow: auto; + ${autoScrollbar} + + > .first-screen { + min-height: 100%; + } + } +`; + +function Detail({ style, music }: { style: CSSProperties; music: MusicDetail }) { + return ( + +
+
+ + + + {music.forkFromList.length ? ( + + ) : null} + {music.forkList.length ? ( + + ) : null} + +
+ + +
+
+ ); +} + +function MusicContent({ id }: { id: string }) { + const { data, reload } = useData(id); + const transitions = useTransition(data, { + from: { opacity: 0 }, + enter: { opacity: 1 }, + leave: { opacity: 0 }, + }); + + return transitions((style, d) => { + if (d.error) { + return ( + + + + ); + } + + if (d.loading) { + return ( + + + + ); + } + + return ; + }); +} + +export default MusicContent; diff --git a/apps/pwa/src/pages/player/music_drawer/index.tsx b/apps/pwa/src/pages/player/music_drawer/index.tsx index 3692a622..51b28332 100644 --- a/apps/pwa/src/pages/player/music_drawer/index.tsx +++ b/apps/pwa/src/pages/player/music_drawer/index.tsx @@ -3,8 +3,8 @@ import useOpen from './use_open'; import MusicDrawer from './music_drawer'; function Wrapper() { - const { zIndex, open, onClose, id } = useOpen(); - if (id) { + const { zIndex, open, onClose, id, miniMode } = useOpen(); + if (id && !miniMode) { return ( ); diff --git a/apps/pwa/src/pages/player/music_drawer/music_drawer.tsx b/apps/pwa/src/pages/player/music_drawer/music_drawer.tsx index 80f08574..b9a736fb 100644 --- a/apps/pwa/src/pages/player/music_drawer/music_drawer.tsx +++ b/apps/pwa/src/pages/player/music_drawer/music_drawer.tsx @@ -1,81 +1,12 @@ -import { useTransition, animated } from 'react-spring'; -import styled from 'styled-components'; -import ErrorCard from '@/components/error_card'; import Drawer from '@/components/drawer'; import { CSSProperties } from 'react'; -import absoluteFullSize from '@/style/absolute_full_size'; -import { flexCenter } from '@/style/flexbox'; -import Spinner from '@/components/spinner'; -import Cover, { Shape } from '@/components/cover'; -import autoScrollbar from '@/style/auto_scrollbar'; -import useData from './use_data'; -import { MusicDetail } from './constants'; -import CreateUser from '../components/create_user'; -import SingerList from './singer_list'; -import Toolbar from './toolbar'; -import Lyric from './lyric'; -import SubMusicList from './sub_music_list'; -import Info from './info'; -import { t } from '@/i18n'; +import MusicContent from './content'; const bodyProps: { style: CSSProperties } = { style: { width: 'min(350px, 85%)', }, }; -const Container = styled(animated.div)` - ${absoluteFullSize} -`; -const StatusBox = styled(Container)` - ${flexCenter} -`; -const DetailBox = styled(Container)` - > .scrollable { - ${absoluteFullSize} - - overflow: auto; - ${autoScrollbar} - - > .first-screen { - min-height: 100dvb; - } - } -`; - -function Detail({ style, music }: { style: unknown; music: MusicDetail }) { - return ( - // @ts-expect-error: style is known - -
-
- - - - {music.forkFromList.length ? ( - - ) : null} - {music.forkList.length ? ( - - ) : null} - -
- - -
- -
- ); -} function MusicDrawer({ zIndex, @@ -88,12 +19,6 @@ function MusicDrawer({ open: boolean; onClose: () => void; }) { - const { data, reload } = useData(id); - const transitions = useTransition(data, { - from: { opacity: 0 }, - enter: { opacity: 1 }, - leave: { opacity: 0 }, - }); return ( - {transitions((style, d) => { - if (d.error) { - return ( - - - - ); - } - - if (d.loading) { - return ( - - - - ); - } - - return ; - })} + ); } diff --git a/apps/pwa/src/pages/player/music_drawer/toolbar.tsx b/apps/pwa/src/pages/player/music_drawer/toolbar.tsx index 85d09174..11eea3a0 100644 --- a/apps/pwa/src/pages/player/music_drawer/toolbar.tsx +++ b/apps/pwa/src/pages/player/music_drawer/toolbar.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdPlayArrow, MdReadMore, @@ -44,7 +44,10 @@ function Toolbar({ music }: { music: MusicDetail }) { return ( ); diff --git a/apps/pwa/src/pages/player/music_drawer/use_open.ts b/apps/pwa/src/pages/player/music_drawer/use_open.ts index ab76990e..0eaeaf72 100644 --- a/apps/pwa/src/pages/player/music_drawer/use_open.ts +++ b/apps/pwa/src/pages/player/music_drawer/use_open.ts @@ -1,12 +1,25 @@ import { useState, useEffect, useCallback } from 'react'; +import { + matchPath, + useLocation, + useNavigate as useRouterNavigate, +} from 'react-router-dom'; import useNavigate from '@/utils/use_navigate'; import { Query } from '@/constants'; +import { PLAYER_PATH, ROOT_PATH } from '@/constants/route'; import useQuery from '@/utils/use_query'; +import { useTheme } from '@/global_states/theme'; import useDynamicZIndex from '../use_dynamic_z_index'; import eventemitter, { EventType } from '../eventemitter'; +const getMusicPath = (id: string) => + `${ROOT_PATH.PLAYER}${PLAYER_PATH.MUSIC.replace(':id', id)}`; + export default () => { const navigate = useNavigate(); + const routerNavigate = useRouterNavigate(); + const { pathname } = useLocation(); + const { miniMode } = useTheme(); const onClose = useCallback( () => navigate({ @@ -18,44 +31,68 @@ export default () => { ); const { music_drawer_id: urlId } = useQuery(); const [id, setId] = useState(urlId); + const musicMatch = matchPath( + `${ROOT_PATH.PLAYER}${PLAYER_PATH.MUSIC}`, + pathname, + ); useEffect(() => { setId((i) => urlId || i); }, [urlId]); + useEffect(() => { + if (miniMode && urlId) { + routerNavigate(getMusicPath(urlId), { replace: true }); + } + }, [miniMode, routerNavigate, urlId]); + useEffect(() => { const unlistenOpenMusicDrawer = eventemitter.listen( EventType.OPEN_MUSIC_DRAWER, (data) => window.setTimeout( - () => + () => { + if (miniMode) { + routerNavigate(getMusicPath(data.id)); + return; + } navigate({ query: { [Query.MUSIC_DRAWER_ID]: data.id, }, - }), + }); + }, 0, ), ); return unlistenOpenMusicDrawer; - }, [navigate]); + }, [miniMode, navigate, routerNavigate]); useEffect(() => { const unlistenMusicDeleted = eventemitter.listen( EventType.MUSIC_DELETED, (data) => { + if (musicMatch?.params.id === data.id) { + if (window.history.length > 1) { + routerNavigate(-1); + return; + } + navigate({ path: ROOT_PATH.PLAYER }); + return; + } if (data.id === id) { onClose(); } }, ); return unlistenMusicDeleted; - }, [id, onClose]); + }, [id, musicMatch?.params.id, navigate, onClose, routerNavigate]); return { - open: !!urlId, + open: !miniMode && !!urlId, onClose, id, + miniMode, zIndex: useDynamicZIndex(EventType.OPEN_MUSIC_DRAWER), }; }; diff --git a/apps/pwa/src/pages/player/musicbill_music_drawer/top.tsx b/apps/pwa/src/pages/player/musicbill_music_drawer/top.tsx index 23245433..ff063e92 100644 --- a/apps/pwa/src/pages/player/musicbill_music_drawer/top.tsx +++ b/apps/pwa/src/pages/player/musicbill_music_drawer/top.tsx @@ -1,7 +1,7 @@ import styled from 'styled-components'; import useTitlebarArea from '@/utils/use_titlebar_area_rect'; import { CSSVariable } from '@/global_style'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdOutlineAddBox } from 'react-icons/md'; import getResizedImage from '@/server/asset/get_resized_image'; import { Music } from '../constants'; @@ -60,9 +60,9 @@ function Top({ music }: { music: Music }) { />
添加到乐单
- +
); diff --git a/apps/pwa/src/pages/player/musicbill_shared_user_drawer/user.tsx b/apps/pwa/src/pages/player/musicbill_shared_user_drawer/user.tsx index 19630b4d..bf8c9165 100644 --- a/apps/pwa/src/pages/player/musicbill_shared_user_drawer/user.tsx +++ b/apps/pwa/src/pages/player/musicbill_shared_user_drawer/user.tsx @@ -7,7 +7,7 @@ import { MdOutlineForwardToInbox, MdClose, } from 'react-icons/md'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { CSSProperties } from 'react'; import dialog from '@/utils/dialog'; import logger from '@/utils/logger'; @@ -21,9 +21,8 @@ import playerEventemitter, { } from '../eventemitter'; const AVATAR_SIZE = 24; -const ACTION_SIZE = 24; const statusStyle: CSSProperties = { - width: ACTION_SIZE, + width: 24, color: CSSVariable.TEXT_COLOR_SECONDARY, }; const Style = styled.div` @@ -103,8 +102,10 @@ function User({ /> )} {deletable ? ( - { event.stopPropagation(); return dialog.confirm({ @@ -131,7 +132,7 @@ function User({ }} > - + ) : null} diff --git a/apps/pwa/src/pages/player/pages/downloading_music/music_list/index.tsx b/apps/pwa/src/pages/player/pages/downloading_music/music_list/index.tsx index 58e8b876..c71ed802 100644 --- a/apps/pwa/src/pages/player/pages/downloading_music/music_list/index.tsx +++ b/apps/pwa/src/pages/player/pages/downloading_music/music_list/index.tsx @@ -20,7 +20,7 @@ import { TOOLBAR_HEIGHT } from '../constants'; import context from '@/pages/player/context'; import Empty from '@/components/empty'; import absoluteFullSize from '@/style/absolute_full_size'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import eventemitter, { EventType } from '@/pages/player/eventemitter'; import dialog from '@/utils/dialog'; @@ -106,7 +106,10 @@ function MusicList() { lineAfter={ - { event.stopPropagation(); const removeItem = () => @@ -129,7 +132,7 @@ function MusicList() { }} > - + } /> diff --git a/apps/pwa/src/pages/player/pages/downloading_music/toolbar/index.tsx b/apps/pwa/src/pages/player/pages/downloading_music/toolbar/index.tsx index d286f2f8..487c662f 100644 --- a/apps/pwa/src/pages/player/pages/downloading_music/toolbar/index.tsx +++ b/apps/pwa/src/pages/player/pages/downloading_music/toolbar/index.tsx @@ -1,6 +1,6 @@ import styled from 'styled-components'; import { TOOLBAR_HEIGHT } from '../constants'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdPlaylistRemove, MdOutlineRestartAlt } from 'react-icons/md'; import { useContext, useMemo } from 'react'; import context from '@/pages/player/context'; @@ -32,7 +32,10 @@ function Toolbar() { ); return ( ); } diff --git a/apps/pwa/src/pages/player/pages/music/index.tsx b/apps/pwa/src/pages/player/pages/music/index.tsx new file mode 100644 index 00000000..36a5cb53 --- /dev/null +++ b/apps/pwa/src/pages/player/pages/music/index.tsx @@ -0,0 +1,28 @@ +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; +import Page from '../page'; +import { HEADER_HEIGHT } from '../../constants'; +import MusicContent from '../../music_drawer/content'; + +const Style = styled(Page)` + position: absolute; + top: ${HEADER_HEIGHT}px; + left: 0; + width: 100%; + height: calc(100% - ${HEADER_HEIGHT}px); +`; + +function Wrapper() { + const { id } = useParams<{ id: string }>(); + + if (!id) { + return null; + } + return ( + + ); +} + +export default Wrapper; diff --git a/apps/pwa/src/pages/player/pages/music_play_record/music_play_record_list/music_play_record.tsx b/apps/pwa/src/pages/player/pages/music_play_record/music_play_record_list/music_play_record.tsx index 86116090..a6f19f86 100644 --- a/apps/pwa/src/pages/player/pages/music_play_record/music_play_record_list/music_play_record.tsx +++ b/apps/pwa/src/pages/player/pages/music_play_record/music_play_record_list/music_play_record.tsx @@ -2,7 +2,7 @@ import { CSSVariable } from '@/global_style'; import day from '#/utils/day'; import styled from 'styled-components'; import { MdAvTimer, MdDeleteOutline } from 'react-icons/md'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import dialog from '@/utils/dialog'; import logger from '@/utils/logger'; import notice from '@/utils/notice'; @@ -45,8 +45,10 @@ function MusicWithExternalInfo({ music={musicPlayRecord} lineAfter={ - { event.stopPropagation(); return dialog.confirm({ @@ -64,7 +66,7 @@ function MusicWithExternalInfo({ }} > - + } addon={ diff --git a/apps/pwa/src/pages/player/pages/music_play_record/toolbar/index.tsx b/apps/pwa/src/pages/player/pages/music_play_record/toolbar/index.tsx index 3a7b2705..731b32a7 100644 --- a/apps/pwa/src/pages/player/pages/music_play_record/toolbar/index.tsx +++ b/apps/pwa/src/pages/player/pages/music_play_record/toolbar/index.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdHelpOutline } from 'react-icons/md'; import dialog from '@/utils/dialog'; import { useUser } from '@/global_states/server'; @@ -26,7 +26,10 @@ function Toolbar() { const user = useUser()!; return ( ); diff --git a/apps/pwa/src/pages/player/pages/musicbill/operation.tsx b/apps/pwa/src/pages/player/pages/musicbill/operation.tsx index a0c3591d..8ae95cba 100644 --- a/apps/pwa/src/pages/player/pages/musicbill/operation.tsx +++ b/apps/pwa/src/pages/player/pages/musicbill/operation.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdRefresh, MdPlaylistAdd, @@ -29,7 +29,10 @@ function Operation({ musicbill }: { musicbill: Musicbill }) { const { status, musicList } = musicbill; return ( ); } diff --git a/apps/pwa/src/pages/player/pages/singer/index.tsx b/apps/pwa/src/pages/player/pages/singer/index.tsx new file mode 100644 index 00000000..fc4785da --- /dev/null +++ b/apps/pwa/src/pages/player/pages/singer/index.tsx @@ -0,0 +1,28 @@ +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; +import Page from '../page'; +import { HEADER_HEIGHT } from '../../constants'; +import SingerContent from '../../singer_drawer/content'; + +const Style = styled(Page)` + position: absolute; + top: ${HEADER_HEIGHT}px; + left: 0; + width: 100%; + height: calc(100% - ${HEADER_HEIGHT}px); +`; + +function Wrapper() { + const { id } = useParams<{ id: string }>(); + + if (!id) { + return null; + } + return ( + + ); +} + +export default Wrapper; diff --git a/apps/pwa/src/pages/player/pages/user_manage/toolbar/index.tsx b/apps/pwa/src/pages/player/pages/user_manage/toolbar/index.tsx index fedde327..9f1b0701 100644 --- a/apps/pwa/src/pages/player/pages/user_manage/toolbar/index.tsx +++ b/apps/pwa/src/pages/player/pages/user_manage/toolbar/index.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdOutlineAddBox } from 'react-icons/md'; import { TOOLBAR_HEIGHT } from '../constants'; import Filter from './filter'; @@ -27,9 +27,9 @@ const Style = styled.div` function Toolbar() { return ( ); diff --git a/apps/pwa/src/pages/player/pages/user_manage/user_list/user.tsx b/apps/pwa/src/pages/player/pages/user_manage/user_list/user.tsx index 0356c028..cbfbbee5 100644 --- a/apps/pwa/src/pages/player/pages/user_manage/user_list/user.tsx +++ b/apps/pwa/src/pages/player/pages/user_manage/user_list/user.tsx @@ -3,9 +3,8 @@ import { CSSVariable } from '@/global_style'; import ellipsis from '@/style/ellipsis'; import styled from 'styled-components'; import Cover from '@/components/cover'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdMoreVert } from 'react-icons/md'; -import { ComponentSize } from '@/constants/style'; import getResizedImage from '@/server/asset/get_resized_image'; import { t } from '@/i18n'; import capitalize from '@/style/capitalize'; @@ -116,15 +115,17 @@ function User({ user, width }: { user: UserType; width: string }) { : t('unknown')} - { event.stopPropagation(); return e.emit(EventType.OPEN_USER_EDIT_DRAWER, { user }); }} > - + ); diff --git a/apps/pwa/src/pages/player/playlist_playqueue_drawer/playlist/index.tsx b/apps/pwa/src/pages/player/playlist_playqueue_drawer/playlist/index.tsx index 70fbce7f..edce3412 100644 --- a/apps/pwa/src/pages/player/playlist_playqueue_drawer/playlist/index.tsx +++ b/apps/pwa/src/pages/player/playlist_playqueue_drawer/playlist/index.tsx @@ -9,9 +9,8 @@ import { import styled from 'styled-components'; import absoluteFullSize from '@/style/absolute_full_size'; import List from 'react-list'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdPlayArrow, MdReadMore, MdOutlineClose } from 'react-icons/md'; -import { ComponentSize } from '@/constants/style'; import { CSSVariable } from '@/global_style'; import Empty from '@/components/empty'; import { flexCenter } from '@/style/flexbox'; @@ -99,8 +98,10 @@ function Playlist({ style }: { style: unknown }) { active={music.id === currentMusic?.id} lineAfter={ - { e.stopPropagation(); return playerEventemitter.emit( @@ -110,9 +111,11 @@ function Playlist({ style }: { style: unknown }) { }} > - - + } /> diff --git a/apps/pwa/src/pages/player/playlist_playqueue_drawer/playlist/toolbar.tsx b/apps/pwa/src/pages/player/playlist_playqueue_drawer/playlist/toolbar.tsx index 179a905b..7178d213 100644 --- a/apps/pwa/src/pages/player/playlist_playqueue_drawer/playlist/toolbar.tsx +++ b/apps/pwa/src/pages/player/playlist_playqueue_drawer/playlist/toolbar.tsx @@ -1,7 +1,7 @@ import Input from '@/components_next/input'; import { useContext, useEffect, useState } from 'react'; import styled from 'styled-components'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdPlaylistRemove } from 'react-icons/md'; import dialog from '@/utils/dialog'; import { t } from '@/i18n'; @@ -48,7 +48,10 @@ function Toolbar({ const { playlist } = useContext(context); return ( ); } diff --git a/apps/pwa/src/pages/player/route.tsx b/apps/pwa/src/pages/player/route.tsx index 07806bcd..1aedb1ce 100644 --- a/apps/pwa/src/pages/player/route.tsx +++ b/apps/pwa/src/pages/player/route.tsx @@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import { PLAYER_PATH } from '@/constants/route'; import Search from './pages/search'; import Musicbill from './pages/musicbill'; +import Music from './pages/music'; import Setting from './pages/setting'; import MyMusic from './pages/my_music'; import PublicMusicbillCollection from './pages/public_musicbill_collection'; @@ -9,6 +10,7 @@ import Exploration from './pages/exploration'; import MusicPlayRecord from './pages/music_play_record'; import SharedMusicbillInvitation from './pages/shared_musicbill_invitation'; import DownloadingMusic from './pages/downloading_music'; +import Singer from './pages/singer'; function Wrapper() { return ( @@ -16,7 +18,9 @@ function Wrapper() { } /> } /> } /> + } /> } /> + } /> } />
{t('musicbill')}
- - - + ); } diff --git a/apps/pwa/src/pages/player/singer_drawer/content.tsx b/apps/pwa/src/pages/player/singer_drawer/content.tsx new file mode 100644 index 00000000..7e3b6877 --- /dev/null +++ b/apps/pwa/src/pages/player/singer_drawer/content.tsx @@ -0,0 +1,93 @@ +import styled from 'styled-components'; +import { CSSProperties } from 'react'; +import { animated, useTransition } from 'react-spring'; +import absoluteFullSize from '@/style/absolute_full_size'; +import { flexCenter } from '@/style/flexbox'; +import ErrorCard from '@/components/error_card'; +import Spinner from '@/components/spinner'; +import autoScrollbar from '@/style/auto_scrollbar'; +import day from '#/utils/day'; +import useData from './use_data'; +import { Singer } from './constants'; +import Info from './info'; +import Toolbar from './toolbar'; +import MusicList from './music_list'; +import CreateUser from '../components/create_user'; +import EditMenu from './edit_menu'; + +const Container = styled(animated.div)` + ${absoluteFullSize} +`; +const CardContainer = styled(Container)` + ${flexCenter} +`; +const DetailContainer = styled(Container)` + > .scrollable { + ${absoluteFullSize} + + overflow: auto; + ${autoScrollbar} + + > .first-screen { + min-height: 100%; + } + } +`; + +function Detail({ style, singer }: { style: CSSProperties; singer: Singer }) { + const hasCreateUser = !!singer.createUser.id && !!singer.createUser.nickname; + return ( + +
+
+ + ({ + ...m, + index: singer.musicList.length - index, + }))} + /> +
+ {hasCreateUser ? ( + + ) : null} + +
+ + +
+ ); +} + +function SingerContent({ id }: { id: string }) { + const { data, reload } = useData(id); + + const transitions = useTransition(data, { + from: { opacity: 0 }, + enter: { opacity: 1 }, + leave: { opacity: 0 }, + }); + return transitions((style, d) => { + if (d.error) { + return ( + + + + ); + } + if (d.loading) { + return ( + + + + ); + } + return ; + }); +} + +export default SingerContent; diff --git a/apps/pwa/src/pages/player/singer_drawer/index.tsx b/apps/pwa/src/pages/player/singer_drawer/index.tsx index a4c68fc9..cc8a050d 100644 --- a/apps/pwa/src/pages/player/singer_drawer/index.tsx +++ b/apps/pwa/src/pages/player/singer_drawer/index.tsx @@ -2,9 +2,9 @@ import SingerDrawer from './singer_drawer'; import useOpen from './use_open'; function Wrapper() { - const { zIndex, id, open, onClose } = useOpen(); + const { zIndex, id, open, onClose, miniMode } = useOpen(); - if (!id) { + if (!id || miniMode) { return null; } return ; diff --git a/apps/pwa/src/pages/player/singer_drawer/singer_drawer.tsx b/apps/pwa/src/pages/player/singer_drawer/singer_drawer.tsx index 81ea2992..55030cd8 100644 --- a/apps/pwa/src/pages/player/singer_drawer/singer_drawer.tsx +++ b/apps/pwa/src/pages/player/singer_drawer/singer_drawer.tsx @@ -1,74 +1,12 @@ -import styled from 'styled-components'; import Drawer from '@/components/drawer'; import { CSSProperties } from 'react'; -import { animated, useTransition } from 'react-spring'; -import absoluteFullSize from '@/style/absolute_full_size'; -import { flexCenter } from '@/style/flexbox'; -import ErrorCard from '@/components/error_card'; -import Spinner from '@/components/spinner'; -import autoScrollbar from '@/style/auto_scrollbar'; -import day from '#/utils/day'; -import useData from './use_data'; -import { Singer } from './constants'; -import Info from './info'; -import Toolbar from './toolbar'; -import MusicList from './music_list'; -import CreateUser from '../components/create_user'; -import EditMenu from './edit_menu'; +import SingerContent from './content'; const bodyProps: { style: CSSProperties } = { style: { width: 'min(85%, 400px)', }, }; -const Container = styled(animated.div)` - ${absoluteFullSize} -`; -const CardContainer = styled(Container)` - ${flexCenter} -`; -const DetailContainer = styled(Container)` - > .scrollable { - ${absoluteFullSize} - - overflow: auto; - ${autoScrollbar} - - > .first-screen { - min-height: 100dvb; - } - } -`; - -function Detail({ style, singer }: { style: unknown; singer: Singer }) { - const hasCreateUser = !!singer.createUser.id && !!singer.createUser.nickname; - return ( - // @ts-expect-error: style is known - -
-
- - ({ - ...m, - index: singer.musicList.length - index, - }))} - /> -
- {hasCreateUser ? ( - - ) : null} - -
- - -
- ); -} function SingerDrawer({ zIndex, @@ -81,13 +19,6 @@ function SingerDrawer({ onClose: () => void; id: string; }) { - const { data, reload } = useData(id); - - const transitions = useTransition(data, { - from: { opacity: 0 }, - enter: { opacity: 1 }, - leave: { opacity: 0 }, - }); return ( - {transitions((style, d) => { - if (d.error) { - return ( - - - - ); - } - if (d.loading) { - return ( - - - - ); - } - return ; - })} + ); } diff --git a/apps/pwa/src/pages/player/singer_drawer/toolbar.tsx b/apps/pwa/src/pages/player/singer_drawer/toolbar.tsx index 77b3daf2..0ff492ec 100644 --- a/apps/pwa/src/pages/player/singer_drawer/toolbar.tsx +++ b/apps/pwa/src/pages/player/singer_drawer/toolbar.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdPlaylistAdd, MdOutlineEdit, MdCopyAll } from 'react-icons/md'; import notice from '@/utils/notice'; import logger from '@/utils/logger'; @@ -37,7 +37,10 @@ function Toolbar({ singer }: { singer: Singer }) { return ( ); diff --git a/apps/pwa/src/pages/player/singer_drawer/use_open.ts b/apps/pwa/src/pages/player/singer_drawer/use_open.ts index 2baf206e..3b96e7eb 100644 --- a/apps/pwa/src/pages/player/singer_drawer/use_open.ts +++ b/apps/pwa/src/pages/player/singer_drawer/use_open.ts @@ -1,12 +1,20 @@ import { useCallback, useEffect, useState } from 'react'; +import { useNavigate as useRouterNavigate } from 'react-router-dom'; import useNavigate from '@/utils/use_navigate'; import { Query } from '@/constants'; +import { PLAYER_PATH, ROOT_PATH } from '@/constants/route'; import useQuery from '@/utils/use_query'; +import { useTheme } from '@/global_states/theme'; import e, { EventType } from '../eventemitter'; import useDynamicZIndex from '../use_dynamic_z_index'; +const getSingerPath = (id: string) => + `${ROOT_PATH.PLAYER}${PLAYER_PATH.SINGER.replace(':id', id)}`; + export default () => { const navigate = useNavigate(); + const routerNavigate = useRouterNavigate(); + const { miniMode } = useTheme(); const onClose = useCallback( () => navigate({ @@ -23,25 +31,37 @@ export default () => { setId((i) => urlId || i); }, [urlId]); + useEffect(() => { + if (miniMode && urlId) { + routerNavigate(getSingerPath(urlId), { replace: true }); + } + }, [miniMode, routerNavigate, urlId]); + useEffect(() => { const unlistenOpen = e.listen(EventType.OPEN_SINGER_DRAWER, (data) => window.setTimeout( - () => + () => { + if (miniMode) { + routerNavigate(getSingerPath(data.id)); + return; + } navigate({ query: { [Query.SINGER_DRAWER_ID]: data.id, }, - }), + }); + }, 0, ), ); return unlistenOpen; - }, [navigate]); + }, [miniMode, navigate, routerNavigate]); return { zIndex: useDynamicZIndex(EventType.OPEN_SINGER_DRAWER), id, - open: !!urlId, + miniMode, + open: !miniMode && !!urlId, onClose, }; }; diff --git a/apps/pwa/src/pages/player/stop_timer/content.tsx b/apps/pwa/src/pages/player/stop_timer/content.tsx index e57ff9ba..f10e6d73 100644 --- a/apps/pwa/src/pages/player/stop_timer/content.tsx +++ b/apps/pwa/src/pages/player/stop_timer/content.tsx @@ -1,7 +1,7 @@ import styled, { css } from 'styled-components'; import { type StopTimer as StopTimerType } from '../constants'; import { CSSProperties, useEffect, useState } from 'react'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdClose } from 'react-icons/md'; import { CSSVariable } from '@/global_style'; import dialog from '@/utils/dialog'; @@ -85,9 +85,9 @@ function Content({
- + ); } diff --git a/apps/pwa/src/pages/player/use_playlist/use_playlist_restore.tsx b/apps/pwa/src/pages/player/use_playlist/use_playlist_restore.tsx index 6a679f43..765fd138 100644 --- a/apps/pwa/src/pages/player/use_playlist/use_playlist_restore.tsx +++ b/apps/pwa/src/pages/player/use_playlist/use_playlist_restore.tsx @@ -4,7 +4,7 @@ import storage, { Key } from '../storage'; import logger from '@/utils/logger'; import notice from '@/utils/notice'; import styled from 'styled-components'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdCheck, MdClose } from 'react-icons/md'; import upperCaseFirstLetter from '@/style/upper_case_first_letter'; import eventemitter, { EventType } from '../eventemitter'; @@ -62,8 +62,11 @@ function usePlaylistRestore(playlist: PlaylistMusic[]) {
{t('question_restore_playlist')}
- { notice.close(noticeId!); return eventemitter.emit( @@ -75,13 +78,16 @@ function usePlaylistRestore(playlist: PlaylistMusic[]) { }} > - - +
, { duration: 0, closable: false }, diff --git a/apps/pwa/src/updater.tsx b/apps/pwa/src/updater.tsx index d2bdddbf..cf124d9a 100644 --- a/apps/pwa/src/updater.tsx +++ b/apps/pwa/src/updater.tsx @@ -1,7 +1,7 @@ import notice from '@/utils/notice'; import { useState } from 'react'; import styled from 'styled-components'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdCheck, MdClose } from 'react-icons/md'; import definition from './definition'; import { t } from './i18n'; @@ -46,8 +46,11 @@ function VersionUpdateNotice({
{t('pwa_update_question')}
- { if (updating) { @@ -73,14 +76,17 @@ function VersionUpdateNotice({ }} > - - +
); diff --git a/apps/pwa/src/utils/dialog/input_list.tsx b/apps/pwa/src/utils/dialog/input_list.tsx index d00b4a0e..06adab3e 100644 --- a/apps/pwa/src/utils/dialog/input_list.tsx +++ b/apps/pwa/src/utils/dialog/input_list.tsx @@ -3,8 +3,6 @@ import Button from '@/components_next/button'; import Label from '@/components/label'; import Input from '@/components_next/input'; import { useState } from 'react'; -import IconButton from '@/components/icon_button'; -import { ComponentSize } from '@/constants/style'; import { MdDelete } from 'react-icons/md'; import { t } from '@/i18n'; import DialogBase from './dialog_base'; @@ -82,13 +80,15 @@ function InputListContent({ key={value.id} label={`${options.label} ${index + 1}`} addon={ - onDelete(value.id)} disabled={confirming || canceling} > - + } > - onOpenFile(value.id)} disabled={confirming || canceling} > - - + } > diff --git a/apps/pwa/src/utils/notice/notice_item.tsx b/apps/pwa/src/utils/notice/notice_item.tsx index dab03a41..8e347cf5 100644 --- a/apps/pwa/src/utils/notice/notice_item.tsx +++ b/apps/pwa/src/utils/notice/notice_item.tsx @@ -6,7 +6,7 @@ import { UtilZIndex } from '@/constants/style'; import upperCaseFirstLetter from '@/style/upper_case_first_letter'; import { Notice, TRANSITION_DURATION, NoticeType } from './constants'; import e, { EventType } from './eventemitter'; -import IconButton from '../../components/icon_button'; +import Button from '@/components_next/button'; const NOTICE_TYPE_MAP: Record< NoticeType, @@ -119,9 +119,9 @@ function NoticeItem({ notice }: { notice: Notice }) {
{content}
{closable ? ( - + ) : null}
{duration === 0 ? null : ( diff --git a/docs/ui_designment/index.md b/docs/ui_designment/index.md new file mode 100644 index 00000000..bd9d8ba1 --- /dev/null +++ b/docs/ui_designment/index.md @@ -0,0 +1,3 @@ +# UI Designment + +`cicada` prefers comic style and currently mostly likes `duolingo`. \ No newline at end of file From d6f3a334947945de49c268ff34c43a9d59ed9eac Mon Sep 17 00:00:00 2001 From: anyone Date: Wed, 22 Apr 2026 19:30:30 +0800 Subject: [PATCH 07/18] new drawer --- apps/pwa/readme.md | 22 + apps/pwa/src/components/drawer/constants.ts | 4 - apps/pwa/src/components/drawer/drawer.tsx | 145 ---- apps/pwa/src/components/drawer/index.tsx | 6 - apps/pwa/src/components/drawer/title.tsx | 27 - .../components_next/drawer/drawer.stories.tsx | 186 +++++ apps/pwa/src/components_next/drawer/index.tsx | 287 ++++++++ apps/pwa/src/components_next/index.ts | 13 + apps/pwa/src/global_style.ts | 4 + .../music_management/music_edit_drawer.tsx | 44 +- .../pages/login/first_step/manage_drawer.tsx | 86 ++- .../pages/player/music_drawer/edit_menu.tsx | 660 +++++++++--------- .../src/pages/player/music_drawer/index.tsx | 4 +- .../player/music_drawer/music_drawer.tsx | 22 +- .../src/pages/player/music_drawer/use_open.ts | 2 - .../musicbill_music_drawer.tsx | 30 +- .../musicbill_shared_user_drawer.tsx | 149 ++-- .../user_manage/user_edit_drawer/index.tsx | 30 +- .../playlist_playqueue_drawer/index.tsx | 25 +- .../public_musicbill_drawer.tsx | 62 +- .../src/pages/player/sidebar/mini_mode.tsx | 34 +- .../src/pages/player/singer_drawer/index.tsx | 4 +- .../player/singer_drawer/singer_drawer.tsx | 22 +- .../pages/player/singer_drawer/use_open.ts | 2 - .../singer_modify_record_drawer.tsx | 30 +- .../sort_musicbill_drawer.tsx | 57 +- .../pages/player/user_drawer/user_drawer.tsx | 69 +- 27 files changed, 1088 insertions(+), 938 deletions(-) create mode 100644 apps/pwa/readme.md delete mode 100644 apps/pwa/src/components/drawer/constants.ts delete mode 100644 apps/pwa/src/components/drawer/drawer.tsx delete mode 100644 apps/pwa/src/components/drawer/index.tsx delete mode 100644 apps/pwa/src/components/drawer/title.tsx create mode 100644 apps/pwa/src/components_next/drawer/drawer.stories.tsx create mode 100644 apps/pwa/src/components_next/drawer/index.tsx diff --git a/apps/pwa/readme.md b/apps/pwa/readme.md new file mode 100644 index 00000000..9494f40f --- /dev/null +++ b/apps/pwa/readme.md @@ -0,0 +1,22 @@ +# PWA + +## Requirement + +- [Node.js](https://nodejs.org) environment + +## Start DEV Server + +```sh +npm install +npm run dev +``` + +And the `pwa` can be visited on `http://localhost:8001`. + +## Components Docs + +`cicada` has own components. Visit its docs on `http://localhost:6006` by running: + +```sh +npm run storybook +``` \ No newline at end of file diff --git a/apps/pwa/src/components/drawer/constants.ts b/apps/pwa/src/components/drawer/constants.ts deleted file mode 100644 index 434ab9a0..00000000 --- a/apps/pwa/src/components/drawer/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum Direction { - LEFT, - RIGHT, -} diff --git a/apps/pwa/src/components/drawer/drawer.tsx b/apps/pwa/src/components/drawer/drawer.tsx deleted file mode 100644 index 50017950..00000000 --- a/apps/pwa/src/components/drawer/drawer.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { - HTMLAttributes, - useRef, - MouseEventHandler, - PropsWithChildren, - memo, -} from 'react'; -import ReactDOM from 'react-dom'; -import styled, { css } from 'styled-components'; -import { useTransition, animated, UseTransitionProps } from 'react-spring'; -import { Direction } from './constants'; - -const DIRECTION_MAP: Record< - Direction, - { - transition: UseTransitionProps; - bodyCss: ReturnType; - } -> = { - [Direction.LEFT]: { - transition: { - from: { - opacity: 0, - transform: 'translate(-120%)', - }, - enter: { - opacity: 1, - transform: 'translate(0%)', - }, - leave: { - opacity: 0, - transform: 'translate(-120%)', - }, - }, - bodyCss: css` - left: 0; - `, - }, - [Direction.RIGHT]: { - transition: { - from: { - opacity: 0, - transform: 'translate(120%)', - }, - enter: { - opacity: 1, - transform: 'translate(0%)', - }, - leave: { - opacity: 0, - transform: 'translate(120%)', - }, - }, - bodyCss: css` - right: 0; - `, - }, -}; -const Mask = styled(animated.div)` - position: fixed; - width: 100%; - height: 100%; - top: 0; - left: 0; - background-color: rgb(0 0 0 / 0.5); - -webkit-app-region: no-drag; -`; -const Body = styled(animated.div)<{ direction: Direction }>` - position: absolute; - top: 0; - height: 100%; - background-color: white; - overflow: hidden; - box-shadow: 0px 8px 10px -5px rgba(0, 0, 0, 0.2), - 0px 16px 24px 2px rgba(0, 0, 0, 0.14), 0px 6px 30px 5px rgba(0, 0, 0, 0.12); - box-sizing: border-box; - - ${({ direction }) => DIRECTION_MAP[direction].bodyCss} -`; - -const Drawer = ({ - open, - onClose, - - direction = Direction.RIGHT, - - maskProps = {}, - bodyProps = {}, - - children, -}: PropsWithChildren<{ - open: boolean; - onClose: () => void; - - direction?: Direction; - - maskProps?: HTMLAttributes; - bodyProps?: HTMLAttributes; -}>) => { - const bodyRef = useRef(null); - const onClickWrapper: MouseEventHandler = (event) => { - maskProps.onClick && maskProps.onClick(event); - - if (onClose && !bodyRef.current!.contains(event.target as HTMLElement)) { - onClose(); - } - }; - - const transitions = useTransition(open, DIRECTION_MAP[direction].transition); - return ReactDOM.createPortal( - transitions(({ opacity, transform }, o) => - o ? ( - - - {children} - - - ) : null, - ), - document.body, - ); -}; - -export default memo(Drawer, (prevProps, props) => { - if (prevProps.open || props.open) { - return false; - } - return true; -}); diff --git a/apps/pwa/src/components/drawer/index.tsx b/apps/pwa/src/components/drawer/index.tsx deleted file mode 100644 index 3a48514f..00000000 --- a/apps/pwa/src/components/drawer/index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Drawer from './drawer'; -import { Direction } from './constants'; -import Title from './title'; - -export { Direction, Title }; -export default Drawer; diff --git a/apps/pwa/src/components/drawer/title.tsx b/apps/pwa/src/components/drawer/title.tsx deleted file mode 100644 index b2358237..00000000 --- a/apps/pwa/src/components/drawer/title.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { CSSVariable } from '@/global_style'; -import capitalize from '@/style/capitalize'; -import useTitlebarArea from '@/utils/use_titlebar_area_rect'; -import { HtmlHTMLAttributes } from 'react'; -import styled from 'styled-components'; - -const Style = styled.div` - color: ${CSSVariable.TEXT_COLOR_PRIMARY}; - font-size: ${CSSVariable.TEXT_SIZE_TITLE}; - font-weight: bold; - ${capitalize} -`; - -function Title({ style, ...props }: HtmlHTMLAttributes) { - const { height } = useTitlebarArea(); - return ( - + ))} + + + ); } diff --git a/apps/pwa/src/pages/player/music_drawer/edit_menu.tsx b/apps/pwa/src/pages/player/music_drawer/edit_menu.tsx index 6a0cd6f1..5ae9518b 100644 --- a/apps/pwa/src/pages/player/music_drawer/edit_menu.tsx +++ b/apps/pwa/src/pages/player/music_drawer/edit_menu.tsx @@ -1,7 +1,6 @@ -import Drawer from '@/components/drawer'; +import { Drawer, DrawerContent } from '@/components_next'; import { CSSProperties, - MouseEventHandler, useCallback, useEffect, useState, @@ -48,7 +47,7 @@ import useTitlebarArea from '@/utils/use_titlebar_area_rect'; import getResizedImage from '@/server/asset/get_resized_image'; import { t } from '@/i18n'; import autoScrollbar from '@/style/auto_scrollbar'; -import { Music, ZIndex } from '../constants'; +import { Music } from '../constants'; import { MusicDetail } from './constants'; import e, { EventType } from './eventemitter'; import playerEventemitter, { @@ -87,16 +86,6 @@ const Style = styled.div` overflow: auto; ${autoScrollbar} `; -const maskProps: { - style: CSSProperties; - onClick: MouseEventHandler; -} = { - style: { zIndex: ZIndex.DRAWER }, - onClick: (event) => event.stopPropagation(), -}; -const bodyProps: { style: CSSProperties } = { - style: { width: 300 }, -}; const dangerousIconStyle: CSSProperties = { color: CSSVariable.COLOR_DANGEROUS, }; @@ -127,189 +116,227 @@ function EditMenu({ music }: { music: MusicDetail }) { }, []); return ( - - + }, + }); + }} + /> + + ); } diff --git a/apps/pwa/src/pages/player/music_drawer/index.tsx b/apps/pwa/src/pages/player/music_drawer/index.tsx index 51b28332..fe9b7960 100644 --- a/apps/pwa/src/pages/player/music_drawer/index.tsx +++ b/apps/pwa/src/pages/player/music_drawer/index.tsx @@ -3,10 +3,10 @@ import useOpen from './use_open'; import MusicDrawer from './music_drawer'; function Wrapper() { - const { zIndex, open, onClose, id, miniMode } = useOpen(); + const { open, onClose, id, miniMode } = useOpen(); if (id && !miniMode) { return ( - + ); } return null; diff --git a/apps/pwa/src/pages/player/music_drawer/music_drawer.tsx b/apps/pwa/src/pages/player/music_drawer/music_drawer.tsx index b9a736fb..b568d00d 100644 --- a/apps/pwa/src/pages/player/music_drawer/music_drawer.tsx +++ b/apps/pwa/src/pages/player/music_drawer/music_drawer.tsx @@ -1,32 +1,20 @@ -import Drawer from '@/components/drawer'; -import { CSSProperties } from 'react'; +import { Drawer, DrawerContent } from '@/components_next'; import MusicContent from './content'; -const bodyProps: { style: CSSProperties } = { - style: { - width: 'min(350px, 85%)', - }, -}; - function MusicDrawer({ - zIndex, id, open, onClose, }: { - zIndex: number; id: string; open: boolean; onClose: () => void; }) { return ( - - + !v && onClose()}> + + + ); } diff --git a/apps/pwa/src/pages/player/music_drawer/use_open.ts b/apps/pwa/src/pages/player/music_drawer/use_open.ts index 0eaeaf72..4952b749 100644 --- a/apps/pwa/src/pages/player/music_drawer/use_open.ts +++ b/apps/pwa/src/pages/player/music_drawer/use_open.ts @@ -9,7 +9,6 @@ import { Query } from '@/constants'; import { PLAYER_PATH, ROOT_PATH } from '@/constants/route'; import useQuery from '@/utils/use_query'; import { useTheme } from '@/global_states/theme'; -import useDynamicZIndex from '../use_dynamic_z_index'; import eventemitter, { EventType } from '../eventemitter'; const getMusicPath = (id: string) => @@ -93,6 +92,5 @@ export default () => { onClose, id, miniMode, - zIndex: useDynamicZIndex(EventType.OPEN_MUSIC_DRAWER), }; }; diff --git a/apps/pwa/src/pages/player/musicbill_music_drawer/musicbill_music_drawer.tsx b/apps/pwa/src/pages/player/musicbill_music_drawer/musicbill_music_drawer.tsx index dcdc8da0..123b358f 100644 --- a/apps/pwa/src/pages/player/musicbill_music_drawer/musicbill_music_drawer.tsx +++ b/apps/pwa/src/pages/player/musicbill_music_drawer/musicbill_music_drawer.tsx @@ -1,18 +1,11 @@ -import { CSSProperties, memo } from 'react'; -import Drawer from '@/components/drawer'; +import { memo } from 'react'; +import { Drawer, DrawerContent } from '@/components_next'; import styled from 'styled-components'; import autoScrollbar from '@/style/auto_scrollbar'; -import { EventType } from '../eventemitter'; import { MusicWithSingerAliases } from '../constants'; -import useDynamicZIndex from '../use_dynamic_z_index'; import Top from './top'; import MusicbillList from './musicbill_list'; -const bodyProps: { style: CSSProperties } = { - style: { - width: 300, - }, -}; const Content = styled.div` height: 100%; @@ -29,19 +22,14 @@ function MusicbillMusicDrawer({ onClose: () => void; music: MusicWithSingerAliases; }) { - const zIndex = useDynamicZIndex(EventType.OPEN_MUSICBILL_MUSIC_DRAWER); - return ( - - - - - + !v && onClose()}> + + + + + + ); } diff --git a/apps/pwa/src/pages/player/musicbill_shared_user_drawer/musicbill_shared_user_drawer.tsx b/apps/pwa/src/pages/player/musicbill_shared_user_drawer/musicbill_shared_user_drawer.tsx index 4e77fb55..5ce81922 100644 --- a/apps/pwa/src/pages/player/musicbill_shared_user_drawer/musicbill_shared_user_drawer.tsx +++ b/apps/pwa/src/pages/player/musicbill_shared_user_drawer/musicbill_shared_user_drawer.tsx @@ -1,4 +1,4 @@ -import Drawer from '@/components/drawer'; +import { Drawer, DrawerContent } from '@/components_next'; import { CSSProperties } from 'react'; import styled from 'styled-components'; import useNavigate from '@/utils/use_navigate'; @@ -18,13 +18,7 @@ import User from './user'; import { Musicbill } from '../constants'; import e, { EventType } from '../eventemitter'; import { quitSharedMusicbill } from '../pages/musicbill/utils'; -import useDynamicZIndex from '../use_dynamic_z_index'; -const bodyProps: { style: CSSProperties } = { - style: { - width: 300, - }, -}; const Content = styled.div` height: 100%; @@ -55,96 +49,87 @@ function ShareDrawer({ onClose: () => void; musicbill: Musicbill; }) { - const zIndex = useDynamicZIndex(EventType.OPEN_MUSICBILL_SHARED_USER_DRAWER); - const navigate = useNavigate(); const user = useUser()!; const owned = musicbill.owner.id === user.id; return ( - - - {t('shared_user')} -
- - {musicbill.sharedUserList.map((u) => ( + !v && onClose()}> + + + {t('shared_user')} +
- ))} -
- - {owned ? null : ( + {musicbill.sharedUserList.map((u) => ( + + ))} +
- )} -
+ {owned ? null : ( + + )} + +
); } diff --git a/apps/pwa/src/pages/player/pages/user_manage/user_edit_drawer/index.tsx b/apps/pwa/src/pages/player/pages/user_manage/user_edit_drawer/index.tsx index 87ba6ec4..922f009f 100644 --- a/apps/pwa/src/pages/player/pages/user_manage/user_edit_drawer/index.tsx +++ b/apps/pwa/src/pages/player/pages/user_manage/user_edit_drawer/index.tsx @@ -1,22 +1,11 @@ -import Drawer from '@/components/drawer'; -import { CSSProperties, useEffect, useState } from 'react'; +import { Drawer, DrawerContent } from '@/components_next'; +import { useEffect, useState } from 'react'; import styled from 'styled-components'; import autoScrollbar from '@/style/auto_scrollbar'; import e, { EventType } from '../eventemitter'; import { User } from '../constants'; -import { ZIndex } from '../../../constants'; import UserEdit from './user_edit'; -const maskProps: { style: CSSProperties } = { - style: { - zIndex: ZIndex.POPUP, - }, -}; -const bodyProps: { - style: CSSProperties; -} = { - style: { width: 350 }, -}; const Content = styled.div` height: 100%; @@ -41,15 +30,12 @@ function UserEditDrawer() { return null; } return ( - - - - + !v && onClose()}> + + + + + ); } diff --git a/apps/pwa/src/pages/player/playlist_playqueue_drawer/index.tsx b/apps/pwa/src/pages/player/playlist_playqueue_drawer/index.tsx index 72b91d5a..1714fda8 100644 --- a/apps/pwa/src/pages/player/playlist_playqueue_drawer/index.tsx +++ b/apps/pwa/src/pages/player/playlist_playqueue_drawer/index.tsx @@ -1,30 +1,15 @@ -import Drawer from '@/components/drawer'; -import { CSSProperties } from 'react'; -import useDynamicZIndex from '../use_dynamic_z_index'; +import { Drawer, DrawerContent } from '@/components_next'; import useOpen from './use_open'; -import { EventType as PlayerEventType } from '../eventemitter'; import Content from './content'; -const bodyProps: { style: CSSProperties } = { - style: { - width: 'min(400px, 85%)', - }, -}; - function PlaylistPlayqueueDrawer() { - const zIndex = useDynamicZIndex( - PlayerEventType.OPEN_PLAYLIST_PLAYQUEUE_DRAWER, - ); const { open, onClose } = useOpen(); return ( - - + !v && onClose()}> + + + ); } diff --git a/apps/pwa/src/pages/player/public_musicbill_drawer/public_musicbill_drawer.tsx b/apps/pwa/src/pages/player/public_musicbill_drawer/public_musicbill_drawer.tsx index 99e368ea..6c36ddb5 100644 --- a/apps/pwa/src/pages/player/public_musicbill_drawer/public_musicbill_drawer.tsx +++ b/apps/pwa/src/pages/player/public_musicbill_drawer/public_musicbill_drawer.tsx @@ -1,5 +1,3 @@ -import Drawer from '@/components/drawer'; -import { CSSProperties } from 'react'; import { animated, useTransition } from 'react-spring'; import styled from 'styled-components'; import { flexCenter } from '@/style/flexbox'; @@ -7,19 +5,13 @@ import ErrorCard from '@/components/error_card'; import Spinner from '@/components/spinner'; import absoluteFullSize from '@/style/absolute_full_size'; import autoScrollbar from '@/style/auto_scrollbar'; -import { EventType } from '../eventemitter'; -import useDynamicZIndex from '../use_dynamic_z_index'; +import { Drawer, DrawerContent } from '@/components_next'; import useData from './use_data'; import { Musicbill as MusicbillType } from './constants'; import Info from './info'; import MusicList from './music_list'; import Toolbar from './toolbar'; -const bodyProps: { style: CSSProperties } = { - style: { - width: 'min(85%, 400px)', - }, -}; const Container = styled(animated.div)` ${absoluteFullSize} `; @@ -57,7 +49,6 @@ function Wrapper({ onClose: () => void; id: string; }) { - const zIndex = useDynamicZIndex(EventType.OPEN_PUBLIC_MUSICBILL_DRAWER); const { data, reload, collected } = useData(id); const transitions = useTransition(data, { @@ -66,36 +57,31 @@ function Wrapper({ leave: { opacity: 0 }, }); return ( - - {transitions((style, d) => { - const { error, loading, musicbill } = d; - if (error) { + !v && onClose()}> + + {transitions((style, d) => { + const { error, loading, musicbill } = d; + if (error) { + return ( + + + + ); + } + if (loading) { + return ( + + + + ); + } return ( - - - + + + ); - } - if (loading) { - return ( - - - - ); - } - return ( - - - - ); - })} + })} + ); } diff --git a/apps/pwa/src/pages/player/sidebar/mini_mode.tsx b/apps/pwa/src/pages/player/sidebar/mini_mode.tsx index f038401a..e4bd0c6e 100644 --- a/apps/pwa/src/pages/player/sidebar/mini_mode.tsx +++ b/apps/pwa/src/pages/player/sidebar/mini_mode.tsx @@ -1,23 +1,12 @@ -import Drawer, { Direction } from '@/components/drawer'; -import { CSSProperties, useEffect, useState } from 'react'; +import { Drawer, DrawerContent } from '@/components_next'; +import { useEffect, useState } from 'react'; import styled from 'styled-components'; import autoScrollbar from '@/style/auto_scrollbar'; import Content from './content'; import e, { EventType } from '../eventemitter'; import { WIDTH } from './constants'; -import { ZIndex } from '../constants'; const onClose = () => e.emit(EventType.MINI_MODE_CLOSE_SIDEBAR, null); -const maskProps: { style: CSSProperties } = { - style: { zIndex: ZIndex.DRAWER }, -}; -const bodyProps: { - style: CSSProperties; -} = { - style: { - width: WIDTH, - }, -}; const ContentWrapper = styled.div` height: 100%; @@ -42,19 +31,12 @@ function MiniMode() { }, []); return ( - - - - + !v && onClose()}> + + + + + ); } diff --git a/apps/pwa/src/pages/player/singer_drawer/index.tsx b/apps/pwa/src/pages/player/singer_drawer/index.tsx index cc8a050d..0c46c565 100644 --- a/apps/pwa/src/pages/player/singer_drawer/index.tsx +++ b/apps/pwa/src/pages/player/singer_drawer/index.tsx @@ -2,12 +2,12 @@ import SingerDrawer from './singer_drawer'; import useOpen from './use_open'; function Wrapper() { - const { zIndex, id, open, onClose, miniMode } = useOpen(); + const { id, open, onClose, miniMode } = useOpen(); if (!id || miniMode) { return null; } - return ; + return ; } export default Wrapper; diff --git a/apps/pwa/src/pages/player/singer_drawer/singer_drawer.tsx b/apps/pwa/src/pages/player/singer_drawer/singer_drawer.tsx index 55030cd8..9428b837 100644 --- a/apps/pwa/src/pages/player/singer_drawer/singer_drawer.tsx +++ b/apps/pwa/src/pages/player/singer_drawer/singer_drawer.tsx @@ -1,32 +1,20 @@ -import Drawer from '@/components/drawer'; -import { CSSProperties } from 'react'; +import { Drawer, DrawerContent } from '@/components_next'; import SingerContent from './content'; -const bodyProps: { style: CSSProperties } = { - style: { - width: 'min(85%, 400px)', - }, -}; - function SingerDrawer({ - zIndex, open, onClose, id, }: { - zIndex: number; open: boolean; onClose: () => void; id: string; }) { return ( - - + !v && onClose()}> + + + ); } diff --git a/apps/pwa/src/pages/player/singer_drawer/use_open.ts b/apps/pwa/src/pages/player/singer_drawer/use_open.ts index 3b96e7eb..634d5944 100644 --- a/apps/pwa/src/pages/player/singer_drawer/use_open.ts +++ b/apps/pwa/src/pages/player/singer_drawer/use_open.ts @@ -6,7 +6,6 @@ import { PLAYER_PATH, ROOT_PATH } from '@/constants/route'; import useQuery from '@/utils/use_query'; import { useTheme } from '@/global_states/theme'; import e, { EventType } from '../eventemitter'; -import useDynamicZIndex from '../use_dynamic_z_index'; const getSingerPath = (id: string) => `${ROOT_PATH.PLAYER}${PLAYER_PATH.SINGER.replace(':id', id)}`; @@ -58,7 +57,6 @@ export default () => { }, [miniMode, navigate, routerNavigate]); return { - zIndex: useDynamicZIndex(EventType.OPEN_SINGER_DRAWER), id, miniMode, open: !miniMode && !!urlId, diff --git a/apps/pwa/src/pages/player/singer_modify_record_drawer/singer_modify_record_drawer.tsx b/apps/pwa/src/pages/player/singer_modify_record_drawer/singer_modify_record_drawer.tsx index d4a553f5..3bb3a3e8 100644 --- a/apps/pwa/src/pages/player/singer_modify_record_drawer/singer_modify_record_drawer.tsx +++ b/apps/pwa/src/pages/player/singer_modify_record_drawer/singer_modify_record_drawer.tsx @@ -1,18 +1,10 @@ -import Drawer from '@/components/drawer'; -import { CSSProperties } from 'react'; +import { Drawer, DrawerContent } from '@/components_next'; import styled from 'styled-components'; import autoScrollbar from '@/style/auto_scrollbar'; import { Singer } from './constants'; -import useDynamicZIndex from '../use_dynamic_z_index'; -import { EventType } from '../eventemitter'; import Content from './content'; import Hint from './hint'; -const bodyProps: { style: CSSProperties } = { - style: { - width: 300, - }, -}; const ContentWrapper = styled.div` height: 100%; @@ -29,20 +21,14 @@ function SingerModifyRecordDrawer({ open: boolean; onClose: () => void; }) { - const zIndex = useDynamicZIndex(EventType.OPEN_SINGER_MODIFY_RECORD_DRAWER); return ( - - - - - + !v && onClose()}> + + + + + + ); } diff --git a/apps/pwa/src/pages/player/sort_musicbilll_drawer/sort_musicbill_drawer.tsx b/apps/pwa/src/pages/player/sort_musicbilll_drawer/sort_musicbill_drawer.tsx index eac73818..f5f8654e 100644 --- a/apps/pwa/src/pages/player/sort_musicbilll_drawer/sort_musicbill_drawer.tsx +++ b/apps/pwa/src/pages/player/sort_musicbilll_drawer/sort_musicbill_drawer.tsx @@ -1,5 +1,5 @@ -import Drawer, { Title } from '@/components/drawer'; -import { CSSProperties, useCallback, useEffect, useState } from 'react'; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components_next'; +import { useCallback, useEffect, useState } from 'react'; import { DndContext, DragEndEvent, @@ -18,20 +18,10 @@ import styled from 'styled-components'; import autoScrollbar from '@/style/auto_scrollbar'; import { t } from '@/i18n'; import { reloadUser } from '@/global_states/server'; -import { Musicbill as MusicbillType, ZIndex } from '../constants'; +import { Musicbill as MusicbillType } from '../constants'; import { LocalMusicbill } from './constant'; import Musicbill from './musicbill'; -const maskProps: { style: CSSProperties } = { - style: { - zIndex: ZIndex.DRAWER, - }, -}; -const bodyProps: { style: CSSProperties } = { - style: { - width: 250, - }, -}; const Content = styled.div` height: 100%; padding-bottom: env(safe-area-inset-bottom, 0); @@ -104,27 +94,26 @@ function MusicbillOrderDrawer({ }, [musicbillList]); return ( - - - {t('sort_musicbill')} - - m.id)} - strategy={verticalListSortingStrategy} - > -
- {localMusicbillList.map((musicbill) => ( - - ))} -
-
-
-
+ !v && onCloseWrapper()}> + + + {t('sort_musicbill')} + + + + m.id)} + strategy={verticalListSortingStrategy} + > +
+ {localMusicbillList.map((musicbill) => ( + + ))} +
+
+
+
+
); } diff --git a/apps/pwa/src/pages/player/user_drawer/user_drawer.tsx b/apps/pwa/src/pages/player/user_drawer/user_drawer.tsx index b68d1ca5..3338acb3 100644 --- a/apps/pwa/src/pages/player/user_drawer/user_drawer.tsx +++ b/apps/pwa/src/pages/player/user_drawer/user_drawer.tsx @@ -1,6 +1,4 @@ -import Drawer from '@/components/drawer'; import { - CSSProperties, UIEventHandler, useLayoutEffect, useRef, @@ -14,8 +12,7 @@ import Spinner from '@/components/spinner'; import TabList from '@/components/tab_list'; import absoluteFullSize from '@/style/absolute_full_size'; import autoScrollbar from '@/style/auto_scrollbar'; -import { EventType } from '../eventemitter'; -import useDynamicZIndex from '../use_dynamic_z_index'; +import { Drawer, DrawerContent } from '@/components_next'; import useData from './use_data'; import { MINI_INFO_HEIGHT, @@ -39,11 +36,6 @@ const TAB_LIST: { label: string; tab: Tab }[] = Object.values(Tab).map( label: TAB_MAP_LABEL[tab], }), ); -const bodyProps: { style: CSSProperties } = { - style: { - width: 'min(85%, 400px)', - }, -}; const Container = styled(animated.div)` ${absoluteFullSize} `; @@ -64,14 +56,11 @@ const Style = styled.div` } } `; -const tabListStyle: CSSProperties = { +const tabListStyle = { zIndex: 1, - - position: 'sticky', + position: 'sticky' as const, top: MINI_INFO_HEIGHT, - padding: '5px 20px', - backdropFilter: 'blur(5px)', backgroundColor: 'rgb(255 255 255 / 0.5)', }; @@ -155,41 +144,35 @@ function Wrapper({ onClose: () => void; id: string; }) { - const zIndex = useDynamicZIndex(EventType.OPEN_USER_DRAWER); const { data, reload } = useData(id); const transitions = useTransition(data, TRANSITION); return ( - - {transitions((style, d) => { - const { error, loading, userDetail } = d; - if (error) { - return ( - - - - ); - } - if (loading) { + !v && onClose()}> + + {transitions((style, d) => { + const { error, loading, userDetail } = d; + if (error) { + return ( + + + + ); + } + if (loading) { + return ( + + + + ); + } return ( - - - + + + ); - } - return ( - - - - ); - })} + })} + ); } From 5a42e4acc530335a93f44be0579d8f22a4d2a2fb Mon Sep 17 00:00:00 2001 From: anyone Date: Wed, 22 Apr 2026 19:30:55 +0800 Subject: [PATCH 08/18] client for apple --- apps/apple/.gitignore | 3 + apps/apple/Cicada.xcodeproj/project.pbxproj | 420 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/xcschemes/Cicada.xcscheme | 93 ++++ apps/apple/Cicada/App/CicadaApp.swift | 31 ++ .../AccentColor.colorset/Contents.json | 20 + .../AppIcon.appiconset/Contents.json | 7 + .../Cicada/Assets.xcassets/Contents.json | 6 + .../ServerSetup/ServerSetupStore.swift | 224 ++++++++++ .../ServerSetup/ServerSetupView.swift | 256 +++++++++++ apps/apple/Cicada/Info.plist | 47 ++ apps/apple/Cicada/Models/ServerRecord.swift | 48 ++ .../Services/ServerMetadataClient.swift | 96 ++++ apps/apple/Cicada/Support/AppColors.swift | 25 ++ apps/apple/Cicada/Support/PlatformInfo.swift | 41 ++ apps/apple/Cicada/Views/ContentView.swift | 15 + apps/apple/project.yml | 65 +++ apps/apple/readme.md | 43 ++ 18 files changed, 1447 insertions(+) create mode 100644 apps/apple/.gitignore create mode 100644 apps/apple/Cicada.xcodeproj/project.pbxproj create mode 100644 apps/apple/Cicada.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 apps/apple/Cicada.xcodeproj/xcshareddata/xcschemes/Cicada.xcscheme create mode 100644 apps/apple/Cicada/App/CicadaApp.swift create mode 100644 apps/apple/Cicada/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/apple/Cicada/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 apps/apple/Cicada/Assets.xcassets/Contents.json create mode 100644 apps/apple/Cicada/Features/ServerSetup/ServerSetupStore.swift create mode 100644 apps/apple/Cicada/Features/ServerSetup/ServerSetupView.swift create mode 100644 apps/apple/Cicada/Info.plist create mode 100644 apps/apple/Cicada/Models/ServerRecord.swift create mode 100644 apps/apple/Cicada/Services/ServerMetadataClient.swift create mode 100644 apps/apple/Cicada/Support/AppColors.swift create mode 100644 apps/apple/Cicada/Support/PlatformInfo.swift create mode 100644 apps/apple/Cicada/Views/ContentView.swift create mode 100644 apps/apple/project.yml create mode 100644 apps/apple/readme.md diff --git a/apps/apple/.gitignore b/apps/apple/.gitignore new file mode 100644 index 00000000..1082e252 --- /dev/null +++ b/apps/apple/.gitignore @@ -0,0 +1,3 @@ +*.xcuserstate +xcuserdata/ +.derivedData/ diff --git a/apps/apple/Cicada.xcodeproj/project.pbxproj b/apps/apple/Cicada.xcodeproj/project.pbxproj new file mode 100644 index 00000000..b23b9f69 --- /dev/null +++ b/apps/apple/Cicada.xcodeproj/project.pbxproj @@ -0,0 +1,420 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0F984FDDD69B564E0230A4CB /* CicadaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B910A3665B4DDA99878FF71 /* CicadaApp.swift */; }; + 540311FF630F605EB2D029B6 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBA9673A23B3A3AF4CDB32B /* AppColors.swift */; }; + 8FDB0E06C64B71D69AABD4B5 /* PlatformInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2F0A927788D9B8B22DC854 /* PlatformInfo.swift */; }; + 9941AEA5E64BF322A1E85221 /* ServerSetupStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36EF13F40A064872555EB02B /* ServerSetupStore.swift */; }; + BA878239D0B590108F8B4AA4 /* ServerRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE82971270AEBC4A2CEAF2A /* ServerRecord.swift */; }; + C949567AF84958831A724FB9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 11DF74913AF882BC9F3654D6 /* Assets.xcassets */; }; + D45A1192D9D8B8471640DC09 /* ServerMetadataClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51CB5BD527128393183232E7 /* ServerMetadataClient.swift */; }; + DA49924C26B41DAB98303DB6 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844DE39A003BD06B5E365B8B /* ContentView.swift */; }; + DAB7FB034762D499332DF78A /* ServerSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A15DAAA9D87410AC3E70B9 /* ServerSetupView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 11DF74913AF882BC9F3654D6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 2B910A3665B4DDA99878FF71 /* CicadaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CicadaApp.swift; sourceTree = ""; }; + 36EF13F40A064872555EB02B /* ServerSetupStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSetupStore.swift; sourceTree = ""; }; + 51CB5BD527128393183232E7 /* ServerMetadataClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMetadataClient.swift; sourceTree = ""; }; + 7CBA9673A23B3A3AF4CDB32B /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = ""; }; + 844DE39A003BD06B5E365B8B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 9C663FB13EEF5FDD127BC458 /* Cicada.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cicada.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AB2F0A927788D9B8B22DC854 /* PlatformInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformInfo.swift; sourceTree = ""; }; + AC69C6574E7CD3012188A930 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + BCE82971270AEBC4A2CEAF2A /* ServerRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerRecord.swift; sourceTree = ""; }; + C9A15DAAA9D87410AC3E70B9 /* ServerSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSetupView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 00D1D5527B4D131DC5FC3825 /* Features */ = { + isa = PBXGroup; + children = ( + 7FEC245E90F46072ACAF8FDA /* ServerSetup */, + ); + path = Features; + sourceTree = ""; + }; + 15ECD16330D6D168583775E6 /* Products */ = { + isa = PBXGroup; + children = ( + 9C663FB13EEF5FDD127BC458 /* Cicada.app */, + ); + name = Products; + sourceTree = ""; + }; + 278F84A51E18DDAF379C696D /* Services */ = { + isa = PBXGroup; + children = ( + 51CB5BD527128393183232E7 /* ServerMetadataClient.swift */, + ); + path = Services; + sourceTree = ""; + }; + 57FBA0E7E939B674E1E61726 /* Cicada */ = { + isa = PBXGroup; + children = ( + 11DF74913AF882BC9F3654D6 /* Assets.xcassets */, + AC69C6574E7CD3012188A930 /* Info.plist */, + 5EC1E076E2C5CDA3244BDD54 /* App */, + 00D1D5527B4D131DC5FC3825 /* Features */, + 67D9BE56502E37BEA9237F97 /* Models */, + 278F84A51E18DDAF379C696D /* Services */, + D34B57D98321BA8178A5C181 /* Support */, + DF9BA1D17F3BB6AADE450965 /* Views */, + ); + path = Cicada; + sourceTree = ""; + }; + 5EC1E076E2C5CDA3244BDD54 /* App */ = { + isa = PBXGroup; + children = ( + 2B910A3665B4DDA99878FF71 /* CicadaApp.swift */, + ); + path = App; + sourceTree = ""; + }; + 67D9BE56502E37BEA9237F97 /* Models */ = { + isa = PBXGroup; + children = ( + BCE82971270AEBC4A2CEAF2A /* ServerRecord.swift */, + ); + path = Models; + sourceTree = ""; + }; + 7FEC245E90F46072ACAF8FDA /* ServerSetup */ = { + isa = PBXGroup; + children = ( + 36EF13F40A064872555EB02B /* ServerSetupStore.swift */, + C9A15DAAA9D87410AC3E70B9 /* ServerSetupView.swift */, + ); + path = ServerSetup; + sourceTree = ""; + }; + BDE7D3869486DF739938B1BE = { + isa = PBXGroup; + children = ( + 57FBA0E7E939B674E1E61726 /* Cicada */, + 15ECD16330D6D168583775E6 /* Products */, + ); + sourceTree = ""; + }; + D34B57D98321BA8178A5C181 /* Support */ = { + isa = PBXGroup; + children = ( + 7CBA9673A23B3A3AF4CDB32B /* AppColors.swift */, + AB2F0A927788D9B8B22DC854 /* PlatformInfo.swift */, + ); + path = Support; + sourceTree = ""; + }; + DF9BA1D17F3BB6AADE450965 /* Views */ = { + isa = PBXGroup; + children = ( + 844DE39A003BD06B5E365B8B /* ContentView.swift */, + ); + path = Views; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 124777041E4254679B8F1E36 /* Cicada */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1EB0075F985E6F4A0505539B /* Build configuration list for PBXNativeTarget "Cicada" */; + buildPhases = ( + 4FDB8193BFC408D145666020 /* Sources */, + 617D4FF4DE7DB7A400E78EDB /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Cicada; + packageProductDependencies = ( + ); + productName = Cicada; + productReference = 9C663FB13EEF5FDD127BC458 /* Cicada.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 14800BBF7013A048425A1E92 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 124777041E4254679B8F1E36 = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 6D4CEC91F8F3195FAA393E5F /* Build configuration list for PBXProject "Cicada" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = BDE7D3869486DF739938B1BE; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 15ECD16330D6D168583775E6 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 124777041E4254679B8F1E36 /* Cicada */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 617D4FF4DE7DB7A400E78EDB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C949567AF84958831A724FB9 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 4FDB8193BFC408D145666020 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 540311FF630F605EB2D029B6 /* AppColors.swift in Sources */, + 0F984FDDD69B564E0230A4CB /* CicadaApp.swift in Sources */, + DA49924C26B41DAB98303DB6 /* ContentView.swift in Sources */, + 8FDB0E06C64B71D69AABD4B5 /* PlatformInfo.swift in Sources */, + D45A1192D9D8B8471640DC09 /* ServerMetadataClient.swift in Sources */, + BA878239D0B590108F8B4AA4 /* ServerRecord.swift in Sources */, + 9941AEA5E64BF322A1E85221 /* ServerSetupStore.swift in Sources */, + DAB7FB034762D499332DF78A /* ServerSetupView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 346532BEFD397AE80A237650 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = GCDGT9G53T; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Cicada/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.github.manyone.cicada.apple; + PRODUCT_NAME = Cicada; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 4896A955FB9B7A1D8D348FFB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = GCDGT9G53T; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Cicada/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.github.manyone.cicada.apple; + PRODUCT_NAME = Cicada; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 77F58339CAA6A99337CD5B4B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 0.1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 9CF469A5DB27A108769F2DE1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 0.1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1EB0075F985E6F4A0505539B /* Build configuration list for PBXNativeTarget "Cicada" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4896A955FB9B7A1D8D348FFB /* Debug */, + 346532BEFD397AE80A237650 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 6D4CEC91F8F3195FAA393E5F /* Build configuration list for PBXProject "Cicada" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 77F58339CAA6A99337CD5B4B /* Debug */, + 9CF469A5DB27A108769F2DE1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 14800BBF7013A048425A1E92 /* Project object */; +} diff --git a/apps/apple/Cicada.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/apple/Cicada.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/apps/apple/Cicada.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/apple/Cicada.xcodeproj/xcshareddata/xcschemes/Cicada.xcscheme b/apps/apple/Cicada.xcodeproj/xcshareddata/xcschemes/Cicada.xcscheme new file mode 100644 index 00000000..6bc4b587 --- /dev/null +++ b/apps/apple/Cicada.xcodeproj/xcshareddata/xcschemes/Cicada.xcscheme @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/apple/Cicada/App/CicadaApp.swift b/apps/apple/Cicada/App/CicadaApp.swift new file mode 100644 index 00000000..490a56a6 --- /dev/null +++ b/apps/apple/Cicada/App/CicadaApp.swift @@ -0,0 +1,31 @@ +import SwiftUI + +#if os(macOS) +private enum WindowMetrics { + static let minimumWidth: CGFloat = 360 + static let minimumHeight: CGFloat = 624 +} +#endif + +@main +struct CicadaApp: App { + var body: some Scene { + WindowGroup { + ContentView() + #if os(macOS) + .frame( + minWidth: WindowMetrics.minimumWidth, + minHeight: WindowMetrics.minimumHeight + ) + #endif + } + #if os(macOS) + .defaultSize( + width: WindowMetrics.minimumWidth, + height: WindowMetrics.minimumHeight + ) + .windowStyle(.hiddenTitleBar) + .windowResizability(.contentMinSize) + #endif + } +} diff --git a/apps/apple/Cicada/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/apple/Cicada/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..2fea086b --- /dev/null +++ b/apps/apple/Cicada/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.757", + "green" : "0.561", + "red" : "0.169" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/apple/Cicada/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/apple/Cicada/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..bd2bbdb3 --- /dev/null +++ b/apps/apple/Cicada/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/apple/Cicada/Assets.xcassets/Contents.json b/apps/apple/Cicada/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/apps/apple/Cicada/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/apple/Cicada/Features/ServerSetup/ServerSetupStore.swift b/apps/apple/Cicada/Features/ServerSetup/ServerSetupStore.swift new file mode 100644 index 00000000..0fff9539 --- /dev/null +++ b/apps/apple/Cicada/Features/ServerSetup/ServerSetupStore.swift @@ -0,0 +1,224 @@ +import Foundation + +@MainActor +final class ServerSetupStore: ObservableObject { + static let storageKey = "io.github.manyone.cicada.apple.serverSnapshot" + + @Published private(set) var savedServers: [ServerRecord] + @Published var selectedServerOrigin: String? + @Published var draftOrigin: String + @Published var isConnecting = false + @Published var errorMessage: String? + @Published var pendingDeletion: ServerRecord? + + private let client: ServerMetadataClient + private let storage: UserDefaults + + init( + client: ServerMetadataClient = .live, + storage: UserDefaults = .standard + ) { + self.client = client + self.storage = storage + + let snapshot = Self.loadSnapshot(from: storage) + let initialSelectedOrigin = snapshot.selectedServerOrigin + ?? snapshot.savedServers.first?.origin + + savedServers = snapshot.savedServers + selectedServerOrigin = initialSelectedOrigin + draftOrigin = initialSelectedOrigin ?? "" + } + + var selectedServer: ServerRecord? { + savedServers.first(where: { $0.origin == selectedServerOrigin }) + } + + func select(_ server: ServerRecord) { + selectedServerOrigin = server.origin + draftOrigin = server.origin + persist() + } + + func connectDraftOrigin() async { + guard !isConnecting else { return } + + do { + let normalizedOrigin = try normalizeOrigin(from: draftOrigin) + draftOrigin = normalizedOrigin + + if let existingServer = savedServers.first(where: { $0.origin == normalizedOrigin }) { + select(existingServer) + return + } + + isConnecting = true + defer { isConnecting = false } + + let metadata = try await client.fetchMetadata(normalizedOrigin) + let record = ServerRecord( + version: metadata.version, + hostname: metadata.hostname, + origin: normalizedOrigin, + users: [], + selectedUserID: nil + ) + + savedServers.insert(record, at: 0) + selectedServerOrigin = record.origin + draftOrigin = record.origin + persist() + } catch { + errorMessage = presentableMessage(for: error) + } + } + + func prepareToDelete(_ server: ServerRecord) { + pendingDeletion = server + } + + func deletePendingServer() { + guard let pendingDeletion else { return } + + savedServers.removeAll(where: { $0.origin == pendingDeletion.origin }) + if selectedServerOrigin == pendingDeletion.origin { + selectedServerOrigin = savedServers.first?.origin + } + if draftOrigin == pendingDeletion.origin { + draftOrigin = selectedServerOrigin ?? "" + } + self.pendingDeletion = nil + persist() + } + + func dismissError() { + errorMessage = nil + } + + static var preview: ServerSetupStore { + let suiteName = "preview.server.setup" + guard let defaults = UserDefaults(suiteName: suiteName) else { + return ServerSetupStore(client: .preview) + } + + defaults.removePersistentDomain(forName: suiteName) + + let snapshot = ServerSnapshot( + savedServers: [ + ServerRecord( + version: "0.24.1", + hostname: "studio.cicada.local", + origin: "https://studio.cicada.local", + users: [], + selectedUserID: nil + ), + ServerRecord( + version: "0.23.8", + hostname: "archive.cicada.local", + origin: "https://archive.cicada.local", + users: [], + selectedUserID: nil + ), + ], + selectedServerOrigin: "https://studio.cicada.local" + ) + + if let data = try? JSONEncoder().encode(snapshot) { + defaults.set(data, forKey: storageKey) + } + + return ServerSetupStore(client: .preview, storage: defaults) + } + + private func normalizeOrigin(from rawValue: String) throws -> String { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw ServerSetupError.emptyAddress + } + + let candidate = trimmed.contains("://") ? trimmed : "https://\(trimmed)" + guard var components = URLComponents(string: candidate) else { + throw ServerSetupError.invalidAddress + } + + guard + let scheme = components.scheme?.lowercased(), + ["http", "https"].contains(scheme), + let host = components.host?.lowercased(), + !host.isEmpty + else { + throw ServerSetupError.invalidAddress + } + + if components.user != nil || components.password != nil { + throw ServerSetupError.invalidAddress + } + + if !components.percentEncodedPath.isEmpty, components.percentEncodedPath != "/" { + throw ServerSetupError.pathNotSupported + } + + if components.query != nil || components.fragment != nil { + throw ServerSetupError.invalidAddress + } + + components.scheme = scheme + components.host = host + components.percentEncodedPath = "" + + guard let normalizedURL = components.url else { + throw ServerSetupError.invalidAddress + } + + return normalizedURL.absoluteString + } + + private func persist() { + let snapshot = ServerSnapshot( + savedServers: savedServers, + selectedServerOrigin: selectedServerOrigin + ) + + guard let data = try? JSONEncoder().encode(snapshot) else { + return + } + storage.set(data, forKey: Self.storageKey) + } + + private static func loadSnapshot(from storage: UserDefaults) -> ServerSnapshot { + guard + let data = storage.data(forKey: storageKey), + let snapshot = try? JSONDecoder().decode(ServerSnapshot.self, from: data) + else { + return ServerSnapshot(savedServers: [], selectedServerOrigin: nil) + } + return snapshot + } + + private func presentableMessage(for error: Error) -> String { + if let localizedError = error as? LocalizedError, + let description = localizedError.errorDescription, + !description.isEmpty { + return description + } + + return error.localizedDescription + } +} + +enum ServerSetupError: LocalizedError { + case emptyAddress + case invalidAddress + case pathNotSupported + + var errorDescription: String? { + switch self { + case .emptyAddress: + return "Enter a server address first." + case .invalidAddress: + return "Use a valid server origin such as https://music.example.com." + case .pathNotSupported: + return "Enter only the server origin. Paths like /base are not supported here." + } + } +} diff --git a/apps/apple/Cicada/Features/ServerSetup/ServerSetupView.swift b/apps/apple/Cicada/Features/ServerSetup/ServerSetupView.swift new file mode 100644 index 00000000..ffddbdbb --- /dev/null +++ b/apps/apple/Cicada/Features/ServerSetup/ServerSetupView.swift @@ -0,0 +1,256 @@ +import SwiftUI + +struct ServerSetupView: View { + @ObservedObject var store: ServerSetupStore + @FocusState private var originFieldFocused: Bool + + var body: some View { + GeometryReader { geometry in + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if !store.savedServers.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("Saved Servers") + .font(.headline) + + VStack(spacing: 12) { + ForEach(store.savedServers) { server in + savedServerRow(for: server) + } + } + } + + HStack(spacing: 12) { + Divider() + Text("OR") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Divider() + } + } + + VStack(alignment: .leading, spacing: 12) { + Text(store.savedServers.isEmpty ? "Add Server" : "New Server") + .font(.headline) + + Card { + VStack(alignment: .leading, spacing: 14) { + Text("Origin") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + TextField( + "https://music.example.com", + text: $store.draftOrigin + ) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .autocorrectionDisabled() + #if os(iOS) + .keyboardType(.URL) + #endif + .font(.system(.body, design: .monospaced)) + .focused($originFieldFocused) + .onSubmit { + Task { + await store.connectDraftOrigin() + } + } + + Text("Enter only the server origin.") + .font(.footnote) + .foregroundStyle(.secondary) + + HStack(alignment: .center, spacing: 12) { + Button { + Task { + await store.connectDraftOrigin() + } + } label: { + if store.isConnecting { + Label("Checking…", systemImage: "ellipsis.circle") + } else { + Label("Add Server", systemImage: "plus.circle.fill") + } + } + .buttonStyle(.borderedProminent) + .disabled(store.draftOrigin.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || store.isConnecting) + + if let selectedServer = store.selectedServer { + Text("Selected: \(selectedServer.hostname)") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + } + } + } + .padding(.horizontal, 20) + .padding(.top, contentTopPadding) + .padding(.bottom, 24) + .frame(maxWidth: 460, alignment: .leading) + .frame( + maxWidth: .infinity, + minHeight: geometry.size.height, + alignment: store.savedServers.isEmpty ? .center : .top + ) + } + .background(backgroundGradient) + } + #if os(iOS) + .navigationTitle("Add Server") + #endif + .alert( + "Unable to Add Server", + isPresented: Binding( + get: { store.errorMessage != nil }, + set: { isPresented in + if !isPresented { + store.dismissError() + } + } + ) + ) { + Button("OK", role: .cancel) { + store.dismissError() + } + } message: { + Text(store.errorMessage ?? "") + } + .confirmationDialog( + "Remove Server?", + isPresented: Binding( + get: { store.pendingDeletion != nil }, + set: { isPresented in + if !isPresented { + store.pendingDeletion = nil + } + } + ), + titleVisibility: .visible + ) { + Button("Delete Server", role: .destructive) { + store.deletePendingServer() + } + Button("Cancel", role: .cancel) { + store.pendingDeletion = nil + } + } message: { + Text(store.pendingDeletion?.origin ?? "") + } + .onAppear { + originFieldFocused = store.savedServers.isEmpty + } + } + + private var contentTopPadding: CGFloat { + #if os(macOS) + store.savedServers.isEmpty ? 24 : 56 + #else + 24 + #endif + } + + @ViewBuilder + private func savedServerRow(for server: ServerRecord) -> some View { + ServerRow( + server: server, + isSelected: store.selectedServerOrigin == server.origin, + onSelect: { + store.select(server) + }, + onDelete: { + store.prepareToDelete(server) + } + ) + } + + private var backgroundGradient: some View { + LinearGradient( + colors: [ + Color.accentColor.opacity(0.06), + .cicadaBackground, + .cicadaBackground, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + } +} + +private struct Card: View { + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + content + } + .padding(18) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.cicadaSecondaryBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06)) + ) + } +} + +private struct ServerRow: View { + let server: ServerRecord + let isSelected: Bool + let onSelect: () -> Void + let onDelete: () -> Void + + var body: some View { + Card { + HStack(alignment: .top, spacing: 14) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "network") + .font(.title3) + .foregroundStyle(isSelected ? .green : Color.accentColor) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(server.hostname) + .font(.headline) + Spacer() + Text(server.version) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + } + + Text(server.origin) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + + Menu { + Button("Remove Server", role: .destructive, action: onDelete) + } label: { + Image(systemName: "ellipsis.circle") + .font(.title3) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .contentShape(Rectangle()) + .onTapGesture(perform: onSelect) + .contextMenu { + Button("Remove Server", role: .destructive, action: onDelete) + } + } +} + +#Preview { + NavigationStack { + ServerSetupView(store: .preview) + } +} diff --git a/apps/apple/Cicada/Info.plist b/apps/apple/Cicada/Info.plist new file mode 100644 index 00000000..fae23f2d --- /dev/null +++ b/apps/apple/Cicada/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.music + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/apps/apple/Cicada/Models/ServerRecord.swift b/apps/apple/Cicada/Models/ServerRecord.swift new file mode 100644 index 00000000..cf691286 --- /dev/null +++ b/apps/apple/Cicada/Models/ServerRecord.swift @@ -0,0 +1,48 @@ +import Foundation + +struct ServerUserRecord: Codable, Hashable, Identifiable { + let id: String + var username: String + var avatar: String + var nickname: String + var joinTimestamp: TimeInterval + var admin: Bool + var musicbillOrders: [String] + var musicbillMaxAmount: Int + var createMusicMaxAmountPerDay: Int + var musicPlayRecordIndate: Int + var twoFAEnabled: Bool + var token: String +} + +struct ServerRecord: Codable, Hashable, Identifiable { + let version: String + let hostname: String + let origin: String + var users: [ServerUserRecord] + var selectedUserID: String? + + var id: String { origin } + + var userCountLabel: String { + switch users.count { + case 1: + return "1 saved user" + default: + return "\(users.count) saved users" + } + } + + enum CodingKeys: String, CodingKey { + case version + case hostname + case origin + case users + case selectedUserID = "selectedUserId" + } +} + +struct ServerSnapshot: Codable { + var savedServers: [ServerRecord] + var selectedServerOrigin: String? +} diff --git a/apps/apple/Cicada/Services/ServerMetadataClient.swift b/apps/apple/Cicada/Services/ServerMetadataClient.swift new file mode 100644 index 00000000..92099648 --- /dev/null +++ b/apps/apple/Cicada/Services/ServerMetadataClient.swift @@ -0,0 +1,96 @@ +import Foundation + +struct ServerMetadata: Decodable { + let version: String + let hostname: String +} + +struct ServerMetadataClient { + var fetchMetadata: @Sendable (_ origin: String) async throws -> ServerMetadata + + static let live = ServerMetadataClient { origin in + let metadataURL = try metadataURL(for: origin) + var request = URLRequest(url: metadataURL) + request.timeoutInterval = 10 + request.cachePolicy = .reloadIgnoringLocalCacheData + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw ServerMetadataClientError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + throw ServerMetadataClientError.httpStatus(httpResponse.statusCode) + } + + let envelope = try JSONDecoder().decode(MetadataEnvelope.self, from: data) + guard envelope.code == "success" else { + throw ServerMetadataClientError.serverMessage( + envelope.message ?? envelope.code + ) + } + guard let metadata = envelope.data else { + throw ServerMetadataClientError.missingPayload + } + return metadata + } + + static let preview = ServerMetadataClient { _ in + ServerMetadata(version: "preview", hostname: "demo.cicada.local") + } + + private static func metadataURL(for origin: String) throws -> URL { + guard let baseURL = URL(string: origin) else { + throw ServerMetadataClientError.invalidResponse + } + + var components = URLComponents( + url: baseURL.appending(path: "/base/metadata"), + resolvingAgainstBaseURL: false + ) + components?.queryItems = [ + URLQueryItem(name: "version", value: appVersion), + URLQueryItem(name: "language", value: preferredLanguage), + ] + + guard let url = components?.url else { + throw ServerMetadataClientError.invalidResponse + } + return url + } + + private static var appVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + ?? "apple" + } + + private static var preferredLanguage: String { + Locale.preferredLanguages.first ?? Locale.current.identifier + } +} + +private struct MetadataEnvelope: Decodable { + let code: String + let message: String? + let data: ServerMetadata? +} + +enum ServerMetadataClientError: LocalizedError { + case invalidResponse + case httpStatus(Int) + case serverMessage(String) + case missingPayload + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "The server returned an invalid response." + case .httpStatus(let statusCode): + return "The server responded with HTTP \(statusCode)." + case .serverMessage(let message): + return message + case .missingPayload: + return "The server metadata response was empty." + } + } +} diff --git a/apps/apple/Cicada/Support/AppColors.swift b/apps/apple/Cicada/Support/AppColors.swift new file mode 100644 index 00000000..403445bb --- /dev/null +++ b/apps/apple/Cicada/Support/AppColors.swift @@ -0,0 +1,25 @@ +import SwiftUI + +#if os(macOS) +import AppKit +#else +import UIKit +#endif + +extension Color { + static var cicadaBackground: Color { + #if os(macOS) + Color(nsColor: .windowBackgroundColor) + #else + Color(uiColor: .systemBackground) + #endif + } + + static var cicadaSecondaryBackground: Color { + #if os(macOS) + Color(nsColor: .controlBackgroundColor) + #else + Color(uiColor: .secondarySystemBackground) + #endif + } +} diff --git a/apps/apple/Cicada/Support/PlatformInfo.swift b/apps/apple/Cicada/Support/PlatformInfo.swift new file mode 100644 index 00000000..eae39ebe --- /dev/null +++ b/apps/apple/Cicada/Support/PlatformInfo.swift @@ -0,0 +1,41 @@ +import Foundation + +#if os(iOS) +import UIKit +#endif + +enum PlatformInfo { + static var currentDisplayName: String { + #if os(macOS) + return "macOS" + #elseif os(iOS) + switch UIDevice.current.userInterfaceIdiom { + case .pad: + return "iPadOS" + case .phone: + return "iOS" + default: + return "iOS" + } + #else + return "Apple" + #endif + } + + static var currentDescription: String { + #if os(macOS) + return "The native Mac experience can grow into menu commands, multiple windows, and keyboard-first navigation." + #elseif os(iOS) + switch UIDevice.current.userInterfaceIdiom { + case .pad: + return "The same target can expand into wider split views, richer sidebars, and drag-and-drop workflows on iPad." + case .phone: + return "On iPhone the shared target collapses into a compact navigation flow while keeping the same domain model." + default: + return "This shared Apple target is ready for more device-specific polish when the product surface grows." + } + #else + return "This shared Apple target is ready for more device-specific polish when the product surface grows." + #endif + } +} diff --git a/apps/apple/Cicada/Views/ContentView.swift b/apps/apple/Cicada/Views/ContentView.swift new file mode 100644 index 00000000..6d948630 --- /dev/null +++ b/apps/apple/Cicada/Views/ContentView.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct ContentView: View { + @StateObject private var store = ServerSetupStore() + + var body: some View { + NavigationStack { + ServerSetupView(store: store) + } + } +} + +#Preview { + ContentView() +} diff --git a/apps/apple/project.yml b/apps/apple/project.yml new file mode 100644 index 00000000..3cec2d0e --- /dev/null +++ b/apps/apple/project.yml @@ -0,0 +1,65 @@ +name: Cicada +options: + minimumXcodeGenVersion: 2.45.4 + bundleIdPrefix: io.github.manyone.cicada + createIntermediateGroups: true + developmentLanguage: en +settings: + base: + MARKETING_VERSION: 0.1.0 + CURRENT_PROJECT_VERSION: 1 + IPHONEOS_DEPLOYMENT_TARGET: "17.0" + MACOSX_DEPLOYMENT_TARGET: "14.0" +targets: + Cicada: + type: application + supportedDestinations: + - iOS + - macOS + sources: + - path: Cicada + info: + path: Cicada/Info.plist + properties: + LSApplicationCategoryType: public.app-category.music + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: true + UIApplicationSupportsIndirectInputEvents: true + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + settings: + base: + PRODUCT_NAME: Cicada + PRODUCT_BUNDLE_IDENTIFIER: io.github.manyone.cicada.apple + SWIFT_VERSION: 6.0 + GENERATE_INFOPLIST_FILE: NO + CODE_SIGN_STYLE: Automatic + TARGETED_DEVICE_FAMILY: "1,2" + SUPPORTS_MACCATALYST: NO + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + ENABLE_PREVIEWS: YES + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks" + LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]: "$(inherited) @executable_path/../Frameworks" +schemes: + Cicada: + build: + targets: + Cicada: all + run: + config: Debug + analyze: + config: Debug + archive: + config: Release + profile: + config: Release diff --git a/apps/apple/readme.md b/apps/apple/readme.md new file mode 100644 index 00000000..4d91fa14 --- /dev/null +++ b/apps/apple/readme.md @@ -0,0 +1,43 @@ +# Apple + +SwiftUI multiplatform client scaffold for `cicada`. + +## Requirements + +- xCode + +## Targets + +- `iOS` +- `iPadOS` via the shared `iOS` destination and `TARGETED_DEVICE_FAMILY=1,2` +- `macOS` + +## Generate Project + +```sh +cd apps/apple +xcodegen generate +``` + +This spec generates [Cicada.xcodeproj](/Users/slave/project/cicada/apps/apple/Cicada.xcodeproj). + +## Open Project + +```sh +open apps/apple/Cicada.xcodeproj +``` + +## Build From CLI + +If `xcode-select -p` still points at `/Library/Developer/CommandLineTools`, run `xcodebuild` with an explicit developer directory: + +```sh +cd apps/apple +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -project Cicada.xcodeproj -scheme Cicada -destination 'generic/platform=macOS' build +``` + +## Notes + +- The shared app target lives in [project.yml](/Users/slave/project/cicada/apps/apple/project.yml). +- Runtime platform differences are handled in [PlatformInfo.swift](/Users/slave/project/cicada/apps/apple/Cicada/Support/PlatformInfo.swift). +- Verified with `xcodebuild` for `generic/platform=macOS` and `generic/platform=iOS`. From 7ce58d53e2efa6031e8fe27d8c3e0ac9080c981e Mon Sep 17 00:00:00 2001 From: anyone Date: Wed, 22 Apr 2026 19:31:12 +0800 Subject: [PATCH 09/18] update docs --- Makefile | 47 ++++++++++--------- {docs/development => apps}/cli/database.d2 | 0 .../cli/index.md => apps/cli/readme.md | 16 +++++-- docs/development/index.md | 5 +- docs/development/pwa/index.md | 23 --------- docs/ui_designment/index.md | 2 +- scripts/build_version.mjs | 8 +++- 7 files changed, 49 insertions(+), 52 deletions(-) rename {docs/development => apps}/cli/database.d2 (100%) rename docs/development/cli/index.md => apps/cli/readme.md (90%) delete mode 100644 docs/development/pwa/index.md diff --git a/Makefile b/Makefile index b303a6a3..e6f20f85 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := release -VERSION := $(or $(strip $(CICADA_VERSION)),$(shell node scripts/build_version.mjs 2>/dev/null || echo unknown)) +VERSION := $(shell node scripts/build_version.mjs latest-tag 2>/dev/null || echo unknown) ROOT_DIR := $(CURDIR) BUILD_DIR := $(ROOT_DIR)/build CLI_DIR := $(ROOT_DIR)/apps/cli @@ -22,26 +22,31 @@ pwa: release: pwa rm -rf $(BUILD_DIR) mkdir -p $(BUILD_DIR) - $(call build_cli,darwin,arm64,$(BUILD_DIR)/cicada-darwin-arm64) - $(call build_cli,darwin,amd64,$(BUILD_DIR)/cicada-darwin-amd64) - $(call build_cli,windows,amd64,$(BUILD_DIR)/cicada-windows-amd64.exe) - $(call build_cli,windows,arm64,$(BUILD_DIR)/cicada-windows-arm64.exe) - $(call build_cli,linux,amd64,$(BUILD_DIR)/cicada-linux-amd64) - $(call build_cli,linux,arm64,$(BUILD_DIR)/cicada-linux-arm64) - cd $(BUILD_DIR) && \ - tar -zcf cicada-macos-arm-$(VERSION).tar.gz cicada-darwin-arm64 && \ - tar -zcf cicada-macos-x64-$(VERSION).tar.gz cicada-darwin-amd64 && \ - tar -zcf cicada-windows-x64-$(VERSION).tar.gz cicada-windows-amd64.exe && \ - tar -zcf cicada-windows-arm-$(VERSION).tar.gz cicada-windows-arm64.exe && \ - tar -zcf cicada-linux-x64-$(VERSION).tar.gz cicada-linux-amd64 && \ - tar -zcf cicada-linux-arm-$(VERSION).tar.gz cicada-linux-arm64 - rm \ - $(BUILD_DIR)/cicada-darwin-arm64 \ - $(BUILD_DIR)/cicada-darwin-amd64 \ - $(BUILD_DIR)/cicada-windows-amd64.exe \ - $(BUILD_DIR)/cicada-windows-arm64.exe \ - $(BUILD_DIR)/cicada-linux-amd64 \ - $(BUILD_DIR)/cicada-linux-arm64 + mkdir -p $(BUILD_DIR)/darwin-arm64 + $(call build_cli,darwin,arm64,$(BUILD_DIR)/darwin-arm64/cicada) + cd $(BUILD_DIR)/darwin-arm64 && tar -zcf ../cicada-$(VERSION)-darwin-arm64.tar.gz cicada + mkdir -p $(BUILD_DIR)/darwin-amd64 + $(call build_cli,darwin,amd64,$(BUILD_DIR)/darwin-amd64/cicada) + cd $(BUILD_DIR)/darwin-amd64 && tar -zcf ../cicada-$(VERSION)-darwin-amd64.tar.gz cicada + mkdir -p $(BUILD_DIR)/windows-amd64 + $(call build_cli,windows,amd64,$(BUILD_DIR)/windows-amd64/cicada.exe) + cd $(BUILD_DIR)/windows-amd64 && tar -zcf ../cicada-$(VERSION)-windows-amd64.tar.gz cicada.exe + mkdir -p $(BUILD_DIR)/windows-arm64 + $(call build_cli,windows,arm64,$(BUILD_DIR)/windows-arm64/cicada.exe) + cd $(BUILD_DIR)/windows-arm64 && tar -zcf ../cicada-$(VERSION)-windows-arm64.tar.gz cicada.exe + mkdir -p $(BUILD_DIR)/linux-amd64 + $(call build_cli,linux,amd64,$(BUILD_DIR)/linux-amd64/cicada) + cd $(BUILD_DIR)/linux-amd64 && tar -zcf ../cicada-$(VERSION)-linux-amd64.tar.gz cicada + mkdir -p $(BUILD_DIR)/linux-arm64 + $(call build_cli,linux,arm64,$(BUILD_DIR)/linux-arm64/cicada) + cd $(BUILD_DIR)/linux-arm64 && tar -zcf ../cicada-$(VERSION)-linux-arm64.tar.gz cicada + rm -rf \ + $(BUILD_DIR)/darwin-arm64 \ + $(BUILD_DIR)/darwin-amd64 \ + $(BUILD_DIR)/windows-amd64 \ + $(BUILD_DIR)/windows-arm64 \ + $(BUILD_DIR)/linux-amd64 \ + $(BUILD_DIR)/linux-arm64 ## 构建 Linux x64 二进制 (供 Docker 使用, 不压缩) docker: pwa diff --git a/docs/development/cli/database.d2 b/apps/cli/database.d2 similarity index 100% rename from docs/development/cli/database.d2 rename to apps/cli/database.d2 diff --git a/docs/development/cli/index.md b/apps/cli/readme.md similarity index 90% rename from docs/development/cli/index.md rename to apps/cli/readme.md index c61b049e..8514186c 100644 --- a/docs/development/cli/index.md +++ b/apps/cli/readme.md @@ -1,5 +1,16 @@ # CLI +Currently, `cicada` can be run on: + +- AMD64 + - Linux + - macOS + - Windows +- ARM64 + - Linux + - macOS + - Windows + ## Requirement - [Go](https://go.dev) environment @@ -44,7 +55,6 @@ d2 docs/development/database.d2 local_dir/database.svg In development, `cicada` uses [air](https://github.com/air-verse/air) to start dev server. First, you need to install it. ```sh -cd apps/cli go install github.com/air-verse/air@latest ``` @@ -62,9 +72,7 @@ CICADA_DATA=/path_to/data air `/path_to/data` means the directory of the data, you should replace to yours. -> attention: you should run `air` under the `apps/cli` - -And the server will listen on `:8000`. +And the server can be visited on `http://localhost:8000`. ## API Reference diff --git a/docs/development/index.md b/docs/development/index.md index 174c79eb..6712c850 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -2,8 +2,9 @@ Cicada is monorepo which has multiple apps: -- apps/cli: CLI tool for serving data and managing data, powered by `go`. See the [development docs](./cli/index.md). -- apps/pwa: Client for browser, powered by `react`/`typescript`. See the [development docs](./pwa/index.md). +- apps/cli: CLI tool for serving data and managing data, powered by `go`. See the [development docs](../../apps/cli/readme.md). +- apps/pwa: Client for browser, powered by `react`/`typescript`. See the [development docs](../../apps/pwa/readme.md). +- apps/apple: Client for iOS/iPadOS/macOS, powered by `swift`. See the [development docs](../../apps/apple/readme.md). ## Nouns diff --git a/docs/development/pwa/index.md b/docs/development/pwa/index.md deleted file mode 100644 index 6571cb91..00000000 --- a/docs/development/pwa/index.md +++ /dev/null @@ -1,23 +0,0 @@ -# PWA - -## Requirement - -- [Node.js](https://nodejs.org) environment - -## Start DEV Server - -```sh -cd apps/pwa -npm install -npm run dev -``` - -And the `pwa` can be visited on `http://localhost:8001`. - -## Components Docs - -`cicada` has own components. Visit its docs on `http://localhost:6006` by running: - -```sh -npm run storybook -``` \ No newline at end of file diff --git a/docs/ui_designment/index.md b/docs/ui_designment/index.md index bd9d8ba1..07172bd5 100644 --- a/docs/ui_designment/index.md +++ b/docs/ui_designment/index.md @@ -1,3 +1,3 @@ # UI Designment -`cicada` prefers comic style and currently mostly likes `duolingo`. \ No newline at end of file +`cicada` prefers comic styles and currently mostly likes `duolingo`. \ No newline at end of file diff --git a/scripts/build_version.mjs b/scripts/build_version.mjs index 26363fe0..b183b312 100644 --- a/scripts/build_version.mjs +++ b/scripts/build_version.mjs @@ -95,5 +95,11 @@ export function resolveVersion(options = {}) { } if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || '')) { - process.stdout.write(`${resolveVersion()}\n`); + const mode = process.argv[2]; + + if (mode === 'latest-tag') { + process.stdout.write(`${getLatestTag()}\n`); + } else { + process.stdout.write(`${resolveVersion()}\n`); + } } From 164dcd23f9ca6de0d8935ed70cd0ac42d584a04d Mon Sep 17 00:00:00 2001 From: anyone Date: Wed, 22 Apr 2026 23:11:55 +0800 Subject: [PATCH 10/18] add ffmpeg/ffprobe to cli --- .gitignore | 2 + Makefile | 39 +- apps/cli/.air.toml | 4 +- apps/cli/cmd/start.go | 16 +- apps/cli/internal/ffmpeg/ffmpeg.go | 143 +++++ apps/cli/readme.md | 43 +- apps/pwa/src/pages/login/first_step/index.tsx | 10 +- .../pages/login/first_step/manage_content.tsx | 236 +++++++++ .../pages/login/first_step/manage_drawer.tsx | 107 +--- .../pages/login/first_step/server_list.tsx | 30 +- apps/pwa/src/pages/login/index.tsx | 10 +- apps/pwa/src/pages/login/manage_page.tsx | 106 ++++ scripts/ensure_ffmpeg_bundle.mjs | 76 +++ scripts/prepare_ffmpeg_bundle.mjs | 492 ++++++++++++++++++ 14 files changed, 1185 insertions(+), 129 deletions(-) create mode 100644 apps/cli/internal/ffmpeg/ffmpeg.go create mode 100644 apps/pwa/src/pages/login/first_step/manage_content.tsx create mode 100644 apps/pwa/src/pages/login/manage_page.tsx create mode 100644 scripts/ensure_ffmpeg_bundle.mjs create mode 100644 scripts/prepare_ffmpeg_bundle.mjs diff --git a/.gitignore b/.gitignore index 22bf2186..1a156e17 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ tmp/ build/ +apps/cli/internal/ffmpeg/generated/ +apps/cli/internal/ffmpeg/zz_bundle_*.go # claude .claude/ diff --git a/Makefile b/Makefile index e6f20f85..75f03c6f 100644 --- a/Makefile +++ b/Makefile @@ -4,12 +4,17 @@ VERSION := $(shell node scripts/build_version.mjs latest-tag 2>/dev/null || ec ROOT_DIR := $(CURDIR) BUILD_DIR := $(ROOT_DIR)/build CLI_DIR := $(ROOT_DIR)/apps/cli +FFMPEG_VERSION ?= unknown define build_cli cd $(CLI_DIR) && CGO_ENABLED=0 GOOS=$(1) GOARCH=$(2) go build -tags prod -ldflags "-X cicada/internal/version.Version=$(VERSION)" -o $(3) . endef -.PHONY: pwa release docker clean +define stage_ffmpeg_bundle + node scripts/prepare_ffmpeg_bundle.mjs --target $(1) --version "$(FFMPEG_VERSION)" $(if $($(2)),--archive "$($(2))") $(if $($(3)),--sha256 "$($(3))") +endef + +.PHONY: pwa release docker clean ffmpeg-bundles ffmpeg-bundle-darwin-arm64 ffmpeg-bundle-darwin-amd64 ffmpeg-bundle-windows-amd64 ffmpeg-bundle-windows-arm64 ffmpeg-bundle-linux-amd64 ffmpeg-bundle-linux-arm64 ## 构建 PWA 并嵌入 CLI pwa: @@ -18,8 +23,34 @@ pwa: rm -rf $(CLI_DIR)/pwa/dist cp -R apps/pwa/dist $(CLI_DIR)/pwa/dist +ffmpeg-bundle-darwin-arm64: + $(call stage_ffmpeg_bundle,darwin-arm64,FFMPEG_ARCHIVE_DARWIN_ARM64,FFMPEG_SHA256_DARWIN_ARM64) + +ffmpeg-bundle-darwin-amd64: + $(call stage_ffmpeg_bundle,darwin-amd64,FFMPEG_ARCHIVE_DARWIN_AMD64,FFMPEG_SHA256_DARWIN_AMD64) + +ffmpeg-bundle-windows-amd64: + $(call stage_ffmpeg_bundle,windows-amd64,FFMPEG_ARCHIVE_WINDOWS_AMD64,FFMPEG_SHA256_WINDOWS_AMD64) + +ffmpeg-bundle-windows-arm64: + $(call stage_ffmpeg_bundle,windows-arm64,FFMPEG_ARCHIVE_WINDOWS_ARM64,FFMPEG_SHA256_WINDOWS_ARM64) + +ffmpeg-bundle-linux-amd64: + $(call stage_ffmpeg_bundle,linux-amd64,FFMPEG_ARCHIVE_LINUX_AMD64,FFMPEG_SHA256_LINUX_AMD64) + +ffmpeg-bundle-linux-arm64: + $(call stage_ffmpeg_bundle,linux-arm64,FFMPEG_ARCHIVE_LINUX_ARM64,FFMPEG_SHA256_LINUX_ARM64) + +ffmpeg-bundles: \ + ffmpeg-bundle-darwin-arm64 \ + ffmpeg-bundle-darwin-amd64 \ + ffmpeg-bundle-windows-amd64 \ + ffmpeg-bundle-windows-arm64 \ + ffmpeg-bundle-linux-amd64 \ + ffmpeg-bundle-linux-arm64 + ## 全平台构建发布包 (默认目标) -release: pwa +release: pwa ffmpeg-bundles rm -rf $(BUILD_DIR) mkdir -p $(BUILD_DIR) mkdir -p $(BUILD_DIR)/darwin-arm64 @@ -49,7 +80,7 @@ release: pwa $(BUILD_DIR)/linux-arm64 ## 构建 Linux x64 二进制 (供 Docker 使用, 不压缩) -docker: pwa +docker: pwa ffmpeg-bundle-linux-amd64 rm -rf $(BUILD_DIR) mkdir -p $(BUILD_DIR) $(call build_cli,linux,amd64,$(BUILD_DIR)/cicada) @@ -57,4 +88,4 @@ docker: pwa ## 清理构建产物 clean: - rm -rf $(BUILD_DIR) apps/cli/pwa/dist apps/pwa/dist + rm -rf $(BUILD_DIR) apps/cli/pwa/dist apps/pwa/dist apps/cli/internal/ffmpeg/generated apps/cli/internal/ffmpeg/zz_bundle_*.go diff --git a/apps/cli/.air.toml b/apps/cli/.air.toml index 1cf419c2..5ab56dfb 100644 --- a/apps/cli/.air.toml +++ b/apps/cli/.air.toml @@ -2,9 +2,9 @@ # Run with: CICADA_DATA=.dev-data air [build] + pre_cmd = ["node ../../scripts/ensure_ffmpeg_bundle.mjs"] cmd = "go build -o ./tmp/cicada ." - bin = "./tmp/cicada" - args_bin = ["start", "--port", "8000"] + entrypoint = ["./tmp/cicada", "start", "--port", "8000"] include_ext = ["go"] exclude_dir = ["tmp", ".dev-data"] delay = 500 diff --git a/apps/cli/cmd/start.go b/apps/cli/cmd/start.go index 82abac4b..01adb255 100644 --- a/apps/cli/cmd/start.go +++ b/apps/cli/cmd/start.go @@ -2,6 +2,7 @@ package cmd import ( "cicada/internal/config" + "cicada/internal/ffmpeg" "cicada/internal/scheduler" "cicada/internal/server" "cicada/internal/store" @@ -43,17 +44,24 @@ func runStart(cmd *cobra.Command, args []string) error { } config.Set(cfg) + if err := store.Initialize(); err != nil { + return fmt.Errorf("initialize: %w", err) + } + + paths, err := ffmpeg.PrepareEmbeddedTools() + if err != nil { + return fmt.Errorf("prepare embedded ffmpeg tools: %w", err) + } + fmt.Println("---") fmt.Printf("data: %s\n", cfg.Data) fmt.Printf("mode: %s\n", cfg.Mode) fmt.Printf("port: %d\n", cfg.Port) fmt.Printf("jwtExpiry: %s\n", formatExpiry(cfg.JWTExpiry)) + fmt.Printf("ffmpegPath: %s\n", paths.FFmpeg) + fmt.Printf("ffprobePath: %s\n", paths.FFprobe) fmt.Println("---") - if err := store.Initialize(); err != nil { - return fmt.Errorf("initialize: %w", err) - } - scheduler.Start() r := server.NewServer() diff --git a/apps/cli/internal/ffmpeg/ffmpeg.go b/apps/cli/internal/ffmpeg/ffmpeg.go new file mode 100644 index 00000000..51018297 --- /dev/null +++ b/apps/cli/internal/ffmpeg/ffmpeg.go @@ -0,0 +1,143 @@ +package ffmpeg + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "sync" +) + +type embeddedBundle struct { + target string + version string + ffmpegName string + ffprobeName string + ffmpegData []byte + ffprobeData []byte +} + +type Paths struct { + FFmpeg string + FFprobe string +} + +var ( + bundle embeddedBundle + prepareOnce sync.Once + prepared Paths + prepareErr error +) + +func registerEmbeddedBundle(b embeddedBundle) { + bundle = b +} + +func PrepareEmbeddedTools() (Paths, error) { + prepareOnce.Do(func() { + prepared, prepareErr = preparePaths() + }) + return prepared, prepareErr +} + +func HasEmbeddedTools() bool { + return len(bundle.ffmpegData) > 0 && len(bundle.ffprobeData) > 0 +} + +func preparePaths() (Paths, error) { + if !HasEmbeddedTools() { + return Paths{}, errors.New("embedded ffmpeg/ffprobe not available") + } + return extractEmbeddedTools() +} + +func extractEmbeddedTools() (Paths, error) { + target := bundle.target + if target == "" { + target = currentTarget() + } + + cacheRoot, err := embeddedCacheRoot() + if err != nil { + return Paths{}, err + } + + dir := filepath.Join(cacheRoot, target+"-"+bundleDigest()) + if err := os.MkdirAll(dir, 0755); err != nil { + return Paths{}, fmt.Errorf("create ffmpeg cache dir: %w", err) + } + + ffmpegPath := filepath.Join(dir, bundle.ffmpegName) + if err := writeExecutable(ffmpegPath, bundle.ffmpegData); err != nil { + return Paths{}, fmt.Errorf("write ffmpeg: %w", err) + } + + ffprobePath := filepath.Join(dir, bundle.ffprobeName) + if err := writeExecutable(ffprobePath, bundle.ffprobeData); err != nil { + return Paths{}, fmt.Errorf("write ffprobe: %w", err) + } + + return Paths{ + FFmpeg: ffmpegPath, + FFprobe: ffprobePath, + }, nil +} + +func embeddedCacheRoot() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("resolve user cache dir: %w", err) + } + return filepath.Join(dir, "cicada", "ffmpeg"), nil +} + +func writeExecutable(path string, data []byte) error { + current, err := os.ReadFile(path) + if err == nil && bytesEqual(current, data) { + return setExecutableBit(path) + } + if err := os.WriteFile(path, data, 0755); err != nil { + return err + } + return setExecutableBit(path) +} + +func setExecutableBit(path string) error { + if runtime.GOOS == "windows" { + return nil + } + return os.Chmod(path, 0755) +} + +func bundleDigest() string { + sum := sha256.New() + _, _ = sum.Write(bundle.ffmpegData) + _, _ = sum.Write(bundle.ffprobeData) + if bundle.version != "" { + _, _ = sum.Write([]byte(bundle.version)) + } + digest := hex.EncodeToString(sum.Sum(nil)) + if len(digest) > 12 { + return digest[:12] + } + return digest +} + +func currentTarget() string { + return runtime.GOOS + "-" + runtime.GOARCH +} + +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/apps/cli/readme.md b/apps/cli/readme.md index 8514186c..cb776bc0 100644 --- a/apps/cli/readme.md +++ b/apps/cli/readme.md @@ -13,7 +13,42 @@ Currently, `cicada` can be run on: ## Requirement -- [Go](https://go.dev) environment +- Go environment +- Node.js environment + +## Embedded ffmpeg Bundle + +Production builds can embed `ffmpeg` and `ffprobe` directly into the CLI binary. + +By default, `make release` now downloads upstream binaries automatically for every target: + +```sh +make release +``` + +Current default providers are: + +- `darwin/amd64`: Evermeet release ZIP endpoints +- `darwin/arm64`: osxexperts Apple Silicon builds +- `linux/*`, `windows/*`: BtbN latest GPL archives + +You can still override the download source per target: + +```sh +FFMPEG_ARCHIVE_LINUX_AMD64=/path/to/custom-linux-amd64.tar.xz make ffmpeg-bundle-linux-amd64 +``` + +For targets that distribute `ffmpeg` and `ffprobe` separately, use: + +```sh +FFMPEG_FFMPEG_SOURCE_DARWIN_ARM64=/path/to/ffmpeg.zip \ +FFMPEG_FFPROBE_SOURCE_DARWIN_ARM64=/path/to/ffprobe.zip \ +make ffmpeg-bundle-darwin-arm64 +``` + +Optional `FFMPEG_SHA256_` variables can be used to verify custom archive overrides before embedding. + +Embedded `ffmpeg` and `ffprobe` are extracted at runtime into the OS user cache directory returned by `os.UserCacheDir()`, not `CICADA_DATA/cache`. ## Data structure @@ -26,7 +61,7 @@ All of `cicada` data is under a directory, here is its structure: |- music_cover |- singer_avatar |- user_avatar -|- cache +|- cache # app runtime cache under data, cleaned up periodically |- logs |- trash # save removed data temporarily |- v # its content indicates version of data @@ -72,6 +107,8 @@ CICADA_DATA=/path_to/data air `/path_to/data` means the directory of the data, you should replace to yours. +On the first local `air` run, the current host platform bundle is prepared automatically before build. For example, Apple Silicon macOS resolves to `darwin-arm64`. + And the server can be visited on `http://localhost:8000`. ## API Reference @@ -80,4 +117,4 @@ After starting dev server, the API reference can be visited on `http://localhost ## Rules -- Alter database must also update [database.d2](./database.d2) \ No newline at end of file +- Alter database must also update [database.d2](./database.d2) diff --git a/apps/pwa/src/pages/login/first_step/index.tsx b/apps/pwa/src/pages/login/first_step/index.tsx index 704842ec..56b06a76 100644 --- a/apps/pwa/src/pages/login/first_step/index.tsx +++ b/apps/pwa/src/pages/login/first_step/index.tsx @@ -24,7 +24,13 @@ const Style = styled.div` } `; -function FirstStep({ toNext }: { toNext: () => void }) { +function FirstStep({ + toNext, + onManage, +}: { + toNext: () => void; + onManage: () => void; +}) { const [loading, setLoading] = useState(false); const [origin, setOrigin] = useState( () => useServer.getState().selectedServerOrigin || window.location.origin, @@ -80,7 +86,7 @@ function FirstStep({ toNext }: { toNext: () => void }) {
- + .name { + font-family: ${FONT}; + font-size: 15px; + font-weight: 700; + color: rgb(50 50 50); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + > .origin { + font-family: ${FONT}; + font-size: 12px; + font-weight: 600; + color: rgb(155 155 155); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 2px; + } + + > .user-avatars { + margin-top: 6px; + } +`; + +const DeleteButton = styled.button` + width: 36px; + height: 36px; + border: none; + border-radius: 10px; + background: rgb(255 240 240); + color: #f25042; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: background 120ms; + + &:hover { + background: rgb(255 220 220); + } + + &:active { + background: rgb(255 200 200); + } + + > svg { + font-size: 18px; + } +`; + +const MAX_AVATARS = 4; + +const AvatarStack = styled.div` + display: flex; + align-items: center; +`; + +const avatarBase = ` + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid #fff; + flex-shrink: 0; + + &:not(:first-child) { + margin-left: -7px; + } +`; + +const UserAvatarImg = styled.img` + ${avatarBase} + object-fit: cover; +`; + +const UserAvatarFallback = styled.div` + ${avatarBase} + background: rgb(44 182 125); + color: #fff; + font-family: ${FONT}; + font-size: 9px; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + text-transform: uppercase; +`; + +const OverflowBadge = styled.div` + ${avatarBase} + background: rgb(230 230 230); + color: rgb(110 110 110); + font-family: ${FONT}; + font-size: 9px; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; +`; + +function UserAvatar({ user }: { user: User }) { + if (user.avatar) { + return ( + + ); + } + return ( + + {user.nickname[0]} + + ); +} + +function UserAvatars({ users }: { users: User[] }) { + if (!users.length) { + return null; + } + const shown = users.slice(0, MAX_AVATARS); + const overflow = users.length - MAX_AVATARS; + return ( + + {shown.map((u) => ( + + ))} + {overflow > 0 && +{overflow}} + + ); +} + +function getInitials(hostname: string) { + const parts = hostname.split(/[\s\-._ ]+/).filter(Boolean); + if (parts.length >= 2) { + return parts[0][0] + parts[1][0]; + } + return hostname.slice(0, 2); +} + +function ManageContent({ onEmpty }: { onEmpty?: () => void }) { + const { serverList } = useServer(); + + useEffect(() => { + if (!serverList.length && onEmpty) { + onEmpty(); + } + }, [onEmpty, serverList.length]); + + return ( + + {serverList.map((s) => ( + + {getInitials(s.hostname)} + +
{s.hostname}
+
{s.origin}
+
+ +
+
+ + dialog.confirm({ + content: t('delete_origin_question'), + onConfirm: () => + useServer.setState((server) => ({ + serverList: server.serverList.filter( + (is) => is.origin !== s.origin, + ), + })), + }) + } + > + + +
+ ))} +
+ ); +} + +export default ManageContent; diff --git a/apps/pwa/src/pages/login/first_step/manage_drawer.tsx b/apps/pwa/src/pages/login/first_step/manage_drawer.tsx index f2bbf60a..5dcf4d89 100644 --- a/apps/pwa/src/pages/login/first_step/manage_drawer.tsx +++ b/apps/pwa/src/pages/login/first_step/manage_drawer.tsx @@ -1,66 +1,6 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components_next'; -import { CSSVariable } from '@/global_style'; import { t } from '@/i18n'; -import scrollbar from '@/style/scrollbar'; -import { useEffect } from 'react'; -import styled from 'styled-components'; -import Button from '@/components_next/button'; -import { MdDeleteOutline } from 'react-icons/md'; -import ellipsis from '@/style/ellipsis'; -import upperCaseFirstLetter from '@/style/upper_case_first_letter'; -import dialog from '@/utils/dialog'; -import { useServer } from '@/global_states/server'; - -const Style = styled.div` - overflow: auto; - ${scrollbar} - - >.list { - > .server { - margin: 0 15px 20px 15px; - padding: 10px 15px; - - display: flex; - align-items: center; - gap: 10px; - - border-radius: ${CSSVariable.BORDER_RADIUS_NORMAL}; - background-color: ${CSSVariable.BACKGROUND_COLOR_LEVEL_ONE}; - - &:hover { - background-color: ${CSSVariable.BACKGROUND_COLOR_LEVEL_TWO}; - } - - > .info { - flex: 1; - min-width: 0; - - line-height: 1.5; - - > .name { - font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; - color: ${CSSVariable.TEXT_COLOR_PRIMARY}; - } - - > .origin { - font-size: ${CSSVariable.TEXT_SIZE_SMALL}; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - ${ellipsis} - } - - > .users { - font-size: ${CSSVariable.TEXT_SIZE_SMALL}; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - ${upperCaseFirstLetter} - } - } - - > .delete { - color: ${CSSVariable.COLOR_DANGEROUS}; - } - } - } -`; +import ManageContent from './manage_content'; function ManageDrawer({ open, @@ -69,54 +9,13 @@ function ManageDrawer({ open: boolean; onClose: () => void; }) { - const { serverList } = useServer(); - - useEffect(() => { - if (!serverList.length) { - onClose(); - } - }, [onClose, serverList.length]); - return ( !v && onClose()}> - + {t('manage_origins')} - + ); diff --git a/apps/pwa/src/pages/login/first_step/server_list.tsx b/apps/pwa/src/pages/login/first_step/server_list.tsx index c588e0f4..43436a24 100644 --- a/apps/pwa/src/pages/login/first_step/server_list.tsx +++ b/apps/pwa/src/pages/login/first_step/server_list.tsx @@ -1,11 +1,12 @@ import { CSSVariable } from '@/global_style'; import { t } from '@/i18n'; -import { useCallback, useState } from 'react'; +import { useState } from 'react'; import styled from 'styled-components'; import { Select } from '@/components_next'; import upperCaseFirstLetter from '@/style/upper_case_first_letter'; import ManageDrawer from './manage_drawer'; import { useServer } from '@/global_states/server'; +import { useTheme } from '@/global_states/theme'; const Style = styled.div` > .select-wrapper { @@ -74,21 +75,27 @@ const Addon = styled.button` opacity: 0.5; } `; -const getServerList = () => useServer.getState().serverList; function ServerList({ disabled, toNext, + onManage, }: { disabled: boolean; toNext: () => void; + onManage: () => void; }) { - const [serverList, setServerList] = useState(getServerList); + const { serverList } = useServer(); + const { miniMode } = useTheme(); const [manageDrawerOpen, setManageDrawerOpen] = useState(false); - const onManageDrawerClose = useCallback(() => { - setManageDrawerOpen(false); - return window.setTimeout(() => setServerList(getServerList), 1000); - }, []); + + const handleManage = () => { + if (miniMode) { + onManage(); + } else { + setManageDrawerOpen(true); + } + }; if (serverList.length) { return ( @@ -111,7 +118,7 @@ function ServerList({ setManageDrawerOpen(true)} + onClick={handleManage} > {t('manage')} @@ -122,7 +129,12 @@ function ServerList({
- + {!miniMode && ( + setManageDrawerOpen(false)} + /> + )} ); } diff --git a/apps/pwa/src/pages/login/index.tsx b/apps/pwa/src/pages/login/index.tsx index 3f4cf45b..f527122e 100644 --- a/apps/pwa/src/pages/login/index.tsx +++ b/apps/pwa/src/pages/login/index.tsx @@ -6,6 +6,7 @@ import FirstStep from './first_step'; import SecondStep from './second_step'; import { Step } from './constants'; import AppRegion from './app_region'; +import ManagePage from './manage_page'; const Style = styled(PageContainer)` overflow: hidden; @@ -21,6 +22,7 @@ const AnimatedDiv = styled(animated.div)` function Login() { const [step, setStep] = useState(Step.FIRST); + const [showManagePage, setShowManagePage] = useState(false); const transitions = useTransition(step, { from: { opacity: 0, transform: 'translate(-150%, -50%)' }, @@ -34,7 +36,10 @@ function Login() { case Step.FIRST: { return ( - setStep(Step.SECOND)} /> + setStep(Step.SECOND)} + onManage={() => setShowManagePage(true)} + /> ); } @@ -50,6 +55,9 @@ function Login() { } } })} + {showManagePage && ( + setShowManagePage(false)} /> + )} ); diff --git a/apps/pwa/src/pages/login/manage_page.tsx b/apps/pwa/src/pages/login/manage_page.tsx new file mode 100644 index 00000000..569dbc09 --- /dev/null +++ b/apps/pwa/src/pages/login/manage_page.tsx @@ -0,0 +1,106 @@ +import styled, { keyframes } from 'styled-components'; +import { MdArrowBack } from 'react-icons/md'; +import { t } from '@/i18n'; +import ManageContent from './first_step/manage_content'; + +const FONT = `'Nunito', 'Varela Round', system-ui, sans-serif`; + +const slideIn = keyframes` + from { transform: translateX(100%); } + to { transform: translateX(0); } +`; + +const Wrapper = styled.div` + position: absolute; + inset: 0; + z-index: 10; + background: rgb(248 248 248); + display: flex; + flex-direction: column; + animation: ${slideIn} 300ms cubic-bezier(0.16, 1, 0.3, 1); +`; + +const Header = styled.div` + flex-shrink: 0; + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + padding-top: max(14px, env(safe-area-inset-top, 14px)); + background: #fff; + border-bottom: 2px solid rgb(220 220 220); + box-shadow: 0 4px 0 rgb(210 210 210); +`; + +const BackButton = styled.button` + width: 40px; + height: 40px; + border: none; + border-radius: 12px; + background: rgb(240 240 240); + color: rgb(88 88 88); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: background 120ms, transform 120ms; + + &:hover { + background: rgb(228 228 228); + } + + &:active { + transform: scale(0.93); + } + + > svg { + font-size: 22px; + } +`; + +const Title = styled.h2` + margin: 0; + font-family: ${FONT}; + font-size: 20px; + font-weight: 800; + letter-spacing: 0.2px; + color: rgb(50 50 50); + line-height: 1.2; +`; + +const ScrollArea = styled.div` + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + + &::-webkit-scrollbar { + width: 4px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: rgb(210 210 210); + border-radius: 4px; + } +`; + +function ManagePage({ onClose }: { onClose: () => void }) { + return ( + +
+ + + + {t('manage_origins')} +
+ + + +
+ ); +} + +export default ManagePage; diff --git a/scripts/ensure_ffmpeg_bundle.mjs b/scripts/ensure_ffmpeg_bundle.mjs new file mode 100644 index 00000000..ffb9276d --- /dev/null +++ b/scripts/ensure_ffmpeg_bundle.mjs @@ -0,0 +1,76 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const FFMPEG_DIR = path.join(ROOT_DIR, 'apps', 'cli', 'internal', 'ffmpeg'); +const GENERATED_DIR = path.join(FFMPEG_DIR, 'generated'); +const PREPARE_SCRIPT = path.join(ROOT_DIR, 'scripts', 'prepare_ffmpeg_bundle.mjs'); + +const TARGETS = { + 'darwin-amd64': { ffmpeg: 'ffmpeg', ffprobe: 'ffprobe' }, + 'darwin-arm64': { ffmpeg: 'ffmpeg', ffprobe: 'ffprobe' }, + 'linux-amd64': { ffmpeg: 'ffmpeg', ffprobe: 'ffprobe' }, + 'linux-arm64': { ffmpeg: 'ffmpeg', ffprobe: 'ffprobe' }, + 'windows-amd64': { ffmpeg: 'ffmpeg.exe', ffprobe: 'ffprobe.exe' }, + 'windows-arm64': { ffmpeg: 'ffmpeg.exe', ffprobe: 'ffprobe.exe' }, +}; + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function currentTarget() { + const platformMap = { + darwin: 'darwin', + linux: 'linux', + win32: 'windows', + }; + const archMap = { + x64: 'amd64', + arm64: 'arm64', + }; + + const goos = platformMap[process.platform]; + const goarch = archMap[process.arch]; + if (!goos || !goarch) { + fail(`unsupported host platform: ${process.platform}/${process.arch}`); + } + + return `${goos}-${goarch}`; +} + +function bundleFiles(target) { + const targetInfo = TARGETS[target]; + if (!targetInfo) { + fail(`unsupported target: ${target}`); + } + + return { + ffmpeg: path.join(GENERATED_DIR, target, targetInfo.ffmpeg), + ffprobe: path.join(GENERATED_DIR, target, targetInfo.ffprobe), + bundleGo: path.join(FFMPEG_DIR, `zz_bundle_${target}.go`), + }; +} + +function hasBundle(target) { + const files = bundleFiles(target); + return fs.existsSync(files.ffmpeg) && fs.existsSync(files.ffprobe) && fs.existsSync(files.bundleGo); +} + +function main() { + const target = currentTarget(); + if (hasBundle(target)) { + process.stdout.write(`ffmpeg bundle ready for ${target}\n`); + return; + } + + execFileSync('node', [PREPARE_SCRIPT, '--target', target], { + cwd: ROOT_DIR, + stdio: 'inherit', + }); +} + +main(); diff --git a/scripts/prepare_ffmpeg_bundle.mjs b/scripts/prepare_ffmpeg_bundle.mjs new file mode 100644 index 00000000..bdf3b73d --- /dev/null +++ b/scripts/prepare_ffmpeg_bundle.mjs @@ -0,0 +1,492 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const TARGET_DIR = path.join(ROOT_DIR, 'apps', 'cli', 'internal', 'ffmpeg'); +const GENERATED_DIR = path.join(TARGET_DIR, 'generated'); +const OSX_EXPERTS_URL = 'https://www.osxexperts.net/'; +const EVERMEET_URL = 'https://evermeet.cx/ffmpeg/'; +const BTBN_RELEASE_BASE = 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/'; + +const TARGETS = { + 'darwin-amd64': { + goos: 'darwin', + goarch: 'amd64', + ffmpeg: 'ffmpeg', + ffprobe: 'ffprobe', + provider: 'evermeet', + }, + 'darwin-arm64': { + goos: 'darwin', + goarch: 'arm64', + ffmpeg: 'ffmpeg', + ffprobe: 'ffprobe', + provider: 'osxexperts', + }, + 'linux-amd64': { + goos: 'linux', + goarch: 'amd64', + ffmpeg: 'ffmpeg', + ffprobe: 'ffprobe', + provider: 'btbn', + archiveName: 'ffmpeg-master-latest-linux64-gpl.tar.xz', + }, + 'linux-arm64': { + goos: 'linux', + goarch: 'arm64', + ffmpeg: 'ffmpeg', + ffprobe: 'ffprobe', + provider: 'btbn', + archiveName: 'ffmpeg-master-latest-linuxarm64-gpl.tar.xz', + }, + 'windows-amd64': { + goos: 'windows', + goarch: 'amd64', + ffmpeg: 'ffmpeg.exe', + ffprobe: 'ffprobe.exe', + provider: 'btbn', + archiveName: 'ffmpeg-master-latest-win64-gpl.zip', + }, + 'windows-arm64': { + goos: 'windows', + goarch: 'arm64', + ffmpeg: 'ffmpeg.exe', + ffprobe: 'ffprobe.exe', + provider: 'btbn', + archiveName: 'ffmpeg-master-latest-winarm64-gpl.zip', + }, +}; + +function parseArgs(argv) { + const options = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith('--')) { + throw new Error(`unexpected argument: ${arg}`); + } + const key = arg.slice(2); + const value = argv[i + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`missing value for --${key}`); + } + options[key] = value; + i += 1; + } + return options; +} + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function isURL(value) { + return /^https?:\/\//i.test(value); +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function removeDir(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function sha256(filePath) { + const hash = createHash('sha256'); + hash.update(fs.readFileSync(filePath)); + return hash.digest('hex'); +} + +async function fetchText(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`request failed for ${url}: ${response.status} ${response.statusText}`); + } + return response.text(); +} + +async function downloadToFile(url, destination) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`download failed: ${response.status} ${response.statusText}`); + } + + const data = Buffer.from(await response.arrayBuffer()); + fs.writeFileSync(destination, data); +} + +function extractArchive(archivePath, destination) { + ensureDir(destination); + const lower = archivePath.toLowerCase(); + if (lower.endsWith('.zip')) { + execFileSync('unzip', ['-q', archivePath, '-d', destination], { stdio: 'inherit' }); + return; + } + if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) { + execFileSync('tar', ['-xzf', archivePath, '-C', destination], { stdio: 'inherit' }); + return; + } + if (lower.endsWith('.tar.xz') || lower.endsWith('.txz')) { + execFileSync('tar', ['-xJf', archivePath, '-C', destination], { stdio: 'inherit' }); + return; + } + throw new Error(`unsupported archive format: ${archivePath}`); +} + +function isArchive(filePath) { + const lower = filePath.toLowerCase(); + return ( + lower.endsWith('.zip') || + lower.endsWith('.tar.gz') || + lower.endsWith('.tgz') || + lower.endsWith('.tar.xz') || + lower.endsWith('.txz') + ); +} + +function walk(dir) { + const output = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + output.push(...walk(fullPath)); + continue; + } + output.push(fullPath); + } + return output; +} + +function findBinary(rootDir, name) { + const matches = walk(rootDir).filter((file) => path.basename(file).toLowerCase() === name.toLowerCase()); + if (matches.length === 0) { + throw new Error(`cannot find ${name} in extracted archive`); + } + if (matches.length > 1) { + matches.sort((a, b) => a.length - b.length); + } + return matches[0]; +} + +function envArchiveKey(target) { + return `FFMPEG_ARCHIVE_${target.toUpperCase().replace(/-/g, '_')}`; +} + +function envArchiveSHAKey(target) { + return `FFMPEG_SHA256_${target.toUpperCase().replace(/-/g, '_')}`; +} + +function envFFmpegSourceKey(target) { + return `FFMPEG_FFMPEG_SOURCE_${target.toUpperCase().replace(/-/g, '_')}`; +} + +function envFFprobeSourceKey(target) { + return `FFMPEG_FFPROBE_SOURCE_${target.toUpperCase().replace(/-/g, '_')}`; +} + +function normalizeSourceInput(value, fallbackName) { + if (!value) { + return null; + } + if (typeof value === 'string') { + const location = value.trim(); + return { + location, + fileName: deriveFileName(location, fallbackName), + }; + } + const location = value.location?.trim() || value.url?.trim() || value.path?.trim(); + if (!location) { + throw new Error('source location is required'); + } + return { + location, + fileName: value.fileName?.trim() || deriveFileName(location, fallbackName), + sha256: value.sha256?.trim() || '', + binarySha256: value.binarySha256?.trim() || '', + }; +} + +function deriveFileName(location, fallbackName) { + if (isURL(location)) { + const pathname = new URL(location).pathname; + const base = path.basename(pathname); + if (base && base !== '/' && base !== '.') { + return base; + } + } else { + const base = path.basename(location); + if (base && base !== '.' && base !== path.sep) { + return base; + } + } + if (!fallbackName) { + throw new Error(`cannot determine filename for ${location}`); + } + return fallbackName; +} + +async function materializeSource(source, tmpRoot, fallbackName) { + const resolved = normalizeSourceInput(source, fallbackName); + if (!resolved) { + throw new Error('missing source'); + } + const destination = path.join(tmpRoot, resolved.fileName); + if (isURL(resolved.location)) { + await downloadToFile(resolved.location, destination); + } else { + fs.copyFileSync(path.resolve(resolved.location), destination); + } + if (resolved.sha256) { + const actualSHA = sha256(destination); + if (actualSHA.toLowerCase() !== resolved.sha256.toLowerCase()) { + throw new Error(`sha256 mismatch for ${resolved.fileName}: expected ${resolved.sha256}, got ${actualSHA}`); + } + } + return destination; +} + +function parseChecksumFile(content, filename) { + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) { + continue; + } + const parts = line.split(/\s+/); + if (parts.length < 2) { + continue; + } + const hash = parts[0]; + const name = parts[parts.length - 1].replace(/^\*/, ''); + if (name === filename) { + return hash; + } + } + return ''; +} + +async function resolveBtbNSourcePlan(targetConfig) { + const checksumURL = new URL('checksums.sha256', BTBN_RELEASE_BASE).toString(); + const checksumFile = await fetchText(checksumURL); + const archiveName = targetConfig.archiveName; + const checksum = parseChecksumFile(checksumFile, archiveName); + return { + kind: 'archive', + version: 'btbn-latest', + archive: { + location: new URL(archiveName, BTBN_RELEASE_BASE).toString(), + fileName: archiveName, + sha256: checksum, + }, + }; +} + +async function resolveEvermeetSourcePlan(targetConfig) { + return { + kind: 'pair', + version: 'evermeet-release', + ffmpeg: { + location: new URL('getrelease/zip', EVERMEET_URL).toString(), + fileName: `${targetConfig.ffmpeg}.zip`, + }, + ffprobe: { + location: new URL('getrelease/ffprobe/zip', EVERMEET_URL).toString(), + fileName: `${targetConfig.ffprobe}.zip`, + }, + }; +} + +function parseOsxExpertsBinary(html, binaryName, label) { + const anchorPattern = new RegExp( + `]+href="([^"]+)"[^>]*>\\s*Download\\s+${binaryName}\\s+([^<]+?)\\s*\\(${label}\\)\\s*<\\/a>`, + 'i', + ); + const match = html.match(anchorPattern); + if (!match || typeof match.index !== 'number') { + throw new Error(`cannot find ${binaryName} (${label}) on ${OSX_EXPERTS_URL}`); + } + const window = html.slice(match.index, match.index + 800); + const checksumMatch = window.match(/SHA256 checksum of[^:]*:\s*([a-f0-9]{64})/i); + return { + version: match[2].trim(), + source: { + location: new URL(match[1], OSX_EXPERTS_URL).toString(), + fileName: path.basename(match[1]), + binarySha256: checksumMatch ? checksumMatch[1] : '', + }, + }; +} + +async function resolveOsxExpertsSourcePlan() { + const html = await fetchText(OSX_EXPERTS_URL); + const ffmpeg = parseOsxExpertsBinary(html, 'ffmpeg', 'Apple Silicon'); + const ffprobe = parseOsxExpertsBinary(html, 'ffprobe', 'Apple Silicon'); + return { + kind: 'pair', + version: `osxexperts-${ffmpeg.version}`, + ffmpeg: ffmpeg.source, + ffprobe: ffprobe.source, + }; +} + +async function resolveDefaultSourcePlan(target, targetConfig) { + switch (targetConfig.provider) { + case 'btbn': + return resolveBtbNSourcePlan(targetConfig); + case 'evermeet': + return resolveEvermeetSourcePlan(targetConfig); + case 'osxexperts': + return resolveOsxExpertsSourcePlan(); + default: + throw new Error(`no default source provider for ${target}`); + } +} + +async function resolveSourcePlan(target, targetConfig, options) { + const archiveOverride = options.archive?.trim() || process.env[envArchiveKey(target)]?.trim(); + if (archiveOverride) { + return { + kind: 'archive', + version: options.version?.trim() || process.env.FFMPEG_VERSION?.trim() || 'custom', + archive: { + location: archiveOverride, + fileName: deriveFileName(archiveOverride, `ffmpeg-${target}`), + sha256: options.sha256?.trim() || process.env[envArchiveSHAKey(target)]?.trim() || '', + }, + }; + } + + const ffmpegOverride = options['ffmpeg-source']?.trim() || process.env[envFFmpegSourceKey(target)]?.trim(); + const ffprobeOverride = options['ffprobe-source']?.trim() || process.env[envFFprobeSourceKey(target)]?.trim(); + if (ffmpegOverride || ffprobeOverride) { + if (!ffmpegOverride || !ffprobeOverride) { + throw new Error(`both ${envFFmpegSourceKey(target)} and ${envFFprobeSourceKey(target)} must be set together`); + } + return { + kind: 'pair', + version: options.version?.trim() || process.env.FFMPEG_VERSION?.trim() || 'custom', + ffmpeg: { + location: ffmpegOverride, + fileName: deriveFileName(ffmpegOverride, targetConfig.ffmpeg), + }, + ffprobe: { + location: ffprobeOverride, + fileName: deriveFileName(ffprobeOverride, targetConfig.ffprobe), + }, + }; + } + + const plan = await resolveDefaultSourcePlan(target, targetConfig); + if (options.version?.trim()) { + plan.version = options.version.trim(); + } else if (process.env.FFMPEG_VERSION?.trim()) { + plan.version = process.env.FFMPEG_VERSION.trim(); + } + return plan; +} + +function copyResolvedBinary(inputPath, source, binaryName, destinationDir) { + const resolvedSource = normalizeSourceInput(source, binaryName); + let binaryPath = inputPath; + if (isArchive(inputPath)) { + const extractedDir = path.join(destinationDir, `${binaryName}-extract`); + removeDir(extractedDir); + extractArchive(inputPath, extractedDir); + binaryPath = findBinary(extractedDir, binaryName); + } + if (resolvedSource.binarySha256) { + const actualSHA = sha256(binaryPath); + if (actualSHA.toLowerCase() !== resolvedSource.binarySha256.toLowerCase()) { + throw new Error(`sha256 mismatch for ${path.basename(binaryPath)}: expected ${resolvedSource.binarySha256}, got ${actualSHA}`); + } + } + return binaryPath; +} + +function generateGoFile(target, targetConfig, version) { + const safeTarget = target.replace(/-/g, '_'); + return `// Code generated by scripts/prepare_ffmpeg_bundle.mjs. DO NOT EDIT. +//go:build ${targetConfig.goos} && ${targetConfig.goarch} + +package ffmpeg + +import _ "embed" + +//go:embed generated/${target}/${targetConfig.ffmpeg} +var embeddedFFmpeg_${safeTarget} []byte + +//go:embed generated/${target}/${targetConfig.ffprobe} +var embeddedFFprobe_${safeTarget} []byte + +func init() { +\tregisterEmbeddedBundle(embeddedBundle{ +\t\ttarget: "${target}", +\t\tversion: "${version}", +\t\tffmpegName: "${targetConfig.ffmpeg}", +\t\tffprobeName: "${targetConfig.ffprobe}", +\t\tffmpegData: embeddedFFmpeg_${safeTarget}, +\t\tffprobeData: embeddedFFprobe_${safeTarget}, +\t}) +} +`; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const target = options.target?.trim(); + if (!target) { + fail('missing --target'); + } + const targetConfig = TARGETS[target]; + if (!targetConfig) { + fail(`unsupported target: ${target}`); + } + + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cicada-ffmpeg-')); + + try { + const plan = await resolveSourcePlan(target, targetConfig, options); + const version = plan.version || 'unknown'; + + let ffmpegPath; + let ffprobePath; + if (plan.kind === 'archive') { + const archivePath = await materializeSource(plan.archive, tmpRoot, plan.archive.fileName); + const extractedDir = path.join(tmpRoot, 'archive-extracted'); + extractArchive(archivePath, extractedDir); + ffmpegPath = findBinary(extractedDir, targetConfig.ffmpeg); + ffprobePath = findBinary(extractedDir, targetConfig.ffprobe); + } else { + const ffmpegInput = await materializeSource(plan.ffmpeg, tmpRoot, targetConfig.ffmpeg); + const ffprobeInput = await materializeSource(plan.ffprobe, tmpRoot, targetConfig.ffprobe); + ffmpegPath = copyResolvedBinary(ffmpegInput, plan.ffmpeg, targetConfig.ffmpeg, tmpRoot); + ffprobePath = copyResolvedBinary(ffprobeInput, plan.ffprobe, targetConfig.ffprobe, tmpRoot); + } + + const outputDir = path.join(GENERATED_DIR, target); + removeDir(outputDir); + ensureDir(outputDir); + + fs.copyFileSync(ffmpegPath, path.join(outputDir, targetConfig.ffmpeg)); + fs.copyFileSync(ffprobePath, path.join(outputDir, targetConfig.ffprobe)); + + if (targetConfig.goos !== 'windows') { + fs.chmodSync(path.join(outputDir, targetConfig.ffmpeg), 0o755); + fs.chmodSync(path.join(outputDir, targetConfig.ffprobe), 0o755); + } + + const goFilePath = path.join(TARGET_DIR, `zz_bundle_${target}.go`); + fs.writeFileSync(goFilePath, generateGoFile(target, targetConfig, version)); + + process.stdout.write(`prepared embedded ffmpeg bundle for ${target} (${version})\n`); + } finally { + removeDir(tmpRoot); + } +} + +main().catch((error) => fail(error instanceof Error ? error.message : String(error))); From b5435573c7735cb3a3daa2a77a7398a05d3d0dc6 Mon Sep 17 00:00:00 2001 From: anyone Date: Thu, 23 Apr 2026 10:21:13 +0800 Subject: [PATCH 11/18] close captcha after logining --- apps/pwa/src/pages/login/second_step/index.tsx | 4 ++-- apps/pwa/src/utils/dialog/captcha/index.tsx | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/pwa/src/pages/login/second_step/index.tsx b/apps/pwa/src/pages/login/second_step/index.tsx index cca151b0..9089fe74 100644 --- a/apps/pwa/src/pages/login/second_step/index.tsx +++ b/apps/pwa/src/pages/login/second_step/index.tsx @@ -95,7 +95,7 @@ function SecondStep({ toPrevious }: { toPrevious: () => void }) { try { const token = await loginWith2FA({ username, password, twoFAToken }); await addProfile(token); - window.setTimeout(redirect, 0); + redirect(); } catch (error) { logger.error(error, 'Failed to login with 2FA'); notice.error(error.message); @@ -116,7 +116,7 @@ function SecondStep({ toPrevious }: { toPrevious: () => void }) { captchaValue, }); await addProfile(token); - window.setTimeout(redirect, 0); + redirect(); } catch (error) { logger.error(error, 'Failed to login'); diff --git a/apps/pwa/src/utils/dialog/captcha/index.tsx b/apps/pwa/src/utils/dialog/captcha/index.tsx index fb2360fe..219130fe 100644 --- a/apps/pwa/src/utils/dialog/captcha/index.tsx +++ b/apps/pwa/src/utils/dialog/captcha/index.tsx @@ -67,6 +67,9 @@ function CaptchaContent({ reload(); } }) + .catch(() => { + reload(); + }) .finally(() => setConfirming(false)); }); From 21d17f08ebed6de6b461c8b5df6135d6e71d39c2 Mon Sep 17 00:00:00 2001 From: anyone Date: Thu, 23 Apr 2026 10:25:14 +0800 Subject: [PATCH 12/18] improve storybook --- apps/pwa/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/pwa/package.json b/apps/pwa/package.json index 8722b159..391bbe17 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -9,7 +9,7 @@ "dev-with-sw": "WITH_SW=true vite", "build": "vite build", "test": "node -e \"require('node:fs').rmSync('.test-dist', { recursive: true, force: true })\" && tsc -p tsconfig.test.json && cd .test-dist && node --test", - "storybook": "storybook dev -p 6006", + "storybook": "storybook dev -p 6006 --no-open", "build-storybook": "storybook build" }, "dependencies": { From 2dfe0863733eb74a64eda5180bebfd30c6bcd2a1 Mon Sep 17 00:00:00 2001 From: anyone Date: Thu, 23 Apr 2026 15:48:09 +0800 Subject: [PATCH 13/18] admin i18n --- apps/pwa/src/components/language_select.tsx | 63 +++++++++++++++++++ apps/pwa/src/pages/admin/index.tsx | 42 ++++++++++--- .../src/pages/login/first_step/language.tsx | 21 ++----- .../pages/player/pages/setting/language.tsx | 29 ++------- 4 files changed, 105 insertions(+), 50 deletions(-) create mode 100644 apps/pwa/src/components/language_select.tsx diff --git a/apps/pwa/src/components/language_select.tsx b/apps/pwa/src/components/language_select.tsx new file mode 100644 index 00000000..314e4925 --- /dev/null +++ b/apps/pwa/src/components/language_select.tsx @@ -0,0 +1,63 @@ +import { CSSProperties } from 'react'; +import { Select, SelectOption, SelectProps } from '@/components_next'; +import { useSetting } from '@/global_states/setting'; +import { LANGUAGE_MAP, t } from '@/i18n'; +import dialog from '@/utils/dialog'; +import { LANGUAGES, Language } from '#/constants'; + +const options: SelectOption[] = LANGUAGES.map((language) => ({ + label: LANGUAGE_MAP[language].label, + value: language, +})); + +function reloadAfterLanguageChange(language: Language) { + useSetting.setState({ language }); + window.setTimeout(() => window.location.reload(), 0); +} + +interface Props { + className?: string; + confirmBeforeReload?: boolean; + disabled?: boolean; + label?: string; + size?: SelectProps['size']; + style?: CSSProperties; +} + +function LanguageSelect({ + className, + confirmBeforeReload = false, + disabled = false, + label, + size, + style, +}: Props) { + const { language } = useSetting(); + + return ( + + className={className} + disabled={disabled} + label={label} + options={options} + size={size} + style={style} + value={language} + onChange={(value) => { + if (value === language) return; + + if (confirmBeforeReload) { + dialog.confirm({ + content: t('change_language_question'), + onConfirm: () => reloadAfterLanguageChange(value), + }); + return; + } + + reloadAfterLanguageChange(value); + }} + /> + ); +} + +export default LanguageSelect; diff --git a/apps/pwa/src/pages/admin/index.tsx b/apps/pwa/src/pages/admin/index.tsx index 53699191..57a29612 100644 --- a/apps/pwa/src/pages/admin/index.tsx +++ b/apps/pwa/src/pages/admin/index.tsx @@ -8,6 +8,7 @@ import { t } from '@/i18n'; import { CSSVariable } from '@/global_style'; import capitalize from '#/utils/capitalize'; import UserManage from '@/pages/player/pages/user_manage'; +import LanguageSelect from '@/components/language_select'; import MusicManagement from './music_management'; const enum Tab { @@ -31,11 +32,31 @@ const Header = styled.header` `; const HeaderTop = styled.div` - height: 56px; - padding: 0 28px; + min-height: 56px; + padding: 10px 28px; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +`; + +const HeaderInfo = styled.div` display: flex; align-items: center; gap: 12px; + min-width: 0; +`; + +const HeaderActions = styled.div` + margin-left: auto; + width: 176px; + min-width: 176px; + + @media (max-width: 640px) { + margin-left: 0; + width: 100%; + min-width: 0; + } `; const Brand = styled.div` @@ -133,12 +154,17 @@ function AdminPage() {
- - - {capitalize(t('cicada'))} - - - {capitalize(t('admin_panel'))} + + + + {capitalize(t('cicada'))} + + + {capitalize(t('admin_panel'))} + + + + [] = LANGUAGES.map((l) => ({ - label: LANGUAGE_MAP[l].label, - value: l, -})); +import LanguageSelect from '@/components/language_select'; +import { t } from '@/i18n'; function Wrapper({ disabled }: { disabled: boolean }) { - const { language } = useSetting(); return ( - + { - useSetting.setState({ language: value }); - return window.setTimeout(() => window.location.reload(), 0); - }} - options={languageOptions} disabled={disabled} + confirmBeforeReload={false} /> ); } diff --git a/apps/pwa/src/pages/player/pages/setting/language.tsx b/apps/pwa/src/pages/player/pages/setting/language.tsx index cc762fbc..76da79e0 100644 --- a/apps/pwa/src/pages/player/pages/setting/language.tsx +++ b/apps/pwa/src/pages/player/pages/setting/language.tsx @@ -1,39 +1,18 @@ import { memo, CSSProperties } from 'react'; -import { Select, SelectOption } from '@/components_next'; -import { LANGUAGE_MAP, t } from '@/i18n'; -import { Language } from '#/constants'; -import dialog from '@/utils/dialog'; +import { t } from '@/i18n'; import Item from './item'; import { itemStyle } from './constants'; -import { useSetting } from '@/global_states/setting'; +import LanguageSelect from '@/components/language_select'; -const LANGUAGES = Object.values(Language); const style: CSSProperties = { width: 200, }; -const options: SelectOption[] = LANGUAGES.map((l) => ({ - label: LANGUAGE_MAP[l].label, - value: l, -})); function Wrapper() { - const { language } = useSetting(); return ( - - value={language} - onChange={(value) => { - if (value !== language) { - dialog.confirm({ - content: t('change_language_question'), - onConfirm: () => { - useSetting.setState({ language: value }); - window.setTimeout(() => window.location.reload(), 0); - }, - }); - } - }} - options={options} + From 043ca71e85ea5dfcf89fbabfcf5ace96c7f7f1cb Mon Sep 17 00:00:00 2001 From: anyone Date: Thu, 23 Apr 2026 16:48:39 +0800 Subject: [PATCH 14/18] improve fist step of login --- .../divider/divider.stories.tsx | 48 ++++ .../pwa/src/components_next/divider/index.tsx | 50 ++++ apps/pwa/src/components_next/index.ts | 2 + apps/pwa/src/components_next/input/index.tsx | 2 + apps/pwa/src/components_next/select/index.tsx | 2 + .../components_next/server_card.stories.tsx | 111 +++++++++ apps/pwa/src/pages/login/first_step/index.tsx | 13 +- .../pages/login/first_step/server_card.tsx | 228 ++++++++++++++++++ .../pages/login/first_step/server_list.tsx | 172 +++++-------- apps/pwa/src/pages/login/index.tsx | 31 ++- .../src/pages/login/second_step/user_list.tsx | 30 +-- 11 files changed, 531 insertions(+), 158 deletions(-) create mode 100644 apps/pwa/src/components_next/divider/divider.stories.tsx create mode 100644 apps/pwa/src/components_next/divider/index.tsx create mode 100644 apps/pwa/src/components_next/server_card.stories.tsx create mode 100644 apps/pwa/src/pages/login/first_step/server_card.tsx diff --git a/apps/pwa/src/components_next/divider/divider.stories.tsx b/apps/pwa/src/components_next/divider/divider.stories.tsx new file mode 100644 index 00000000..176ebfc0 --- /dev/null +++ b/apps/pwa/src/components_next/divider/divider.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Divider from '.'; + +const meta = { + title: 'Basic/Divider', + component: Divider, + tags: ['autodocs'], + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Duolingo-style divider. Without a label renders a plain 2px horizontal rule. With a label renders an "OR"-style separator with the text centred between two lines.', + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + label: { + control: 'text', + description: 'Optional text shown between the two lines. Omit for a plain rule.', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Plain: Story = { + name: 'Plain', + args: {}, +}; + +export const WithLabel: Story = { + name: 'With label', + args: { label: 'or' }, +}; + +export const CustomLabel: Story = { + name: 'Custom label', + args: { label: 'continue with' }, +}; diff --git a/apps/pwa/src/components_next/divider/index.tsx b/apps/pwa/src/components_next/divider/index.tsx new file mode 100644 index 00000000..63f4932d --- /dev/null +++ b/apps/pwa/src/components_next/divider/index.tsx @@ -0,0 +1,50 @@ +import styled, { css } from 'styled-components'; + +const FONT = `'Nunito', 'Varela Round', system-ui, sans-serif`; + +const lineBase = css` + height: 2px; + background-color: rgb(230 230 230); + border-radius: 1px; +`; + +const Plain = styled.div` + ${lineBase} +`; + +const WithLabel = styled.div` + display: flex; + align-items: center; + gap: 12px; + + > .line { + ${lineBase} + flex: 1; + min-width: 0; + } + + > .label { + font-family: ${FONT}; + font-size: 12px; + font-weight: 800; + color: rgb(180 180 180); + text-transform: uppercase; + letter-spacing: 0.06em; + white-space: nowrap; + } +`; + +function Divider({ label }: { label?: string }) { + if (label) { + return ( + +
+ {label} +
+ + ); + } + return ; +} + +export default Divider; diff --git a/apps/pwa/src/components_next/index.ts b/apps/pwa/src/components_next/index.ts index 3b995d52..226fbce2 100644 --- a/apps/pwa/src/components_next/index.ts +++ b/apps/pwa/src/components_next/index.ts @@ -41,3 +41,5 @@ export type { DrawerProps, DrawerContentProps, DrawerSide } from './drawer'; export { ThemeProvider, useTheme, DEFAULT_THEME } from './theme'; export type { Theme, ThemeProviderProps } from './theme'; + +export { default as Divider } from './divider'; diff --git a/apps/pwa/src/components_next/input/index.tsx b/apps/pwa/src/components_next/input/index.tsx index caa1c6bd..3e0d7a22 100644 --- a/apps/pwa/src/components_next/input/index.tsx +++ b/apps/pwa/src/components_next/input/index.tsx @@ -6,6 +6,7 @@ import { } from 'react'; import styled, { css } from 'styled-components'; import { CSS_VAR } from '../theme'; +import upperCaseFirstLetter from '@/style/upper_case_first_letter'; export type InputSize = 'sm' | 'md' | 'lg'; @@ -38,6 +39,7 @@ const Label = styled.label` letter-spacing: 0.2px; color: rgb(66 66 66); user-select: none; + ${upperCaseFirstLetter} `; const Wrapper = styled.div<{ diff --git a/apps/pwa/src/components_next/select/index.tsx b/apps/pwa/src/components_next/select/index.tsx index b5330be9..c0ca738e 100644 --- a/apps/pwa/src/components_next/select/index.tsx +++ b/apps/pwa/src/components_next/select/index.tsx @@ -1,4 +1,5 @@ import { CSSProperties, useCallback, useId, useMemo } from 'react'; +import upperCaseFirstLetter from '@/style/upper_case_first_letter'; import ReactSelect, { type StylesConfig, type SingleValue, @@ -55,6 +56,7 @@ const LabelEl = styled.label` letter-spacing: 0.2px; color: rgb(66 66 66); user-select: none; + ${upperCaseFirstLetter} `; const Bottom = styled.p<{ $error: boolean }>` diff --git a/apps/pwa/src/components_next/server_card.stories.tsx b/apps/pwa/src/components_next/server_card.stories.tsx new file mode 100644 index 00000000..3fbe630b --- /dev/null +++ b/apps/pwa/src/components_next/server_card.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { type User } from '@/constants/server'; +import { ServerCardItem } from '@/pages/login/first_step/server_card'; + +const meta = { + title: 'Login/ServerCard', + component: ServerCardItem, + parameters: { + layout: 'centered', + backgrounds: { default: 'surface' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Mock data ──────────────────────────────────────────────────────────────── + +function mockUser(id: string, nickname: string): User { + return { + id, + nickname, + username: nickname.toLowerCase(), + avatar: '', + joinTimestamp: 0, + admin: false, + musicbillOrders: [], + musicbillMaxAmount: 100, + createMusicMaxAmountPerDay: 10, + musicPlayRecordIndate: 30, + twoFAEnabled: false, + token: 'mock-token', + }; +} + +const ALL_USERS = [ + mockUser('1', 'Alice'), + mockUser('2', 'Bob'), + mockUser('3', 'Carol'), + mockUser('4', 'Dave'), + mockUser('5', 'Eve'), + mockUser('6', 'Frank'), + mockUser('7', 'Grace'), + mockUser('8', 'Hank'), + mockUser('9', 'Ivy'), +]; + +const BASE_PROPS = { + hostname: 'My Music Server', + origin: 'https://music.example.com', + selectedUserId: '1', + onClick: () => {}, + onDelete: () => {}, +}; + +const CASES = [1, 3, 5, 8, 9] as const; + +// ─── Individual stories ─────────────────────────────────────────────────────── + +export const OneUser: Story = { + name: '1 user', + args: { ...BASE_PROPS, users: ALL_USERS.slice(0, 1) }, +}; + +export const ThreeUsers: Story = { + name: '3 users', + args: { ...BASE_PROPS, users: ALL_USERS.slice(0, 3) }, +}; + +export const FiveUsers: Story = { + name: '5 users', + args: { ...BASE_PROPS, users: ALL_USERS.slice(0, 5) }, +}; + +export const EightUsers: Story = { + name: '8 users', + args: { ...BASE_PROPS, users: ALL_USERS.slice(0, 8) }, +}; + +export const NineUsers: Story = { + name: '9 users (overflow)', + args: { ...BASE_PROPS, users: ALL_USERS.slice(0, 9) }, +}; + +// ─── All cases side by side ─────────────────────────────────────────────────── + +export const AllCases: Story = { + name: 'All cases (1/3/5/8/9 users)', + render: () => ( +
+ {CASES.map((n) => ( + 1 ? 's' : ''}`} + origin="https://music.example.com" + users={ALL_USERS.slice(0, n)} + selectedUserId="1" + onClick={() => {}} + onDelete={() => {}} + /> + ))} +
+ ), +}; diff --git a/apps/pwa/src/pages/login/first_step/index.tsx b/apps/pwa/src/pages/login/first_step/index.tsx index 56b06a76..3d419b9a 100644 --- a/apps/pwa/src/pages/login/first_step/index.tsx +++ b/apps/pwa/src/pages/login/first_step/index.tsx @@ -5,11 +5,11 @@ import Input from '@/components_next/input'; import logger from '@/utils/logger'; import Button from '@/components_next/button'; import { t } from '@/i18n'; -import { CSSVariable } from '@/global_style'; import Logo from '../logo'; import Language from './language'; import ServerList from './server_list'; import { useServer } from '@/global_states/server'; +import { Divider } from '@/components_next'; const Style = styled.div` display: flex; @@ -17,16 +17,11 @@ const Style = styled.div` gap: 20px; -webkit-app-region: no-drag; - - > .divider { - height: 1px; - background-color: ${CSSVariable.COLOR_BORDER}; - } `; function FirstStep({ toNext, - onManage, + onManage: _onManage, }: { toNext: () => void; onManage: () => void; @@ -85,8 +80,8 @@ function FirstStep({ - {!miniMode && ( - setManageDrawerOpen(false)} + if (!serverList.length) return null; + + return ( + + ); } export default ServerList; diff --git a/apps/pwa/src/pages/login/index.tsx b/apps/pwa/src/pages/login/index.tsx index f527122e..f5c6840f 100644 --- a/apps/pwa/src/pages/login/index.tsx +++ b/apps/pwa/src/pages/login/index.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { animated, useTransition } from 'react-spring'; import styled from 'styled-components'; import PageContainer from '@/components/page_container'; @@ -23,11 +23,32 @@ const AnimatedDiv = styled(animated.div)` function Login() { const [step, setStep] = useState(Step.FIRST); const [showManagePage, setShowManagePage] = useState(false); + const directionRef = useRef<1 | -1>(1); + + const toNext = () => { + directionRef.current = 1; + setStep(Step.SECOND); + }; + + const toPrevious = () => { + directionRef.current = -1; + setStep(Step.FIRST); + }; const transitions = useTransition(step, { - from: { opacity: 0, transform: 'translate(-150%, -50%)' }, + from: { + opacity: 0, + transform: directionRef.current === 1 + ? 'translate(50%, -50%)' + : 'translate(-150%, -50%)', + }, enter: { opacity: 1, transform: 'translate(-50%, -50%)' }, - leave: { opacity: 0, transform: 'translate(50%, -50%)' }, + leave: { + opacity: 0, + transform: directionRef.current === 1 + ? 'translate(-150%, -50%)' + : 'translate(50%, -50%)', + }, }); return ( - ); -} - -export default forwardRef(Label); diff --git a/apps/pwa/src/components_next/index.ts b/apps/pwa/src/components_next/index.ts index 226fbce2..cd3b05d1 100644 --- a/apps/pwa/src/components_next/index.ts +++ b/apps/pwa/src/components_next/index.ts @@ -4,6 +4,9 @@ export type { ButtonProps, Variant as ButtonVariant, Size as ButtonSize } from ' export { default as Input } from './input'; export type { InputProps, InputSize } from './input'; +export { default as Label } from './label'; +export type { LabelProps } from './label'; + export { default as Slider } from './slider'; export type { SliderProps, SliderEdge } from './slider'; diff --git a/apps/pwa/src/components_next/input/index.tsx b/apps/pwa/src/components_next/input/index.tsx index 3e0d7a22..b4a2520d 100644 --- a/apps/pwa/src/components_next/input/index.tsx +++ b/apps/pwa/src/components_next/input/index.tsx @@ -6,7 +6,7 @@ import { } from 'react'; import styled, { css } from 'styled-components'; import { CSS_VAR } from '../theme'; -import upperCaseFirstLetter from '@/style/upper_case_first_letter'; +import Label from '../label'; export type InputSize = 'sm' | 'md' | 'lg'; @@ -32,16 +32,6 @@ const Root = styled.div` width: 100%; `; -const Label = styled.label` - font-family: ${FONT}; - font-size: 14px; - font-weight: 700; - letter-spacing: 0.2px; - color: rgb(66 66 66); - user-select: none; - ${upperCaseFirstLetter} -`; - const Wrapper = styled.div<{ $size: InputSize; $error: boolean; diff --git a/apps/pwa/src/components_next/label/index.tsx b/apps/pwa/src/components_next/label/index.tsx new file mode 100644 index 00000000..3c170ab6 --- /dev/null +++ b/apps/pwa/src/components_next/label/index.tsx @@ -0,0 +1,68 @@ +import upperCaseFirstLetter from '@/style/upper_case_first_letter'; +import { + ForwardedRef, + LabelHTMLAttributes, + ReactNode, + forwardRef, +} from 'react'; +import styled from 'styled-components'; + +const FONT = `'Nunito', 'Varela Round', system-ui, sans-serif`; + +const Root = styled.label` + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + transition: inherit; + + > .top { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + transition: inherit; + user-select: none; + + &:empty { + display: none; + } + + > .text { + flex: 1; + min-width: 0; + font-family: ${FONT}; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.2px; + color: rgb(66 66 66); + ${upperCaseFirstLetter} + } + } +`; + +export interface LabelProps extends LabelHTMLAttributes { + label?: ReactNode; + addon?: ReactNode; +} + +function Label( + { label, children, addon, ...props }: LabelProps, + ref: ForwardedRef, +) { + const hasWrappedContent = label !== undefined || addon !== undefined; + const text = hasWrappedContent ? label : children; + const content = hasWrappedContent ? children : null; + + return ( + +
+ {text ? {text} : null} + {addon} +
+ {content} +
+ ); +} + +export default forwardRef(Label); diff --git a/apps/pwa/src/components_next/select/index.tsx b/apps/pwa/src/components_next/select/index.tsx index c0ca738e..4d65769c 100644 --- a/apps/pwa/src/components_next/select/index.tsx +++ b/apps/pwa/src/components_next/select/index.tsx @@ -1,5 +1,4 @@ import { CSSProperties, useCallback, useId, useMemo } from 'react'; -import upperCaseFirstLetter from '@/style/upper_case_first_letter'; import ReactSelect, { type StylesConfig, type SingleValue, @@ -9,6 +8,7 @@ import ReactSelect, { } from 'react-select'; import AsyncReactSelect from 'react-select/async'; import styled from 'styled-components'; +import Label from '../label'; import { useTheme } from '../theme'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -49,16 +49,6 @@ const Root = styled.div` width: 100%; `; -const LabelEl = styled.label` - font-family: ${FONT}; - font-size: 14px; - font-weight: 700; - letter-spacing: 0.2px; - color: rgb(66 66 66); - user-select: none; - ${upperCaseFirstLetter} -`; - const Bottom = styled.p<{ $error: boolean }>` margin: 0; font-family: ${FONT}; @@ -292,7 +282,7 @@ export function Select({ return ( - {label && {label}} + {label && } > inputId={inputId} options={options} @@ -363,7 +353,7 @@ export function MultiSelect({ return ( - {label && {label}} + {label && } {loadOptions ? ( , true> {...sharedProps} diff --git a/apps/pwa/src/pages/login/first_step/server_list.tsx b/apps/pwa/src/pages/login/first_step/server_list.tsx index 71d05ca3..e73be373 100644 --- a/apps/pwa/src/pages/login/first_step/server_list.tsx +++ b/apps/pwa/src/pages/login/first_step/server_list.tsx @@ -3,17 +3,17 @@ import { t } from '@/i18n'; import styled from 'styled-components'; import { useServer } from '@/global_states/server'; import dialog from '@/utils/dialog'; -import upperCaseFirstLetter from '@/style/upper_case_first_letter'; import { Divider } from '@/components_next'; import { FONT, ServerCardItem } from './server_card'; const Style = styled.div` > .label { font-family: ${FONT}; - font-size: ${CSSVariable.TEXT_SIZE_SMALL}; + font-size: 15px; font-weight: 700; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - ${upperCaseFirstLetter} + letter-spacing: 0.3px; + text-transform: capitalize; + color: ${CSSVariable.TEXT_COLOR_PRIMARY}; margin-bottom: 10px; } diff --git a/apps/pwa/src/pages/login/second_step/index.tsx b/apps/pwa/src/pages/login/second_step/index.tsx index 9089fe74..f689df01 100644 --- a/apps/pwa/src/pages/login/second_step/index.tsx +++ b/apps/pwa/src/pages/login/second_step/index.tsx @@ -18,7 +18,7 @@ import { ExceptionCode } from '#/constants/exception'; import dialog from '@/utils/dialog'; import Logo from '../logo'; import UserList from './user_list'; -import { useServer } from '@/global_states/server'; +import { getSelectedServer, useServer } from '@/global_states/server'; const Style = styled.div` display: flex; @@ -66,6 +66,8 @@ const addProfile = async (token: string) => { function SecondStep({ toPrevious }: { toPrevious: () => void }) { const location = useLocation(); const navigate = useNavigate(); + const selectedServer = useServer(getSelectedServer); + const hasExistingUser = !!selectedServer?.users.length; const [username, setUserName] = useState(''); const onUsernameChange: ChangeEventHandler = (event) => @@ -143,7 +145,7 @@ function SecondStep({ toPrevious }: { toPrevious: () => void }) { value={username} onChange={onUsernameChange} maxLength={USERNAME_MAX_LENGTH} - autoFocus + autoFocus={!hasExistingUser} /> void }) { > {t('login')} - + ); } diff --git a/apps/pwa/src/pages/login/second_step/user_list.tsx b/apps/pwa/src/pages/login/second_step/user_list.tsx index 345fce86..7aac302e 100644 --- a/apps/pwa/src/pages/login/second_step/user_list.tsx +++ b/apps/pwa/src/pages/login/second_step/user_list.tsx @@ -1,36 +1,186 @@ -import { Divider, Select } from '@/components_next'; +import { Divider } from '@/components_next'; +import { CSSVariable } from '@/global_style'; import { getSelectedServer, useServer } from '@/global_states/server'; import { t } from '@/i18n'; +import getResizedImage from '@/server/asset/get_resized_image'; import { useMemo } from 'react'; +import styled from 'styled-components'; + +const FONT = `'Nunito', 'Varela Round', system-ui, sans-serif`; + +const Style = styled.div` + > .label { + margin-bottom: 10px; + font-family: ${FONT}; + font-size: 15px; + font-weight: 700; + letter-spacing: 0.3px; + text-transform: capitalize; + color: ${CSSVariable.TEXT_COLOR_PRIMARY}; + } + + > .user-items { + display: flex; + justify-content: center; + gap: 12px; + width: 100%; + overflow-x: auto; + overflow-y: hidden; + padding-inline: 2px; + padding-bottom: 4px; + } + + > .divider { + margin-top: 20px; + } +`; + +const UserItem = styled.button` + appearance: none; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 10px; + width: 96px; + min-width: 96px; + padding: 14px 10px 12px; + border: 2px solid rgb(220 220 220); + border-radius: 18px; + background: #fff; + box-shadow: 0 4px 0 rgb(210 210 210); + cursor: pointer; + text-align: center; + -webkit-tap-highlight-color: transparent; + transition: + border-color 120ms, + box-shadow 80ms, + transform 80ms, + background 120ms; + + &:hover { + border-color: ${CSSVariable.COLOR_PRIMARY}; + box-shadow: 0 4px 0 rgb(30 150 100); + } + + &:hover > .avatar, + &:focus-visible > .avatar { + box-shadow: 0 0 0 2px ${CSSVariable.COLOR_PRIMARY}; + } + + &:active { + box-shadow: 0 1px 0 rgb(210 210 210); + transform: translateY(3px); + } + + &:focus-visible { + outline: 3px solid rgb(44 182 125 / 0.2); + outline-offset: 3px; + } + + > .avatar { + width: 56px; + height: 56px; + border-radius: 50%; + border: 2px solid #fff; + flex-shrink: 0; + overflow: hidden; + background: rgb(200 200 200); + color: #fff; + font-family: ${FONT}; + font-size: 16px; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + text-transform: uppercase; + box-shadow: 0 0 0 2px rgb(230 230 230); + transition: box-shadow 120ms; + + > img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + > .name { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: rgb(50 50 50); + font-family: ${FONT}; + font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; + font-weight: 800; + } +`; + +function getUserInitial(nickname: string, username: string) { + return (nickname || username || '?')[0]; +} function UserList({ redirect }: { redirect: () => void }) { - const userList = useMemo( - () => getSelectedServer(useServer.getState())?.users || [], - [], - ); + const selectedServer = useServer(getSelectedServer); + const userList = selectedServer?.users || []; + const selectedUserId = selectedServer?.selectedUserId; + const sortedUserList = useMemo(() => { + if (!selectedUserId) { + return userList; + } + + const selectedUser = userList.find((u) => u.id === selectedUserId); + if (!selectedUser) { + return userList; + } + + return [selectedUser].concat(userList.filter((u) => u.id !== selectedUserId)); + }, [selectedUserId, userList]); if (userList.length) { return ( - <> - - - - - - - - - ); -} - -export default CreateMusicDialog; diff --git a/apps/pwa/src/pages/player/pages/my_music/create_music_dialog/use_open.ts b/apps/pwa/src/pages/player/pages/my_music/create_music_dialog/use_open.ts deleted file mode 100644 index 93470080..00000000 --- a/apps/pwa/src/pages/player/pages/my_music/create_music_dialog/use_open.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useCallback } from 'react'; -import useQuery from '@/utils/use_query'; -import { Query } from '@/constants'; -import useNavigate from '@/utils/use_navigate'; - -export default () => { - const navigate = useNavigate(); - - const query = useQuery(); - const onClose = useCallback( - () => - navigate({ - query: { - [Query.CREATE_MUSIC_DIALOG_OPEN]: '', - }, - }), - [navigate], - ); - - return { open: !!query[Query.CREATE_MUSIC_DIALOG_OPEN], onClose }; -}; diff --git a/apps/pwa/src/pages/player/pages/my_music/eventemitter.ts b/apps/pwa/src/pages/player/pages/my_music/eventemitter.ts deleted file mode 100644 index e31d9be3..00000000 --- a/apps/pwa/src/pages/player/pages/my_music/eventemitter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import Eventin from 'eventin'; - -export enum EventType { - RELOAD_MUSIC_LIST = 'reload_music_list', -} - -export default new Eventin< - EventType, - { - [EventType.RELOAD_MUSIC_LIST]: null; - } ->(); diff --git a/apps/pwa/src/pages/player/pages/my_music/index.tsx b/apps/pwa/src/pages/player/pages/my_music/index.tsx deleted file mode 100644 index 6752dd61..00000000 --- a/apps/pwa/src/pages/player/pages/my_music/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import styled from 'styled-components'; -import Page from '../page'; -import Toolbar from './toolbar'; -import MusicList from './music_list'; - -const Style = styled(Page)` - display: flex; - flex-direction: column; -`; - -function MyMusic() { - return ( - - ); -} - -export default MyMusic; diff --git a/apps/pwa/src/pages/player/pages/my_music/music_list/index.tsx b/apps/pwa/src/pages/player/pages/my_music/music_list/index.tsx deleted file mode 100644 index 5adb6cbd..00000000 --- a/apps/pwa/src/pages/player/pages/my_music/music_list/index.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import styled from 'styled-components'; -import Spinner from '@/components/spinner'; -import { flexCenter } from '@/style/flexbox'; -import Empty from '@/components/empty'; -import Pagination from '@/components/pagination'; -import { CSSProperties, useCallback } from 'react'; -import ErrorCard from '@/components/error_card'; -import useNavigate from '@/utils/use_navigate'; -import { Query } from '@/constants'; -import { animated, useTransition } from 'react-spring'; -import absoluteFullSize from '@/style/absolute_full_size'; -import Button from '@/components_next/button'; -import autoScrollbar from '@/style/auto_scrollbar'; -import { t } from '@/i18n'; -import { HEADER_HEIGHT } from '../../../constants'; -import useMusicList from './use_music_list'; -import { PAGE_SIZE, TOOLBAR_HEIGHT } from '../constants'; -import Music from './music'; - -const Style = styled.div` - flex: 1; - min-height: 0; - - position: relative; -`; -const Container = styled(animated.div)` - ${absoluteFullSize} - - padding-top: ${HEADER_HEIGHT}px; -`; -const CardContainer = styled(Container)` - ${flexCenter} - - flex-direction: column; - gap: 20px; -`; -const MusicListContainer = styled(Container)` - padding-bottom: ${TOOLBAR_HEIGHT}px; - - overflow: auto; - ${autoScrollbar} -`; -const paginationStyle: CSSProperties = { - margin: '10px 0', -}; - -function MusicList() { - const navigate = useNavigate(); - - const onPageChange = useCallback( - (p: number) => - navigate({ - query: { - [Query.PAGE]: p, - }, - }), - [navigate], - ); - - const { page, data, reload } = useMusicList(); - - const transitions = useTransition(data, { - from: { opacity: 0 }, - enter: { opacity: 1 }, - leave: { opacity: 0 }, - }); - return ( - - ); -} - -export default MusicList; diff --git a/apps/pwa/src/pages/player/pages/my_music/music_list/music.tsx b/apps/pwa/src/pages/player/pages/my_music/music_list/music.tsx deleted file mode 100644 index d202e537..00000000 --- a/apps/pwa/src/pages/player/pages/my_music/music_list/music.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { CSSVariable } from '@/global_style'; -import day from '#/utils/day'; -import styled from 'styled-components'; -import { MdOutlineLocalFireDepartment } from 'react-icons/md'; -import { useContext } from 'react'; -import Music from '../../../components/music'; -import { Music as MusicType } from '../constants'; -import Context from '../../../context'; - -const Addon = styled.div` - padding: 5px 0 10px 0; - - border-top: 1px solid ${CSSVariable.BACKGROUND_COLOR_LEVEL_TWO}; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - font-size: ${CSSVariable.TEXT_SIZE_SMALL}; - font-family: monospace; - - display: flex; - align-items: center; - gap: 5px; -`; - -function MusicWithExternalInfo({ music }: { music: MusicType }) { - const { playqueue, currentPlayqueuePosition } = useContext(Context); - return ( - - -
{music.heat}
-
|
-
{day(music.createTimestamp).format('YYYY-MM-DD')}
- - } - /> - ); -} - -export default MusicWithExternalInfo; diff --git a/apps/pwa/src/pages/player/pages/my_music/music_list/use_music_list.ts b/apps/pwa/src/pages/player/pages/my_music/music_list/use_music_list.ts deleted file mode 100644 index f9fcc7e6..00000000 --- a/apps/pwa/src/pages/player/pages/my_music/music_list/use_music_list.ts +++ /dev/null @@ -1,96 +0,0 @@ -import logger from '@/utils/logger'; -import { Query } from '@/constants'; -import getMusicList from '@/server/api/get_music_list'; -import useQuery from '@/utils/use_query'; -import { useCallback, useEffect, useState } from 'react'; -import { PAGE_SIZE, Music } from '../constants'; -import em, { EventType } from '../eventemitter'; -import playerEventemitter, { - EventType as PlayerEventType, -} from '../../../eventemitter'; - -interface Data { - error: Error | null; - loading: boolean; - value: { - musicList: Music[]; - total: number; - } | null; -} -const dataLoading: Data = { - error: null, - loading: true, - value: null, -}; - -export default () => { - const { keyword = '', page: pageString } = useQuery< - Query.KEYWORD | Query.PAGE - >(); - const page = pageString ? Number(pageString) || 1 : 1; - - const [data, setData] = useState(dataLoading); - const getPageMusicList = useCallback( - async ({ keyword: k, page: p }: { keyword: string; page: number }) => { - setData(dataLoading); - try { - const d = await getMusicList({ - keyword: k, - page: p, - pageSize: PAGE_SIZE, - }); - - setData({ - error: null, - loading: false, - value: { - total: d.total, - musicList: d.musicList.map((music, index) => ({ - ...music, - index: d.total - index - (p - 1) * PAGE_SIZE, - })), - }, - }); - } catch (e) { - logger.error(e, '获取我的音乐列表失败'); - setData({ - error: e, - loading: false, - value: null, - }); - } - }, - [], - ); - const reload = useCallback( - () => getPageMusicList({ keyword, page }), - [getPageMusicList, keyword, page], - ); - - useEffect(() => { - getPageMusicList({ keyword, page }); - }, [getPageMusicList, keyword, page]); - - useEffect(() => { - const unlistenReload = em.listen(EventType.RELOAD_MUSIC_LIST, reload); - const unlistenMusicUpdated = playerEventemitter.listen( - PlayerEventType.MUSIC_UPDATED, - reload, - ); - const unlistenMusicDeleted = playerEventemitter.listen( - PlayerEventType.MUSIC_DELETED, - reload, - ); - return () => { - unlistenReload(); - unlistenMusicUpdated(); - unlistenMusicDeleted(); - }; - }, [reload]); - - return { - page, - data, - reload, - }; -}; diff --git a/apps/pwa/src/pages/player/pages/my_music/toolbar/filter.tsx b/apps/pwa/src/pages/player/pages/my_music/toolbar/filter.tsx deleted file mode 100644 index 0554440b..00000000 --- a/apps/pwa/src/pages/player/pages/my_music/toolbar/filter.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import Input from '@/components_next/input'; -import useNavigate from '@/utils/use_navigate'; -import { Query } from '@/constants'; -import { IS_TOUCHABLE } from '@/constants/browser'; -import parseSearch from '@/utils/parse_search'; -import { CSSProperties, useEffect, useState } from 'react'; -import { useLocation } from 'react-router-dom'; -import { t } from '@/i18n'; -import capitalize from '#/utils/capitalize'; - -const style: CSSProperties = { - flex: 1, - minWidth: 0, -}; - -function Filter() { - const location = useLocation(); - - const [keyword, setKeyword] = useState(() => { - const query = parseSearch(location.search); - return query.keyword || ''; - }); - const navigate = useNavigate(); - - useEffect(() => { - const timer = window.setTimeout( - () => - navigate({ - query: { - [Query.KEYWORD]: keyword.replace(/\s+/g, ' ').trim(), - [Query.PAGE]: 1, - }, - }), - 500, - ); - return () => window.clearTimeout(timer); - }, [keyword, navigate]); - - return ( - setKeyword(event.target.value)} - /> - ); -} - -export default Filter; diff --git a/apps/pwa/src/pages/player/pages/my_music/toolbar/index.tsx b/apps/pwa/src/pages/player/pages/my_music/toolbar/index.tsx deleted file mode 100644 index 247624fd..00000000 --- a/apps/pwa/src/pages/player/pages/my_music/toolbar/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import styled from 'styled-components'; -import Filter from './filter'; -import { TOOLBAR_HEIGHT } from '../constants'; - -const Style = styled.div` - position: absolute; - width: 100%; - height: ${TOOLBAR_HEIGHT}px; - left: 0; - bottom: 0; - - padding: 0 20px; - - display: flex; - align-items: center; - gap: 10px; - - backdrop-filter: blur(5px); -`; - -function Toolbar() { - return ( - - ); -} - -export default Toolbar; diff --git a/apps/pwa/src/pages/player/pages/search/create_music_guide.tsx b/apps/pwa/src/pages/player/pages/search/create_music_guide.tsx deleted file mode 100644 index f0dd86ff..00000000 --- a/apps/pwa/src/pages/player/pages/search/create_music_guide.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { memo } from 'react'; -import { PLAYER_PATH, ROOT_PATH } from '@/constants/route'; -import { Query } from '@/constants'; -import useNavigate from '@/utils/use_navigate'; -import { t } from '@/i18n'; -import TextGuide from './text_guide'; - -function CreateMusicGuide() { - const navigate = useNavigate(); - return ( - - navigate({ - path: ROOT_PATH.PLAYER + PLAYER_PATH.MY_MUSIC, - query: { - [Query.CREATE_MUSIC_DIALOG_OPEN]: 1, - }, - }) - } - /> - ); -} - -export default memo(CreateMusicGuide); diff --git a/apps/pwa/src/pages/player/pages/search/lyric/index.tsx b/apps/pwa/src/pages/player/pages/search/lyric/index.tsx index b5a422b0..dda5a134 100644 --- a/apps/pwa/src/pages/player/pages/search/lyric/index.tsx +++ b/apps/pwa/src/pages/player/pages/search/lyric/index.tsx @@ -9,15 +9,12 @@ import Pagination from '@/components/pagination'; import useNavigate from '@/utils/use_navigate'; import { Query } from '@/constants'; import { CSSProperties, useContext } from 'react'; -import Button from '@/components_next/button'; -import { PLAYER_PATH, ROOT_PATH } from '@/constants/route'; import autoScrollbar from '@/style/auto_scrollbar'; import { t } from '@/i18n'; import { TOOLBAR_HEIGHT, MINI_MODE_TOOLBAR_HEIGHT } from '../constants'; import { PAGE_SIZE } from './constants'; import useData from './use_data'; import MusicWithLyric from './music_with_lyric'; -import CreateMusicGuide from '../create_music_guide'; import Context from '../../../context'; const Container = styled(animated.div)` @@ -71,19 +68,6 @@ function Wrapper() { return ( - ); } @@ -115,9 +99,6 @@ function Wrapper() { } /> ) : null} - {page === Math.ceil(d.value!.total / PAGE_SIZE) ? ( - - ) : null} ); }); diff --git a/apps/pwa/src/pages/player/pages/search/music/index.tsx b/apps/pwa/src/pages/player/pages/search/music/index.tsx index 52a52737..510b940f 100644 --- a/apps/pwa/src/pages/player/pages/search/music/index.tsx +++ b/apps/pwa/src/pages/player/pages/search/music/index.tsx @@ -9,8 +9,6 @@ import Pagination from '@/components/pagination'; import useNavigate from '@/utils/use_navigate'; import { Query } from '@/constants'; import { CSSProperties, useContext } from 'react'; -import Button from '@/components_next/button'; -import { PLAYER_PATH, ROOT_PATH } from '@/constants/route'; import autoScrollbar from '@/style/auto_scrollbar'; import { t } from '@/i18n'; import { @@ -20,7 +18,6 @@ import { } from '../constants'; import useData from './use_data'; import Music from '../../../components/music'; -import CreateMusicGuide from '../create_music_guide'; import Context from '../../../context'; const Container = styled(animated.div)` @@ -74,19 +71,6 @@ function Wrapper() { return ( - ); } @@ -117,9 +101,6 @@ function Wrapper() { } /> ) : null} - {page !== Math.ceil(d.value!.total / PAGE_SIZE) ? null : ( - - )} ); }); diff --git a/apps/pwa/src/pages/player/route.tsx b/apps/pwa/src/pages/player/route.tsx index 1aedb1ce..a6b8867e 100644 --- a/apps/pwa/src/pages/player/route.tsx +++ b/apps/pwa/src/pages/player/route.tsx @@ -4,7 +4,6 @@ import Search from './pages/search'; import Musicbill from './pages/musicbill'; import Music from './pages/music'; import Setting from './pages/setting'; -import MyMusic from './pages/my_music'; import PublicMusicbillCollection from './pages/public_musicbill_collection'; import Exploration from './pages/exploration'; import MusicPlayRecord from './pages/music_play_record'; @@ -17,7 +16,6 @@ function Wrapper() { } /> } /> - } /> } /> } /> } /> diff --git a/apps/pwa/src/pages/player/sidebar/menu.tsx b/apps/pwa/src/pages/player/sidebar/menu.tsx index 718ed593..a68617ac 100644 --- a/apps/pwa/src/pages/player/sidebar/menu.tsx +++ b/apps/pwa/src/pages/player/sidebar/menu.tsx @@ -2,7 +2,6 @@ import { PLAYER_PATH, ROOT_PATH } from '@/constants/route'; import { MdLooks, MdOutlineSettings, - MdOutlineMusicNote, MdHistory, MdOutlineDownload, MdAdminPanelSettings, @@ -38,13 +37,6 @@ function Menu() { label={t('exploration')} icon={} /> - navigate(`${ROOT_PATH.PLAYER}${PLAYER_PATH.MY_MUSIC}`)} - label={t('my_music')} - icon={} - /> - */ -async function getMusicList({ - keyword, - page, - pageSize, -}: { - keyword: string; - page: number; - pageSize: number; -}) { - const data = await request<{ - total: number; - musicList: { - id: string; - cover: string; - type: MusicType; - name: string; - aliases: string[]; - heat: number; - asset: string; - singers: { - id: string; - name: string; - aliases: string[]; - }[]; - createTimestamp: number; - }[]; - }>({ - path: '/api/music_list', - params: { keyword, page, pageSize }, - withToken: true, - }); - return { - ...data, - musicList: data.musicList.map((m) => ({ - ...m, - cover: prefixServerOrigin(m.cover), - asset: prefixServerOrigin(m.asset), - })), - }; -} - -export default getMusicList; diff --git a/apps/pwa/src/pages/player/pages/my_music/create_music_dialog/utils.ts b/apps/pwa/src/shared/utils/music_file.ts similarity index 70% rename from apps/pwa/src/pages/player/pages/my_music/create_music_dialog/utils.ts rename to apps/pwa/src/shared/utils/music_file.ts index 335e02bc..bd42e2bb 100644 --- a/apps/pwa/src/pages/player/pages/my_music/create_music_dialog/utils.ts +++ b/apps/pwa/src/shared/utils/music_file.ts @@ -26,21 +26,6 @@ export async function base64ToCover(base64: string) { ); } -export function canAudioPlay(file: File) { - const url = URL.createObjectURL(file); - const audio = new Audio(); - return new Promise((resolve) => { - audio.muted = true; - audio.autoplay = true; - audio.onplay = () => resolve(true); - audio.onerror = () => resolve(false); - audio.src = url; - }).finally(() => { - audio.pause(); - URL.revokeObjectURL(url); - }); -} - export function getMusicNameFromFilename(filename: string) { const lastIndex = filename.lastIndexOf('.'); return lastIndex === -1 From c6feab33e5da7eb613e6da467d3003f7e605d4c0 Mon Sep 17 00:00:00 2001 From: anyone Date: Thu, 23 Apr 2026 22:48:30 +0800 Subject: [PATCH 18/18] user page --- .../components_next/avatar/avatar.stories.tsx | 33 +++ apps/pwa/src/components_next/avatar/index.tsx | 140 ++++++++++ apps/pwa/src/components_next/button/index.tsx | 8 +- .../src/components_next/icon/icon.stories.tsx | 3 +- .../src/components_next/icon/icons/edit.tsx | 13 + apps/pwa/src/components_next/icon/index.ts | 1 + apps/pwa/src/components_next/index.ts | 3 + apps/pwa/src/components_next/input/index.tsx | 11 +- apps/pwa/src/constants/route.ts | 1 + apps/pwa/src/i18n/en.ts | 1 + apps/pwa/src/i18n/zh_hans.ts | 1 + apps/pwa/src/pages/player/eventemitter.ts | 2 - apps/pwa/src/pages/player/header/use_title.ts | 4 + apps/pwa/src/pages/player/index.tsx | 2 - .../pwa/src/pages/player/pages/user/index.tsx | 257 ++++++++++++++++++ .../src/pages/player/profile_edit_popup.tsx | 203 -------------- apps/pwa/src/pages/player/route.tsx | 2 + apps/pwa/src/pages/player/sidebar/profile.tsx | 27 +- apps/pwa/src/utils/dialog/index.tsx | 42 ++- apps/pwa/src/utils/notice/index.tsx | 34 ++- 20 files changed, 543 insertions(+), 245 deletions(-) create mode 100644 apps/pwa/src/components_next/avatar/avatar.stories.tsx create mode 100644 apps/pwa/src/components_next/avatar/index.tsx create mode 100644 apps/pwa/src/components_next/icon/icons/edit.tsx create mode 100644 apps/pwa/src/pages/player/pages/user/index.tsx delete mode 100644 apps/pwa/src/pages/player/profile_edit_popup.tsx diff --git a/apps/pwa/src/components_next/avatar/avatar.stories.tsx b/apps/pwa/src/components_next/avatar/avatar.stories.tsx new file mode 100644 index 00000000..42e90cb0 --- /dev/null +++ b/apps/pwa/src/components_next/avatar/avatar.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import DefaultCover from '@/asset/default_cover.jpeg'; +import Avatar from '.'; + +const meta = { + title: 'Basic/Avatar', + component: Avatar, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + args: { + src: DefaultCover, + size: 72, + active: false, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const States: Story = { + render: (args) => ( +
+ + + +
+ ), +}; diff --git a/apps/pwa/src/components_next/avatar/index.tsx b/apps/pwa/src/components_next/avatar/index.tsx new file mode 100644 index 00000000..b0c632c8 --- /dev/null +++ b/apps/pwa/src/components_next/avatar/index.tsx @@ -0,0 +1,140 @@ +import Cover, { Shape } from '@/components/cover'; +import { HTMLAttributes } from 'react'; +import styled, { css } from 'styled-components'; +import { CSS_VAR } from '../theme'; + +const PRIMARY = `var(${CSS_VAR.colorPrimary})`; +const PRIMARY_SHADOW = `var(${CSS_VAR.colorPrimaryShadow})`; +const NEUTRAL_SHADOW = 'rgb(180 180 180)'; +const FACE = '#ffffff'; + +function getBorderWidth(size: number | string) { + return 2; +} + +function getRadius(size: number | string) { + if (typeof size === 'number') { + return Math.max(14, Math.round(size * 0.24)); + } + + return 20; +} + +function getInnerRadius(radius: number, borderWidth: number) { + return Math.max(0, radius - borderWidth); +} + +function getShadowOffset(size: number | string) { + if (typeof size === 'number') { + if (size <= 40) { + return 3; + } + if (size <= 88) { + return 4; + } + return 5; + } + + return 4; +} + +const Root = styled.div<{ + $size: number | string; + $borderWidth: number; + $radius: number; + $shadowOffset: number; + $active: boolean; + $interactive: boolean; +}>` + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: ${({ $size }) => (typeof $size === 'number' ? `${$size}px` : $size)}; + aspect-ratio: 1; + box-sizing: border-box; + + border-radius: ${({ $radius }) => `${$radius}px`}; + border: ${({ $borderWidth }) => `${$borderWidth}px`} solid + ${({ $active }) => ($active ? PRIMARY : NEUTRAL_SHADOW)}; + background: ${FACE}; + box-shadow: 0 ${({ $shadowOffset }) => `${$shadowOffset}px`} 0 + ${({ $active }) => ($active ? PRIMARY_SHADOW : NEUTRAL_SHADOW)}; + transition: + transform 150ms ease-out, + box-shadow 150ms ease-out, + border-color 150ms ease-out, + filter 120ms; + + ${({ $interactive, $shadowOffset, $active }) => + $interactive && + css` + cursor: pointer; + -webkit-tap-highlight-color: transparent; + + &:hover { + filter: brightness(1.06); + } + + &:active { + transform: translateY(${$shadowOffset}px); + box-shadow: none; + transition: + transform 60ms ease-in, + box-shadow 60ms ease-in, + filter 60ms; + } + `} +`; + +const Frame = styled.div<{ $radius: number }>` + width: 100%; + aspect-ratio: 1; + overflow: hidden; + + border-radius: ${({ $radius }) => `${$radius}px`}; + background: transparent; +`; + +const ImageBox = styled.div` + width: 100%; + height: 100%; +`; + +export interface AvatarProps extends HTMLAttributes { + src: string; + size?: number | string; + active?: boolean; +} + +function Avatar({ + src, + size = 72, + active = false, + onClick, + ...props +}: AvatarProps) { + const borderWidth = getBorderWidth(size); + const radius = getRadius(size); + + return ( + + + + + + + + ); +} + +export default Avatar; diff --git a/apps/pwa/src/components_next/button/index.tsx b/apps/pwa/src/components_next/button/index.tsx index b84d18ed..03807a0c 100644 --- a/apps/pwa/src/components_next/button/index.tsx +++ b/apps/pwa/src/components_next/button/index.tsx @@ -8,6 +8,7 @@ export type Size = 'sm' | 'md' | 'lg'; const cn = (v: string) => `var(${v})`; const PRIMARY = cn(CSS_VAR.colorPrimary); const PRIMARY_SHADOW = cn(CSS_VAR.colorPrimaryShadow); +const DISABLED_SHADOW = 'rgb(214 214 214)'; // ─── 阴影偏移量 ──────────────────────────────────────────────────────────────── @@ -46,7 +47,7 @@ const SIZE_MAP: Record> = { // 悬停 — 整体略亮(filter brightness) // 按下 — translateY(offset) + box-shadow 归零 // 释放 — 慢速弹回(150ms ease-out) -// 禁用 — 去阴影 + 降不透明度 +// 禁用 — 保留更浅的硬阴影,避免视觉高度变矮 const makeVariant = ( face: string, @@ -77,8 +78,9 @@ const makeVariant = ( } &:disabled { - box-shadow: none; - opacity: 0.5; + box-shadow: 0 ${({ $offset }) => $offset}px 0 ${DISABLED_SHADOW}; + filter: saturate(0.45); + opacity: 0.65; } `; diff --git a/apps/pwa/src/components_next/icon/icon.stories.tsx b/apps/pwa/src/components_next/icon/icon.stories.tsx index 2d268d89..2285111d 100644 --- a/apps/pwa/src/components_next/icon/icon.stories.tsx +++ b/apps/pwa/src/components_next/icon/icon.stories.tsx @@ -1,9 +1,10 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { IconList, IconPlayQueue } from '.'; +import { IconEdit, IconList, IconPlayQueue } from '.'; import type { IconProps } from '.'; const ALL_ICONS: { name: string; Component: (p: Omit) => React.ReactElement }[] = [ + { name: 'IconEdit', Component: IconEdit }, { name: 'IconList', Component: IconList }, { name: 'IconPlayQueue', Component: IconPlayQueue }, ]; diff --git a/apps/pwa/src/components_next/icon/icons/edit.tsx b/apps/pwa/src/components_next/icon/icons/edit.tsx new file mode 100644 index 00000000..8341a266 --- /dev/null +++ b/apps/pwa/src/components_next/icon/icons/edit.tsx @@ -0,0 +1,13 @@ +import Icon, { IconProps } from '../base'; + +function IconEdit(props: Omit) { + return ( + + + + + + ); +} + +export default IconEdit; diff --git a/apps/pwa/src/components_next/icon/index.ts b/apps/pwa/src/components_next/icon/index.ts index 559395b9..a7f49262 100644 --- a/apps/pwa/src/components_next/icon/index.ts +++ b/apps/pwa/src/components_next/icon/index.ts @@ -7,3 +7,4 @@ export type { IconProps } from './base'; export { default as IconList } from './icons/list'; export { default as IconPlayQueue } from './icons/play-queue'; +export { default as IconEdit } from './icons/edit'; diff --git a/apps/pwa/src/components_next/index.ts b/apps/pwa/src/components_next/index.ts index cd3b05d1..ce32974b 100644 --- a/apps/pwa/src/components_next/index.ts +++ b/apps/pwa/src/components_next/index.ts @@ -7,6 +7,9 @@ export type { InputProps, InputSize } from './input'; export { default as Label } from './label'; export type { LabelProps } from './label'; +export { default as Avatar } from './avatar'; +export type { AvatarProps } from './avatar'; + export { default as Slider } from './slider'; export type { SliderProps, SliderEdge } from './slider'; diff --git a/apps/pwa/src/components_next/input/index.tsx b/apps/pwa/src/components_next/input/index.tsx index b4a2520d..3a89d336 100644 --- a/apps/pwa/src/components_next/input/index.tsx +++ b/apps/pwa/src/components_next/input/index.tsx @@ -22,6 +22,9 @@ const SIZE: Record< }; const FONT = `'Nunito', 'Varela Round', system-ui, sans-serif`; +const DISABLED_BACKGROUND = 'rgb(248 248 248)'; +const DISABLED_BORDER = 'rgb(226 226 226)'; +const DISABLED_SHADOW = 'rgb(214 214 214)'; // ─── Styled ─────────────────────────────────────────────────────────────────── @@ -85,11 +88,12 @@ const Wrapper = styled.div<{ `} /* 禁用 */ - ${({ $disabled }) => + ${({ $disabled, $size }) => $disabled && css` - opacity: 0.5; - box-shadow: none; + background: ${DISABLED_BACKGROUND}; + border-color: ${DISABLED_BORDER}; + box-shadow: 0 ${SIZE[$size].shadow}px 0 ${DISABLED_SHADOW}; cursor: not-allowed; `} `; @@ -126,6 +130,7 @@ const NativeInput = styled.input<{ $size: InputSize }>` &:disabled { cursor: not-allowed; + color: rgb(145 145 145); } `; diff --git a/apps/pwa/src/constants/route.ts b/apps/pwa/src/constants/route.ts index a6d61933..4a5190e0 100644 --- a/apps/pwa/src/constants/route.ts +++ b/apps/pwa/src/constants/route.ts @@ -9,6 +9,7 @@ export const PLAYER_PATH = { MUSIC: '/music/:id', MUSICBILL: '/musicbill/:id', SINGER: '/singer/:id', + USER: '/user', SETTING: '/setting', SHARED_MUSICBILL_INVITATION: '/shared_musicbill_invitation', SEARCH: '/search', diff --git a/apps/pwa/src/i18n/en.ts b/apps/pwa/src/i18n/en.ts index 31416aa1..814410aa 100644 --- a/apps/pwa/src/i18n/en.ts +++ b/apps/pwa/src/i18n/en.ts @@ -3,6 +3,7 @@ export default { cicada_description: 'a multi-user music service for self-hosting', incompatible_tips: "your browser is incompatible with cicada, because it's lack of below features", + profile: 'profile', setting: 'setting', confirm: 'confirm', cancel: 'cancel', diff --git a/apps/pwa/src/i18n/zh_hans.ts b/apps/pwa/src/i18n/zh_hans.ts index 5ad9d487..6fb04c5a 100644 --- a/apps/pwa/src/i18n/zh_hans.ts +++ b/apps/pwa/src/i18n/zh_hans.ts @@ -6,6 +6,7 @@ const zhCN: { cicada: '知了', cicada_description: '一个自托管的多用户音乐服务', incompatible_tips: '你的浏览器无法兼容知了, 因为缺少以下功能', + profile: '个人资料', setting: '设置', confirm: '确认', cancel: '取消', diff --git a/apps/pwa/src/pages/player/eventemitter.ts b/apps/pwa/src/pages/player/eventemitter.ts index 8b7cfac9..f47ef152 100644 --- a/apps/pwa/src/pages/player/eventemitter.ts +++ b/apps/pwa/src/pages/player/eventemitter.ts @@ -51,7 +51,6 @@ export enum EventType { TOGGLE_PLAYLIST_PLAYQUEUE_DRAWER = 'toggle_playlist_playqueue_drawer', OPEN_USER_DRAWER = 'open_user_drawer', OPEN_PUBLIC_MUSICBILL_DRAWER = 'open_public_musicbill_drawer', - OPEN_PROFILE_EDIT_POPUP = 'open_profile_edit_popup', OPEN_2FA_DIALOG = 'open_2fa_dialog', FOCUS_SEARCH_INPUT = 'focus_search_input', @@ -135,7 +134,6 @@ export default new Eventin< [EventType.TOGGLE_PLAYLIST_PLAYQUEUE_DRAWER]: null; [EventType.OPEN_USER_DRAWER]: { id: string }; [EventType.OPEN_PUBLIC_MUSICBILL_DRAWER]: { id: string }; - [EventType.OPEN_PROFILE_EDIT_POPUP]: null; [EventType.OPEN_2FA_DIALOG]: null; [EventType.FOCUS_SEARCH_INPUT]: null; diff --git a/apps/pwa/src/pages/player/header/use_title.ts b/apps/pwa/src/pages/player/header/use_title.ts index bd30ae21..c4b14901 100644 --- a/apps/pwa/src/pages/player/header/use_title.ts +++ b/apps/pwa/src/pages/player/header/use_title.ts @@ -40,6 +40,10 @@ export default () => { title = t('user_management'); break; } + case ROOT_PATH.PLAYER + PLAYER_PATH.USER: { + title = t('profile'); + break; + } case ROOT_PATH.PLAYER + PLAYER_PATH.SETTING: { title = t('setting'); break; diff --git a/apps/pwa/src/pages/player/index.tsx b/apps/pwa/src/pages/player/index.tsx index f3fb96fc..75b1b66f 100644 --- a/apps/pwa/src/pages/player/index.tsx +++ b/apps/pwa/src/pages/player/index.tsx @@ -23,7 +23,6 @@ import MusicbillSharedUserDrawer from './musicbill_shared_user_drawer'; import { QueueMusic } from './constants'; import LyricPanel from './lyric_panel'; import SingerDrawer from './singer_drawer'; -import ProfileEditPopup from './profile_edit_popup'; import UserDrawer from './user_drawer'; import PublicMusicbillDrawer from './public_musicbill_drawer'; import useLyricPanelOpen from './use_lyric_panel_open'; @@ -154,7 +153,6 @@ function Wrapper() { {/* fixed z-index */} - ); diff --git a/apps/pwa/src/pages/player/pages/user/index.tsx b/apps/pwa/src/pages/player/pages/user/index.tsx new file mode 100644 index 00000000..dfb1bb93 --- /dev/null +++ b/apps/pwa/src/pages/player/pages/user/index.tsx @@ -0,0 +1,257 @@ +import { KeyboardEvent, memo, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { MdPassword, MdSecurity } from 'react-icons/md'; +import Page from '../page'; +import autoScrollbar from '@/style/auto_scrollbar'; +import { HEADER_HEIGHT } from '../../constants'; +import { CSSVariable } from '@/global_style'; +import getResizedImage from '@/server/asset/get_resized_image'; +import { reloadUser, useUser } from '@/global_states/server'; +import day from '#/utils/day'; +import Button from '@/components_next/button'; +import dialog from '@/utils/dialog'; +import uploadAsset from '@/server/form/upload_asset'; +import { AssetType } from '#/constants'; +import updateProfile from '@/server/api/update_profile'; +import { AllowUpdateKey, NICKNAME_MAX_LENGTH } from '#/constants/user'; +import notice from '@/utils/notice'; +import logger from '@/utils/logger'; +import { t } from '@/i18n'; +import playerEventemitter, { EventType } from '../../eventemitter'; +import { IconEdit } from '@/components_next/icon'; +import Input from '@/components_next/input'; +import Avatar from '@/components_next/avatar'; + +const AVATAR_SIZE = 120; +const Style = styled(Page)` + padding: ${HEADER_HEIGHT + 20}px 20px 20px; + + overflow: auto; + ${autoScrollbar} + + display: flex; + flex-direction: column; + gap: 20px; +`; +const ProfileCard = styled.section` + display: flex; + align-items: center; + padding: 20px; + + border-radius: ${CSSVariable.BORDER_RADIUS_NORMAL}; + + > .avatar-box { + display: flex; + align-items: center; + gap: 10px; + + > .avatar-action { + margin-bottom: 0; + } + } + + @media (max-width: 720px) { + > .avatar-box { + align-items: center; + } + } +`; +const FieldGrid = styled.section` + display: flex; + flex-direction: column; + gap: 16px; +`; +const NicknameSection = styled.section` + width: 100%; + + > .row { + display: flex; + width: 100%; + align-items: flex-end; + gap: 12px; + + > .input { + flex: 1; + min-width: 0; + } + + > .button { + flex-shrink: 0; + } + } +`; +const ActionGrid = styled.section` + display: flex; + flex-direction: column; + gap: 16px; +`; + +function User() { + const user = useUser()!; + const [nickname, setNickname] = useState(user.nickname); + const [nicknameUpdating, setNicknameUpdating] = useState(false); + + useEffect(() => { + setNickname(user.nickname); + }, [user.nickname]); + + const trimmedNickname = nickname.replace(/\s+/g, ' ').trim(); + const nicknameChanged = trimmedNickname !== user.nickname; + const nicknameError = + nickname.length > 0 && !trimmedNickname ? t('empty_nickname_warning') : ''; + const canUpdateNickname = + !nicknameUpdating && !!trimmedNickname && nicknameChanged; + + const editAvatar = () => + dialog.imageCut({ + title: t('edit_avatar'), + onConfirm: async (avatar) => { + if (!avatar) { + notice.error(t('empty_avatar_warning')); + return false; + } + try { + const { id } = await uploadAsset(avatar, AssetType.USER_AVATAR); + await updateProfile({ + key: AllowUpdateKey.AVATAR, + value: id, + }); + await reloadUser(); + } catch (error) { + logger.error(error, 'Failed to update avatar'); + notice.error(error.message); + return false; + } + }, + }); + + const updateNickname = async () => { + if (!canUpdateNickname) { + if (!trimmedNickname) { + notice.error(t('empty_nickname_warning')); + } + return; + } + + setNicknameUpdating(true); + try { + await updateProfile({ + key: AllowUpdateKey.NICKNAME, + value: trimmedNickname, + }); + await reloadUser(); + } catch (error) { + logger.error(error, 'Failed to update nickname'); + notice.error(error.message); + } + setNicknameUpdating(false); + }; + + const onNicknameKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + void updateNickname(); + } + }; + + const changePassword = () => + dialog.password({ + confirmVariant: 'primary', + onConfirm: async (password) => { + try { + await updateProfile({ + key: AllowUpdateKey.PASSWORD, + value: password, + }); + notice.info(t('password_has_changed')); + } catch (error) { + logger.error(error, 'Failed to update password'); + notice.error(error.message); + return false; + } + }, + }); + + return ( + + ); +} + +export default memo(User); diff --git a/apps/pwa/src/pages/player/profile_edit_popup.tsx b/apps/pwa/src/pages/player/profile_edit_popup.tsx deleted file mode 100644 index 39c51530..00000000 --- a/apps/pwa/src/pages/player/profile_edit_popup.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import Popup from '@/components/popup'; -import { CSSProperties, memo, useEffect, useState } from 'react'; -import styled from 'styled-components'; -import MenuItem from '@/components/menu_item'; -import { MdImage, MdTitle, MdPassword, MdSecurity } from 'react-icons/md'; -import Cover from '@/components/cover'; -import { CSSVariable } from '@/global_style'; -import ellipsis from '@/style/ellipsis'; -import uploadAsset from '@/server/form/upload_asset'; -import { AssetType } from '#/constants'; -import updateProfile from '@/server/api/update_profile'; -import { AllowUpdateKey, NICKNAME_MAX_LENGTH } from '#/constants/user'; -import dialog from '@/utils/dialog'; -import notice from '@/utils/notice'; -import logger from '@/utils/logger'; -import { reloadUser, useUser } from '@/global_states/server'; -import getResizedImage from '@/server/asset/get_resized_image'; -import { t } from '@/i18n'; -import { ZIndex } from './constants'; -import e, { EventType } from './eventemitter'; - -const open2FADialog = () => e.emit(EventType.OPEN_2FA_DIALOG, null); -const AVATAR_SIZE = 36; -const maskProps: { - style: CSSProperties; -} = { - style: { - zIndex: ZIndex.POPUP, - }, -}; -const Style = styled.div` - padding: 10px 0 max(env(safe-area-inset-bottom, 10px), 10px) 0; - - > .profile { - padding: 10px; - margin: 0 10px; - - display: flex; - align-items: center; - gap: 10px; - - border-radius: ${CSSVariable.BORDER_RADIUS_NORMAL}; - cursor: pointer; - transition: 300ms; - - > .info { - flex: 1; - min-width: 0; - - > .primary { - color: ${CSSVariable.TEXT_COLOR_PRIMARY}; - font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; - ${ellipsis} - } - - > .secondary { - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - font-size: ${CSSVariable.TEXT_SIZE_SMALL}; - ${ellipsis} - } - } - - &:hover { - background-color: ${CSSVariable.BACKGROUND_COLOR_LEVEL_ONE}; - } - - &:active { - background-color: ${CSSVariable.BACKGROUND_COLOR_LEVEL_ONE}; - } - } -`; -const itemStyle: CSSProperties = { margin: '0 10px' }; - -function ProfileEditPopup() { - const user = useUser()!; - - const [open, setOpen] = useState(false); - const onClose = () => setOpen(false); - - useEffect(() => { - const unlistenOpen = e.listen(EventType.OPEN_PROFILE_EDIT_POPUP, () => - setOpen(true), - ); - return unlistenOpen; - }, []); - - const openUserDrawer = () => - e.emit(EventType.OPEN_USER_DRAWER, { id: user.id }); - return ( - - - - ); -} - -export default memo(ProfileEditPopup); diff --git a/apps/pwa/src/pages/player/route.tsx b/apps/pwa/src/pages/player/route.tsx index a6b8867e..862c3b05 100644 --- a/apps/pwa/src/pages/player/route.tsx +++ b/apps/pwa/src/pages/player/route.tsx @@ -3,6 +3,7 @@ import { PLAYER_PATH } from '@/constants/route'; import Search from './pages/search'; import Musicbill from './pages/musicbill'; import Music from './pages/music'; +import User from './pages/user'; import Setting from './pages/setting'; import PublicMusicbillCollection from './pages/public_musicbill_collection'; import Exploration from './pages/exploration'; @@ -19,6 +20,7 @@ function Wrapper() { } /> } /> } /> + } /> } /> .avatar { - cursor: pointer; - outline: 1px solid ${CSSVariable.COLOR_PRIMARY}; - transition: 300ms; - - &:hover { - outline-width: 3px; - } - } - > .nickname { padding: 0 30px; max-width: 100%; @@ -32,19 +23,21 @@ const Style = styled.div` ${ellipsis} } `; -const openProfileEditPopup = () => - e.emit(EventType.OPEN_PROFILE_EDIT_POPUP, null); function Profile() { const user = useUser()!; + const navigate = useNavigate(); + const { pathname } = useLocation(); + const profilePath = `${ROOT_PATH.PLAYER}${PLAYER_PATH.USER}`; + return (