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..f43a9735 --- /dev/null +++ b/.github/workflows/pull_request_checks.yaml @@ -0,0 +1,53 @@ +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: Type-check PWA + run: npm run typecheck + + - name: Build PWA + run: npm run build + + - name: Run PWA tests + run: npm run test diff --git a/.gitignore b/.gitignore index 7f548e1e..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/ \ No newline at end of file +.claude/ diff --git a/Makefile b/Makefile index 4658dcef..75f03c6f 100644 --- a/Makefile +++ b/Makefile @@ -1,50 +1,86 @@ .DEFAULT_GOAL := release -VERSION := $(shell git describe --abbrev=0 --tags 2>/dev/null || echo dev) +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 +FFMPEG_VERSION ?= unknown define build_cli - cd $(CLI_DIR) && CGO_ENABLED=0 GOOS=$(1) GOARCH=$(2) go build -tags prod -o $(3) . + 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: npm ci --prefix apps/pwa - npm run build --prefix apps/pwa + CICADA_VERSION=$(VERSION) npm run build --prefix apps/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) - $(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 +docker: pwa ffmpeg-bundle-linux-amd64 rm -rf $(BUILD_DIR) mkdir -p $(BUILD_DIR) $(call build_cli,linux,amd64,$(BUILD_DIR)/cicada) @@ -52,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/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`. 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/root.go b/apps/cli/cmd/root.go index 6837a535..3fa0a49e 100644 --- a/apps/cli/cmd/root.go +++ b/apps/cli/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "cicada/internal/version" "fmt" "os" @@ -8,8 +9,9 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "cicada", - Short: "A multi-user music service for self-hosting.", + Use: "cicada", + Short: "A multi-user music service for self-hosting.", + Version: version.Get(), } func Execute() { 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/docs/development/database.d2 b/apps/cli/database.d2 similarity index 100% rename from docs/development/database.d2 rename to apps/cli/database.d2 diff --git a/apps/cli/internal/api/handler/auth.go b/apps/cli/internal/api/handler/auth.go index 7d9609b8..25ba82e0 100644 --- a/apps/cli/internal/api/handler/auth.go +++ b/apps/cli/internal/api/handler/auth.go @@ -7,6 +7,7 @@ import ( "cicada/internal/api/middleware" "cicada/internal/auth" "cicada/internal/store" + "cicada/internal/version" "os" "sync" "time" @@ -16,11 +17,9 @@ import ( // ── Metadata ────────────────────────────────────────────────────────────────── -const appVersion = "beta" - func GetMetadata(c *gin.Context) { hostname, _ := os.Hostname() - api.OK(c, gin.H{"hostname": hostname, "version": appVersion}) + api.OK(c, gin.H{"hostname": hostname, "version": version.Get()}) } // ── Captcha ─────────────────────────────────────────────────────────────────── 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/music.go b/apps/cli/internal/api/handler/music.go index 63ebb3d2..fd2563d7 100644 --- a/apps/cli/internal/api/handler/music.go +++ b/apps/cli/internal/api/handler/music.go @@ -53,25 +53,6 @@ func SearchMusicByLyric(c *gin.Context) { api.OK(c, musicListResponse(musics, total)) } -// ── Get music list (current user's) ────────────────────────────────────────── - -func GetMusicList(c *gin.Context) { - u := middleware.GetUser(c) - keyword := c.Query("keyword") - page := queryInt(c, "page", 1) - pageSize := queryInt(c, "pageSize", 20) - if page < 1 || pageSize < 1 || pageSize > 100 { - api.Fail(c, apperr.WrongParameter) - return - } - total, musics, err := store.SearchMusicByUser(u.ID, keyword, page, pageSize) - if err != nil { - api.Fail(c, apperr.ServerError) - return - } - api.OK(c, musicListResponse(musics, total)) -} - // ── Get single music ────────────────────────────────────────────────────────── func GetMusic(c *gin.Context) { 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/apidoc/openapi.go b/apps/cli/internal/apidoc/openapi.go index 83071ec9..aa96f282 100644 --- a/apps/cli/internal/apidoc/openapi.go +++ b/apps/cli/internal/apidoc/openapi.go @@ -2,6 +2,7 @@ package apidoc import ( "cicada/internal/config" + "cicada/internal/version" "net/http" "strings" @@ -35,6 +36,7 @@ func Register(r *gin.Engine) { // Spec returns the OpenAPI 3.0 document for the current HTTP API. func Spec() map[string]any { + appVersion := version.Get() paths := map[string]any{} for _, op := range operations() { addOperation(paths, op) @@ -44,7 +46,7 @@ func Spec() map[string]any { "openapi": "3.0.3", "info": map[string]any{ "title": "Cicada API", - "version": "beta", + "version": appVersion, "description": "Cicada server API documentation.\n\n" + "Except for static asset downloads, business endpoints usually return HTTP 200 for both success and failure.\n" + "Use the `code` field in the response body to determine success: `success` means success; any other value is a business error code.", @@ -212,7 +214,7 @@ func operations() []operation { Description: "Return the current node hostname and version.", Tags: []string{"Base"}, SuccessSchema: metadataSchema(), - SuccessExample: map[string]any{"hostname": "cicada.local", "version": "beta"}, + SuccessExample: map[string]any{"hostname": "cicada.local", "version": version.Get()}, }, { Method: "GET", @@ -452,20 +454,6 @@ func operations() []operation { SuccessExample: musicListPageExample("musicList"), ErrorCodes: []string{"wrong_parameter", "server_error", "not_authorized"}, }, - { - Method: "GET", - Path: "/api/music_list", - Summary: "Get current user music list", - Description: "Return the list of music created by the current signed-in user.", - Tags: []string{"Music"}, - Auth: true, - Parameters: paginationParams( - queryParam("keyword", "Filter by name.", false, strSchema("", "night")), - ), - SuccessSchema: musicListPageSchema("musicList"), - SuccessExample: musicListPageExample("musicList"), - ErrorCodes: []string{"wrong_parameter", "server_error", "not_authorized"}, - }, { Method: "GET", Path: "/api/singer", @@ -1237,11 +1225,12 @@ func nullableSchema(schema map[string]any) map[string]any { } func metadataSchema() map[string]any { + appVersion := version.Get() return objSchema( []string{"hostname", "version"}, map[string]any{ "hostname": strSchema("Hostname of the current service node.", "cicada.local"), - "version": strSchema("Application version.", "beta"), + "version": strSchema("Application version.", appVersion), }, ) } diff --git a/apps/cli/internal/auth/captcha.go b/apps/cli/internal/auth/captcha.go index 530da01b..9e2bc53a 100644 --- a/apps/cli/internal/auth/captcha.go +++ b/apps/cli/internal/auth/captcha.go @@ -51,7 +51,7 @@ func buildSVG(text string) string { r := rand.New(rand.NewSource(time.Now().UnixNano())) const w, h = 160, 60 var sb strings.Builder - sb.WriteString(fmt.Sprintf(``, w, h)) + sb.WriteString(fmt.Sprintf(``, w, h)) sb.WriteString(fmt.Sprintf(``, w, h)) for i := 0; i < 4; i++ { sb.WriteString(fmt.Sprintf(``, 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/internal/server/server.go b/apps/cli/internal/server/server.go index 5958656e..31a20590 100644 --- a/apps/cli/internal/server/server.go +++ b/apps/cli/internal/server/server.go @@ -64,7 +64,6 @@ func NewServer() *gin.Engine { api.DELETE("/music", handler.DeleteMusic) api.GET("/music/search", handler.SearchMusic) api.GET("/music/search_by_lyric", handler.SearchMusicByLyric) - api.GET("/music_list", handler.GetMusicList) // Singer api.GET("/singer", handler.GetSinger) 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/cli/internal/store/music.go b/apps/cli/internal/store/music.go index 03cfec3f..a4c8b057 100644 --- a/apps/cli/internal/store/music.go +++ b/apps/cli/internal/store/music.go @@ -263,42 +263,6 @@ func searchMusicRandom(pageSize int) (int, []Music, error) { return total, musics, err } -// SearchMusicByUser lists music created by a user (paginated, optional keyword). -func SearchMusicByUser(userID, keyword string, page, pageSize int) (int, []Music, error) { - var ( - total int - args []any - where string - ) - if keyword != "" { - pat := "%" + keyword + "%" - where = `WHERE id IN ( - SELECT id FROM music WHERE createUserId=? AND (name LIKE ? OR aliases LIKE ?) - UNION - SELECT msr.musicId FROM music_singer_relation msr - JOIN singer s ON msr.singerId=s.id - JOIN music m ON msr.musicId=m.id - WHERE m.createUserId=? AND (s.name LIKE ? OR s.aliases LIKE ?) - )` - args = []any{userID, pat, pat, userID, pat, pat} - } else { - where = `WHERE createUserId=?` - args = []any{userID} - } - DB().QueryRow(`SELECT COUNT(1) FROM music `+where, args...).Scan(&total) - paginatedArgs := append(args, pageSize, (page-1)*pageSize) - rows, err := DB().Query( - `SELECT id,type,name,aliases,cover,asset,heat,createUserId,createTimestamp,year FROM music `+where+` ORDER BY createTimestamp DESC LIMIT ? OFFSET ?`, - paginatedArgs..., - ) - if err != nil { - return 0, nil, err - } - defer rows.Close() - musics, err := scanMusicRows(rows) - return total, musics, err -} - func scanMusicRows(rows *sql.Rows) ([]Music, error) { var out []Music for rows.Next() { diff --git a/apps/cli/internal/version/build_profile_dev.go b/apps/cli/internal/version/build_profile_dev.go new file mode 100644 index 00000000..13776140 --- /dev/null +++ b/apps/cli/internal/version/build_profile_dev.go @@ -0,0 +1,5 @@ +//go:build !prod + +package version + +const currentBuildProfile = buildProfileDevelopment diff --git a/apps/cli/internal/version/build_profile_prod.go b/apps/cli/internal/version/build_profile_prod.go new file mode 100644 index 00000000..e600fdc5 --- /dev/null +++ b/apps/cli/internal/version/build_profile_prod.go @@ -0,0 +1,5 @@ +//go:build prod + +package version + +const currentBuildProfile = buildProfileProduction diff --git a/apps/cli/internal/version/version.go b/apps/cli/internal/version/version.go new file mode 100644 index 00000000..c23a88fe --- /dev/null +++ b/apps/cli/internal/version/version.go @@ -0,0 +1,86 @@ +package version + +import ( + "os" + "os/exec" + "strings" + "sync" + "time" +) + +const ( + betaBranch = "beta" + buildProfileBeta = "beta" + buildProfileDevelopment = "development" + buildProfileProduction = "production" +) + +var Version string + +var ( + nowFunc = time.Now + once sync.Once + cached string +) + +func Get() string { + once.Do(func() { + cached = resolveCurrentVersion(currentBuildProfile) + }) + + return cached +} + +func resolveCurrentVersion(buildProfile string) string { + if injected := strings.TrimSpace(Version); injected != "" { + return injected + } + + return buildVersion(buildProfile, latestTag(), currentBranch(), nowFunc()) +} + +func buildVersion(buildProfile, latestTagValue, branch string, now time.Time) string { + if latestTagValue == "" { + latestTagValue = "unknown" + } + + if buildProfile == buildProfileDevelopment { + return latestTagValue + "-local" + } + + if buildProfile == buildProfileBeta || branch == betaBranch { + return latestTagValue + "-beta-" + now.Format("200601021504") + } + + return latestTagValue +} + +func latestTag() string { + tag := runGit( + "for-each-ref", + "--sort=-creatordate", + "--count=1", + "--format=%(refname:short)", + "refs/tags", + ) + return strings.TrimSpace(tag) +} + +func currentBranch() string { + for _, key := range []string{"GITHUB_REF_NAME", "CI_COMMIT_REF_NAME", "BRANCH_NAME"} { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + } + + return strings.TrimSpace(runGit("branch", "--show-current")) +} + +func runGit(args ...string) string { + output, err := exec.Command("git", args...).Output() + if err != nil { + return "" + } + + return string(output) +} diff --git a/apps/cli/internal/version/version_test.go b/apps/cli/internal/version/version_test.go new file mode 100644 index 00000000..664c4e3a --- /dev/null +++ b/apps/cli/internal/version/version_test.go @@ -0,0 +1,50 @@ +package version + +import ( + "testing" + "time" +) + +func TestBuildVersion(t *testing.T) { + now := time.Date(2026, time.April, 21, 17, 8, 0, 0, time.UTC) + + t.Run("development", func(t *testing.T) { + got := buildVersion(buildProfileDevelopment, "2.13.0", "main", now) + want := "2.13.0-local" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + }) + + t.Run("production", func(t *testing.T) { + got := buildVersion(buildProfileProduction, "2.13.0", "main", now) + want := "2.13.0" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + }) + + t.Run("beta branch build", func(t *testing.T) { + got := buildVersion(buildProfileProduction, "2.13.0", betaBranch, now) + want := "2.13.0-beta-202604211708" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + }) + + t.Run("explicit beta profile", func(t *testing.T) { + got := buildVersion(buildProfileBeta, "2.13.0", "main", now) + want := "2.13.0-beta-202604211708" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + }) + + t.Run("missing tag", func(t *testing.T) { + got := buildVersion(buildProfileProduction, "", "main", now) + want := "unknown" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } + }) +} diff --git a/apps/cli/readme.md b/apps/cli/readme.md new file mode 100644 index 00000000..cb776bc0 --- /dev/null +++ b/apps/cli/readme.md @@ -0,0 +1,120 @@ +# CLI + +Currently, `cicada` can be run on: + +- AMD64 + - Linux + - macOS + - Windows +- ARM64 + - Linux + - macOS + - Windows + +## Requirement + +- 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 + +All of `cicada` data is under a directory, here is its structure: + +``` +|- assets + |- music + |- musicbill_cover + |- music_cover + |- singer_avatar + |- user_avatar +|- cache # app runtime cache under data, cleaned up periodically +|- logs +|- trash # save removed data temporarily +|- v # its content indicates version of data +|- db # the database of sqlite +|- 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. + +The SQLite schema diagram is maintained in [database.d2](./database.d2) which powered by [d2](https://d2lang.com). + +Render the diagram locally with: + +```bash +d2 docs/development/database.d2 local_dir/database.svg +``` + +## Start DEV Server + +In development, `cicada` uses [air](https://github.com/air-verse/air) to start dev server. First, you need to install it. + +```sh +go install github.com/air-verse/air@latest +``` + +Then you need to add go bin to `PATH`: + +```sh +export PATH=$PATH:$(go env GOPATH)/bin +``` + +Use the follow command to start dev server: + +```sh +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 + +After starting dev server, the API reference can be visited on `http://localhost:8000/api_reference`. + +## Rules + +- Alter database must also update [database.d2](./database.d2) 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-lock.json b/apps/pwa/package-lock.json index c1a2d0b5..e43007ac 100644 --- a/apps/pwa/package-lock.json +++ b/apps/pwa/package-lock.json @@ -9,6 +9,7 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slider": "^1.3.6", "classnames": "^2.3.2", "clrc": "^3.1.4", @@ -61,6 +62,9 @@ "workbox-strategies": "^6.5.4", "workbox-window": "^6.5.4", "xss": "^1.0.14" + }, + "engines": { + "node": "24.x" } }, "node_modules/@adobe/css-tools": { @@ -115,7 +119,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1671,7 +1674,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2334,6 +2336,7 @@ "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -2343,6 +2346,7 @@ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "license": "MIT", + "peer": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -2355,6 +2359,7 @@ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "license": "MIT", + "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -2863,6 +2868,42 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -2878,6 +2919,139 @@ } } }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", @@ -2952,6 +3126,21 @@ } } }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-controllable-state": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", @@ -2989,6 +3178,24 @@ } } }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -3042,6 +3249,7 @@ "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.1.tgz", "integrity": "sha512-QODQ15teXThKaKdb7lnx4RifNUGnsGZ/NMKtkNBE89nJuK93+mPsb1ozp5xkGyLw7ZNVYO4Nkqsp4MsBOuAX8g==", "license": "MIT", + "peer": true, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } @@ -3051,6 +3259,7 @@ "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.1.tgz", "integrity": "sha512-Ge8F5VejnI7ng/NGObqBBovuLbItvmmZDFQ1Qwt/nBhHtk7l2tOffNMVNTta9Jt8TW0oXxVj6FG3hr6nx03JrQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", @@ -3072,6 +3281,7 @@ "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.85.1.tgz", "integrity": "sha512-vZtNEYv5qMYvbA9cTBMuZ3QkCqyJ7lDQgbxh4MpoZHZ0+62qjJpCXn9xzFM0Rm5ZG2hO8WDDxcFdI581BdASdg==", "license": "MIT", + "peer": true, "dependencies": { "@react-native/dev-middleware": "0.85.1", "debug": "^4.4.0", @@ -3102,6 +3312,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -3114,6 +3325,7 @@ "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.85.1.tgz", "integrity": "sha512-GUC2ZEy+J/Goc4l243XeeY/8NdNXVXPXoRTc6Yy14OiDcy7Yk87VyrMARbp23wCbzhnrz0dnYB8rxJ+AJvMzCg==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } @@ -3123,6 +3335,7 @@ "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.85.1.tgz", "integrity": "sha512-M/ogODh0uDFJ7xOlCc+v9nKUucUXGJwVOupl+zb3VT8tJnI2Cie/Fiv9NszAD/bzRQhJSrPZkJSAO6VW0XbWyA==", "license": "MIT", + "peer": true, "dependencies": { "cross-spawn": "^7.0.6", "debug": "^4.4.0", @@ -3137,6 +3350,7 @@ "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.85.1.tgz", "integrity": "sha512-vJSIZP7yymZMnwOrdNjalVf8jqcAFtmi6zT3sC9MRMgJPGkDy05g8y5zgAkgTxpNtVsv+/q5pst8woYp7pgRkA==", "license": "MIT", + "peer": true, "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.85.1", @@ -3160,6 +3374,7 @@ "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "license": "MIT", + "peer": true, "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" @@ -3176,6 +3391,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.3.0" }, @@ -3197,6 +3413,7 @@ "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.85.1.tgz", "integrity": "sha512-KeTntbnsH/NOdzZrSE8tgep+9jEMlEfklVDtgxnjjb5nDhhBD016judwyo9bsinZnuwXxmemXnOOqOfcEawxbg==", "license": "MIT", + "peer": true, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } @@ -3206,6 +3423,7 @@ "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.1.tgz", "integrity": "sha512-VseQZAKnDbmpZThLWviDIJ0NmuSiwiHA6vc2HNJTTVqTy2mQR0+858y9kDdDBQPYe0HH8+W1mYui2i4eUWGh4g==", "license": "MIT", + "peer": true, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } @@ -3214,13 +3432,15 @@ "version": "0.85.1", "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.85.1.tgz", "integrity": "sha512-w+4ZZ2PvvtC0IODEmxizYOrHmeDgdzpM7CKhtTNWoNtDWZoi7/ZY3UmNntn9poPorUy5cwFbfYiP/8rJFEsFvQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@react-native/virtualized-lists": { "version": "0.85.1", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.85.1.tgz", "integrity": "sha512-RLpoATkxeTaYxna5dDLIxEtoStp9UL7ryHIIOmKnE9NQW3ggR+U9DWQPXQkOfRc7/kPYba4ynKA2fIISGysVTg==", "license": "MIT", + "peer": true, "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" @@ -3888,7 +4108,8 @@ "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@storybook/addon-actions": { "version": "8.6.14", @@ -4486,7 +4707,6 @@ "integrity": "sha512-u/RwfWMyHcH0N2hqfMTw2CoZ58IXdeED3b8NmcHc8bmERB3byI5vVAkwYbcD7+WeRHIiym38ZHi0SRn+IpkO3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/instrumenter": "8.6.18", @@ -4555,7 +4775,6 @@ "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4716,13 +4935,15 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", "license": "MIT", + "peer": true, "dependencies": { "@types/istanbul-lib-coverage": "*" } @@ -4732,6 +4953,7 @@ "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/istanbul-lib-report": "*" } @@ -4770,7 +4992,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4781,7 +5002,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4801,6 +5021,7 @@ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "*" } @@ -4858,7 +5079,8 @@ "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/wicg-file-system-access": { "version": "2023.10.7", @@ -4872,6 +5094,7 @@ "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "license": "MIT", + "peer": true, "dependencies": { "@types/yargs-parser": "*" } @@ -4880,7 +5103,8 @@ "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", @@ -5014,6 +5238,7 @@ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", + "peer": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -5026,6 +5251,7 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", + "peer": true, "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -5051,6 +5277,7 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 14" } @@ -5061,7 +5288,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5077,7 +5303,8 @@ "version": "1.4.10", "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ansi-regex": { "version": "5.0.1", @@ -5103,6 +5330,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -5156,7 +5395,8 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/assertion-error": { "version": "2.0.1", @@ -5300,6 +5540,7 @@ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz", "integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==", "license": "MIT", + "peer": true, "dependencies": { "hermes-parser": "0.33.3" } @@ -5329,7 +5570,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/baseline-browser-mapping": { "version": "2.10.17", @@ -5371,6 +5613,7 @@ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", + "peer": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -5403,7 +5646,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5423,6 +5665,7 @@ "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "node-int64": "^0.4.0" } @@ -5446,6 +5689,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" @@ -5521,6 +5765,7 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -5605,6 +5850,7 @@ "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", @@ -5623,6 +5869,7 @@ "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz", "integrity": "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", @@ -5635,7 +5882,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/classnames": { "version": "2.5.1", @@ -5648,6 +5896,7 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "license": "ISC", + "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -5661,13 +5910,15 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -5682,6 +5933,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -5694,6 +5946,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -5735,6 +5988,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -5754,6 +6008,7 @@ "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", @@ -5769,6 +6024,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -5777,7 +6033,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -6055,6 +6312,7 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -6074,6 +6332,7 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -6090,6 +6349,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -6146,7 +6411,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ejs": { "version": "3.1.10", @@ -6182,6 +6448,7 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -6200,6 +6467,7 @@ "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", "license": "MIT", + "peer": true, "dependencies": { "stackframe": "^1.3.4" } @@ -6347,7 +6615,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6409,7 +6676,8 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/escape-string-regexp": { "version": "4.0.0", @@ -6459,6 +6727,7 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -6468,6 +6737,7 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -6482,7 +6752,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -6520,6 +6791,7 @@ "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", "license": "(MIT OR Apache-2.0)", + "peer": true, "bin": { "dotslash": "bin/dotslash" }, @@ -6532,6 +6804,7 @@ "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "bser": "2.1.1" } @@ -6587,6 +6860,7 @@ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "license": "MIT", + "peer": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -6599,6 +6873,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", @@ -6617,6 +6892,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -6625,7 +6901,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/find-root": { "version": "1.1.0", @@ -6654,7 +6931,8 @@ "version": "0.0.6", "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/for-each": { "version": "0.3.5", @@ -6707,6 +6985,7 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -6790,6 +7069,7 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "license": "ISC", + "peer": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -6819,6 +7099,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-own-enumerable-property-symbols": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", @@ -7070,19 +7359,22 @@ "version": "250829098.0.10", "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.10.tgz", "integrity": "sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/hermes-estree": { "version": "0.33.3", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/hermes-parser": { "version": "0.33.3", "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", "license": "MIT", + "peer": true, "dependencies": { "hermes-estree": "0.33.3" } @@ -7107,6 +7399,7 @@ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", + "peer": true, "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", @@ -7127,6 +7420,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -7136,6 +7430,7 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -7169,13 +7464,15 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/image-size": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", "license": "MIT", + "peer": true, "dependencies": { "queue": "6.0.2" }, @@ -7251,6 +7548,7 @@ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.0.0" } @@ -7535,6 +7833,7 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.12.0" } @@ -7764,6 +8063,7 @@ "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", "license": "MIT", + "peer": true, "dependencies": { "@types/react-reconciler": "^0.28.9" }, @@ -7810,6 +8110,7 @@ "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", "license": "MIT", + "peer": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -7819,6 +8120,7 @@ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -7842,6 +8144,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -7851,6 +8154,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.6" }, @@ -7863,6 +8167,7 @@ "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", @@ -7880,6 +8185,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -7892,6 +8198,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "license": "MIT", + "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -7905,13 +8212,15 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/jest-worker": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", @@ -7927,6 +8236,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7947,7 +8257,8 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/jsdoc-type-pratt-parser": { "version": "4.8.0", @@ -8075,6 +8386,7 @@ "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" @@ -8085,6 +8397,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8093,7 +8406,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lines-and-columns": { "version": "1.2.4", @@ -8156,7 +8470,8 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/loose-envify": { "version": "1.4.0", @@ -8211,6 +8526,7 @@ "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "tmpl": "1.0.5" } @@ -8226,7 +8542,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/math-intrinsics": { "version": "1.1.0", @@ -8258,13 +8575,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/metro": { "version": "0.84.3", "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.3.tgz", "integrity": "sha512-1h3lbVrE6hGf1e/764HfhPGg/bGrWMJDDh7G2rc4gFYZboVuI40BlG/y+UhtbhQDNlO/csMvrcnK0YrTlHUVew==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", @@ -8319,6 +8638,7 @@ "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.3.tgz", "integrity": "sha512-svAA+yMLpeMiGcz/jKJs4oHpIGEx4nBqNEJ5AGj4CYIg1efvK+A0TjR6tgIuc6tKO5e8JmN/1lglpN2+f3/z/w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", @@ -8334,13 +8654,15 @@ "version": "0.35.0", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/metro-babel-transformer/node_modules/hermes-parser": { "version": "0.35.0", "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", "license": "MIT", + "peer": true, "dependencies": { "hermes-estree": "0.35.0" } @@ -8350,6 +8672,7 @@ "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.3.tgz", "integrity": "sha512-0QElxwLaHqLZf+Xqio8QrjVbuXP/8sJfQBGSPiITlKDVXrVLefuzYVSH9Sj+QL6lrPj2gYZd/iwQh1yZuVKnLA==", "license": "MIT", + "peer": true, "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", @@ -8365,6 +8688,7 @@ "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.3.tgz", "integrity": "sha512-TnSL1Fdvrw+2glTdBSRmA5TL8l/i16ECjsrUdf3E5HncA+sNx8KcwDG8r+3ct1UhfYcusJypzZqTN55FZZcwGg==", "license": "MIT", + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -8377,6 +8701,7 @@ "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.3.tgz", "integrity": "sha512-JmCzZWOETR+O22q8oPBWyQppx3roU9EbkbGzD8Gf1jukQ4b5T1fTzqqHruu6K4sTiNq5zVQySmKF6bp4kVARew==", "license": "MIT", + "peer": true, "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", @@ -8396,6 +8721,7 @@ "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.3.tgz", "integrity": "sha512-cc0pvAa80ai1nDmqqz0P59a+0ZqCZ/YHU/3jEekZL6spFnYDfX8iDLdn9FR6kX+67rmzKxHNrbrSRFLX2AYocw==", "license": "MIT", + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", @@ -8410,6 +8736,7 @@ "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.3.tgz", "integrity": "sha512-1cL4m4Jv1yRUt9RJExZQLfccscdlMNOcRG6LHLtmJhf3BG9j3MujPVc7CIpKYdFl+KUl+sdjge6oO3+meKCHQA==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", @@ -8430,6 +8757,7 @@ "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.3.tgz", "integrity": "sha512-3ofrG2OQyJbO9RNhCfOcl8QU7EE2WrSsnN5dFkuZaJO5+4Imujr9bUXmspeNlXRsOVk0F/rVRbEFH98lFSCkBQ==", "license": "MIT", + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" @@ -8443,6 +8771,7 @@ "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.3.tgz", "integrity": "sha512-pjEzGDtoM8DTHAIPK/9u9ZxszEiuRohYUVImWvgbnB91V4gqYJpQcoEYUugf2NIm1lrX5HNu0OvNqWmPBnGYjA==", "license": "MIT", + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -8455,6 +8784,7 @@ "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.3.tgz", "integrity": "sha512-o7HLRfMyVk9N2dUZ9VjQfB6xxUItL9Pi9WcqxURE7MEKOH6wbGt9/E92YdYLluTOtkzYAEVfdC6h6lcxqA+hMQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" @@ -8468,6 +8798,7 @@ "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.3.tgz", "integrity": "sha512-jS48CeSzw78M8y6VE0f9uy3lVmfbOS677j2VCxnlmlYmnahcXuC6IhoN9K6LynNvos9517yUadcfgioju38xYQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", @@ -8488,6 +8819,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8497,6 +8829,7 @@ "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.3.tgz", "integrity": "sha512-J9Tpo8NCycYrozRvBIUyOwGAu4xkawOsAppmTscFiaegK0WvuDGwIM53GbzVSnytCHjVAF0io5GQxpkrKTuc7g==", "license": "MIT", + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", @@ -8517,6 +8850,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8526,6 +8860,7 @@ "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.3.tgz", "integrity": "sha512-8S3baq2XhBaafHEH5Q8sJW6tmzsEJk80qKc3RU/nZV1MsnYq94RdjTUR6AyKjQd6Rfsk1BtBxhtiNnk7mgslCg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", @@ -8543,6 +8878,7 @@ "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.3.tgz", "integrity": "sha512-Wjba7PyYktNRsHbPmkx2J2UX32rAzcDXjCu49zPHeF/viJlYJhwRaNePQcHaCRqQ+kmgQT4ThprsnJfDj71ZMA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", @@ -8566,13 +8902,15 @@ "version": "0.35.0", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/metro/node_modules/hermes-parser": { "version": "0.35.0", "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", "license": "MIT", + "peer": true, "dependencies": { "hermes-estree": "0.35.0" } @@ -8582,6 +8920,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8591,6 +8930,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.3.0" }, @@ -8612,6 +8952,7 @@ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "license": "MIT", + "peer": true, "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -8625,6 +8966,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.6" }, @@ -8637,6 +8979,7 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", + "peer": true, "bin": { "mime": "cli.js" }, @@ -8649,6 +8992,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -8658,6 +9002,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", + "peer": true, "dependencies": { "mime-db": "^1.54.0" }, @@ -8720,6 +9065,7 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "license": "MIT", + "peer": true, "bin": { "mkdirp": "bin/cmd.js" }, @@ -8757,6 +9103,7 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -8773,7 +9120,8 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/node-releases": { "version": "2.0.37", @@ -8785,13 +9133,15 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ob1": { "version": "0.84.3", "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.3.tgz", "integrity": "sha512-J7554Ef8bzmKaDY365Afq6PF+qtdnY/d5PKUQFrsKlZHV/N3OGZewVrvDrQDyX5V5NJjTpcAKtlrFZcDr+HvpQ==", "license": "MIT", + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -8857,6 +9207,7 @@ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "license": "MIT", + "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -8974,6 +9325,7 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -9175,6 +9527,7 @@ "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", "license": "MIT", + "peer": true, "dependencies": { "asap": "~2.0.6" } @@ -9220,6 +9573,7 @@ "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", "license": "MIT", + "peer": true, "dependencies": { "inherits": "~2.0.3" } @@ -9239,6 +9593,7 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -9248,7 +9603,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9258,6 +9612,7 @@ "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -9268,6 +9623,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.3.0" }, @@ -9321,7 +9677,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9363,6 +9718,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@types/react-reconciler": "^0.33.0", "its-fine": "^2.0.0", @@ -9380,6 +9736,7 @@ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", "integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "*" } @@ -9472,6 +9829,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -9483,13 +9841,15 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-native/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "license": "MIT", + "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -9503,13 +9863,15 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-native/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -9522,6 +9884,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.3.0" }, @@ -9543,6 +9906,7 @@ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9558,10 +9922,58 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", @@ -9639,6 +10051,28 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -9660,6 +10094,7 @@ "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", "license": "MIT", + "peer": true, "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" @@ -9675,6 +10110,7 @@ "resolved": "https://registry.npmjs.org/react-zdog/-/react-zdog-1.2.2.tgz", "integrity": "sha512-Ix7ALha91aOEwiHuxumCeYbARS5XNpc/w0v145oGkM6poF/CvhKJwzLhM5sEZbtrghMA+psAhOJkCTzJoseicA==", "license": "MIT", + "peer": true, "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", @@ -9699,6 +10135,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9712,6 +10149,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -9807,7 +10245,8 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", @@ -9873,6 +10312,7 @@ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9928,7 +10368,6 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -10095,6 +10534,7 @@ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -10119,6 +10559,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -10127,13 +10568,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/send/node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -10143,6 +10586,7 @@ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", + "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -10155,6 +10599,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -10164,6 +10609,7 @@ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10183,6 +10629,7 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", + "peer": true, "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", @@ -10198,6 +10645,7 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -10261,7 +10709,8 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/shallowequal": { "version": "1.1.0", @@ -10295,6 +10744,7 @@ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" }, @@ -10429,13 +10879,15 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/stacktrace-parser": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", "license": "MIT", + "peer": true, "dependencies": { "type-fest": "^0.7.1" }, @@ -10448,6 +10900,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=8" } @@ -10457,6 +10910,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -10481,7 +10935,6 @@ "integrity": "sha512-p8seiSI6FiVY6P3V0pG+5v7c8pDMehMAFRWEhG5XqIBSQszzOjDnW2rNvm3odoLKfo3V3P6Cs6Hv9ILzymULyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/core": "8.6.18" }, @@ -10827,6 +11280,7 @@ "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", "license": "MIT", + "peer": true, "peerDependencies": { "react": ">=17.0" } @@ -10895,7 +11349,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tiny-invariant": { "version": "1.3.3", @@ -10944,13 +11399,15 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "license": "MIT", + "peer": true, "dependencies": { "is-number": "^7.0.0" }, @@ -10963,6 +11420,7 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.6" } @@ -11114,7 +11572,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11220,6 +11677,7 @@ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -11279,6 +11737,27 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-isomorphic-layout-effect": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", @@ -11293,11 +11772,34 @@ } } }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -11327,6 +11829,7 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -11351,7 +11854,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -11890,13 +12392,15 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "makeerror": "1.0.12" } @@ -11919,7 +12423,8 @@ "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/whatwg-url": { "version": "7.1.0", @@ -12253,6 +12758,7 @@ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "license": "ISC", + "peer": true, "engines": { "node": ">=10" } @@ -12268,6 +12774,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -12283,6 +12790,7 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", + "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -12301,6 +12809,7 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -12309,13 +12818,15 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -12330,6 +12841,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, diff --git a/apps/pwa/package.json b/apps/pwa/package.json index b0de9966..ed26451c 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -1,17 +1,24 @@ { "name": "pwa", "type": "module", + "engines": { + "node": "24.x" + }, "scripts": { "dev": "vite", "dev-with-sw": "WITH_SW=true vite", "build": "vite build", - "storybook": "storybook dev -p 6006", + "typecheck": "tsc --noEmit -p tsconfig.json", + "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 --no-open", "build-storybook": "storybook build" }, "dependencies": { "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-slider": "^1.3.6", "classnames": "^2.3.2", "clrc": "^3.1.4", "cropperjs": "^1.5.12", @@ -30,7 +37,6 @@ "react-lrc": "^3.2.1", "react-router-dom": "^7.14.0", "react-select": "^5.7.7", - "@radix-ui/react-slider": "^1.3.6", "react-spring": "^10.0.3", "sanitize-filename": "^1.6.3", "styled-components": "^5.3.5", @@ -47,7 +53,6 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/react-list": "^0.8.7", - "@types/resize-observer-browser": "^0.1.5", "@types/styled-components": "^5.1.25", "@types/wicg-file-system-access": "^2023.10.7", diff --git a/docs/development/pwa/index.md b/apps/pwa/readme.md similarity index 96% rename from docs/development/pwa/index.md rename to apps/pwa/readme.md index 6571cb91..9494f40f 100644 --- a/docs/development/pwa/index.md +++ b/apps/pwa/readme.md @@ -7,7 +7,6 @@ ## Start DEV Server ```sh -cd apps/pwa npm install npm run dev ``` diff --git a/apps/pwa/src/app/index.tsx b/apps/pwa/src/app/index.tsx index 775ea59c..89f4111c 100644 --- a/apps/pwa/src/app/index.tsx +++ b/apps/pwa/src/app/index.tsx @@ -1,6 +1,5 @@ import ErrorBoundary from '@/components/error_boundary'; import { GlobalStyle } from '@/global_style'; -import { GlobalStyle as SelectGlobalStyle } from '@/components/select'; import { ThemeProvider } from 'styled-components'; import { ThemeProvider as CicadaThemeProvider } from '@/components_next/theme'; import { HashRouter } from 'react-router-dom'; @@ -20,7 +19,6 @@ function Wrapper() { - diff --git a/apps/pwa/src/components/dialog.tsx b/apps/pwa/src/components/dialog.tsx deleted file mode 100644 index 0bc66c61..00000000 --- a/apps/pwa/src/components/dialog.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { HtmlHTMLAttributes, useRef } from 'react'; -import * as React from 'react'; -import ReactDOM from 'react-dom'; -import styled from 'styled-components'; -import { useTransition, animated } from 'react-spring'; -import upperCaseFirstLetter from '@/style/upper_case_first_letter'; -import scrollbar from '@/style/scrollbar'; -import { CSSVariable } from '../global_style'; - -const TRANSITION = { - from: { - opacity: 0, - transform: 'translate(0, 100%)', - }, - enter: { - opacity: 1, - transform: 'translate(0, 0%)', - }, - leave: { - opacity: 0, - transform: 'translate(0, 100%)', - }, -}; - -const Mask = styled(animated.div)` - position: fixed; - width: 100%; - height: 100%; - top: 0; - left: 0; - - background-color: rgb(0 0 0 / 0.5); - - display: flex; - align-items: center; - justify-content: center; -`; -const Body = styled(animated.div)` - width: 100%; - width: min(350px, 80%); - max-height: 80%; - - background-color: white; - border-radius: ${CSSVariable.BORDER_RADIUS_NORMAL}; - transform-origin: bottom; - - display: flex; - flex-direction: column; -`; - -/** - * 弹窗 - * @author mebtte - */ -const Dialog = ({ - open, - onClose, - - maskProps = {}, - bodyProps = {}, - - children, -}: React.PropsWithChildren<{ - /** 开启状态 */ - open: boolean; - /** 关闭回调 */ - onClose?: () => void; - - /** 遮罩属性 */ - maskProps?: HtmlHTMLAttributes; - /** 内容属性 */ - bodyProps?: HtmlHTMLAttributes; -}>) => { - const bodyRef = useRef(null); - const onRequestClose = (event) => { - maskProps.onClick && maskProps.onClick(event); - - if (onClose && !bodyRef.current?.contains(event.target)) { - onClose(); - } - }; - - const transitions = useTransition(open, TRANSITION); - return ReactDOM.createPortal( - transitions(({ opacity, transform }, o) => - o ? ( - - - {children} - - - ) : null, - ), - document.body, - ); -}; - -export const Container = styled.div` - flex: 1; - min-height: 0; - - display: flex; - flex-direction: column; - gap: 20px; - padding: 20px 0; -`; - -export const Title = styled.div` - font-size: 20px; - font-weight: 600; - color: rgb(55 55 55); - - padding: 0 20px; - - ${upperCaseFirstLetter} -`; - -export const Content = styled.div` - flex: 1; - min-height: 0; - - font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - line-height: 1.5; - overflow: auto; - ${scrollbar} - - padding: 0 20px; - - ${upperCaseFirstLetter} -`; - -export const Action = styled.div` - display: flex; - align-items: center; - justify-content: flex-end; - gap: 20px; - - padding: 0 20px; - - > .left { - flex: 1; - display: flex; - align-items: center; - gap: 20px; - } -`; - -export default React.memo(Dialog, (prev, next) => { - if (!prev.open && !next.open) { - return true; - } - return false; -}); 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/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/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/label/index.tsx b/apps/pwa/src/components/label/index.tsx deleted file mode 100644 index 0496f22b..00000000 --- a/apps/pwa/src/components/label/index.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import upperCaseFirstLetter from '@/style/capitalize'; -import { ForwardedRef, forwardRef, HtmlHTMLAttributes, ReactNode } from 'react'; -import styled from 'styled-components'; -import { CSSVariable } from '../../global_style'; - -const Style = styled.label` - > .top { - margin-bottom: 5px; - - display: flex; - align-items: center; - - transition: inherit; - user-select: none; - - &:empty { - display: none; - } - - > .label { - flex: 1; - min-width: 0; - - color: ${CSSVariable.TEXT_COLOR_PRIMARY}; - font-size: ${CSSVariable.TEXT_SIZE_SMALL}; - transition: inherit; - ${upperCaseFirstLetter} - } - } - - &:focus-within { - > .top { - > .label { - color: ${CSSVariable.COLOR_PRIMARY}; - } - } - } - - &:disabled { - > .top { - > .label { - color: ${CSSVariable.TEXT_COLOR_DISABLED} !important; - } - } - } -`; -type Props = HtmlHTMLAttributes & { - label?: string; - addon?: ReactNode; -}; - -function Label( - { label, addon, children, ...props }: Props, - ref: ForwardedRef, -) { - return ( - - ); -} - -export default forwardRef(Label); 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/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/select/constants.ts b/apps/pwa/src/components/select/constants.ts deleted file mode 100644 index a16fd544..00000000 --- a/apps/pwa/src/components/select/constants.ts +++ /dev/null @@ -1,38 +0,0 @@ -import upperCaseFirstLetter from '#/utils/upper_case_first_letter'; -import { t } from '@/i18n'; - -export type Option = { - label: string; - value: string | number; - actualValue: Value; -}; - -export const DEFAULT_PLACEHOLDER = upperCaseFirstLetter(t('select')); - -export enum ClassName { - CONTAINER = 'react-select-container', - MENU_PORTAL = 'react-select-menu-portal', - MENU = 'react-select-menu', - INDICATOR_SEPARATOR = 'react-select-indicator-separator', - CONTROL = 'react-select-control', - OPTION = 'react-select-option', - MENU_LIST = 'react-select-menu-list', - PLACEHOLDER = 'react-select-placeholder', - VALUE_CONTAINER = 'react-select-value-container', - INPUT = 'react-select-input', -} - -export enum SingleClassName { - SINGLE_VALUE = 'react-select-single-value', -} - -export enum MultiClassName { - MULTI_VALUE = 'react-select-multi-value', - MULTI_VALUE_REMOVE = 'react-select-multi-value-remove', -} - -export enum StateClassName { - FOCUSED = 'react-select-focused', - DISABLED = 'react-select-disabled', - SELECTED = 'react-select-selected', -} diff --git a/apps/pwa/src/components/select/dropdown_indicator.tsx b/apps/pwa/src/components/select/dropdown_indicator.tsx deleted file mode 100644 index 23f50f0f..00000000 --- a/apps/pwa/src/components/select/dropdown_indicator.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { CSSVariable } from '@/global_style'; -import { CSSProperties } from 'react'; -import { MdOutlineArrowDropDown } from 'react-icons/md'; - -const style: CSSProperties = { - margin: '0 5px', - color: CSSVariable.TEXT_COLOR_PRIMARY, -}; - -function DropdownIndicator() { - return ; -} - -export default DropdownIndicator; diff --git a/apps/pwa/src/components/select/global_style.ts b/apps/pwa/src/components/select/global_style.ts deleted file mode 100644 index 453e30e0..00000000 --- a/apps/pwa/src/components/select/global_style.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { ComponentSize, UtilZIndex } from '@/constants/style'; -import { CSSVariable } from '@/global_style'; -import autoScrollbar from '@/style/auto_scrollbar'; -import { createGlobalStyle } from 'styled-components'; -import { - ClassName, - MultiClassName, - SingleClassName, - StateClassName, -} from './constants'; - -export default createGlobalStyle` - .${ClassName.CONTAINER} { - -webkit-app-region: no-drag; - } - - .${ClassName.MENU} { - -webkit-app-region: no-drag; - overflow: hidden; - font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; - border-radius: ${CSSVariable.BORDER_RADIUS_NORMAL} !important; - } - - .${ClassName.MENU_PORTAL} { - z-index: ${UtilZIndex.SELECT} !important; - } - - .${ClassName.INDICATOR_SEPARATOR} { - background-color: ${CSSVariable.COLOR_BORDER} !important; - } - - .${ClassName.CONTROL} { - font-size: ${CSSVariable.TEXT_SIZE_NORMAL} !important; - cursor: pointer !important; - min-height: ${ComponentSize.NORMAL}px !important; - box-shadow: none !important; - color: ${CSSVariable.TEXT_COLOR_PRIMARY}; - border-radius: ${CSSVariable.BORDER_RADIUS_NORMAL} !important; - border-color: ${CSSVariable.COLOR_BORDER} !important; - - &.${StateClassName.FOCUSED} { - border-color: ${CSSVariable.COLOR_PRIMARY} !important; - } - - &.${StateClassName.DISABLED} { - border-color: ${CSSVariable.TEXT_COLOR_DISABLED} !important; - } - } - - .${ClassName.OPTION} { - cursor: pointer !important; - color: ${CSSVariable.TEXT_COLOR_PRIMARY} !important; - background-color: transparent !important; - - &:hover { - background-color: ${CSSVariable.BACKGROUND_COLOR_LEVEL_ONE} !important; - } - - &.${StateClassName.SELECTED} { - color: #fff !important; - background-color: ${CSSVariable.COLOR_PRIMARY} !important; - } - } - - .${ClassName.VALUE_CONTAINER} { - padding: 2px 10px !important; - } - - .${ClassName.INPUT} { - color: ${CSSVariable.TEXT_COLOR_PRIMARY} !important; - margin: 0 !important; - padding: 0 !important; - } - - .${ClassName.MENU_LIST} { - max-height: 200px !important; - ${autoScrollbar} - } - - .${ClassName.PLACEHOLDER} { - color: ${CSSVariable.TEXT_COLOR_SECONDARY} !important; - margin: 0 !important; - } - - .${SingleClassName.SINGLE_VALUE} { - color: ${CSSVariable.TEXT_COLOR_PRIMARY} !important; - margin: 0 !important; - } - - .${MultiClassName.MULTI_VALUE} { - font-size: ${CSSVariable.TEXT_SIZE_SMALL}; - background-color: ${CSSVariable.BACKGROUND_COLOR_LEVEL_ONE} !important; - color: ${CSSVariable.TEXT_COLOR_PRIMARY}; - border-radius: ${CSSVariable.BORDER_RADIUS_LIGHT} !important; - margin-left: 0; - - &.${StateClassName.DISABLED} { - opacity: 0.5; - } - } - - .${MultiClassName.MULTI_VALUE_REMOVE} { - background-color: transparent; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - - &:hover { - background-color: ${CSSVariable.BACKGROUND_COLOR_LEVEL_ONE} !important; - color: ${CSSVariable.COLOR_DANGEROUS} !important; - } - } -`; diff --git a/apps/pwa/src/components/select/index.ts b/apps/pwa/src/components/select/index.ts deleted file mode 100644 index 118b287f..00000000 --- a/apps/pwa/src/components/select/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import Select from './select'; -import MultipleSelect from './multiple_select'; -import GlobalStyle from './global_style'; -import type { Option } from './constants'; - -export { GlobalStyle, Option, Select, MultipleSelect }; diff --git a/apps/pwa/src/components/select/loading_message.tsx b/apps/pwa/src/components/select/loading_message.tsx deleted file mode 100644 index a063be1d..00000000 --- a/apps/pwa/src/components/select/loading_message.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { CSSProperties } from 'react'; -import Spinner from '../spinner'; - -const style: CSSProperties = { - margin: '10px', -}; - -function LoadingIndicator() { - return ; -} - -export default LoadingIndicator; diff --git a/apps/pwa/src/components/select/multiple_select.tsx b/apps/pwa/src/components/select/multiple_select.tsx deleted file mode 100644 index 191f2aa1..00000000 --- a/apps/pwa/src/components/select/multiple_select.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { CSSProperties, ComponentProps, useMemo } from 'react'; -import Select from 'react-select/async'; -import classnames from 'classnames'; -import { throttle } from 'lodash-es'; -import { - ClassName, - DEFAULT_PLACEHOLDER, - MultiClassName, - Option as OptionType, - StateClassName, -} from './constants'; -import LoadingMessage from './loading_message'; -import Vacant from './vacant'; -import { noOptionsMessage } from './utils'; -import DropdownIndicator from './dropdown_indicator'; - -const classNames: ComponentProps ({ - ...baseStyles, - ...style, - }), - }} - /> - ); -} - -export default MultipleSelect; diff --git a/apps/pwa/src/components/select/select.tsx b/apps/pwa/src/components/select/select.tsx deleted file mode 100644 index 62fdaf6b..00000000 --- a/apps/pwa/src/components/select/select.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { ComponentProps, CSSProperties } from 'react'; -import Select from 'react-select'; -import classnames from 'classnames'; -import { - ClassName, - DEFAULT_PLACEHOLDER, - Option, - SingleClassName, - StateClassName, -} from './constants'; -import { noOptionsMessage } from './utils'; -import DropdownIndicator from './dropdown_indicator'; - -const classNames: ComponentProps ({ - ...baseStyles, - ...style, - }), - }} - /> - ); -} - -export default Wrapper; diff --git a/apps/pwa/src/components/select/utils.ts b/apps/pwa/src/components/select/utils.ts deleted file mode 100644 index 1cfa6fe6..00000000 --- a/apps/pwa/src/components/select/utils.ts +++ /dev/null @@ -1,4 +0,0 @@ -import capitalize from '#/utils/capitalize'; -import { t } from '@/i18n'; - -export const noOptionsMessage = () => capitalize(t('no_data')); diff --git a/apps/pwa/src/components/select/vacant.tsx b/apps/pwa/src/components/select/vacant.tsx deleted file mode 100644 index 079b568a..00000000 --- a/apps/pwa/src/components/select/vacant.tsx +++ /dev/null @@ -1,5 +0,0 @@ -function Vacant() { - return null; -} - -export default Vacant; 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/button.stories.tsx b/apps/pwa/src/components_next/button/button.stories.tsx index 753b5db8..125b2f87 100644 --- a/apps/pwa/src/components_next/button/button.stories.tsx +++ b/apps/pwa/src/components_next/button/button.stories.tsx @@ -10,38 +10,42 @@ const meta = { docs: { description: { component: - '基础按钮组件,支持 4 种变体、3 种尺寸,内置加载和禁用状态,移动端触摸友好。', + '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: '视觉变体', + 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: { 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', + }, + 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: '按钮文字', + description: 'Button label', }, onClick: { action: 'clicked' }, }, @@ -50,8 +54,6 @@ const meta = { export default meta; type Story = StoryObj; -// ── Single stories (用于 Controls 面板交互调试) ── - export const Primary: Story = { args: { children: 'Primary', variant: 'primary' }, }; @@ -68,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 }, }; @@ -87,8 +93,6 @@ export const Block: Story = { ], }; -// ── Showcase stories (静态对比展示) ── - export const AllVariants: Story = { name: 'All Variants', render: () => ( @@ -97,6 +101,7 @@ export const AllVariants: Story = { + ), }; @@ -141,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..03807a0c 100644 --- a/apps/pwa/src/components_next/button/index.tsx +++ b/apps/pwa/src/components_next/button/index.tsx @@ -2,12 +2,13 @@ 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})`; 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, @@ -76,6 +77,28 @@ const makeVariant = ( filter 60ms; } + &:disabled { + box-shadow: 0 ${({ $offset }) => $offset}px 0 ${DISABLED_SHADOW}; + filter: saturate(0.45); + opacity: 0.65; + } +`; + +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; @@ -87,6 +110,7 @@ const VARIANT_MAP: Record> = { 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 +147,7 @@ const StyledButton = styled.button<{ $block: boolean; $loading: boolean; $offset: number; + $square: boolean; }>` position: relative; display: inline-flex; @@ -159,6 +184,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 +201,7 @@ export interface ButtonProps extends ButtonHTMLAttributes { size?: Size; loading?: boolean; block?: boolean; + square?: boolean; icon?: ReactNode; } @@ -180,6 +210,7 @@ function Button({ size = 'md', loading = false, block = false, + square = false, disabled = false, icon, children, @@ -192,6 +223,7 @@ function Button({ $variant={variant} $size={size} $block={block} + $square={square} $loading={loading} $offset={offset} disabled={loading || disabled} diff --git a/apps/pwa/src/components_next/dialog/dialog.stories.tsx b/apps/pwa/src/components_next/dialog/dialog.stories.tsx new file mode 100644 index 00000000..af7a96a1 --- /dev/null +++ b/apps/pwa/src/components_next/dialog/dialog.stories.tsx @@ -0,0 +1,194 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogBody, + DialogFooter, + DialogClose, +} from '.'; +import Button from '../button'; +import Input from '../input'; + +const meta = { + title: 'Basic/Dialog', + component: DialogContent, + tags: ['autodocs'], + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Accessible dialog built on **@radix-ui/react-dialog**. ' + + 'On mobile (< 640 px) it renders as a bottom sheet; on desktop it appears as a centered modal. ' + + 'Compose with `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogBody`, and `DialogFooter`.', + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Basic ──────────────────────────────────────────────────────────────────── + +export const Basic: Story = { + name: 'Basic', + parameters: { controls: { disable: true } }, + render: () => { + const [open, setOpen] = useState(false); + return ( + + + + + + + Delete playlist + + This action cannot be undone. The playlist will be permanently + removed from your library. + + + + + + + + + + + ); + }, +}; + +// ─── With form ──────────────────────────────────────────────────────────────── + +export const WithForm: Story = { + name: 'With form', + parameters: { controls: { disable: true } }, + render: () => { + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + return ( + + + + + + + New playlist + + Give your playlist a name to get started. + + + + setName(e.target.value)} + maxLength={60} + /> + + + + + + + + + + ); + }, +}; + +// ─── Long content (scroll) ──────────────────────────────────────────────────── + +export const LongContent: Story = { + name: 'Long content (scroll)', + parameters: { controls: { disable: true } }, + render: () => { + const [open, setOpen] = useState(false); + return ( + + + + + + + Terms of Service + Last updated April 2026 + + + {Array.from({ length: 12 }, (_, i) => ( +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex ea commodo consequat. +

+ ))} +
+ + + +
+
+ ); + }, +}; + +// ─── No close button ────────────────────────────────────────────────────────── + +export const NoCloseButton: Story = { + name: 'No close button', + parameters: { controls: { disable: true } }, + render: () => { + const [open, setOpen] = useState(false); + return ( + + + + + + + Processing… + + Please wait while we upload your file. Do not close this window. + + + + + + + + ); + }, +}; diff --git a/apps/pwa/src/components_next/dialog/index.tsx b/apps/pwa/src/components_next/dialog/index.tsx new file mode 100644 index 00000000..72fb5958 --- /dev/null +++ b/apps/pwa/src/components_next/dialog/index.tsx @@ -0,0 +1,316 @@ +/** + * Dialog — Radix UI foundation, Cicada design system styling. + * + * API (shadcn/ui style): + * + * + * + * + * Title + * Subtitle or description. + * + * …content… + * + * + * + * + * + * + * + * Responsive: + * < 640 px → bottom sheet (slides up from bottom, rounded top corners) + * ≥ 640 px → centered modal (scale + fade) + */ + +import { + ComponentPropsWithoutRef, + CSSProperties, + ElementRef, + forwardRef, + HTMLAttributes, +} from 'react'; +import * as RadixDialog from '@radix-ui/react-dialog'; +import styled, { keyframes } from 'styled-components'; +import { useTheme, CSS_VAR } from '../theme'; + +// ─── Tokens ─────────────────────────────────────────────────────────────────── + +const FONT = `'Nunito', 'Varela Round', system-ui, sans-serif`; +const MOBILE = 640; // px — breakpoint between sheet and modal + +// ─── Animations ─────────────────────────────────────────────────────────────── + +const overlayIn = keyframes`from{opacity:0}to{opacity:1}`; +const overlayOut = keyframes`from{opacity:1}to{opacity:0}`; + +const sheetIn = keyframes` + from { opacity: 0.6; transform: translateY(100%); } + to { opacity: 1; transform: translateY(0); } +`; +const sheetOut = keyframes` + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(100%); } +`; + +const modalIn = keyframes` + from { opacity: 0; transform: translate(-50%, -48%) scale(0.96); } + to { opacity: 1; transform: translate(-50%, -50%) scale(1); } +`; +const modalOut = keyframes` + from { opacity: 1; transform: translate(-50%, -50%) scale(1); } + to { opacity: 0; transform: translate(-50%, -48%) scale(0.96); } +`; + +// ─── Overlay ────────────────────────────────────────────────────────────────── + +const Overlay = styled(RadixDialog.Overlay)` + position: fixed; + inset: 0; + z-index: 8999; + background: rgba(0, 0, 0, 0.42); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + + &[data-state='open'] { animation: ${overlayIn} 200ms ease; } + &[data-state='closed'] { animation: ${overlayOut} 180ms ease; } +`; + +// ─── Panel ──────────────────────────────────────────────────────────────────── + +const Panel = styled.div` + position: fixed; + z-index: 9000; + display: flex; + flex-direction: column; + overflow: hidden; + background: #fff; + outline: none; + + /* ── Mobile: bottom sheet ─────────────────────────────── */ + left: 0; + right: 0; + bottom: 0; + max-height: 92dvh; + border-radius: 20px 20px 0 0; + border: 2px solid rgb(220 220 220); + border-bottom: none; + box-shadow: 0 -5px 0 rgb(185 185 185); + + &[data-state='open'] { animation: ${sheetIn} 340ms cubic-bezier(0.16, 1, 0.3, 1); } + &[data-state='closed'] { animation: ${sheetOut} 220ms ease-in; } + + /* ── Desktop: centered modal ──────────────────────────── */ + @media (min-width: ${MOBILE}px) { + left: 50%; + top: 50%; + right: auto; + bottom: auto; + transform: translate(-50%, -50%); + width: min(480px, calc(100vw - 48px)); + max-height: calc(100dvh - 48px); + border-radius: 20px; + border: 2px solid rgb(220 220 220); + box-shadow: 0 8px 0 rgb(185 185 185); + + &[data-state='open'] { animation: ${modalIn} 210ms cubic-bezier(0.16, 1, 0.3, 1); } + &[data-state='closed'] { animation: ${modalOut} 160ms ease-in; } + } +`; + +// ─── Drag handle (mobile only) ──────────────────────────────────────────────── + +const Handle = styled.div` + flex-shrink: 0; + width: 36px; + height: 4px; + border-radius: 2px; + background: rgb(215 215 215); + margin: 12px auto 0; + + @media (min-width: ${MOBILE}px) { + display: none; + } +`; + +// ─── Close button ───────────────────────────────────────────────────────────── + +const CloseButton = styled(RadixDialog.Close)` + position: absolute; + top: 14px; + right: 14px; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: rgb(185 185 185); + cursor: pointer; + transition: color 120ms, background 120ms; + + &:hover { color: rgb(60 60 60); background: rgb(240 240 240); } + &:focus-visible { + outline: 3px solid currentColor; + outline-offset: 2px; + } +`; + +// ─── Scroll area ────────────────────────────────────────────────────────────── +// Wraps all children so the panel header (handle + close) stays fixed +// while inner content can scroll freely. + +const ScrollArea = styled.div` + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + + /* thin custom scrollbar */ + &::-webkit-scrollbar { width: 4px; } + &::-webkit-scrollbar-track { background: transparent; } + &::-webkit-scrollbar-thumb { background: rgb(210 210 210); border-radius: 4px; } +`; + +// ─── DialogContent ──────────────────────────────────────────────────────────── + +export interface DialogContentProps + extends ComponentPropsWithoutRef { + /** Show the × close button. Default true. */ + showClose?: boolean; +} + +export const DialogContent = forwardRef< + ElementRef, + DialogContentProps +>(({ children, showClose = true, style, ...props }, ref) => { + const theme = useTheme(); + const themeVars = { + [CSS_VAR.colorPrimary]: theme.colorPrimary, + [CSS_VAR.colorPrimaryShadow]: `color-mix(in srgb, ${theme.colorPrimary} 70%, #000)`, + } as CSSProperties; + + return ( + + + + + + {showClose && ( + + + + + + )} + {children} + + + + ); +}); +DialogContent.displayName = 'DialogContent'; + +// ─── DialogHeader ───────────────────────────────────────────────────────────── + +export const DialogHeader = styled.div>` + padding: 20px 50px 0 24px; /* right padding leaves room for the × button */ + flex-shrink: 0; +`; + +// ─── DialogTitle ────────────────────────────────────────────────────────────── + +const DialogTitleText = styled.h2` + margin: 0; + font-family: ${FONT}; + font-size: 18px; + font-weight: 800; + letter-spacing: 0.2px; + color: rgb(50 50 50); + line-height: 1.2; +`; + +export const DialogTitle = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ children, ...props }, ref) => ( + + {children} + +)); +DialogTitle.displayName = 'DialogTitle'; + +// ─── DialogDescription ─────────────────────────────────────────────────────── + +const DialogDescriptionText = styled.p` + margin: 6px 0 0; + font-family: ${FONT}; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.1px; + color: rgb(140 140 140); + line-height: 1.55; +`; + +export const DialogDescription = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ children, ...props }, ref) => ( + + {children} + +)); +DialogDescription.displayName = 'DialogDescription'; + +// ─── DialogBody ─────────────────────────────────────────────────────────────── +// Optional wrapper for the main content area below DialogHeader. + +export const DialogBody = styled.div< + HTMLAttributes & { style?: CSSProperties } +>` + padding: 20px 24px 0; + font-family: ${FONT}; + font-size: 14px; + font-weight: 600; + color: rgb(100 100 100); + line-height: 1.55; + letter-spacing: 0.1px; +`; + +// ─── DialogFooter ───────────────────────────────────────────────────────────── +// Mobile: buttons stack full-width (column, primary at bottom) +// Desktop: buttons inline, right-aligned + +export const DialogFooter = styled.div>` + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px 24px; + padding-bottom: max(20px, env(safe-area-inset-bottom, 20px)); + flex-shrink: 0; + + /* full-width buttons on mobile */ + & > * { width: 100%; } + + @media (min-width: ${MOBILE}px) { + flex-direction: row; + justify-content: flex-end; + gap: 10px; + padding-bottom: 20px; + + & > * { width: auto; } + } +`; + +// ─── Re-exports ─────────────────────────────────────────────────────────────── + +export const Dialog = RadixDialog.Root; +export const DialogTrigger = RadixDialog.Trigger; +export const DialogClose = RadixDialog.Close; +export type DialogProps = ComponentPropsWithoutRef; 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/drawer/drawer.stories.tsx b/apps/pwa/src/components_next/drawer/drawer.stories.tsx new file mode 100644 index 00000000..595604c1 --- /dev/null +++ b/apps/pwa/src/components_next/drawer/drawer.stories.tsx @@ -0,0 +1,186 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerDescription, + DrawerBody, + DrawerFooter, + DrawerClose, + DrawerTrigger, +} from '.'; +import Button from '../button'; + +const meta = { + title: 'Layout/Drawer', + component: DrawerContent, + tags: ['autodocs'], + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Side panel with the same visual language as Dialog. Slides in from `left`, `right` (default), or `bottom`. Built on Radix UI Dialog for focus trapping, escape-to-close, and accessibility.', + }, + }, + }, + argTypes: { + side: { + control: 'select', + options: ['left', 'right', 'bottom'], + description: 'Which edge the drawer slides from.', + table: { defaultValue: { summary: 'right' } }, + }, + showClose: { + control: 'boolean', + description: 'Show the × close button.', + table: { defaultValue: { summary: 'true' } }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function DrawerDemo({ + side = 'right', + title = 'Drawer', + description, + showClose = true, + longContent = false, +}: { + side?: 'left' | 'right' | 'bottom'; + title?: string; + description?: string; + showClose?: boolean; + longContent?: boolean; +}) { + const [open, setOpen] = useState(false); + return ( + <> + + + + + {title} + {description && {description}} + + + {longContent + ? Array.from({ length: 20 }, (_, i) => ( +

+ Item {i + 1} — Lorem ipsum dolor sit amet, consectetur adipiscing elit. +

+ )) + :

Drawer content goes here.

} +
+ + + + + + +
+
+ + ); +} + +// ─── Stories ────────────────────────────────────────────────────────────────── + +export const Right: Story = { + name: 'Right (default)', + render: () => ( + + ), +}; + +export const Left: Story = { + name: 'Left', + render: () => ( + + ), +}; + +export const Bottom: Story = { + name: 'Bottom', + render: () => ( + + ), +}; + +export const WithTrigger: Story = { + name: 'With DrawerTrigger', + render: () => ( + + + + + + + Triggered Drawer + Opened via DrawerTrigger — no external state needed. + + +

Content area.

+
+ + + + + +
+
+ ), +}; + +export const LongContent: Story = { + name: 'Long content (scroll)', + render: () => ( + + ), +}; + +export const NoCloseButton: Story = { + name: 'No close button', + render: () => ( + + ), +}; + +export const AllSides: Story = { + name: 'All sides', + render: () => ( +
+ {(['left', 'right', 'bottom'] as const).map((side) => ( + + ))} +
+ ), +}; diff --git a/apps/pwa/src/components_next/drawer/index.tsx b/apps/pwa/src/components_next/drawer/index.tsx new file mode 100644 index 00000000..934715f6 --- /dev/null +++ b/apps/pwa/src/components_next/drawer/index.tsx @@ -0,0 +1,309 @@ +/** + * Drawer — side panel with the same visual language as Dialog. + * + * API (shadcn/ui style): + * + * + * + * + * Settings + * Manage your preferences. + * + * …content… + * + * + * + * + * + * + * + * Sides: + * 'right' — slides in from the right (default) + * 'left' — slides in from the left + * 'bottom' — slides up from the bottom (full-width sheet) + */ + +import { + ComponentPropsWithoutRef, + CSSProperties, + ElementRef, + forwardRef, + HTMLAttributes, +} from 'react'; +import * as RadixDialog from '@radix-ui/react-dialog'; +import styled, { css, keyframes } from 'styled-components'; +import { useTheme, CSS_VAR } from '../theme'; + +// ─── Tokens ─────────────────────────────────────────────────────────────────── + +const FONT = `'Nunito', 'Varela Round', system-ui, sans-serif`; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type DrawerSide = 'left' | 'right' | 'bottom'; + +// ─── Animations ─────────────────────────────────────────────────────────────── + +const overlayIn = keyframes`from{opacity:0}to{opacity:1}`; +const overlayOut = keyframes`from{opacity:1}to{opacity:0}`; + +const slideInRight = keyframes`from{transform:translateX(100%)}to{transform:translateX(0)}`; +const slideOutRight = keyframes`from{transform:translateX(0)}to{transform:translateX(100%)}`; + +const slideInLeft = keyframes`from{transform:translateX(-100%)}to{transform:translateX(0)}`; +const slideOutLeft = keyframes`from{transform:translateX(0)}to{transform:translateX(-100%)}`; + +const slideInBottom = keyframes`from{transform:translateY(100%)}to{transform:translateY(0)}`; +const slideOutBottom = keyframes`from{transform:translateY(0)}to{transform:translateY(100%)}`; + +// ─── Overlay ────────────────────────────────────────────────────────────────── + +const Overlay = styled(RadixDialog.Overlay)` + position: fixed; + inset: 0; + z-index: 8999; + background: rgba(0, 0, 0, 0.42); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + -webkit-app-region: no-drag; + + &[data-state='open'] { animation: ${overlayIn} 200ms ease; } + &[data-state='closed'] { animation: ${overlayOut} 180ms ease; } +`; + +// ─── Panel ──────────────────────────────────────────────────────────────────── +// +// Hard shadow mirrors the design system's Duolingo-style depth: +// right drawer → shadow extends left (−x) +// left drawer → shadow extends right (+x) +// bottom drawer → shadow extends up (−y) + +const SIDE_MAP: Record> = { + right: css` + top: 0; + right: 0; + bottom: 0; + width: min(360px, calc(100vw - 20px)); + border-radius: 20px 0 0 20px; + border: 2px solid rgb(220 220 220); + border-right: none; + box-shadow: -5px 0 0 rgb(185 185 185); + + &[data-state='open'] { animation: ${slideInRight} 340ms cubic-bezier(0.16, 1, 0.3, 1); } + &[data-state='closed'] { animation: ${slideOutRight} 220ms ease-in; } + `, + left: css` + top: 0; + left: 0; + bottom: 0; + width: min(360px, calc(100vw - 20px)); + border-radius: 0 20px 20px 0; + border: 2px solid rgb(220 220 220); + border-left: none; + box-shadow: 5px 0 0 rgb(185 185 185); + + &[data-state='open'] { animation: ${slideInLeft} 340ms cubic-bezier(0.16, 1, 0.3, 1); } + &[data-state='closed'] { animation: ${slideOutLeft} 220ms ease-in; } + `, + bottom: css` + left: 0; + right: 0; + bottom: 0; + max-height: 92dvh; + border-radius: 20px 20px 0 0; + border: 2px solid rgb(220 220 220); + border-bottom: none; + box-shadow: 0 -5px 0 rgb(185 185 185); + + &[data-state='open'] { animation: ${slideInBottom} 340ms cubic-bezier(0.16, 1, 0.3, 1); } + &[data-state='closed'] { animation: ${slideOutBottom} 220ms ease-in; } + `, +}; + +const Panel = styled.div<{ $side: DrawerSide }>` + position: fixed; + z-index: 9000; + display: flex; + flex-direction: column; + overflow: hidden; + background: #fff; + outline: none; + -webkit-app-region: no-drag; + + ${({ $side }) => SIDE_MAP[$side]} +`; + +// ─── Close button ───────────────────────────────────────────────────────────── + +const CloseButton = styled(RadixDialog.Close)` + position: absolute; + top: 14px; + right: 14px; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: rgb(185 185 185); + cursor: pointer; + transition: color 120ms, background 120ms; + flex-shrink: 0; + + &:hover { color: rgb(60 60 60); background: rgb(240 240 240); } + &:focus-visible { + outline: 3px solid currentColor; + outline-offset: 2px; + } +`; + +// ─── Scroll area ────────────────────────────────────────────────────────────── +// Wraps all slotted children so they can scroll while the close button stays fixed. + +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; } +`; + +// ─── DrawerContent ──────────────────────────────────────────────────────────── + +export interface DrawerContentProps + extends ComponentPropsWithoutRef { + /** Which edge the drawer slides from. Default: 'right'. */ + side?: DrawerSide; + /** Show the × close button. Default: true. */ + showClose?: boolean; +} + +export const DrawerContent = forwardRef< + ElementRef, + DrawerContentProps +>(({ children, side = 'right', showClose = true, style, ...props }, ref) => { + const theme = useTheme(); + const themeVars = { + [CSS_VAR.colorPrimary]: theme.colorPrimary, + [CSS_VAR.colorPrimaryShadow]: `color-mix(in srgb, ${theme.colorPrimary} 70%, #000)`, + } as CSSProperties; + + return ( + + + + + {showClose && ( + + + + + + )} + {children} + + + + ); +}); +DrawerContent.displayName = 'DrawerContent'; + +// ─── DrawerHeader ───────────────────────────────────────────────────────────── +// Right padding leaves room for the × close button. + +export const DrawerHeader = styled.div>` + padding: 20px 50px 0 24px; + flex-shrink: 0; +`; + +// ─── DrawerTitle ────────────────────────────────────────────────────────────── + +const DrawerTitleText = styled.h2` + margin: 0; + font-family: ${FONT}; + font-size: 18px; + font-weight: 800; + letter-spacing: 0.2px; + color: rgb(50 50 50); + line-height: 1.2; +`; + +export const DrawerTitle = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ children, ...props }, ref) => ( + + {children} + +)); +DrawerTitle.displayName = 'DrawerTitle'; + +// ─── DrawerDescription ──────────────────────────────────────────────────────── + +const DrawerDescriptionText = styled.p` + margin: 6px 0 0; + font-family: ${FONT}; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.1px; + color: rgb(140 140 140); + line-height: 1.55; +`; + +export const DrawerDescription = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ children, ...props }, ref) => ( + + {children} + +)); +DrawerDescription.displayName = 'DrawerDescription'; + +// ─── DrawerBody ─────────────────────────────────────────────────────────────── +// Main scrollable content area. + +export const DrawerBody = styled.div< + HTMLAttributes & { style?: CSSProperties } +>` + padding: 20px 24px 0; + font-family: ${FONT}; + font-size: 14px; + font-weight: 600; + color: rgb(100 100 100); + line-height: 1.55; + letter-spacing: 0.1px; +`; + +// ─── DrawerFooter ───────────────────────────────────────────────────────────── +// Sticky bottom toolbar — use `position: sticky; bottom: 0` via style if +// needed, or let it flow naturally with the content. + +export const DrawerFooter = styled.div>` + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + gap: 10px; + padding: 20px 24px; + padding-bottom: max(20px, env(safe-area-inset-bottom, 20px)); + flex-shrink: 0; +`; + +// ─── Re-exports ─────────────────────────────────────────────────────────────── + +export const Drawer = RadixDialog.Root; +export const DrawerTrigger = RadixDialog.Trigger; +export const DrawerClose = RadixDialog.Close; +export type DrawerProps = ComponentPropsWithoutRef; diff --git a/apps/pwa/src/components_next/icon/icon.stories.tsx b/apps/pwa/src/components_next/icon/icon.stories.tsx index 6557f30c..2285111d 100644 --- a/apps/pwa/src/components_next/icon/icon.stories.tsx +++ b/apps/pwa/src/components_next/icon/icon.stories.tsx @@ -1,41 +1,43 @@ +import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { IconList, IconPlayQueue } from '.'; +import { IconEdit, 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: 'IconEdit', Component: IconEdit }, { 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 +46,6 @@ const meta = { export default meta; type Story = StoryObj; -// ── Gallery(文档首屏展示) ──────────────────────────────────────────────────── - export const Gallery: Story = { name: 'Gallery', parameters: { controls: { disable: true } }, @@ -82,15 +82,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 +101,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/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/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/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 a0b42a9f..ce32974b 100644 --- a/apps/pwa/src/components_next/index.ts +++ b/apps/pwa/src/components_next/index.ts @@ -1,11 +1,51 @@ 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 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'; +export { Select, MultiSelect } from './select'; +export type { SelectProps, MultiSelectProps, SelectOption, SelectSize } from './select'; + export { Icon, IconList, IconPlayQueue } from './icon'; export type { IconProps } from './icon'; +export { + Dialog, + DialogTrigger, + DialogContent, + DialogClose, + DialogHeader, + DialogTitle, + DialogDescription, + DialogBody, + DialogFooter, +} from './dialog'; +export type { DialogProps, DialogContentProps } from './dialog'; + +export { + Drawer, + DrawerTrigger, + DrawerContent, + DrawerClose, + DrawerHeader, + DrawerTitle, + DrawerDescription, + DrawerBody, + DrawerFooter, +} from './drawer'; +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 new file mode 100644 index 00000000..3a89d336 --- /dev/null +++ b/apps/pwa/src/components_next/input/index.tsx @@ -0,0 +1,209 @@ +import { + forwardRef, + InputHTMLAttributes, + ReactNode, + useId, +} from 'react'; +import styled, { css } from 'styled-components'; +import { CSS_VAR } from '../theme'; +import Label from '../label'; + +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`; +const DISABLED_BACKGROUND = 'rgb(248 248 248)'; +const DISABLED_BORDER = 'rgb(226 226 226)'; +const DISABLED_SHADOW = 'rgb(214 214 214)'; + +// ─── Styled ─────────────────────────────────────────────────────────────────── + +const Root = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +`; + +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, $size }) => + $disabled && + css` + background: ${DISABLED_BACKGROUND}; + border-color: ${DISABLED_BORDER}; + box-shadow: 0 ${SIZE[$size].shadow}px 0 ${DISABLED_SHADOW}; + 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; + color: rgb(145 145 145); + } +`; + +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, + className, + style, + ...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/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 new file mode 100644 index 00000000..4d65769c --- /dev/null +++ b/apps/pwa/src/components_next/select/index.tsx @@ -0,0 +1,373 @@ +import { CSSProperties, useCallback, useId, useMemo } from 'react'; +import ReactSelect, { + type StylesConfig, + type SingleValue, + type GroupBase, + components, + type DropdownIndicatorProps, +} from 'react-select'; +import AsyncReactSelect from 'react-select/async'; +import styled from 'styled-components'; +import Label from '../label'; +import { useTheme } from '../theme'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type SelectSize = 'sm' | 'md' | 'lg'; + +export type SelectOption = { + label: string; + value: T; +}; + +// ─── Size tokens ────────────────────────────────────────────────────────────── + +const SIZE: Record = { + sm: { height: 34, font: 13, radius: 10, shadow: 3, px: 12 }, + md: { height: 44, font: 15, radius: 13, shadow: 4, px: 14 }, + lg: { height: 54, font: 17, radius: 16, shadow: 5, px: 18 }, +}; + +const FONT = `'Nunito', 'Varela Round', system-ui, sans-serif`; + +function toKey(v: T): string { + return JSON.stringify(v); +} + +// ─── Shell layout ───────────────────────────────────────────────────────────── + +const Root = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +`; + +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)')}; +`; + +// ─── Custom chevron (matches original design) ───────────────────────────────── + +function DropdownIndicator(props: DropdownIndicatorProps, boolean, GroupBase>>) { + const { selectProps, innerProps } = props; + const s = SIZE['md']; // size isn't passed to indicator; use as fallback + return ( + + + + + + ); +} + +// ─── Styles factory ─────────────────────────────────────────────────────────── + +function buildStyles( + primary: string, + size: SelectSize, + hasError: boolean, + isDisabled: boolean, +): StylesConfig, IsMulti, GroupBase>> { + const s = SIZE[size]; + const shadowColor = `color-mix(in srgb, ${primary} 70%, #000)`; + + return { + control: (_, state) => ({ + display: 'flex', + alignItems: 'center', + width: '100%', + minHeight: s.height, + background: '#fff', + border: `2px solid ${ + hasError ? 'rgb(242 80 66)' : + state.isFocused ? primary : + 'rgb(220 220 220)' + }`, + borderRadius: s.radius, + boxShadow: isDisabled ? 'none' : + hasError ? `0 ${s.shadow}px 0 rgb(190 46 34)` : + state.isFocused ? `0 ${s.shadow}px 0 ${shadowColor}` : + `0 ${s.shadow}px 0 rgb(185 185 185)`, + cursor: isDisabled ? 'not-allowed' : 'pointer', + fontFamily: FONT, + fontSize: s.font, + fontWeight: 600, + letterSpacing: '0.2px', + opacity: isDisabled ? 0.5 : 1, + transition: 'border-color 150ms ease-out, box-shadow 150ms ease-out', + outline: 'none', + }), + valueContainer: (_) => ({ + display: 'flex', + flex: 1, + flexWrap: 'wrap' as const, + alignItems: 'center', + padding: `4px ${s.px - 4}px`, + gap: 4, + overflow: 'hidden', + }), + singleValue: (provided) => ({ + ...provided, + color: 'rgb(55 55 55)', + fontFamily: FONT, + fontWeight: 600, + fontSize: s.font, + letterSpacing: '0.2px', + }), + placeholder: (provided) => ({ + ...provided, + color: 'rgb(205 205 205)', + fontFamily: FONT, + fontWeight: 500, + fontSize: s.font, + letterSpacing: '0.2px', + }), + indicatorsContainer: (_) => ({ + display: 'flex', + alignItems: 'center', + flexShrink: 0, + paddingRight: s.px - 10, + }), + dropdownIndicator: (_) => ({ + display: 'flex', + alignItems: 'center', + padding: '0 2px', + }), + indicatorSeparator: () => ({ display: 'none' }), + clearIndicator: (_) => ({ + display: 'flex', + alignItems: 'center', + padding: '0 4px', + color: 'rgb(175 175 175)', + cursor: 'pointer', + }), + menu: (_) => ({ + position: 'absolute' as const, + zIndex: 9000, + background: '#fff', + border: '2px solid rgb(220 220 220)', + borderRadius: s.radius, + boxShadow: '0 8px 28px rgba(0,0,0,0.13)', + overflow: 'hidden', + marginTop: 4, + }), + menuPortal: (base) => ({ ...base, zIndex: 9000 }), + menuList: (_) => ({ + padding: 6, + maxHeight: 248, + overflowY: 'auto' as const, + }), + option: (_, state) => ({ + display: 'flex', + alignItems: 'center', + height: Math.round(s.height * 0.82), + padding: `0 ${s.px}px`, + borderRadius: s.radius - 4, + fontFamily: FONT, + fontSize: s.font, + fontWeight: 600, + cursor: 'pointer', + background: state.isSelected ? primary : + state.isFocused ? 'rgb(245 245 245)' : 'transparent', + color: state.isSelected ? '#fff' : 'rgb(55 55 55)', + transition: 'background 80ms, color 80ms', + }), + multiValue: (_) => ({ + display: 'inline-flex', + alignItems: 'center', + padding: '0 2px 0 8px', + height: 22, + borderRadius: 6, + background: 'rgb(240 240 240)', + flexShrink: 0, + }), + multiValueLabel: (provided) => ({ + ...provided, + color: 'rgb(66 66 66)', + fontFamily: FONT, + fontSize: 12, + fontWeight: 700, + letterSpacing: '0.1px', + padding: 0, + }), + multiValueRemove: (_) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: 'rgb(160 160 160)', + padding: '0 3px', + marginLeft: 2, + borderRadius: 3, + cursor: 'pointer', + }), + noOptionsMessage: (_) => ({ + padding: 16, + textAlign: 'center' as const, + fontFamily: FONT, + fontSize: 13, + color: 'rgb(180 180 180)', + fontWeight: 600, + }), + loadingMessage: (_) => ({ + padding: 16, + textAlign: 'center' as const, + fontFamily: FONT, + fontSize: 13, + color: 'rgb(180 180 180)', + fontWeight: 600, + }), + }; +} + +// ─── Select ─────────────────────────────────────────────────────────────────── + +export interface SelectProps { + options: SelectOption[]; + value?: T; + onChange?: (value: T, option: SelectOption) => void; + placeholder?: string; + disabled?: boolean; + size?: SelectSize; + label?: string; + hint?: string; + error?: string; + className?: string; + style?: CSSProperties; +} + +export function Select({ + options, value, onChange, placeholder = 'Select...', disabled = false, + size = 'md', label, hint, error, className, style, +}: SelectProps) { + const inputId = useId(); + const { colorPrimary } = useTheme(); + const styles = useMemo( + () => buildStyles(colorPrimary, size, !!error, !!disabled), + [colorPrimary, size, error, disabled], + ); + + const selectedOption = useMemo( + () => value !== undefined + ? (options.find((o) => toKey(o.value) === toKey(value)) ?? null) + : null, + [options, value], + ); + + const handleChange = useCallback( + (option: SingleValue>) => { + if (option) onChange?.(option.value, option); + }, + [onChange], + ); + + return ( + + {label && } + > + inputId={inputId} + options={options} + value={selectedOption} + onChange={handleChange} + placeholder={placeholder} + isDisabled={disabled} + isSearchable={false} + styles={styles} + getOptionValue={(o) => toKey(o.value)} + menuPortalTarget={document.body} + menuPosition="fixed" + components={{ DropdownIndicator }} + /> + {(error || hint) && {error ?? hint}} + + ); +} + +// ─── MultiSelect ────────────────────────────────────────────────────────────── + +export interface MultiSelectProps { + options?: SelectOption[]; + loadOptions?: (keyword: string) => Promise[]>; + value: SelectOption[]; + onChange?: (options: SelectOption[]) => void; + placeholder?: string; + disabled?: boolean; + size?: SelectSize; + label?: string; + hint?: string; + error?: string; + className?: string; + style?: CSSProperties; +} + +export function MultiSelect({ + options: staticOptions, loadOptions, value, onChange, + placeholder = 'Select...', disabled = false, + size = 'md', label, hint, error, className, style, +}: MultiSelectProps) { + const inputId = useId(); + const { colorPrimary } = useTheme(); + const styles = useMemo( + () => buildStyles(colorPrimary, size, !!error, !!disabled), + [colorPrimary, size, error, disabled], + ); + + const handleChange = useCallback( + (opts: readonly SelectOption[]) => onChange?.(Array.from(opts)), + [onChange], + ); + + const sharedProps = { + inputId, + isMulti: true as const, + value, + onChange: handleChange, + placeholder, + isDisabled: disabled, + styles, + getOptionValue: (o: SelectOption) => toKey(o.value), + menuPortalTarget: document.body, + menuPosition: 'fixed' as const, + components: { DropdownIndicator }, + closeMenuOnSelect: false, + }; + + return ( + + {label && } + {loadOptions ? ( + , true> + {...sharedProps} + loadOptions={loadOptions} + defaultOptions + cacheOptions + /> + ) : ( + , true> + {...sharedProps} + options={staticOptions ?? []} + /> + )} + {(error || hint) && {error ?? hint}} + + ); +} diff --git a/apps/pwa/src/components_next/select/select.stories.tsx b/apps/pwa/src/components_next/select/select.stories.tsx new file mode 100644 index 00000000..d3c808fc --- /dev/null +++ b/apps/pwa/src/components_next/select/select.stories.tsx @@ -0,0 +1,256 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { Select, MultiSelect } from '.'; +import type { SelectOption } from '.'; + +// ─── Select ─────────────────────────────────────────────────────────────────── + +const meta = { + title: 'Basic/Select', + component: Select, + tags: ['autodocs'], + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Single-value and multi-value picker. Same visual language as Button and Input — hard bottom shadow, rounded corners, Nunito font. ' + + 'Options accept any typed `value` (string, number, object). ' + + 'The dropdown is portalled to `document.body` to avoid clipping issues. ' + + 'Use `Select` for single choice and `MultiSelect` for multiple choices.', + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + size: { + control: 'select', + options: ['sm', 'md', 'lg'], + description: 'Select size — aligns with Button / Input sizes', + table: { defaultValue: { summary: 'md' } }, + }, + placeholder: { control: 'text' }, + label: { control: 'text' }, + hint: { control: 'text' }, + error: { control: 'text' }, + disabled: { control: 'boolean' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Data ───────────────────────────────────────────────────────────────────── + +const FRUITS: SelectOption[] = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Cherry', value: 'cherry' }, + { label: 'Durian', value: 'durian' }, + { label: 'Elderberry', value: 'elderberry' }, + { label: 'Fig', value: 'fig' }, +]; + +const LANGUAGES: SelectOption[] = [ + { label: '简体中文', value: 'zh-CN' }, + { label: 'English', value: 'en' }, + { label: '日本語', value: 'ja' }, + { label: '한국어', value: 'ko' }, +]; + +// ─── Stories ────────────────────────────────────────────────────────────────── + +function Controlled({ initialValue = '' }: { initialValue?: string }) { + const [value, setValue] = useState( + initialValue || undefined, + ); + return ( +
+ + + +
+ ), +}; + +export const Sizes: Story = { + name: 'Sizes', + args: { options: FRUITS }, + parameters: { controls: { disable: true } }, + render: () => ( +
+ + setValue(v)} + hint="Changing language reloads the page" + /> +
+ value: {JSON.stringify(value)} +
+
+ ); + }, +}; + +// ─── MultiSelect stories ────────────────────────────────────────────────────── + +function MultiControlled() { + const [value, setValue] = useState[]>([ + { label: 'Apple', value: 'apple' }, + { label: 'Cherry', value: 'cherry' }, + ]); + return ( +
+ setValue(vs)} + placeholder="Pick fruits..." + /> +
+ value: {JSON.stringify(value)} +
+
+ ); +} + +export const Multi: Story = { + name: 'MultiSelect — Interactive', + args: { options: FRUITS }, + parameters: { controls: { disable: true } }, + render: () => , +}; + +export const MultiStates: Story = { + name: 'MultiSelect — States', + args: { options: FRUITS }, + parameters: { controls: { disable: true } }, + render: () => ( +
+ + + + + + +
+ ), +}; 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..2663e417 --- /dev/null +++ b/apps/pwa/src/components_next/server_card.stories.tsx @@ -0,0 +1,112 @@ +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)', + args: { ...BASE_PROPS, users: ALL_USERS.slice(0, 1) }, + 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/components_next/slider/index.tsx b/apps/pwa/src/components_next/slider/index.tsx index 015acbf4..577b12f7 100644 --- a/apps/pwa/src/components_next/slider/index.tsx +++ b/apps/pwa/src/components_next/slider/index.tsx @@ -129,7 +129,7 @@ const StyledRoot = styled(Radix.Root)` export interface SliderProps extends Omit< ComponentPropsWithoutRef, - 'value' | 'onValueChange' | 'onValueCommit' | 'min' | 'max' | 'step' + 'value' | 'onChange' | 'onValueChange' | 'onValueCommit' | 'min' | 'max' | 'step' > { value: number; max?: number; diff --git a/apps/pwa/src/components_next/slider/slider.stories.tsx b/apps/pwa/src/components_next/slider/slider.stories.tsx index 25c5a50b..c225fa67 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,19 +91,20 @@ export const Disabled: Story = { }; export const Interactive: Story = { - name: 'Interactive(可拖拽)', + name: 'Interactive', + args: { value: 0.4 }, render: () => , }; export const InteractiveWithBuffer: Story = { name: 'Interactive with Buffer', + args: { value: 0.25, secondValue: 0.6 }, render: () => , }; -// ─── Showcase ───────────────────────────────────────────────────────────────── - export const AllEdges: Story = { name: 'All Edges', + args: { value: 0.6 }, render: () => (
{(['rounded', 'square'] as const).map((edge) => ( @@ -125,24 +120,25 @@ export const AllEdges: Story = { }; export const Scenarios: Story = { - name: 'Scenarios(使用场景)', + name: 'Scenarios', + args: { value: 0.75 }, render: () => (
- 音量 + Volume
- 播放进度(含缓冲) + Playback (with buffer)
- 禁用 + Disabled
diff --git a/apps/pwa/src/constants/index.ts b/apps/pwa/src/constants/index.ts index 2b3065f2..f2675728 100644 --- a/apps/pwa/src/constants/index.ts +++ b/apps/pwa/src/constants/index.ts @@ -7,7 +7,6 @@ export enum RequestStatus { export enum Query { REDIRECT = 'redirect', - CREATE_MUSIC_DIALOG_OPEN = 'create_music_dialog_open', PAGE = 'page', KEYWORD = 'keyword', SEARCH_TAB = 'search_tab', diff --git a/apps/pwa/src/constants/route.ts b/apps/pwa/src/constants/route.ts index 77d718b0..4a5190e0 100644 --- a/apps/pwa/src/constants/route.ts +++ b/apps/pwa/src/constants/route.ts @@ -6,8 +6,10 @@ export const ROOT_PATH = { export const PLAYER_PATH = { EXPLORATION: '/', - MY_MUSIC: '/my_music', + 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/global_style.ts b/apps/pwa/src/global_style.ts index c300f1b6..bbd18fb5 100644 --- a/apps/pwa/src/global_style.ts +++ b/apps/pwa/src/global_style.ts @@ -60,6 +60,8 @@ export const GlobalStyle = createGlobalStyle` } html { + height: 100%; + ${Object.keys(CSS_VARIABLE_MAP_VALUE) .map( (variable) => @@ -73,6 +75,8 @@ export const GlobalStyle = createGlobalStyle` } body { + height: 100%; + overscroll-behavior: contain; overflow: hidden; diff --git a/apps/pwa/src/i18n/en.ts b/apps/pwa/src/i18n/en.ts index a97c597e..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', @@ -14,7 +15,6 @@ export default { 'changing language will reload application, continue ?', relative_volume: 'relative volume', music_play_record_short: 'play record', - my_music: 'my music', exploration: 'exploration', musicbill: 'musicbill', user_management: 'user management', @@ -160,10 +160,8 @@ export default { '2fa_has_disabled': '2FA has disabled', create_musicbill: 'create musicbill', empty_musicbill_warning: 'musicbill is empty', - create_music_by_yourself: 'create music by yourself', create_musicbill_by_yourself: 'create musicbill by yourself', no_suitable_musicbill_warning: 'No suitable musicbill ?', - no_suitable_music_warning: 'no suitable music ?', no_suitable_singer_warning: 'no suitable singer ?', no_suitable_singer: 'no suitable singer', create_singer: 'create singer', diff --git a/apps/pwa/src/i18n/zh_hans.ts b/apps/pwa/src/i18n/zh_hans.ts index 482a4b8c..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: '取消', @@ -16,7 +17,6 @@ const zhCN: { change_language_question: '更换语言将会重新加载应用, 是否继续?', relative_volume: '相对音量', music_play_record_short: '播放记录', - my_music: '我的音乐', exploration: '发现', musicbill: '乐单', user_management: '用户管理', @@ -157,10 +157,8 @@ const zhCN: { '2fa_has_disabled': '2FA 已被禁用', create_musicbill: '创建乐单', empty_musicbill_warning: '空的乐单', - create_music_by_yourself: '自己创建音乐', create_musicbill_by_yourself: '自己创建乐单', no_suitable_musicbill_warning: '找不到想要的乐单?', - no_suitable_music_warning: '找不到想要的音乐?', no_suitable_singer_warning: '找不到想要的歌手?', no_suitable_singer: '暂无相关歌手', create_singer: '创建歌手', 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'))} + + + + => ({ +const formatSingerToOption = (singer: Singer): SelectOption => ({ label: `${singer.name}${singer.aliases.length ? `(${singer.aliases[0]})` : ''}`, - value: singer.id, - actualValue: singer, + value: singer, }); -const searchSinger = (search: string): Promise[]> => { +const searchSinger = (search: string): Promise[]> => { const keyword = search.trim().substring(0, SINGER_SEARCH_KEYWORD_MAX_LENGTH); return searchSingerRequest({ keyword, page: 1, pageSize: 100 }).then((data) => data.singerList.map(formatSingerToOption), ); }; -const formatMusicToOption = (music: { - id: string; - name: string; - singers: Singer[]; -}): Option<{ id: string; name: string; singers: Singer[] }> => ({ +const formatMusicToOption = ( + music: RelatedMusic, +): SelectOption => ({ label: `${music.name} - ${music.singers.map((s) => s.name).join(',')}`, - value: music.id, - actualValue: music, + value: music, }); -const bodyProps: { style: CSSProperties } = { - style: { width: 320 }, -}; - const dangerousIconStyle: CSSProperties = { color: CSSVariable.COLOR_DANGEROUS, }; @@ -395,7 +398,7 @@ function EditContent({ label: t('singer'), labelAddon: , title: t('modify_singer'), - optionsGetter: searchSinger, + loadOptions: searchSinger, initialValue: music.singers.map(formatSingerToOption), confirmVariant: 'primary', onConfirm: async (options) => { @@ -406,14 +409,14 @@ function EditContent({ if ( !stringArrayEqual( music.singers.map((s) => s.id).sort(), - options.map((o) => o.actualValue.id).sort(), + options.map((o) => o.value.id).sort(), ) ) { try { await updateMusic({ id: music.id, key: AllowUpdateKey.SINGER, - value: options.map((o) => o.actualValue.id), + value: options.map((o) => o.value.id), }); onReload(); } catch (error) { @@ -475,20 +478,20 @@ function EditContent({ dialog.multipleSelect({ title: t('modify_fork_from'), label: t('fork_from'), - optionsGetter: searchMusic, + loadOptions: searchMusic, initialValue: music.forkFromList.map(formatMusicToOption), onConfirm: async (options) => { if ( !stringArrayEqual( music.forkFromList.map((m) => m.id).sort(), - options.map((o) => o.actualValue.id).sort(), + options.map((o) => (o.value as { id: string }).id).sort(), ) ) { try { await updateMusic({ id: music.id, key: AllowUpdateKey.FORK_FROM, - value: options.map((o) => o.actualValue.id), + value: options.map((o) => (o.value as { id: string }).id), }); onReload(); } catch (error) { @@ -638,25 +641,27 @@ function MusicEditDrawer({ }; return ( - - {loading ? ( - - - - ) : error ? ( - - musicId && loadMusic(musicId)} + !v && onClose()}> + + {loading ? ( + + + + ) : error ? ( + + musicId && loadMusic(musicId)} + /> + + ) : music ? ( + - - ) : music ? ( - - ) : null} + ) : null} + ); } 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..3d419b9a 100644 --- a/apps/pwa/src/pages/login/first_step/index.tsx +++ b/apps/pwa/src/pages/login/first_step/index.tsx @@ -1,16 +1,15 @@ 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'; -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; @@ -18,14 +17,15 @@ const Style = styled.div` gap: 20px; -webkit-app-region: no-drag; - - > .divider { - height: 1px; - background-color: ${CSSVariable.COLOR_BORDER}; - } `; -function FirstStep({ toNext }: { toNext: () => void }) { +function FirstStep({ + toNext, + onManage: _onManage, +}: { + toNext: () => void; + onManage: () => void; +}) { const [loading, setLoading] = useState(false); const [origin, setOrigin] = useState( () => useServer.getState().selectedServerOrigin || window.location.origin, @@ -80,18 +80,17 @@ function FirstStep({ toNext }: { toNext: () => void }) { + !v && onClose()}> + + + {t('manage_origins')} + + + ); } diff --git a/apps/pwa/src/pages/login/first_step/server_card.tsx b/apps/pwa/src/pages/login/first_step/server_card.tsx new file mode 100644 index 00000000..f3349ae8 --- /dev/null +++ b/apps/pwa/src/pages/login/first_step/server_card.tsx @@ -0,0 +1,228 @@ +import styled from 'styled-components'; +import { CSSVariable } from '@/global_style'; +import { MdDeleteOutline } from 'react-icons/md'; +import { type User } from '@/constants/server'; + +export const FONT = `'Nunito', 'Varela Round', system-ui, sans-serif`; + +export const MAX_AVATARS = 8; + +// ─── Card ──────────────────────────────────────────────────────────────────── + +export const ServerCard = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 12px 10px 12px 14px; + border: 2px solid rgb(220 220 220); + border-radius: 14px; + background: #fff; + box-shadow: 0 4px 0 rgb(210 210 210); + cursor: pointer; + transition: border-color 120ms, box-shadow 80ms, transform 80ms; + + &:hover { + border-color: ${CSSVariable.COLOR_PRIMARY}; + box-shadow: 0 4px 0 rgb(30 150 100); + } + + &:active { + box-shadow: 0 1px 0 rgb(210 210 210); + transform: translateY(3px); + } + + > .info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + + > .hostname { + font-family: ${FONT}; + font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; + font-weight: 700; + color: rgb(50 50 50); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + > .origin { + font-family: ${FONT}; + font-size: ${CSSVariable.TEXT_SIZE_SMALL}; + font-weight: 600; + color: rgb(155 155 155); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + > .users { + display: flex; + align-items: center; + margin-top: 6px; + } + } +`; + +// ─── Delete button ──────────────────────────────────────────────────────────── + +export const DeleteButton = styled.button` + flex-shrink: 0; + width: 32px; + height: 32px; + border: 2px solid rgb(240 210 210); + border-radius: 10px; + background: rgb(255 245 245); + box-shadow: 0 3px 0 rgb(230 200 200); + color: ${CSSVariable.COLOR_DANGEROUS}; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 120ms, border-color 120ms, box-shadow 80ms, transform 80ms; + + &:hover { + background: rgb(255 230 230); + border-color: rgb(242 80 66); + } + + &:active { + box-shadow: 0 1px 0 rgb(230 200 200); + transform: translateY(2px); + } + + > svg { + font-size: 16px; + } +`; + +// ─── Avatars ────────────────────────────────────────────────────────────────── + +const avatarBase = ` + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid #fff; + flex-shrink: 0; + + &:not(:first-child) { + margin-left: -6px; + } +`; + +const UserAvatarImg = styled.img` + ${avatarBase} + object-fit: cover; +`; + +const UserAvatarFallback = styled.div<{ $selected: boolean }>` + ${avatarBase} + background: ${({ $selected }) => + $selected ? CSSVariable.COLOR_PRIMARY : 'rgb(200 200 200)'}; + 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` + height: 22px; + padding: 0 6px; + margin-left: -6px; + border-radius: 11px; + border: 2px solid #fff; + flex-shrink: 0; + background: ${CSSVariable.COLOR_PRIMARY}; + color: #fff; + font-family: ${FONT}; + font-size: 9px; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + letter-spacing: 0.02em; +`; + +function UserAvatar({ user, selected }: { user: User; selected: boolean }) { + if (user.avatar) { + return ( + + ); + } + return ( + + {user.nickname[0]} + + ); +} + +export function UserAvatars({ + users, + selectedUserId, +}: { + users: User[]; + selectedUserId?: string; +}) { + if (!users.length) return null; + const sorted = selectedUserId + ? [ + ...users.filter((u) => u.id === selectedUserId), + ...users.filter((u) => u.id !== selectedUserId), + ] + : users; + const shown = sorted.slice(0, MAX_AVATARS); + const overflow = sorted.length - MAX_AVATARS; + return ( + <> + {shown.map((u) => ( + + ))} + {overflow > 0 && +{overflow}} + + ); +} + +// ─── Composed card ──────────────────────────────────────────────────────────── + +export function ServerCardItem({ + hostname, + origin, + users, + selectedUserId, + onClick, + onDelete, +}: { + hostname: string; + origin: string; + users: User[]; + selectedUserId?: string; + onClick: () => void; + onDelete: (e: React.MouseEvent) => void; +}) { + return ( + +
+ {hostname} + {origin} + {users.length > 0 && ( +
+ +
+ )} +
+ + + +
+ ); +} 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 df2b3ee4..e73be373 100644 --- a/apps/pwa/src/pages/login/first_step/server_list.tsx +++ b/apps/pwa/src/pages/login/first_step/server_list.tsx @@ -1,48 +1,36 @@ import { CSSVariable } from '@/global_style'; import { t } from '@/i18n'; -import { useCallback, useState } from 'react'; import styled from 'styled-components'; -import Label from '@/components/label'; -import { Select } from '@/components/select'; -import upperCaseFirstLetter from '@/style/upper_case_first_letter'; -import ManageDrawer from './manage_drawer'; import { useServer } from '@/global_states/server'; +import dialog from '@/utils/dialog'; +import { Divider } from '@/components_next'; +import { FONT, ServerCardItem } from './server_card'; const Style = styled.div` - > .divider { - margin-top: 20px; + > .label { + font-family: ${FONT}; + font-size: 15px; + font-weight: 700; + letter-spacing: 0.3px; + text-transform: capitalize; + color: ${CSSVariable.TEXT_COLOR_PRIMARY}; + margin-bottom: 10px; + } + > .server-items { display: flex; - align-items: center; - gap: 10px; - - font-size: ${CSSVariable.TEXT_SIZE_SMALL}; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - - > .line { - flex: 1; - min-width: 0; - - height: 1px; - background-color: ${CSSVariable.COLOR_BORDER}; - } - - > .or { - text-transform: uppercase; - } + flex-direction: column; + gap: 8px; + max-height: 216px; + overflow-y: auto; + padding-right: 2px; + padding-bottom: 4px; } -`; -const Addon = styled.span` - font-size: ${CSSVariable.TEXT_SIZE_SMALL}; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - cursor: pointer; - ${upperCaseFirstLetter} - &:hover { - color: ${CSSVariable.TEXT_COLOR_PRIMARY}; + > .divider { + margin-top: 20px; } `; -const getServerList = () => useServer.getState().serverList; function ServerList({ disabled, @@ -51,56 +39,46 @@ function ServerList({ disabled: boolean; toNext: () => void; }) { - const [serverList, setServerList] = useState(getServerList); - const [manageDrawerOpen, setManageDrawerOpen] = useState(false); - const onManageDrawerClose = useCallback(() => { - setManageDrawerOpen(false); - return window.setTimeout(() => setServerList(getServerList), 1000); - }, []); + const { serverList } = useServer(); - if (serverList.length) { - return ( - <> - - - - ); - } - return null; + 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 3f4cf45b..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'; @@ -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,11 +22,33 @@ 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 ( ); 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/apps/pwa/src/pages/login/second_step/index.tsx b/apps/pwa/src/pages/login/second_step/index.tsx index f71ca071..f689df01 100644 --- a/apps/pwa/src/pages/login/second_step/index.tsx +++ b/apps/pwa/src/pages/login/second_step/index.tsx @@ -1,7 +1,6 @@ import styled from 'styled-components'; import { ChangeEventHandler, useState } from 'react'; -import Label from '@/components/label'; -import Input from '@/components/input'; +import Input from '@/components_next/input'; import Button from '@/components_next/button'; import { t } from '@/i18n'; import { PASSWORD_MAX_LENGTH, USERNAME_MAX_LENGTH } from '#/constants/user'; @@ -19,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; @@ -67,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) => @@ -96,7 +97,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); @@ -117,7 +118,7 @@ function SecondStep({ toPrevious }: { toPrevious: () => void }) { captchaValue, }); await addProfile(token); - window.setTimeout(redirect, 0); + redirect(); } catch (error) { logger.error(error, 'Failed to login'); @@ -139,31 +140,29 @@ function SecondStep({ toPrevious }: { toPrevious: () => void }) { ); } 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 f5871033..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,68 +1,186 @@ -import Label from '@/components/label'; -import { Select } from '@/components/select'; -import { getSelectedServer, useServer } from '@/global_states/server'; +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 Divider = styled.div` +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; - gap: 5px; + 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}; + } - font-size: ${CSSVariable.TEXT_SIZE_SMALL}; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; + &:active { + box-shadow: 0 1px 0 rgb(210 210 210); + transform: translateY(3px); + } - > .line { - flex: 1; - min-width: 0; - height: 1px; - background-color: ${CSSVariable.COLOR_BORDER}; + &:focus-visible { + outline: 3px solid rgb(44 182 125 / 0.2); + outline-offset: 3px; } - > .or { + > .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 ( - <> - - - + setTwoFAToken(event.target.value)} + autoFocus + /> + + @@ -80,8 +71,8 @@ function TwoFADialog() { > {t('confirm')} - - + + ); } diff --git a/apps/pwa/src/pages/player/components/music.tsx b/apps/pwa/src/pages/player/components/music.tsx index c48fb1c2..dddcd2a7 100644 --- a/apps/pwa/src/pages/player/components/music.tsx +++ b/apps/pwa/src/pages/player/components/music.tsx @@ -1,13 +1,11 @@ import styled from 'styled-components'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdPlayArrow, MdReadMore, MdOutlinePostAdd } from 'react-icons/md'; import { HtmlHTMLAttributes, ReactNode } from 'react'; import { MusicWithSingerAliases } from '../constants'; import e, { EventType } from '../eventemitter'; import MusicBase from './music_base'; -const ICON_BUTTON_SIZE = 28; - const LineAfterPart = styled.div` display: flex; align-items: center; @@ -34,17 +32,21 @@ function Music({ music={music} lineAfter={ - { event.stopPropagation(); return e.emit(EventType.ACTION_PLAY_MUSIC, { music }); }} > - - + } addon={addon} diff --git a/apps/pwa/src/pages/player/controller/operation.tsx b/apps/pwa/src/pages/player/controller/operation.tsx index e3da8cb2..236d2857 100644 --- a/apps/pwa/src/pages/player/controller/operation.tsx +++ b/apps/pwa/src/pages/player/controller/operation.tsx @@ -1,5 +1,5 @@ import styled, { css } from 'styled-components'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdOutlineQueueMusic, MdPause, @@ -61,7 +61,10 @@ function Operation({ ); } 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/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..4473fdba --- /dev/null +++ b/apps/pwa/src/pages/player/music_drawer/content.tsx @@ -0,0 +1,103 @@ +import type { ComponentProps } 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%; + } + } +`; + +type AnimatedStyle = ComponentProps['style']; + +function Detail({ style, music }: { style: AnimatedStyle; 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/edit_menu.tsx b/apps/pwa/src/pages/player/music_drawer/edit_menu.tsx index c0130e53..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, @@ -39,7 +38,7 @@ import stringArrayEqual from '#/utils/string_array_equal'; import dialog from '@/utils/dialog'; import deleteMusic from '@/server/api/delete_music'; import logger from '@/utils/logger'; -import { Option } from '@/components/select'; +import type { SelectOption } from '@/components_next'; import searchSingerRequest from '@/server/api/search_singer'; import searchMusicRequest from '@/server/api/search_music'; import { SEARCH_KEYWORD_MAX_LENGTH as SINGER_SEARCH_KEYWORD_MAX_LENGTH } from '#/constants/singer'; @@ -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, { @@ -63,27 +62,21 @@ interface Singer { name: string; aliases: string[]; } -const formatSingerToMultipleSelectOption = ( - singer: Singer, -): Option => ({ - label: `${singer.name}${ - singer.aliases.length ? `(${singer.aliases[0]})` : '' - }`, - value: singer.id, - actualValue: singer, +const formatSingerToOption = (singer: Singer): SelectOption => ({ + label: `${singer.name}${singer.aliases.length ? `(${singer.aliases[0]})` : ''}`, + value: singer, }); -const searchSinger = (search: string): Promise[]> => { +const searchSinger = (search: string): Promise[]> => { const keyword = search.trim().substring(0, SINGER_SEARCH_KEYWORD_MAX_LENGTH); return searchSingerRequest({ keyword, page: 1, pageSize: 100 }).then((data) => - data.singerList.map(formatSingerToMultipleSelectOption), + data.singerList.map(formatSingerToOption), ); }; const emitMusicUpdated = (id: string) => playerEventemitter.emit(PlayerEventType.MUSIC_UPDATED, { id }); -const formatMusicTouMultipleSelectOtion = (music: Music): Option => ({ +const formatMusicToOption = (music: Music): SelectOption => ({ label: `${music.name} - ${music.singers.map((s) => s.name).join(',')}`, - value: music.id, - actualValue: music, + value: music, }); const itemStyle: CSSProperties = { margin: '0 10px' }; @@ -93,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, }; @@ -113,15 +96,13 @@ function EditMenu({ music }: { music: MusicDetail }) { // const [open, setOpen] = useState(true); const onClose = () => setOpen(false); const searchMusic = useCallback( - (search: string): Promise[]> => { - const keyword = search - .trim() - .substring(0, MUSIC_SEARCH_KEYWORD_MAX_LENGTH); + (search: string): Promise[]> => { + const keyword = search.trim().substring(0, MUSIC_SEARCH_KEYWORD_MAX_LENGTH); return searchMusicRequest({ keyword, page: 1, pageSize: 100 }).then( (data) => data.musicList .filter((m) => m.id !== music.id) - .map(formatMusicTouMultipleSelectOtion), + .map(formatMusicToOption), ); }, [music.id], @@ -135,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 3692a622..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 } = useOpen(); - if (id) { + 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 80f08574..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,125 +1,20 @@ -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'; - -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} - -
- - -
- -
- ); -} +import { Drawer, DrawerContent } from '@/components_next'; +import MusicContent from './content'; function MusicDrawer({ - zIndex, id, open, onClose, }: { - zIndex: number; id: string; 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 ; - })} + !v && onClose()}> + + + ); } 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..4952b749 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,24 @@ 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 useDynamicZIndex from '../use_dynamic_z_index'; +import { useTheme } from '@/global_states/theme'; 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 +30,67 @@ 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, - zIndex: useDynamicZIndex(EventType.OPEN_MUSIC_DRAWER), + miniMode, }; }; 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_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/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/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/filter.tsx b/apps/pwa/src/pages/player/pages/music_play_record/toolbar/filter.tsx index 9b717723..0554440b 100644 --- a/apps/pwa/src/pages/player/pages/music_play_record/toolbar/filter.tsx +++ b/apps/pwa/src/pages/player/pages/music_play_record/toolbar/filter.tsx @@ -1,4 +1,4 @@ -import Input from '@/components/input'; +import Input from '@/components_next/input'; import useNavigate from '@/utils/use_navigate'; import { Query } from '@/constants'; import { IS_TOUCHABLE } from '@/constants/browser'; 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/filter.tsx b/apps/pwa/src/pages/player/pages/musicbill/filter.tsx index abf89947..2e2def10 100644 --- a/apps/pwa/src/pages/player/pages/musicbill/filter.tsx +++ b/apps/pwa/src/pages/player/pages/musicbill/filter.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import Input from '@/components/input'; +import Input from '@/components_next/input'; import { useEffect, useState } from 'react'; import useNavigate from '@/utils/use_navigate'; import { Query } from '@/constants'; 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/my_music/constants.ts b/apps/pwa/src/pages/player/pages/my_music/constants.ts deleted file mode 100644 index 3c377944..00000000 --- a/apps/pwa/src/pages/player/pages/my_music/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { MusicWithSingerAliases } from '../../constants'; - -export const PAGE_SIZE = 50; - -export const TOOLBAR_HEIGHT = 60; - -export interface Music extends MusicWithSingerAliases { - heat: number; - createTimestamp: number; - index: number; -} diff --git a/apps/pwa/src/pages/player/pages/my_music/create_music_dialog/constants.ts b/apps/pwa/src/pages/player/pages/my_music/create_music_dialog/constants.ts deleted file mode 100644 index 8b02318e..00000000 --- a/apps/pwa/src/pages/player/pages/my_music/create_music_dialog/constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Singer { - id: string; - name: string; - aliases: string[]; -} diff --git a/apps/pwa/src/pages/player/pages/my_music/create_music_dialog/index.tsx b/apps/pwa/src/pages/player/pages/my_music/create_music_dialog/index.tsx deleted file mode 100644 index 52593565..00000000 --- a/apps/pwa/src/pages/player/pages/my_music/create_music_dialog/index.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { - ChangeEventHandler, - CSSProperties, - useCallback, - useEffect, - useState, -} from 'react'; -import styled from 'styled-components'; -import Dialog, { Container, Title, Content, Action } from '@/components/dialog'; -import Button from '@/components_next/button'; -import Input from '@/components/input'; -import Label from '@/components/label'; -import { Select, MultipleSelect, Option } from '@/components/select'; -import { t } from '@/i18n'; -import { - AllowUpdateKey, - MusicType, - MUSIC_TYPES, - NAME_MAX_LENGTH, -} from '#/constants/music'; -import FileSelect from '@/components/file_select'; -import searchSingerRequest from '@/server/api/search_singer'; -import { AssetType, ASSET_TYPE_MAP } from '#/constants'; -import useEvent from '@/utils/use_event'; -import notice from '@/utils/notice'; -import uploadAsset from '@/server/form/upload_asset'; -import createMusic from '@/server/api/create_music'; -import { SEARCH_KEYWORD_MAX_LENGTH } from '#/constants/singer'; -import updateMusic from '@/server/api/update_music'; -import getMusicFileMetadata from '#/utils/get_music_file_metadata'; -import logger from '@/utils/logger'; -import { MUSIC_TYPE_MAP } from '@/constants/music'; -import capitalize from '#/utils/capitalize'; -import { ZIndex } from '../../../constants'; -import useOpen from './use_open'; -import e, { EventType } from '../eventemitter'; -import MissingSinger from '../../../components/missing_singer'; -import playerEventemitter, { - EventType as PlayerEventType, -} from '../../../eventemitter'; -import { Singer } from './constants'; -import upperCaseFirstLetter from '#/utils/upper_case_first_letter'; -import { base64ToCover, canAudioPlay, getMusicNameFromFilename } from './utils'; - -const maskProps: { style: CSSProperties } = { - style: { zIndex: ZIndex.DIALOG }, -}; -const MUSIC_TYPE_OPTIONS: Option[] = MUSIC_TYPES.map((mt) => ({ - label: capitalize(MUSIC_TYPE_MAP[mt].label), - value: mt, - actualValue: mt, -})); - -const formatSingerToMultipleSelectOption = ( - singer: Singer, -): Option => ({ - label: `${singer.name}${ - singer.aliases.length ? `(${singer.aliases[0]})` : '' - }`, - value: singer.id, - actualValue: singer, -}); -const searchSinger = (search: string): Promise[]> => { - const keyword = search.trim().substring(0, SEARCH_KEYWORD_MAX_LENGTH); - return searchSingerRequest({ keyword, page: 1, pageSize: 100 }).then((data) => - data.singerList.map(formatSingerToMultipleSelectOption), - ); -}; - -const StyledContent = styled(Content)` - display: flex; - flex-direction: column; - gap: 20px; -`; - -function CreateMusicDialog() { - const { open, onClose } = useOpen(); - const [name, setName] = useState(''); - const onNameChange: ChangeEventHandler = (event) => - setName(event.target.value); - - const [singerList, setSingerList] = useState([]); - const onSingerListChange = useCallback( - (sl: Option[]) => setSingerList(sl.map((s) => s.actualValue)), - [], - ); - - const [musicType, setMusicType] = useState(MusicType.SONG); - const onMusicTypeChange = (option: Option) => - setMusicType(option.actualValue); - - const [asset, setAsset] = useState(null); - const onAssetChange = (a: File | null) => { - setAsset(a); - - if (a) { - canAudioPlay(a).then((canPlay) => { - if (!canPlay) { - setAsset(null); - return notice.error(t('can_not_play_audio_file')); - } - }); - getMusicFileMetadata(a) - .then((metadata) => { - const { title, artist } = metadata; - if (!name) { - setName(title || getMusicNameFromFilename(a.name)); - } - if (!singerList.length && artist) { - searchSingerRequest({ - keyword: artist, - page: 1, - pageSize: 10, - requestMinimalDuration: 0, - }) - .then((data) => { - if (!singerList.length) { - setSingerList(data.singerList); - } - }) - .catch((error) => - logger.error(error, 'Failed to search singers'), - ); - } - }) - .catch((error) => - logger.error(error, "Failed to parse music's metadata"), - ); - } - }; - - const [loading, setLoading] = useState(false); - const onCreate = useEvent(async () => { - if (!singerList.length) { - return notice.error(t('emtpy_singers_warning')); - } - - const trimmedName = name.trim(); - if (!trimmedName) { - return notice.error(t('empty_name_warning')); - } - - if (!asset) { - return notice.error(t('empty_file_warning')); - } - - setLoading(true); - try { - const { id: musicAssetId } = await uploadAsset(asset, AssetType.MUSIC); - const id = await createMusic({ - name: trimmedName, - singerIds: singerList.map((s) => s.id), - type: musicType, - asset: musicAssetId, - }); - - try { - const { picture, year } = await getMusicFileMetadata(asset); - const updateCover = async (pb: string) => { - const coverBlob = await base64ToCover(pb); - const { id: assetId } = await uploadAsset( - coverBlob, - AssetType.MUSIC_COVER, - ); - await updateMusic({ - id, - key: AllowUpdateKey.COVER, - value: assetId, - requestMinimalDuration: 0, - }); - }; - - await Promise.all([ - picture ? updateCover(picture.dataURI) : null, - year - ? updateMusic({ - id, - key: AllowUpdateKey.YEAR, - value: year, - requestMinimalDuration: 0, - }) - : null, - ]); - } catch (error) { - logger.error(error, "Failed to parse music's metadata"); - } - - e.emit(EventType.RELOAD_MUSIC_LIST, null); - playerEventemitter.emit(PlayerEventType.OPEN_MUSIC_DRAWER, { id }); - - onClose(); - } catch (error) { - logger.error(error, 'Failed to create music'); - notice.error(error.message); - } - setLoading(false); - }); - - useEffect(() => { - if (!open) { - setName(''); - setSingerList([]); - setMusicType(MusicType.SONG); - setAsset(null); - } - }, [open]); - - return ( - - - {t('create_music')} - - - - - - - - - - - - - ); -} - -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 9b717723..00000000 --- a/apps/pwa/src/pages/player/pages/my_music/toolbar/filter.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import Input from '@/components/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/public_musicbill_collection/toolbar/filter.tsx b/apps/pwa/src/pages/player/pages/public_musicbill_collection/toolbar/filter.tsx index 19a2d4d8..e6e508bb 100644 --- a/apps/pwa/src/pages/player/pages/public_musicbill_collection/toolbar/filter.tsx +++ b/apps/pwa/src/pages/player/pages/public_musicbill_collection/toolbar/filter.tsx @@ -1,4 +1,4 @@ -import Input from '@/components/input'; +import Input from '@/components_next/input'; import useNavigate from '@/utils/use_navigate'; import { Query } from '@/constants'; import parseSearch from '@/utils/parse_search'; 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/input.tsx b/apps/pwa/src/pages/player/pages/search/input.tsx index 43e2ba52..86d72213 100644 --- a/apps/pwa/src/pages/player/pages/search/input.tsx +++ b/apps/pwa/src/pages/player/pages/search/input.tsx @@ -1,4 +1,4 @@ -import Input from '@/components/input'; +import Input from '@/components_next/input'; import useNavigate from '@/utils/use_navigate'; import { Query } from '@/constants'; import parseSearch from '@/utils/parse_search'; 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/pages/setting/extra_info.tsx b/apps/pwa/src/pages/player/pages/setting/extra_info.tsx index 846d428d..80e896f7 100644 --- a/apps/pwa/src/pages/player/pages/setting/extra_info.tsx +++ b/apps/pwa/src/pages/player/pages/setting/extra_info.tsx @@ -1,4 +1,4 @@ -import { BETA_VERSION_START } from '#/constants'; +import { BETA_VERSION_IDENTIFIER } from '#/constants'; import definition from '@/definition'; import { useSelectedServer } from '@/global_states/server'; import { CSSVariable } from '@/global_style'; @@ -23,6 +23,14 @@ const Style = styled.div` } `; +function getVersionLink(version: string) { + if (version.includes(BETA_VERSION_IDENTIFIER)) { + return 'https://github.com/mebtte/cicada/tree/beta'; + } + + return `https://github.com/mebtte/cicada/releases/tag/${version}`; +} + function ExtraInfo() { const selectedServer = useSelectedServer()!; @@ -30,30 +38,14 @@ function ExtraInfo() { + ); +} + +export default Wrapper; 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/pages/user_manage/create_user_dialog.tsx b/apps/pwa/src/pages/player/pages/user_manage/create_user_dialog.tsx index df18291d..8f343bee 100644 --- a/apps/pwa/src/pages/player/pages/user_manage/create_user_dialog.tsx +++ b/apps/pwa/src/pages/player/pages/user_manage/create_user_dialog.tsx @@ -1,25 +1,13 @@ -import Dialog, { Container, Title, Content, Action } from '@/components/dialog'; -import { ChangeEventHandler, CSSProperties, useEffect, useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogBody, DialogFooter } from '@/components_next'; +import { ChangeEventHandler, useEffect, useState } from 'react'; import Button from '@/components_next/button'; -import Label from '@/components/label'; -import Input from '@/components/input'; -import styled from 'styled-components'; +import Input from '@/components_next/input'; import notice from '@/utils/notice'; import logger from '@/utils/logger'; import adminCreateUser from '@/server/api/admin_create_user'; import { t } from '@/i18n'; import { PASSWORD_MAX_LENGTH, USERNAME_MAX_LENGTH } from '#/constants/user'; import e, { EventType } from './eventemitter'; -import { ZIndex } from '../../constants'; - -const maskProps: { style: CSSProperties } = { - style: { zIndex: ZIndex.DIALOG }, -}; -const StyledContent = styled(Content)` - display: flex; - flex-direction: column; - gap: 20px; -`; function CreateUserDialog() { const [open, setOpen] = useState(false); @@ -65,29 +53,31 @@ function CreateUserDialog() { }, []); return ( - - - {t('create_user')} - - - - - - + { if (!v) onClose(); }}> + + + {t('create_user')} + + + + + + + @@ -99,8 +89,8 @@ function CreateUserDialog() { > {t('create')} - - + + ); } diff --git a/apps/pwa/src/pages/player/pages/user_manage/toolbar/filter.tsx b/apps/pwa/src/pages/player/pages/user_manage/toolbar/filter.tsx index f96c14a3..bbe59560 100644 --- a/apps/pwa/src/pages/player/pages/user_manage/toolbar/filter.tsx +++ b/apps/pwa/src/pages/player/pages/user_manage/toolbar/filter.tsx @@ -1,4 +1,4 @@ -import Input from '@/components/input'; +import Input from '@/components_next/input'; import useNavigate from '@/utils/use_navigate'; import { Query } from '@/constants'; import { CSSProperties, useEffect, useState } from 'react'; 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_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/pages/user_manage/user_edit_drawer/user_edit.tsx b/apps/pwa/src/pages/player/pages/user_manage/user_edit_drawer/user_edit.tsx index 5d160737..acc29be8 100644 --- a/apps/pwa/src/pages/player/pages/user_manage/user_edit_drawer/user_edit.tsx +++ b/apps/pwa/src/pages/player/pages/user_manage/user_edit_drawer/user_edit.tsx @@ -1,6 +1,6 @@ import styled from 'styled-components'; -import Input from '@/components/input'; -import Label from '@/components/label'; +import Input from '@/components_next/input'; +import { Label } from '@/components_next'; import Textarea from '@/components/textarea'; import Button from '@/components_next/button'; import day from '#/utils/day'; @@ -165,59 +165,47 @@ function UserEdit({ user, onClose }: { user: User; onClose: () => void }) { return ( ); 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/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 d382adf0..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,8 +1,7 @@ -import Label from '@/components/label'; -import Input from '@/components/input'; +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'; @@ -49,7 +48,10 @@ function Toolbar({ const { playlist } = useContext(context); return ( ); } diff --git a/apps/pwa/src/pages/player/playlist_playqueue_drawer/playqueue.tsx b/apps/pwa/src/pages/player/playlist_playqueue_drawer/playqueue.tsx index 5f39736f..93c303fe 100644 --- a/apps/pwa/src/pages/player/playlist_playqueue_drawer/playqueue.tsx +++ b/apps/pwa/src/pages/player/playlist_playqueue_drawer/playqueue.tsx @@ -1,7 +1,7 @@ import { CSSProperties, useContext } from 'react'; import styled from 'styled-components'; import List from 'react-list'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdOutlineLocationOn, MdOutlineClose, @@ -9,7 +9,6 @@ import { MdArrowDownward, MdShuffle, } from 'react-icons/md'; -import { ComponentSize } from '@/constants/style'; import { flexCenter } from '@/style/flexbox'; import Empty from '@/components/empty'; import absoluteFullSize from '@/style/absolute_full_size'; @@ -26,7 +25,7 @@ import playerEventemitter, { } from '../eventemitter'; const shuffleStyle: CSSProperties = { - width: ComponentSize.SMALL, + width: 24, color: CSSVariable.COLOR_PRIMARY, }; const Style = styled(TabContent)` @@ -88,9 +87,11 @@ function Playqueue({ style }: { style: unknown }) { /> ) : null} {actualIndex === currentPlayqueuePosition ? null : ( - { e.stopPropagation(); return playerEventemitter.emit( @@ -102,12 +103,14 @@ function Playqueue({ style }: { style: unknown }) { }} > - + )} {actualIndex < length - 1 && actualIndex > currentPlayqueuePosition ? ( - { e.stopPropagation(); return playerEventemitter.emit( @@ -119,11 +122,13 @@ function Playqueue({ style }: { style: unknown }) { }} > - + ) : null} {actualIndex > currentPlayqueuePosition + 1 ? ( - { e.stopPropagation(); return playerEventemitter.emit( @@ -135,11 +140,13 @@ function Playqueue({ style }: { style: unknown }) { }} > - + ) : null} {actualIndex > currentPlayqueuePosition ? ( - { e.stopPropagation(); @@ -152,7 +159,7 @@ function Playqueue({ style }: { style: unknown }) { }} > - + ) : null} } 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/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/public_musicbill_drawer/toolbar.tsx b/apps/pwa/src/pages/player/public_musicbill_drawer/toolbar.tsx index 6a68fd8a..13ffcfb1 100644 --- a/apps/pwa/src/pages/player/public_musicbill_drawer/toolbar.tsx +++ b/apps/pwa/src/pages/player/public_musicbill_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, MdStar, MdStarOutline } from 'react-icons/md'; import notice from '@/utils/notice'; import collectPublicMusicbill from '@/server/api/collect_public_musicbill'; @@ -34,7 +34,10 @@ function Toolbar({ }) { return ( ); } diff --git a/apps/pwa/src/pages/player/route.tsx b/apps/pwa/src/pages/player/route.tsx index 07806bcd..862c3b05 100644 --- a/apps/pwa/src/pages/player/route.tsx +++ b/apps/pwa/src/pages/player/route.tsx @@ -2,21 +2,25 @@ 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 User from './pages/user'; 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'; import SharedMusicbillInvitation from './pages/shared_musicbill_invitation'; import DownloadingMusic from './pages/downloading_music'; +import Singer from './pages/singer'; function Wrapper() { return ( } /> } /> - } /> + } /> } /> + } /> + } /> } /> } /> - navigate(`${ROOT_PATH.PLAYER}${PLAYER_PATH.MY_MUSIC}`)} - label={t('my_music')} - icon={} - /> 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/sidebar/musicbill_list/top.tsx b/apps/pwa/src/pages/player/sidebar/musicbill_list/top.tsx index 69228287..1039c724 100644 --- a/apps/pwa/src/pages/player/sidebar/musicbill_list/top.tsx +++ b/apps/pwa/src/pages/player/sidebar/musicbill_list/top.tsx @@ -1,6 +1,6 @@ import { CSSVariable } from '@/global_style'; import styled from 'styled-components'; -import IconButton from '@/components/icon_button'; +import Button from '@/components_next/button'; import { MdOutlineAddBox, MdSort, @@ -8,7 +8,6 @@ import { MdStarOutline, MdOutlinePeopleAlt, } from 'react-icons/md'; -import { ComponentSize } from '@/constants/style'; import { useContext } from 'react'; import { RequestStatus } from '@/constants'; import notice from '@/utils/notice'; @@ -46,21 +45,27 @@ function Top() { return ( ); } diff --git a/apps/pwa/src/pages/player/sidebar/profile.tsx b/apps/pwa/src/pages/player/sidebar/profile.tsx index d66c3682..5d50c715 100644 --- a/apps/pwa/src/pages/player/sidebar/profile.tsx +++ b/apps/pwa/src/pages/player/sidebar/profile.tsx @@ -1,11 +1,12 @@ import styled from 'styled-components'; -import Cover, { Shape } from '@/components/cover'; import ellipsis from '@/style/ellipsis'; import { CSSVariable } from '@/global_style'; import { memo } from 'react'; import getResizedImage from '@/server/asset/get_resized_image'; import { useUser } from '@/global_states/server'; -import e, { EventType } from '../eventemitter'; +import { PLAYER_PATH, ROOT_PATH } from '@/constants/route'; +import { useLocation, useNavigate } from 'react-router-dom'; +import Avatar from '@/components_next/avatar'; const AVATAR_SIZE = 100; const Style = styled.div` @@ -14,16 +15,6 @@ const Style = styled.div` align-items: center; gap: 5px; - > .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 ( ); 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..634d5944 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,19 @@ 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 +30,36 @@ 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/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/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/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 ( - - - - ); - })} + })} + ); } diff --git a/apps/pwa/src/server/api/get_music_list.ts b/apps/pwa/src/server/api/get_music_list.ts deleted file mode 100644 index b755b88c..00000000 --- a/apps/pwa/src/server/api/get_music_list.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { MusicType } from '#/constants/music'; -import { prefixServerOrigin } from '@/global_states/server'; -import { request } from '..'; - -/** - * 获取音乐列表 - * @author mebtte - */ -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/shared/constants/index.ts b/apps/pwa/src/shared/constants/index.ts index a0124af4..41a59adb 100644 --- a/apps/pwa/src/shared/constants/index.ts +++ b/apps/pwa/src/shared/constants/index.ts @@ -107,6 +107,6 @@ export enum CommonQuery { LANGUAGE = '__lang', } -export const BETA_VERSION_START = 'beta.'; +export const BETA_VERSION_IDENTIFIER = '-beta-'; export const HEADER_TOKEN = 'x-cicada-token'; 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 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/alert.tsx b/apps/pwa/src/utils/dialog/alert.tsx index 7848a7a9..3fbf2d02 100644 --- a/apps/pwa/src/utils/dialog/alert.tsx +++ b/apps/pwa/src/utils/dialog/alert.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { t } from '@/i18n'; import { Alert as AlertType } 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'; @@ -18,26 +18,25 @@ function AlertContent({ setConfirming(true); return Promise.resolve(options.onConfirm ? options.onConfirm() : undefined) .then((result) => { - if (result === undefined || !!result) { - onClose(); - } + if (result === undefined || !!result) onClose(); }) .finally(() => setConfirming(false)); }); + return ( - - {options.title ? {options.title} : null} - {options.content ? {options.content} : null} - - - - + + ); } diff --git a/apps/pwa/src/utils/dialog/captcha/captcha.tsx b/apps/pwa/src/utils/dialog/captcha/captcha.tsx index a4b18cc3..35fe73e8 100644 --- a/apps/pwa/src/utils/dialog/captcha/captcha.tsx +++ b/apps/pwa/src/utils/dialog/captcha/captcha.tsx @@ -1,31 +1,29 @@ import { HtmlHTMLAttributes } from 'react'; import styled from 'styled-components'; -import absoluteFullSize from '@/style/absolute_full_size'; import { flexCenter } from '@/style/flexbox'; import ErrorCard from '@/components/error_card'; import Spinner from '@/components/spinner'; import { CaptchaData } from './constants'; const Style = styled.div` - > .content { - position: relative; + width: 100%; + border-radius: 10px; + overflow: hidden; - padding-bottom: 33.33%; - - > .loading { - ${absoluteFullSize} - ${flexCenter} - } - - > .svg { - ${absoluteFullSize} + > .loading { + aspect-ratio: 8 / 3; + ${flexCenter} + background: rgb(240 240 240); + } - cursor: pointer; + > .svg { + cursor: pointer; + line-height: 0; - > svg { - width: 100%; - height: 100%; - } + > svg { + display: block; + width: 100%; + height: auto; } } `; @@ -49,21 +47,17 @@ function Captcha({ } return ( ); } diff --git a/apps/pwa/src/utils/dialog/captcha/index.tsx b/apps/pwa/src/utils/dialog/captcha/index.tsx index 2d1550f7..219130fe 100644 --- a/apps/pwa/src/utils/dialog/captcha/index.tsx +++ b/apps/pwa/src/utils/dialog/captcha/index.tsx @@ -1,9 +1,7 @@ -import { Container, Content, Action } from '@/components/dialog'; +import { DialogBody, DialogFooter } from '@/components_next'; import Button from '@/components_next/button'; -import Label from '@/components/label'; -import Input from '@/components/input'; +import Input from '@/components_next/input'; import { - CSSProperties, ChangeEventHandler, useEffect, useState, @@ -18,11 +16,6 @@ import { Captcha as CaptchaShape } from '../constants'; import useEvent from '../../use_event'; import notice from '../../notice'; -const contentStyle: CSSProperties = { overflow: 'hidden' }; -const captchaStyle: CSSProperties = { - marginBottom: 10, -}; - function CaptchaContent({ onClose, options, @@ -74,6 +67,9 @@ function CaptchaContent({ reload(); } }) + .catch(() => { + reload(); + }) .finally(() => setConfirming(false)); }); @@ -87,23 +83,18 @@ function CaptchaContent({ ); return ( - - - + + + - - - + + @@ -115,8 +106,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/constants.ts b/apps/pwa/src/utils/dialog/constants.ts index a0cc4bcd..b9103b45 100644 --- a/apps/pwa/src/utils/dialog/constants.ts +++ b/apps/pwa/src/utils/dialog/constants.ts @@ -1,5 +1,5 @@ import type { Variant } from '@/components_next/button'; -import { Option } from '@/components/select'; +import type { SelectOption } from '@/components_next'; import { ReactNode } from 'react'; export const ID_LENGTH = 6; @@ -82,15 +82,15 @@ export interface InputList export interface MultipleSelect extends DialogOptions, - Confirmable[]>, + Confirmable[]>, Cancelable { type: DialogType.MULTIPLE_SELECT; title?: string; - initialValue: Option[]; + initialValue: SelectOption[]; label: string; labelAddon?: ReactNode; - optionsGetter: (keyword: string) => Promise[]>; + loadOptions: (keyword: string) => Promise[]>; } export interface FileSelect 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..573da2ee 100644 --- a/apps/pwa/src/utils/dialog/file_select.tsx +++ b/apps/pwa/src/utils/dialog/file_select.tsx @@ -1,15 +1,12 @@ -import { Container, Title, Content, Action } from '@/components/dialog'; +import { DialogHeader, DialogTitle, DialogBody, DialogFooter, Label } from '@/components_next'; import Button from '@/components_next/button'; -import { CSSProperties, useState } from 'react'; -import Label from '@/components/label'; +import { useState } from 'react'; import FileSelect from '@/components/file_select'; import { t } from '@/i18n'; 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 +43,13 @@ function FileSelectContent({ }; return ( - - {options.title ? {options.title} : null} - + <> + {options.title && ( + + {options.title} + + )} + - - + + @@ -71,8 +72,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/index.tsx b/apps/pwa/src/utils/dialog/index.tsx index 0104cd67..865fb0e6 100644 --- a/apps/pwa/src/utils/dialog/index.tsx +++ b/apps/pwa/src/utils/dialog/index.tsx @@ -1,4 +1,4 @@ -import { createRoot } from 'react-dom/client'; +import { createRoot, Root } from 'react-dom/client'; import generateRandomString from '#/utils/generate_random_string'; import { StrictMode } from 'react'; import { @@ -18,14 +18,38 @@ import { import e, { EventType } from './eventemitter'; import DialogApp from './dialog_app'; -const root = document.createElement('div'); -root.className = 'dialog-app'; -document.body.appendChild(root); -createRoot(root).render( - - - , -); +const GLOBAL_KEY = '__cicada_dialog_app__'; +const globalStore = globalThis as typeof globalThis & { + [GLOBAL_KEY]?: { domRoot: HTMLDivElement; reactRoot: Root }; +}; +const hot = (import.meta as ImportMeta & { + hot?: { dispose: (callback: () => void) => void }; +}).hot; + +if (!globalStore[GLOBAL_KEY]) { + const domRoot = document.createElement('div'); + domRoot.className = 'dialog-app'; + document.body.appendChild(domRoot); + + const reactRoot = createRoot(domRoot); + reactRoot.render( + + + , + ); + globalStore[GLOBAL_KEY] = { domRoot, reactRoot }; +} + +if (hot) { + hot.dispose(() => { + const dialogApp = globalStore[GLOBAL_KEY]; + if (dialogApp) { + dialogApp.reactRoot.unmount(); + dialogApp.domRoot.remove(); + delete globalStore[GLOBAL_KEY]; + } + }); +} export default { alert: (a: Omit) => { diff --git a/apps/pwa/src/utils/dialog/input.tsx b/apps/pwa/src/utils/dialog/input.tsx index 66f1fc5f..1990c8ea 100644 --- a/apps/pwa/src/utils/dialog/input.tsx +++ b/apps/pwa/src/utils/dialog/input.tsx @@ -1,15 +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 Label from '@/components/label'; -import Input from '@/components/input'; -import { CSSProperties, ChangeEventHandler, useState } from 'react'; +import Input from '@/components_next/input'; +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, @@ -48,26 +45,29 @@ function InputContent({ }; return ( - - {options.title ? {options.title} : null} - - - - + <> + {options.title && ( + + {options.title} + + )} + + { + if (event.key.toLowerCase() === 'enter') { + onConfirm(); + } + }} + /> + + @@ -79,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 f67e1c64..410a3bc1 100644 --- a/apps/pwa/src/utils/dialog/input_list.tsx +++ b/apps/pwa/src/utils/dialog/input_list.tsx @@ -1,27 +1,13 @@ -import { Container, Title, Content, Action } from '@/components/dialog'; +import { DialogHeader, DialogTitle, DialogBody, DialogFooter, Label } from '@/components_next'; import Button from '@/components_next/button'; -import Label from '@/components/label'; -import Input from '@/components/input'; +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 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,21 +67,27 @@ function InputListContent({ }; return ( - - {options.title ? {options.title} : null} - + <> + {options.title && ( + + {options.title} + + )} + {values.map((value, index) => (
+ } > = options.max! ? null : ( )} - - + + @@ -136,8 +127,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 27b9b3f3..36ebad78 100644 --- a/apps/pwa/src/utils/dialog/multiple_select.tsx +++ b/apps/pwa/src/utils/dialog/multiple_select.tsx @@ -1,15 +1,11 @@ -import { Container, Title, Content, Action } from '@/components/dialog'; +import { DialogHeader, DialogTitle, DialogBody, DialogFooter, Label, 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 { MultipleSelect, Option } from '@/components/select'; 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, @@ -17,10 +13,9 @@ function MultipleSelectContent({ onClose: () => void; options: MultipleSelectShape; }) { - const [options, setOptions] = useState[]>( + const [value, setValue] = useState[]>( multipleSelectOptions.initialValue || [], ); - const onOptionsChange = (os: Option[]) => setOptions(os); const [canceling, setCanceling] = useState(false); const onCancel = useEvent(() => { @@ -43,7 +38,7 @@ function MultipleSelectContent({ setConfirming(true); return Promise.resolve( multipleSelectOptions.onConfirm - ? multipleSelectOptions.onConfirm(options) + ? multipleSelectOptions.onConfirm(value) : undefined, ) .then((result) => { @@ -55,24 +50,26 @@ function MultipleSelectContent({ }; return ( - - {multipleSelectOptions.title ? ( - {multipleSelectOptions.title} - ) : null} - + <> + {multipleSelectOptions.title && ( + + {multipleSelectOptions.title} + + )} + - - + + @@ -84,8 +81,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 098f6988..424b99d6 100644 --- a/apps/pwa/src/utils/dialog/password.tsx +++ b/apps/pwa/src/utils/dialog/password.tsx @@ -1,9 +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/input'; -import Label from '@/components/label'; +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'; @@ -11,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, @@ -68,27 +57,25 @@ function PasswordContent({ }; return ( - - - - - - + <> + + + + + @@ -100,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..3f1ea96c 100644 --- a/apps/pwa/src/utils/dialog/textarea_list.tsx +++ b/apps/pwa/src/utils/dialog/textarea_list.tsx @@ -1,10 +1,7 @@ -import { Container, Title, Content, Action } from '@/components/dialog'; +import { DialogHeader, DialogTitle, DialogBody, DialogFooter, Label } 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 IconButton from '@/components/icon_button'; -import { ComponentSize } from '@/constants/style'; +import { useState } from 'react'; import { MdDelete, MdUploadFile } from 'react-icons/md'; import styled from 'styled-components'; import { t } from '@/i18n'; @@ -13,18 +10,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,29 +97,37 @@ function TextareaListContent({ }; return ( - - {options.title ? {options.title} : null} - + <> + {options.title && ( + + {options.title} + + )} + {values.map((value, index) => ( - + + @@ -178,8 +170,8 @@ function TextareaListContent({ > {options.confirmText || t('confirm')} - - + + ); } @@ -191,7 +183,7 @@ function Wrapper({ options: TextareaListShape; }) { return ( - + {({ onClose }) => ( )} diff --git a/apps/pwa/src/utils/notice/index.tsx b/apps/pwa/src/utils/notice/index.tsx index 23ab2cd3..d7fcd10b 100644 --- a/apps/pwa/src/utils/notice/index.tsx +++ b/apps/pwa/src/utils/notice/index.tsx @@ -1,14 +1,38 @@ -import { createRoot } from 'react-dom/client'; +import { createRoot, Root } from 'react-dom/client'; import { ReactNode } from 'react'; import generateRandomString from '#/utils/generate_random_string'; import { NoticeType } from './constants'; import NoticeApp from './notice_app'; import e, { EventType } from './eventemitter'; -const root = document.createElement('div'); -root.className = 'notice-app'; -document.body.appendChild(root); -createRoot(root).render(); +const GLOBAL_KEY = '__cicada_notice_app__'; +const globalStore = globalThis as typeof globalThis & { + [GLOBAL_KEY]?: { domRoot: HTMLDivElement; reactRoot: Root }; +}; +const hot = (import.meta as ImportMeta & { + hot?: { dispose: (callback: () => void) => void }; +}).hot; + +if (!globalStore[GLOBAL_KEY]) { + const domRoot = document.createElement('div'); + domRoot.className = 'notice-app'; + document.body.appendChild(domRoot); + + const reactRoot = createRoot(domRoot); + reactRoot.render(); + globalStore[GLOBAL_KEY] = { domRoot, reactRoot }; +} + +if (hot) { + hot.dispose(() => { + const noticeApp = globalStore[GLOBAL_KEY]; + if (noticeApp) { + noticeApp.reactRoot.unmount(); + noticeApp.domRoot.remove(); + delete globalStore[GLOBAL_KEY]; + } + }); +} function generateType(type: NoticeType) { return ( 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/apps/pwa/src/utils/use_event.ts b/apps/pwa/src/utils/use_event.ts index ef899301..b9d53e1b 100644 --- a/apps/pwa/src/utils/use_event.ts +++ b/apps/pwa/src/utils/use_event.ts @@ -3,16 +3,16 @@ import { useRef, useCallback } from 'react'; function useEvent unknown>( callback: Callback, ) { - const callbackRef = useRef(); + const callbackRef = useRef(callback); callbackRef.current = callback; - const memoCallback: Callback = useCallback( - // @ts-expect-error: known types - (...args) => callbackRef.current!(...args), + const memoCallback = useCallback( + (...args: Parameters): ReturnType => + callbackRef.current(...args) as ReturnType, [], ); - return memoCallback; + return memoCallback as Callback; } export default useEvent; 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"] +} 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 deleted file mode 100644 index dfa8675d..00000000 --- a/docs/development/cli/index.md +++ /dev/null @@ -1,36 +0,0 @@ -# CLI - -## Requirement - -- [Go](https://go.dev) environment - -## Start DEV Server - -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 -``` - -Then you need to add go bin to `PATH`: - -```sh -export PATH=$PATH:$(go env GOPATH)/bin -``` - -Use the follow command to start dev server: - -```sh -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`. - -## API Reference - -After starting dev server, the API reference can be visited on `http://localhost:8000/api_reference`. \ No newline at end of file diff --git a/docs/development/index.md b/docs/development/index.md index ad6dcd35..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 @@ -13,34 +14,11 @@ Cicada is monorepo which has multiple apps: | Playqueue | 播放队列 | A queue for playing musics, it has sequence | | Playlist | 播放列表 | A collection for playing musics, each music inside is unique | -## Data structure - -``` -|- assets - |- music - |- musicbill_cover - |- music_cover - |- singer_avatar - |- user_avatar -|- cache -|- logs -|- trash # save removed data temporarily -|- v # its content indicates version of data -|- db # the database of sqlite -|- jwt_secret # its content is secret of jwt -``` - -## Database structure - -The SQLite schema diagram is maintained in [database.d2](./database.d2) which powered by [d2](https://d2lang.com). - -Render the diagram locally with: - -```bash -d2 docs/development/database.d2 local_dir/database.svg -``` - ## Rules -- Alter database must also update `database.d2` - 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/docs/ui_designment/index.md b/docs/ui_designment/index.md new file mode 100644 index 00000000..07172bd5 --- /dev/null +++ b/docs/ui_designment/index.md @@ -0,0 +1,3 @@ +# UI Designment + +`cicada` prefers comic styles and currently mostly likes `duolingo`. \ 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..b183b312 --- /dev/null +++ b/scripts/build_version.mjs @@ -0,0 +1,105 @@ +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] || '')) { + const mode = process.argv[2]; + + if (mode === 'latest-tag') { + process.stdout.write(`${getLatestTag()}\n`); + } else { + process.stdout.write(`${resolveVersion()}\n`); + } +} 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)));