├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml └── workflows │ └── codecov.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README-EN.md ├── README.md ├── apps └── electron │ ├── .eslintignore │ ├── .eslintrc.json │ ├── .prettierignore │ ├── build │ ├── entitlements.mac.plist │ └── notarize.js │ ├── dev-app-update.yml │ ├── electron-builder │ ├── base.cjs │ ├── mac.cjs │ └── win.cjs │ ├── electron.vite.config.ts │ ├── package.json │ ├── postcss.config.cjs │ ├── resources │ └── rTemplate@4x.png │ ├── src │ ├── main │ │ ├── index.ts │ │ └── src │ │ │ ├── dock.ts │ │ │ ├── ipc │ │ │ ├── index.ts │ │ │ └── system.ts │ │ │ ├── menu.ts │ │ │ └── tray.ts │ ├── preload │ │ ├── index.d.ts │ │ └── index.ts │ └── renderer │ │ ├── index.html │ │ └── src │ │ ├── App.tsx │ │ ├── Layout │ │ ├── aside.tsx │ │ ├── content.tsx │ │ └── index.tsx │ │ ├── assets │ │ └── icons.svg │ │ ├── components │ │ ├── Content.tsx │ │ ├── Menu.tsx │ │ ├── Row.tsx │ │ ├── Title.tsx │ │ └── WindowsTitleBarBtnGroup.tsx │ │ ├── env.d.ts │ │ ├── hooks │ │ └── index.ts │ │ ├── index.css │ │ ├── main.tsx │ │ └── pages │ │ ├── basic │ │ ├── SyncCircle.tsx │ │ └── index.tsx │ │ ├── color.tsx │ │ ├── index.tsx │ │ ├── setting │ │ ├── index.css │ │ └── index.tsx │ │ └── unsync │ │ └── index.tsx │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── tsconfig.web.json ├── icons ├── create.sh ├── index.html ├── logo-social.png ├── logo.png └── logo.svg ├── package.json ├── packages ├── api │ ├── index.ts │ ├── mocks │ │ ├── folder.json │ │ └── metadata.json │ ├── package.json │ ├── src │ │ ├── color.ts │ │ ├── config.ts │ │ ├── folder.ts │ │ ├── image.ts │ │ ├── library.ts │ │ ├── log.ts │ │ ├── pending.ts │ │ ├── server │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ └── main.ts │ │ ├── sync │ │ │ ├── color.ts │ │ │ ├── folder.ts │ │ │ ├── image.ts │ │ │ ├── index.ts │ │ │ └── tag.ts │ │ ├── tag.ts │ │ └── utils.ts │ ├── test │ │ ├── color.test.ts │ │ ├── config.test.ts │ │ ├── folder.test.ts │ │ ├── image.test.ts │ │ ├── library.test.ts │ │ ├── log.test.ts │ │ ├── pending.test.ts │ │ ├── sync.color.test.ts │ │ ├── sync.folder.test.ts │ │ ├── sync.image.test.ts │ │ ├── sync.tag.test.ts │ │ └── tag.test.ts │ ├── tsconfig.json │ └── types.d.ts ├── constant │ ├── index.ts │ ├── package.json │ ├── server.ts │ └── tsconfig.json ├── db │ ├── index.ts │ ├── migrate.ts │ ├── package.json │ ├── prisma │ │ ├── migrations │ │ │ ├── 20231115085510_1_0_0_alpha_9 │ │ │ │ └── migration.sql │ │ │ ├── 20231206011741_1_0_0_alpha_10 │ │ │ │ └── migration.sql │ │ │ └── migration_lock.toml │ │ └── schema.prisma │ ├── test │ │ └── index.test.ts │ └── tsconfig.json ├── rlog │ ├── index.ts │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── trpc │ ├── package.json │ ├── src │ │ ├── TRPCReactProvider.tsx │ │ ├── index.ts │ │ └── trpc.ts │ └── tsconfig.json ├── utils │ ├── index.ts │ ├── package.json │ ├── src │ │ ├── color.ts │ │ └── index.ts │ ├── test │ │ └── color.test.ts │ └── tsconfig.json └── watch │ ├── index.ts │ ├── package.json │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── privacy.md ├── renovate.json ├── themes └── gallery │ ├── next.config.mjs │ ├── package.json │ ├── postcss.config.cjs │ ├── public │ └── favicon.ico │ ├── src │ ├── app │ │ ├── _components │ │ │ ├── ChildFolderCardList.tsx │ │ │ ├── Error.tsx │ │ │ ├── LayoutWrapper.tsx │ │ │ ├── Masonry.tsx │ │ │ ├── Responsive.tsx │ │ │ └── Setting │ │ │ │ ├── FolderTree.tsx │ │ │ │ ├── index.module.css │ │ │ │ └── index.tsx │ │ ├── layout.tsx │ │ ├── manifest.ts │ │ ├── masonry │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── responsive │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ └── template.tsx │ ├── env.mjs │ ├── hooks │ │ └── useWindowSize.ts │ ├── icons │ │ └── Icon-Shuffle.tsx │ ├── states │ │ └── setting.ts │ ├── styles │ │ └── globals.css │ └── utils │ │ ├── get-image-query.ts │ │ ├── photoswipe-video.ts │ │ └── trpc.ts │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── types │ └── photoswiper.d.ts ├── tooling ├── eslint │ ├── base.js │ ├── nextjs.js │ ├── package.json │ ├── react.js │ └── tsconfig.json ├── prettier │ ├── index.mjs │ ├── package.json │ └── tsconfig.json ├── tailwind │ ├── index.ts │ ├── package.json │ ├── postcss.js │ └── tsconfig.json └── typescript │ ├── base.d.ts │ ├── base.json │ └── package.json ├── turbo.json ├── turbo └── generators │ ├── config.ts │ └── templates │ ├── package.json.hbs │ └── tsconfig.json.hbs ├── vercel.json ├── vite.config.ts └── vitest.workspace.ts /.env.example: -------------------------------------------------------------------------------- 1 | # SENTRY 2 | SENTRY_DSN="" 3 | SENTRY_AUTH_TOKEN="" 4 | 5 | # Windows Electron Builder certs 6 | WIN_CSC_LINK="" 7 | WIN_CSC_KEY_PASSWORD="" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 提交 Bug 2 | description: 创建一个 bug 报告来帮助我们改进 3 | title: "bug: " 4 | labels: ["Bug"] 5 | body: 6 | - type: dropdown 7 | id: systems 8 | attributes: 9 | label: 提供系统信息 10 | description: 选择你使用的系统 11 | options: 12 | - Windows 13 | - MacOS Arm 14 | - MacOS Inter 15 | validations: 16 | required: true 17 | - type: input 18 | attributes: 19 | label: 软件版本 20 | description: 右键点击状态栏图标 => 关于, 可以查看当前所使用的版本 21 | validations: 22 | required: true 23 | - type: input 24 | attributes: 25 | label: 系统版本 26 | description: 提供系统版本可以帮助我们更加快速定位问题 27 | - type: textarea 28 | attributes: 29 | label: 描述bug 30 | description: 清晰而简明地描述bug,以及在遇到bug时您期望发生的情况。 31 | validations: 32 | required: true 33 | - type: textarea 34 | attributes: 35 | label: 复现步骤 36 | description: 描述如何复现您的bug。包括步骤、代码片段、复现仓库等。 37 | validations: 38 | required: true 39 | - type: textarea 40 | attributes: 41 | label: 附加信息 42 | description: 在这里添加与bug相关的任何其他信息,如果适用,还可以包括屏幕截图。 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | # This template is heavily inspired by the Next.js's template: 2 | # See here: https://github.com/vercel/next.js/blob/canary/.github/ISSUE_TEMPLATE/3.feature_request.yml 3 | 4 | name: 🛠 Feature Request 5 | description: Create a feature request for the core packages 6 | title: "feat: " 7 | labels: ["✨ enhancement"] 8 | body: 9 | - type: markdown 10 | attributes: 11 | value: | 12 | Thank you for taking the time to file a feature request. Please fill out this form as completely as possible. 13 | - type: textarea 14 | attributes: 15 | label: Describe the feature you'd like to request 16 | description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed. 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: Describe the solution you'd like to see 22 | description: Please describe the solution you would like to see. Adding example usage is a good way to provide context. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Additional information 28 | description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here. 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: CodeCov 2 | 3 | on: 4 | pull_request: 5 | branches: ["main"] 6 | 7 | # You can leverage Vercel Remote Caching with Turbo to speed up your builds 8 | # @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds 9 | env: 10 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 11 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }} 12 | 13 | jobs: 14 | codecov: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repo 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup pnpm 22 | uses: pnpm/action-setup@v2.4.0 23 | 24 | - name: Setup Node 18 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: 18 28 | 29 | - name: Get pnpm store directory 30 | id: pnpm-cache 31 | run: | 32 | echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT 33 | 34 | - name: Setup pnpm cache 35 | uses: actions/cache@v3 36 | with: 37 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 38 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 39 | restore-keys: | 40 | ${{ runner.os }}-pnpm-store- 41 | 42 | - name: Install deps (with cache) 43 | run: pnpm install --frozen-lockfile 44 | 45 | - name: Test and coverage 46 | run: pnpm test 47 | 48 | - name: Upload coverage reports to Codecov 49 | uses: codecov/codecov-action@v3 50 | env: 51 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | 4 | # icons 5 | icons/* 6 | !icons/logo*.png 7 | !icons/create.sh 8 | !icons/logo.svg 9 | !icons/index.html 10 | themes/gallery/public/icon_*.png 11 | apps/electron/build/icon.* 12 | 13 | .idea 14 | 15 | # prisma 16 | db.sqlite 17 | db.sqlite-journal 18 | packages/db/prisma/migrations/.version 19 | 20 | # dependencies 21 | node_modules 22 | .pnp 23 | .pnp.js 24 | 25 | # testing 26 | coverage 27 | 28 | # next.js 29 | .next/ 30 | out/ 31 | next-env.d.ts 32 | 33 | # expo 34 | .expo/ 35 | dist/ 36 | releases/ 37 | expo-env.d.ts 38 | 39 | # misc 40 | **/.DS_Store 41 | *.pem 42 | 43 | # debug 44 | npm-debug.log* 45 | yarn-debug.log* 46 | yarn-error.log* 47 | .pnpm-debug.log* 48 | 49 | # local env files 50 | .env 51 | .env*.local 52 | 53 | # vercel 54 | .vercel 55 | 56 | # typescript 57 | *.tsbuildinfo 58 | 59 | # turbo 60 | .turbo 61 | 62 | 63 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # https://pnpm.io/zh/npmrc#prefer-workspace-packages 2 | prefer-workspace-packages=true 3 | 4 | registry=https://registry.npmmirror.com 5 | 6 | # electron_mirror is used to speed up electron installation 7 | electron_mirror=https://npmmirror.com/mirrors/electron/ 8 | chromedriver_cdnurl=https://npmmirror.com/mirrors/chromedriver 9 | 10 | 11 | # https://pnpm.io/zh/npmrc#node-linker 12 | node-linker=hoisted 13 | 14 | # node_modules move to the root of the project 15 | # shamefully-hoist=true 16 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.hbs -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bradlc.vscode-tailwindcss", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode", 6 | "yoavbls.pretty-ts-errors" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit" 4 | }, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true, 7 | "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }], 8 | "eslint.workingDirectories": [ 9 | { "pattern": "apps/*/" }, 10 | { "pattern": "packages/*/" }, 11 | { "pattern": "tooling/*/" } 12 | ], 13 | "tailwindCSS.experimental.configFile": "./tooling/tailwind/index.ts", 14 | "typescript.enablePromptUseWorkspaceTsdk": true, 15 | "typescript.tsdk": "node_modules/typescript/lib", 16 | "typescript.preferences.autoImportFileExcludePatterns": [ 17 | "next/router.d.ts", 18 | "next/dist/client/router.d.ts" 19 | ], 20 | "[prisma]": { 21 | "editor.defaultFormatter": "Prisma.prisma" 22 | }, 23 | "files.associations": { 24 | "*.css": "tailwindcss" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 贡献指南 2 | 3 | 我们欢迎对 Rao.Pics 感兴趣的任何人士做出贡献。如果您有兴趣参与贡献,有几种方式可以参与: 4 | 5 | - Bug 修复:如果您发现了任何 bug,请创建一个拉取请求,清晰描述问题并提供解决方案。快速通道 => [🐞 提交 Bug](https://github.com/meetqy/rao-pics/issues/new?assignees=&labels=Bug&projects=&template=bug_report.yml&title=bug%3A+) 6 | 7 | - 改进:如果您对 Rao.Pics 有改进的建议,请先创建一个问题讨论为什么需要这个改进。快速通道 => [🛠 提需求](https://github.com/meetqy/rao-pics/issues/new?assignees=&labels=%E2%9C%A8+enhancement&projects=&template=feature_request.yml&title=feat%3A+) 8 | 9 | ## 构建 10 | 11 | 这些命令仅供维护人员使用。 12 | 13 | **环境信息** 14 | 15 | - nodejs >= `v18.17.1` 16 | - pnpm >= `8.7.6` 17 | 18 | **拉取代码** 19 | 20 | ``` 21 | git clone https://github.com/meetqy/rao-pics.git 22 | ``` 23 | 24 | **安装依赖** 25 | 26 | 使用 pnpm 安装 依赖 27 | 28 | ``` 29 | pnpm i 30 | ``` 31 | 32 | **运行** 33 | 34 | `pnpm dev` 会同时运行三个项目分别为:`packages/db`、`themes/gallery`、`apps/electron` 35 | 36 | ``` 37 | pnpm dev 38 | ``` 39 | -------------------------------------------------------------------------------- /apps/electron/.eslintignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | build -------------------------------------------------------------------------------- /apps/electron/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["@rao-pics/eslint-config/base", "@rao-pics/eslint-config/react"], 4 | "parserOptions": { 5 | "project": ["./tsconfig.node.json", "./tsconfig.web.json"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/electron/.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | out -------------------------------------------------------------------------------- /apps/electron/build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /apps/electron/build/notarize.js: -------------------------------------------------------------------------------- 1 | const { notarize } = require('@electron/notarize') 2 | 3 | module.exports = async (context) => { 4 | if (process.platform !== 'darwin') return 5 | 6 | console.log('aftersign hook triggered, start to notarize app.') 7 | 8 | if (!process.env.CI) { 9 | console.log(`skipping notarizing, not in CI.`) 10 | return 11 | } 12 | 13 | if (!('APPLE_ID' in process.env && 'APPLE_ID_PASS' in process.env)) { 14 | console.warn('skipping notarizing, APPLE_ID and APPLE_ID_PASS env variables must be set.') 15 | return 16 | } 17 | 18 | const appId = 'com.electron.app' 19 | 20 | const { appOutDir } = context 21 | 22 | const appName = context.packager.appInfo.productFilename 23 | 24 | try { 25 | await notarize({ 26 | appBundleId: appId, 27 | appPath: `${appOutDir}/${appName}.app`, 28 | appleId: process.env.APPLE_ID, 29 | appleIdPassword: process.env.APPLEIDPASS 30 | }) 31 | } catch (error) { 32 | console.error(error) 33 | } 34 | 35 | console.log(`done notarizing ${appId}.`) 36 | } 37 | -------------------------------------------------------------------------------- /apps/electron/dev-app-update.yml: -------------------------------------------------------------------------------- 1 | provider: generic 2 | url: https://example.com/auto-updates 3 | updaterCacheDirName: electron-updater 4 | -------------------------------------------------------------------------------- /apps/electron/electron-builder/base.cjs: -------------------------------------------------------------------------------- 1 | const { join } = require("path"); 2 | 3 | const isTestBuilder = process.env.IS_TEST_BUILDER === "true"; 4 | 5 | /** 6 | * @type {import('electron-builder').Configuration} 7 | * @see https://www.electron.build/configuration/configuration 8 | */ 9 | const BaseConfig = { 10 | appId: "com.rao-pics.app", 11 | copyright: `Copyright © 2022 meetqy`, 12 | productName: "Rao Pics", 13 | directories: { 14 | buildResources: "build", 15 | output: "releases", 16 | }, 17 | asar: !isTestBuilder, 18 | files: [ 19 | "!**/.vscode/*", 20 | "!src/*", 21 | "!electron-builder", 22 | "!electron.vite.config.{js,ts,mjs,cjs}", 23 | "!{.eslintignore,.eslintrc.json,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md,tailwind.config.ts,postcss.config.cjs,electron-builder.cjs}", 24 | "!{.env,.env.*,.npmrc,pnpm-lock.yaml}", 25 | "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}", 26 | "!**/node_modules/@rao-pics/**", 27 | "!**/.turbo/*", 28 | "!**/node_modules/prisma/libquery_engine-*", 29 | "!**/node_modules/@prisma/engines/**", 30 | ], 31 | extraResources: [ 32 | { 33 | from: join(__dirname, "../node_modules/@rao-pics/db/prisma"), 34 | to: "extraResources", 35 | filter: ["db.sqlite", "migrations/**/*", "!migrations/.version"], 36 | }, 37 | { 38 | from: join(__dirname, "../../../", "themes", "gallery", "out"), 39 | to: "extraResources/themes/gallery", 40 | }, 41 | ], 42 | }; 43 | 44 | module.exports = BaseConfig; 45 | -------------------------------------------------------------------------------- /apps/electron/electron-builder/mac.cjs: -------------------------------------------------------------------------------- 1 | const BaseConfig = require("./base.cjs"); 2 | const builder = require("electron-builder"); 3 | const { join } = require("path"); 4 | 5 | const { Platform } = builder; 6 | 7 | const isTestBuilder = process.env.IS_TEST_BUILDER === "true"; 8 | 9 | const files = []; 10 | 11 | /** 12 | * @type {import('electron-builder').Configuration} 13 | * @see https://www.electron.build/configuration/configuration 14 | */ 15 | const AppConfig = { 16 | ...BaseConfig, 17 | 18 | mac: { 19 | identity: null, 20 | entitlementsInherit: "build/entitlements.mac.plist", 21 | category: "public.app-category.photography", 22 | darkModeSupport: true, 23 | target: { 24 | target: isTestBuilder ? "dir" : "dmg", 25 | arch: ["x64", "arm64"], 26 | }, 27 | files, 28 | }, 29 | 30 | beforeBuild: (context) => { 31 | files.splice(-1); 32 | 33 | if (context.arch === "x64") { 34 | files.push({ 35 | from: join(__dirname, "../../../node_modules/.prisma"), 36 | to: "node_modules/.prisma", 37 | filter: ["**/*", "!**/*.node", "**/libquery_engine-darwin.dylib.node"], 38 | }); 39 | } 40 | 41 | if (context.arch === "arm64") { 42 | files.push({ 43 | from: join(__dirname, "../../../node_modules/.prisma"), 44 | to: "node_modules/.prisma", 45 | filter: [ 46 | "**/*", 47 | "!**/*.node", 48 | "**/libquery_engine-darwin-arm64.dylib.node", 49 | ], 50 | }); 51 | } 52 | 53 | return Promise.resolve(context); 54 | }, 55 | }; 56 | 57 | builder 58 | .build({ 59 | targets: Platform.MAC.createTarget(), 60 | config: AppConfig, 61 | }) 62 | .then((result) => { 63 | console.log(JSON.stringify(result)); 64 | }) 65 | .catch((error) => { 66 | console.error(error); 67 | }); 68 | -------------------------------------------------------------------------------- /apps/electron/electron-builder/win.cjs: -------------------------------------------------------------------------------- 1 | const BaseConfig = require("./base.cjs"); 2 | const builder = require("electron-builder"); 3 | const { join } = require("path"); 4 | 5 | const { Platform } = builder; 6 | 7 | const isTestBuilder = process.env.IS_TEST_BUILDER === "true"; 8 | 9 | /** 10 | * @type {import('electron-builder').Configuration} 11 | * @see https://www.electron.build/configuration/configuration 12 | */ 13 | const AppConfig = { 14 | ...BaseConfig, 15 | 16 | win: { 17 | target: { 18 | target: isTestBuilder ? "dir" : "nsis", 19 | arch: ["x64"], 20 | }, 21 | files: [ 22 | { 23 | from: join(__dirname, "../../../node_modules/.prisma"), 24 | to: "node_modules/.prisma", 25 | filter: ["**/*", "!**/*.node", "**/query_engine-windows.dll.node"], 26 | }, 27 | ], 28 | publish: ["github"], 29 | publisherName: ["meetqy"], 30 | }, 31 | }; 32 | 33 | builder 34 | .build({ 35 | targets: Platform.WINDOWS.createTarget(), 36 | config: AppConfig, 37 | }) 38 | .then((result) => { 39 | console.log(JSON.stringify(result)); 40 | }) 41 | .catch((error) => { 42 | console.error(error); 43 | }); 44 | -------------------------------------------------------------------------------- /apps/electron/electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig, externalizeDepsPlugin } from "electron-vite"; 3 | import { sentryVitePlugin } from "@sentry/vite-plugin"; 4 | import react from "@vitejs/plugin-react"; 5 | 6 | const IS_DEV = process.env.NODE_ENV != "production"; 7 | 8 | export default defineConfig({ 9 | main: { 10 | build: { 11 | sourcemap: !IS_DEV, 12 | }, 13 | esbuild: { 14 | drop: IS_DEV ? undefined : ["console", "debugger"], 15 | }, 16 | plugins: [ 17 | externalizeDepsPlugin({ 18 | exclude: [ 19 | "@rao-pics/db", 20 | "@rao-pics/api", 21 | "@rao-pics/constant", 22 | "@rao-pics/trpc", 23 | "@rao-pics/rlog", 24 | 25 | // package.json => type:module 的依赖,electron 中需要打包到代码中 26 | "superjson", 27 | ], 28 | include: [ 29 | // @rao-pics/api 中的依赖,electron 中作为外部依赖 30 | "chokidar", 31 | ], 32 | }), 33 | 34 | !IS_DEV && 35 | sentryVitePlugin({ 36 | org: "raopics", 37 | project: "electron", 38 | authToken: process.env.SENTRY_AUTH_TOKEN, 39 | }), 40 | ], 41 | }, 42 | preload: { 43 | plugins: [externalizeDepsPlugin()], 44 | }, 45 | renderer: { 46 | resolve: { 47 | alias: { 48 | "@renderer": resolve("src/renderer/src"), 49 | "@build": resolve("build"), 50 | }, 51 | }, 52 | plugins: [react()], 53 | }, 54 | }); 55 | -------------------------------------------------------------------------------- /apps/electron/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/electron", 3 | "version": "1.0.0-alpha.13", 4 | "description": "远程访问 Eagle 素材资源", 5 | "homepage": "https://rao.pics", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/meetqy/rao-pics.git" 9 | }, 10 | "author": { 11 | "name": "meetqy", 12 | "email": "meetqy@qq.com" 13 | }, 14 | "main": "./out/main/index.js", 15 | "scripts": { 16 | "build": "pnpm typecheck && pnpm with-env electron-vite build", 17 | "build:mac": "pnpm with-env node ./electron-builder/mac.cjs", 18 | "build:win": "pnpm with-env node ./electron-builder/win.cjs", 19 | "clean": "git clean -xdf node_modules out releases .turbo", 20 | "dev": "pnpm with-env electron-vite dev", 21 | "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", 22 | "postinstall": "electron-builder install-app-deps", 23 | "lint": "eslint .", 24 | "releases": "git clean -xdf releases && pnpm build:mac && pnpm build:win", 25 | "start": "electron-vite preview", 26 | "typecheck": "pnpm typecheck:node && pnpm typecheck:web", 27 | "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", 28 | "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", 29 | "with-env": "dotenv -e ../../.env --" 30 | }, 31 | "prettier": "@rao-pics/prettier-config", 32 | "dependencies": { 33 | "@electron-toolkit/preload": "^2.0.0", 34 | "@electron-toolkit/utils": "^2.0.0", 35 | "@heroicons/react": "^2.0.18", 36 | "@rao-pics/api": "workspace:^", 37 | "@rao-pics/constant": "workspace:^", 38 | "@rao-pics/db": "workspace:*", 39 | "@rao-pics/rlog": "workspace:^", 40 | "@rao-pics/trpc": "workspace:^", 41 | "@sentry/electron": "^4.14.0", 42 | "electron-updater": "^6.0.0", 43 | "ip": "^1.1.8", 44 | "lodash": "^4.17.21", 45 | "qrcode.react": "^3.1.0", 46 | "ws": "^8.14.2" 47 | }, 48 | "devDependencies": { 49 | "@electron-toolkit/tsconfig": "^1.0.1", 50 | "@electron/notarize": "^1.2.3", 51 | "@rao-pics/eslint-config": "workspace:^", 52 | "@rao-pics/prettier-config": "workspace:^", 53 | "@rao-pics/tailwind-config": "workspace:*", 54 | "@rao-pics/tsconfig": "workspace:*", 55 | "@sentry/vite-plugin": "^2.7.1", 56 | "@types/ip": "^1.1.0", 57 | "@types/lodash": "^4.14.197", 58 | "@types/node": "^18.17.6", 59 | "@types/react": "^18.2.20", 60 | "@types/react-dom": "^18.2.7", 61 | "@vitejs/plugin-react": "^4.0.3", 62 | "electron": "25.9.5", 63 | "electron-builder": "24.9.1", 64 | "electron-devtools-installer": "^3.2.0", 65 | "electron-vite": "^1.0.25", 66 | "eslint": "^8.47.0", 67 | "prettier": "^3.0.2", 68 | "react": "18.2.0", 69 | "react-dom": "18.2.0", 70 | "tailwindcss": "3.3.6", 71 | "typescript": "^5.1.6", 72 | "vite": "^4.4.2" 73 | }, 74 | "productName": "Rao Pics" 75 | } 76 | -------------------------------------------------------------------------------- /apps/electron/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@rao-pics/tailwind-config/postcss"); 2 | -------------------------------------------------------------------------------- /apps/electron/resources/rTemplate@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meetqy/rao-pics/c86d164c408d9594dda480835ec2ca15087f2d0e/apps/electron/resources/rTemplate@4x.png -------------------------------------------------------------------------------- /apps/electron/src/main/src/dock.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import type { BrowserWindow } from "electron"; 3 | 4 | import { PLATFORM } from "@rao-pics/constant/server"; 5 | 6 | /** 7 | * 隐藏 dock 图标 8 | * @param window 9 | */ 10 | export const hideDock = (window: BrowserWindow) => { 11 | if (PLATFORM === "darwin") { 12 | app.dock.hide(); 13 | } else { 14 | window.setSkipTaskbar(true); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /apps/electron/src/main/src/ipc/index.ts: -------------------------------------------------------------------------------- 1 | import { createSystemIPC } from "./system"; 2 | 3 | let ipc = false; 4 | export const createCustomIPCHandle = () => { 5 | if (ipc) return; 6 | 7 | ipc = true; 8 | createSystemIPC(); 9 | }; 10 | -------------------------------------------------------------------------------- /apps/electron/src/main/src/ipc/system.ts: -------------------------------------------------------------------------------- 1 | import { dialog, ipcMain, shell } from "electron"; 2 | 3 | export const createSystemIPC = () => { 4 | // dialog 5 | ipcMain.handle( 6 | "dialog.showOpenDialog", 7 | (_e, options: Electron.OpenDialogOptions) => 8 | dialog.showOpenDialogSync(options), 9 | ); 10 | 11 | ipcMain.handle( 12 | "dialog.showMessageBox", 13 | (_e, options: Electron.MessageBoxOptions) => 14 | dialog.showMessageBoxSync(options), 15 | ); 16 | 17 | ipcMain.handle( 18 | "dialog.showErrorBox", 19 | (_e, title: string, content: string) => { 20 | dialog.showErrorBox(title, content); 21 | }, 22 | ); 23 | 24 | // shell 25 | ipcMain.handle( 26 | "shell.openExternal", 27 | (_e, url: string, options?: Electron.OpenExternalOptions) => 28 | shell.openExternal(url, options), 29 | ); 30 | ipcMain.handle("shell.showItemInFolder", (_e, path: string) => 31 | shell.showItemInFolder(path), 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /apps/electron/src/main/src/menu.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserWindow } from "electron"; 2 | import { app, Menu } from "electron"; 3 | 4 | /** 5 | * 创建菜单 6 | */ 7 | const createMenu = (window: BrowserWindow) => { 8 | const menu = Menu.buildFromTemplate([ 9 | { 10 | label: app.name, 11 | submenu: [ 12 | { role: "about" }, 13 | { type: "separator" }, 14 | { role: "services" }, 15 | { type: "separator" }, 16 | { role: "hide" }, 17 | { role: "hideOthers" }, 18 | { role: "unhide" }, 19 | { type: "separator" }, 20 | { 21 | label: "Close", 22 | accelerator: "CmdOrCtrl+W", 23 | click: () => { 24 | window.hide(); 25 | }, 26 | }, 27 | { 28 | label: "Quit", 29 | accelerator: "CmdOrCtrl+Q", 30 | click: () => { 31 | window.hide(); 32 | }, 33 | }, 34 | ], 35 | }, 36 | ]); 37 | 38 | Menu.setApplicationMenu(menu); 39 | }; 40 | 41 | export default createMenu; 42 | -------------------------------------------------------------------------------- /apps/electron/src/main/src/tray.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserWindow } from "electron"; 2 | import { app, Menu, nativeImage, Tray } from "electron"; 3 | 4 | import icon from "../../../resources/rTemplate@4x.png?asset"; 5 | 6 | const createTray = (window: BrowserWindow) => { 7 | const tray = new Tray(nativeImage.createFromPath(icon)); 8 | 9 | const contextMenu = Menu.buildFromTemplate([ 10 | { 11 | label: "关于", 12 | click: () => { 13 | app.showAboutPanel(); 14 | }, 15 | }, 16 | { type: "separator" }, 17 | { 18 | label: "退出", 19 | click: () => { 20 | process.env.QUITE = "true"; 21 | app.quit(); 22 | }, 23 | }, 24 | ]); 25 | 26 | tray.on("click", (e) => { 27 | if (e.altKey) { 28 | // 触控板 + alt 显示菜单 29 | tray.popUpContextMenu(contextMenu); 30 | } else { 31 | window.show(); 32 | } 33 | }); 34 | 35 | tray.on("right-click", () => { 36 | tray.popUpContextMenu(contextMenu); 37 | }); 38 | }; 39 | 40 | export default createTray; 41 | -------------------------------------------------------------------------------- /apps/electron/src/preload/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { ElectronAPI } from "@electron-toolkit/preload"; 2 | 3 | declare global { 4 | interface Window { 5 | electron: ElectronAPI; 6 | api: unknown; 7 | dialog: { 8 | /** 9 | * Same as showOpendialog, But it doesn't have 'browserWindow' argument. 10 | */ 11 | showOpenDialog: ( 12 | options: Electron.OpenDialogOptions, 13 | ) => Promise; 14 | showMessageBox: (options: Electron.MessageBoxOptions) => Promise; 15 | showErrorBox: (title: string, content: string) => void; 16 | }; 17 | shell: { 18 | openExternal: ( 19 | url: string, 20 | options?: Electron.OpenExternalOptions, 21 | ) => Promise; 22 | showItemInFolder: (path: string) => void; 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /apps/electron/src/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from "electron"; 2 | import { electronAPI } from "@electron-toolkit/preload"; 3 | 4 | // Custom APIs for renderer 5 | const api = {}; 6 | 7 | // Use `contextBridge` APIs to expose Electron APIs to 8 | // renderer only if context isolation is enabled, otherwise 9 | // just add to the DOM global. 10 | if (process.contextIsolated) { 11 | try { 12 | contextBridge.exposeInMainWorld("electron", electronAPI); 13 | contextBridge.exposeInMainWorld("api", api); 14 | 15 | /** 16 | * window.dialog same as dialog.xxx 17 | */ 18 | contextBridge.exposeInMainWorld("dialog", { 19 | showOpenDialog: (options: Electron.OpenDialogOptions) => 20 | ipcRenderer.invoke("dialog.showOpenDialog", options), 21 | 22 | showMessageBox: (options: Electron.OpenDialogOptions) => 23 | ipcRenderer.invoke("dialog.showMessageBox", options), 24 | 25 | showErrorBox: (title: string, content: string) => 26 | ipcRenderer.invoke("dialog.showErrorBox", title, content), 27 | }); 28 | 29 | /** 30 | * window.shell same as shell.xxx 31 | */ 32 | contextBridge.exposeInMainWorld("shell", { 33 | openExternal: (url: string, options?: Electron.OpenExternalOptions) => 34 | ipcRenderer.invoke("shell.openExternal", url, options), 35 | showItemInFolder: (path: string) => 36 | ipcRenderer.invoke("shell.showItemInFolder", path), 37 | }); 38 | } catch (error) { 39 | console.error(error); 40 | } 41 | } else { 42 | // eslint-disable-next-line @typescript-eslint/dot-notation 43 | window["electron"] = electronAPI; 44 | // eslint-disable-next-line @typescript-eslint/dot-notation 45 | window["api"] = electronAPI; 46 | } 47 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Electron 6 | 7 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { TRPCReactProvider } from "@rao-pics/trpc"; 2 | 3 | import Layout from "./Layout"; 4 | 5 | function App() { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export default App; 14 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/Layout/aside.tsx: -------------------------------------------------------------------------------- 1 | import Menu from "@renderer/components/Menu"; 2 | 3 | interface LayoutAsideProps { 4 | current: number; 5 | setCurrent: (e: number) => void; 6 | libraryName: string; 7 | } 8 | 9 | function LayoutAside({ current, setCurrent, libraryName }: LayoutAsideProps) { 10 | const windows = window.electron.process.platform === "win32"; 11 | 12 | return ( 13 | 37 | ); 38 | } 39 | 40 | export default LayoutAside; 41 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/Layout/content.tsx: -------------------------------------------------------------------------------- 1 | import BasicPage from "@renderer/pages/basic"; 2 | import ColorPage from "@renderer/pages/color"; 3 | import SettingPage from "@renderer/pages/setting"; 4 | import UnsyncPage from "@renderer/pages/unsync"; 5 | 6 | function LayoutContent({ current }: { current: number }) { 7 | return ( 8 | <> 9 | {current === 0 && } 10 | {current === 1 && } 11 | {current === 2 && } 12 | {current === 3 && } 13 | 14 | ); 15 | } 16 | 17 | export default LayoutContent; 18 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import { trpc } from "@rao-pics/trpc"; 4 | 5 | import { useColor } from "../hooks"; 6 | import Index from "../pages/index"; 7 | import LayoutAside from "./aside"; 8 | import LayoutContent from "./content"; 9 | 10 | const Layout = () => { 11 | const [current, setCurrent] = useState(0); 12 | const { color } = useColor(); 13 | 14 | const { data: library } = trpc.library.findUnique.useQuery(); 15 | const libraryName = library?.path.split(/\/|\\/).slice(-1)[0] ?? "暂无资源库"; 16 | 17 | return ( 18 |
22 | {/* aside */} 23 | 28 | 29 | {/* content */} 30 | {library ? : } 31 |
32 | ); 33 | }; 34 | 35 | export default Layout; 36 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/components/Content.tsx: -------------------------------------------------------------------------------- 1 | interface ContentProps { 2 | title: React.ReactNode; 3 | children?: React.ReactNode; 4 | } 5 | 6 | const Content = ({ title, children }: ContentProps) => { 7 | return ( 8 |
9 | {/* title */} 10 | {title} 11 | 12 | {/* main */} 13 |
14 | {children} 15 | {/*
16 | {current === 0 && } 17 | {current === 1 && } 18 | {current === 2 && } 19 | {current === 3 && } 20 |
*/} 21 |
22 |
23 | ); 24 | }; 25 | 26 | export default Content; 27 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/components/Menu.tsx: -------------------------------------------------------------------------------- 1 | interface MenuProps { 2 | current?: number; 3 | onChange?: (index: number) => void; 4 | } 5 | 6 | const Menu = ({ current, onChange }: MenuProps) => { 7 | const menuItems = [ 8 | { 9 | icon: ( 10 | 17 | 23 | 24 | ), 25 | badge: null, 26 | text: "基础信息", 27 | }, 28 | { 29 | icon: ( 30 | 38 | 43 | 44 | ), 45 | badget: null, 46 | text: "未同步记录", 47 | }, 48 | { 49 | className: "mt-4", 50 | icon: ( 51 | 59 | 64 | 69 | 70 | ), 71 | badge: null, 72 | text: "通用", 73 | }, 74 | { 75 | icon: ( 76 | 83 | 87 | 88 | ), 89 | badge: null, 90 | text: "外观", 91 | }, 92 | ]; 93 | 94 | return ( 95 |
    96 | {menuItems.map((item, index) => ( 97 |
  • 98 | 106 |
  • 107 | ))} 108 |
109 | ); 110 | }; 111 | 112 | Menu.defaultProps = { 113 | current: 0, 114 | }; 115 | 116 | export default Menu; 117 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/components/Row.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronRightIcon } from "@heroicons/react/24/outline"; 2 | 3 | interface Props { 4 | left?: React.ReactNode; 5 | right?: React.ReactNode | string; 6 | onLeftClick?: () => void; 7 | onRightClick?: () => void; 8 | compact?: boolean; 9 | } 10 | 11 | export const Row = (props: Props) => { 12 | return ( 13 |
14 | 17 | 18 | 37 |
38 | ); 39 | }; 40 | 41 | export default Row; 42 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/components/Title.tsx: -------------------------------------------------------------------------------- 1 | import WindowsTitleBarBtnGroup from "./WindowsTitleBarBtnGroup"; 2 | 3 | interface TitleProps { 4 | children?: React.ReactNode; 5 | className?: string; 6 | } 7 | 8 | const Title = ({ children, className }: TitleProps) => { 9 | const windows = window.electron.process.platform === "win32"; 10 | 11 | return ( 12 |
15 |
19 | {children} 20 |
21 | 22 | {/* windows btn group */} 23 | {windows && } 24 |
25 | ); 26 | }; 27 | 28 | export default Title; 29 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/components/WindowsTitleBarBtnGroup.tsx: -------------------------------------------------------------------------------- 1 | const WindowsTitleBarBtnGroup = () => { 2 | return ( 3 |
4 | 15 | 16 | 30 | 31 | 48 |
49 | ); 50 | }; 51 | 52 | export default WindowsTitleBarBtnGroup; 53 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { trpc } from "@rao-pics/trpc"; 4 | 5 | /** 6 | * 外观 hook 7 | */ 8 | export function useColor() { 9 | const utils = trpc.useUtils(); 10 | const { data: config } = trpc.config.findUnique.useQuery(); 11 | 12 | const setColor = trpc.config.upsert.useMutation({ 13 | onSuccess: () => { 14 | void utils.config.invalidate(); 15 | }, 16 | }); 17 | 18 | const color = config?.color ?? "light"; 19 | 20 | return { 21 | color, 22 | setColor: (color: string) => setColor.mutate({ color }), 23 | }; 24 | } 25 | 26 | /** 27 | * 防抖 28 | * @param value 29 | * @param delay 30 | * @returns 31 | */ 32 | export function useDebounce(value: string, delay = 500) { 33 | const [debouncedValue, setDebouncedValue] = useState(value); 34 | 35 | useEffect(() => { 36 | const handler: NodeJS.Timeout = setTimeout(() => { 37 | setDebouncedValue(value); 38 | }, delay); 39 | 40 | // Cancel the timeout if value changes (also on delay change or unmount) 41 | return () => { 42 | clearTimeout(handler); 43 | }; 44 | }, [value, delay]); 45 | 46 | return debouncedValue; 47 | } 48 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .scroll-y { 6 | @apply overflow-y-auto scrollbar-thin scrollbar-track-base-200 scrollbar-thumb-primary/50 scrollbar-track-rounded-box scrollbar-thumb-rounded-box; 7 | } 8 | 9 | .card-wrapper { 10 | @apply rounded-box border border-base-content/10 bg-base-200 px-4; 11 | 12 | .card-row { 13 | @apply flex justify-between py-3; 14 | 15 | &.compact { 16 | @apply py-1.5; 17 | } 18 | } 19 | 20 | .card-row:not(:last-child) { 21 | @apply border-b border-base-content/10; 22 | } 23 | 24 | .card-row > span, 25 | .card-row > div { 26 | @apply flex items-center; 27 | } 28 | 29 | .card-row .right-svg { 30 | @apply ml-1 h-4 w-4 text-base-content/30; 31 | } 32 | } 33 | 34 | /* windows 窗口透明 */ 35 | :root { 36 | @apply cursor-default bg-transparent; 37 | } 38 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | 4 | import App from "./App"; 5 | 6 | import "./index.css"; 7 | 8 | ReactDOM.createRoot(document.getElementById("root")!).render(); 9 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/pages/basic/SyncCircle.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import { trpc } from "@rao-pics/trpc"; 4 | 5 | type Status = "completed" | "error" | "ok" | "start"; 6 | 7 | interface SyncCircleProps { 8 | pendingCount: number; 9 | /** 10 | * 读取中 11 | */ 12 | onReadData?: (status: Status) => void; 13 | /** 14 | * 同步中 15 | */ 16 | onSyncData?: (status: Status) => void; 17 | } 18 | 19 | export function SyncCircle({ 20 | pendingCount, 21 | onReadData, 22 | onSyncData, 23 | }: SyncCircleProps) { 24 | const utils = trpc.useUtils(); 25 | 26 | const [data, setData] = useState<{ 27 | status: Status; 28 | count: number; 29 | type: "folder" | "reading" | "image"; 30 | }>(); 31 | 32 | // 监听资源库变化 33 | trpc.library.onWatch.useSubscription(undefined, { 34 | onData: (data) => { 35 | onReadData?.(data.status); 36 | 37 | if (data.status === "completed") { 38 | setTimeout(() => { 39 | void utils.library.invalidate(); 40 | setData(undefined); 41 | }, 100); 42 | 43 | return; 44 | } else if (data.status === "error") { 45 | console.error(data); 46 | } 47 | 48 | setData({ 49 | status: data.status, 50 | count: data.count, 51 | type: "reading", 52 | }); 53 | }, 54 | }); 55 | 56 | // 监听同步变化 57 | trpc.sync.onStart.useSubscription(undefined, { 58 | onData: (data) => { 59 | onSyncData?.(data.status); 60 | if (data.status === "completed") { 61 | setTimeout(() => { 62 | void utils.library.invalidate(); 63 | setData(undefined); 64 | }, 500); 65 | 66 | return; 67 | } else if (data.status === "error") { 68 | console.error(data); 69 | } 70 | 71 | setData({ 72 | status: data.status, 73 | count: data.count, 74 | type: data.type, 75 | }); 76 | }, 77 | }); 78 | 79 | const Description = () => { 80 | let text = "待同步数"; 81 | 82 | const { type = "" } = data ?? {}; 83 | 84 | switch (type) { 85 | case "reading": 86 | text = "读取中..."; 87 | break; 88 | case "image": 89 | text = "同步图片"; 90 | break; 91 | case "folder": 92 | text = "同步中..."; 93 | break; 94 | } 95 | 96 | return ( 97 |

{text}

98 | ); 99 | }; 100 | 101 | return ( 102 |
112 |

113 | {data?.count ?? pendingCount} 114 |

115 | 116 | 117 |
118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/pages/color.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Content from "@renderer/components/Content"; 3 | import Title from "@renderer/components/Title"; 4 | import { useColor } from "@renderer/hooks"; 5 | 6 | interface ItemProps { 7 | active?: boolean; 8 | color: string; 9 | onClick?: () => void; 10 | } 11 | 12 | const Item = ({ color, onClick, active = false }: ItemProps) => { 13 | return ( 14 |
22 |
26 |
27 |
28 |
29 |
30 |
{color}
31 | 32 |
33 |
34 |
A
35 |
36 |
37 |
38 | B 39 |
40 |
41 |
42 |
C
43 |
44 |
45 |
D
46 |
47 |
48 |
49 |
50 |
51 |
52 | ); 53 | }; 54 | 55 | const colorScheme = [ 56 | ["light", "light,angle"], 57 | ["dark", "dark,angle"], 58 | ["cupcake", "light,angle"], 59 | ["bumblebee", "light,angle"], 60 | ["emerald", "light,angle"], 61 | ["corporate", "light,angle"], 62 | ["synthwave", "dark,angle"], 63 | ["retro", "light,angle"], 64 | ["cyberpunk", "neutral,right-angle"], 65 | ["valentine", "neutral,angle"], 66 | ["halloween", "dark,angle"], 67 | ["garden", "light,angle"], 68 | ["forest", "dark,angle"], 69 | ["aqua", "neutral,angle"], 70 | ["lofi", "light,angle"], 71 | ["pastel", "light,angle"], 72 | ["fantasy", "light,angle"], 73 | ["wireframe", "light,angle"], 74 | ["black", "dark,right-angle"], 75 | ["luxury", "dark,angle"], 76 | ["dracula", "dark,angle"], 77 | ["cmyk", "light,angle"], 78 | ["autumn", "light,angle"], 79 | ["business", "dark,angle"], 80 | ["acid", "light,angle"], 81 | ["lemonade", "light,angle"], 82 | ["night", "dark,angle"], 83 | ["coffee", "dark,angle"], 84 | ["winter", "light,angle"], 85 | ["dim", "dark,angle"], 86 | ["nord", "light,angle"], 87 | ["sunset", "dark,angle"], 88 | ]; 89 | 90 | const ColorPage = () => { 91 | const tags = [ 92 | { name: "", text: "全部" }, 93 | { name: "light", text: "浅色" }, 94 | { name: "dark", text: "深色" }, 95 | { name: "angle", text: "圆角" }, 96 | { name: "right-angle", text: "直角" }, 97 | { name: "neutral", text: "中性" }, 98 | ]; 99 | 100 | const { color, setColor } = useColor(); 101 | 102 | const [tag, setTag] = useState(""); 103 | 104 | const filterColors = colorScheme.filter((t) => t[1]?.includes(tag)); 105 | 106 | return ( 107 | 外观}> 108 |
109 |
110 |
111 |
112 | {tags.map((item) => ( 113 | setTag(item.name)} 120 | aria-label={item.text} 121 | /> 122 | ))} 123 |
124 |
125 |
126 | 127 |
128 | {filterColors.map((t) => ( 129 | setColor(t[0] ?? "light")} 133 | active={color === t[0]} 134 | /> 135 | ))} 136 |
137 |
138 |
139 | ); 140 | }; 141 | 142 | export default ColorPage; 143 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Content from "@renderer/components/Content"; 2 | import Title from "@renderer/components/Title"; 3 | 4 | import { PRODUCT_NAME } from "@rao-pics/constant"; 5 | import { trpc } from "@rao-pics/trpc"; 6 | 7 | const Index = () => { 8 | const utils = trpc.useUtils(); 9 | 10 | const addWatchLibrary = trpc.library.watch.useMutation(); 11 | 12 | const addLibrary = trpc.library.add.useMutation({ 13 | onError: (err) => { 14 | window.dialog.showErrorBox("添加资源库失败", err.message); 15 | }, 16 | 17 | onSuccess({ path }) { 18 | addWatchLibrary.mutate({ path }); 19 | void utils.library.findUnique.invalidate(); 20 | }, 21 | }); 22 | 23 | const openDirectory = () => { 24 | void window.dialog 25 | .showOpenDialog({ 26 | properties: ["openDirectory"], 27 | }) 28 | .then((res) => { 29 | const path = res?.[0]; 30 | 31 | if (path) { 32 | addLibrary.mutate(path); 33 | } 34 | }); 35 | }; 36 | 37 | return ( 38 | }> 39 |
40 |

41 | {PRODUCT_NAME.replace(" ", ".")} 42 |

43 |

30+外观随意切换,还可以自定义主题。`, 47 | }} 48 | /> 49 | 52 |

53 |
54 | ); 55 | }; 56 | 57 | export default Index; 58 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/pages/setting/index.css: -------------------------------------------------------------------------------- 1 | .custom-select { 2 | @apply select select-xs w-full rounded-box bg-base-100/50 text-right text-sm font-normal transition-all duration-300 hover:bg-base-100 hover:shadow focus:outline-none; 3 | } 4 | -------------------------------------------------------------------------------- /apps/electron/src/renderer/src/pages/unsync/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Content from "@renderer/components/Content"; 3 | import Row from "@renderer/components/Row"; 4 | import Title from "@renderer/components/Title"; 5 | import { useDebounce } from "@renderer/hooks"; 6 | 7 | import { trpc } from "@rao-pics/trpc"; 8 | 9 | const UnsyncPage = () => { 10 | const [keywords, setKeywords] = useState(""); 11 | const debounceKeywords = useDebounce(keywords, 300); 12 | const lib = trpc.library.findUnique.useQuery(); 13 | 14 | const logQuery = trpc.log.get.useQuery({ 15 | limit: 50, 16 | keywords: debounceKeywords, 17 | orderBy: "desc", 18 | }); 19 | 20 | const libPath = lib.data?.path ?? ""; 21 | 22 | const data = logQuery.data?.data ?? []; 23 | 24 | return ( 25 | 未同步记录}> 26 |
27 |
28 |
29 |
30 | setKeywords(e.target.value)} 36 | /> 37 |
38 |
39 |
40 | 41 |
42 | {data.length > 0 ? ( 43 |
44 | {data?.map((item, index) => ( 45 | 50 | {item.type === "unknown" ? "unkonwn error" : item.type} 51 | 52 | } 53 | right={item.path.replace(libPath, "")} 54 | onRightClick={() => { 55 | if (item.type === "unknown") { 56 | void window.dialog.showErrorBox(item.path, item.message); 57 | } else { 58 | void window.shell.showItemInFolder(item.path); 59 | } 60 | }} 61 | /> 62 | ))} 63 |
64 | ) : ( 65 |
66 | 74 | 79 | 80 | 81 |

暂无记录

82 |
83 | )} 84 |
85 |
86 |
87 | ); 88 | }; 89 | 90 | export default UnsyncPage; 91 | -------------------------------------------------------------------------------- /apps/electron/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | import baseConfig from "@rao-pics/tailwind-config"; 4 | 5 | export default { 6 | content: ["./src/renderer/index.html", "./src/renderer/**/*.{ts,tsx}"], 7 | presets: [baseConfig], 8 | } satisfies Config; 9 | -------------------------------------------------------------------------------- /apps/electron/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.node.json" }, 5 | { "path": "./tsconfig.web.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /apps/electron/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@electron-toolkit/tsconfig/tsconfig.node.json", 4 | "@rao-pics/tsconfig/base.json" 5 | ], 6 | "include": [ 7 | "electron.vite.config.*", 8 | "src/main/**/*", 9 | "src/preload/*", 10 | "tailwind.config.*" 11 | ], 12 | "compilerOptions": { 13 | "composite": true, 14 | "types": ["electron-vite/node"], 15 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 16 | }, 17 | 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /apps/electron/tsconfig.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@electron-toolkit/tsconfig/tsconfig.web.json", 4 | "@rao-pics/tsconfig/base" 5 | ], 6 | "include": [ 7 | "src/renderer/src/env.d.ts", 8 | "src/renderer/src/**/*", 9 | "src/preload/*.d.ts" 10 | ], 11 | "compilerOptions": { 12 | "composite": true, 13 | "baseUrl": ".", 14 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", 15 | "paths": { 16 | "@renderer/*": ["src/renderer/src/*"], 17 | "@build/*": ["build/*"] 18 | } 19 | }, 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /icons/logo-social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meetqy/rao-pics/c86d164c408d9594dda480835ec2ca15087f2d0e/icons/logo-social.png -------------------------------------------------------------------------------- /icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meetqy/rao-pics/c86d164c408d9594dda480835ec2ca15087f2d0e/icons/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-t3-turbo", 3 | "private": true, 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/meetqy/rao-pics" 7 | }, 8 | "scripts": { 9 | "build": "turbo build", 10 | "clean": "git clean -xdf coverage node_modules", 11 | "clean:workspaces": "turbo clean", 12 | "create:pkg": "turbo gen init", 13 | "dev": "turbo dev --parallel --filter='!@rao-pics/docs'", 14 | "format": "turbo format --continue -- --cache --cache-location='node_modules/.cache/.prettiercache'", 15 | "format:fix": "turbo format --continue -- --write --cache --cache-location='node_modules/.cache/.prettiercache'", 16 | "lint": "turbo lint --continue -- --cache --cache-location 'node_modules/.cache/.eslintcache'", 17 | "lint:fix": "turbo lint --continue -- --fix --cache --cache-location 'node_modules/.cache/.eslintcache'", 18 | "releases": "pnpm build && turbo releases --filter electron", 19 | "test": "cross-env NODE_ENV=development vitest run --coverage.enabled", 20 | "test:ui": "cross-env NODE_ENV=development vitest --ui --coverage.enabled", 21 | "typecheck": "turbo typecheck" 22 | }, 23 | "prettier": "@rao-pics/prettier-config", 24 | "dependencies": { 25 | "@rao-pics/prettier-config": "workspace:*", 26 | "prettier": "^3.0.2", 27 | "turbo": "^1.11.0", 28 | "typescript": "^5.1.6" 29 | }, 30 | "devDependencies": { 31 | "@vitest/coverage-v8": "^1.0.1", 32 | "@vitest/ui": "^1.0.0", 33 | "cross-env": "^7.0.3", 34 | "dotenv-cli": "^7.3.0", 35 | "vitest": "^1.0.1" 36 | }, 37 | "packageManager": "pnpm@8.11.0", 38 | "engines": { 39 | "node": ">=v18.17.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/api/index.ts: -------------------------------------------------------------------------------- 1 | import { color } from "./src/color"; 2 | import { config, configCore } from "./src/config"; 3 | import { folder, folderCore } from "./src/folder"; 4 | import { image, imageCore } from "./src/image"; 5 | import { library, libraryCore } from "./src/library"; 6 | import { log, logCore } from "./src/log"; 7 | import { pending, pendingCore } from "./src/pending"; 8 | import { sync } from "./src/sync"; 9 | import { tag } from "./src/tag"; 10 | import { t } from "./src/utils"; 11 | 12 | export const router = t.router({ 13 | config, 14 | library, 15 | pending, 16 | folder, 17 | sync, 18 | image, 19 | tag, 20 | color, 21 | log, 22 | }); 23 | 24 | export const routerCore = { 25 | config: configCore, 26 | folder: folderCore, 27 | image: imageCore, 28 | library: libraryCore, 29 | pending: pendingCore, 30 | log: logCore, 31 | }; 32 | 33 | export type AppRouter = typeof router; 34 | export { t } from "./src/utils"; 35 | export * from "./src/server"; 36 | -------------------------------------------------------------------------------- /packages/api/mocks/folder.json: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "id": "LKRVQVLHGERAX", 5 | "name": "期刊", 6 | "description": "", 7 | "children": [ 8 | { 9 | "id": "LLTF4ZITJSWSS", 10 | "name": "04", 11 | "description": "", 12 | "children": [], 13 | "modificationTime": 1693138821077, 14 | "tags": [], 15 | "password": "1234", 16 | "passwordTips": "1234" 17 | } 18 | ], 19 | "modificationTime": 1690869001589, 20 | "tags": ["期刊"], 21 | "password": "", 22 | "passwordTips": "" 23 | }, 24 | { 25 | "id": "LLNP0K49I63ZA", 26 | "name": "套图", 27 | "description": "", 28 | "children": [ 29 | { 30 | "id": "LLWCB0OZM5GZ3", 31 | "name": "08", 32 | "description": "", 33 | "children": [], 34 | "modificationTime": 1693315462211, 35 | "tags": [], 36 | "password": "", 37 | "passwordTips": "" 38 | } 39 | ], 40 | "modificationTime": 1692792613593, 41 | "tags": ["套图"], 42 | "password": "", 43 | "passwordTips": "" 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /packages/api/mocks/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "LKQU5OBY2N7VH", 3 | "name": "0731-9", 4 | "size": 696512, 5 | "btime": 1690805699337, 6 | "mtime": 1690805699707, 7 | "ext": "jpg", 8 | "tags": ["tag1"], 9 | "folders": [], 10 | "isDeleted": false, 11 | "url": "", 12 | "annotation": "", 13 | "modificationTime": 1690805866614, 14 | "height": 4096, 15 | "width": 2320, 16 | "lastModified": 1691501265577, 17 | "palettes": [ 18 | { "color": [250, 178, 28], "ratio": 43, "$$hashKey": "object:11045" } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/api", 3 | "version": "0.0.1", 4 | "private": true, 5 | "main": "./index.ts", 6 | "scripts": { 7 | "clean": "git clean -xdf .turbo node_modules", 8 | "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", 9 | "lint": "eslint .", 10 | "typecheck": "tsc --noEmit" 11 | }, 12 | "prettier": "@rao-pics/prettier-config", 13 | "eslintConfig": { 14 | "extends": [ 15 | "@rao-pics/eslint-config/base" 16 | ] 17 | }, 18 | "dependencies": { 19 | "@fastify/cors": "^8.4.1", 20 | "@fastify/static": "^6.12.0", 21 | "@fastify/websocket": "^8.2.0", 22 | "@rao-pics/constant": "workspace:*", 23 | "@rao-pics/db": "workspace:*", 24 | "@rao-pics/rlog": "workspace:*", 25 | "@rao-pics/utils": "workspace:*", 26 | "@trpc/server": "next", 27 | "chokidar": "^3.5.3", 28 | "cors": "^2.8.5", 29 | "fastify": "^4.24.3", 30 | "fs-extra": "^11.1.1", 31 | "lodash": "^4.17.21", 32 | "superjson": "^2.2.1", 33 | "ws": "^8.14.2", 34 | "zod": "^3.22.2" 35 | }, 36 | "devDependencies": { 37 | "@rao-pics/eslint-config": "workspace:*", 38 | "@rao-pics/prettier-config": "workspace:*", 39 | "@rao-pics/tsconfig": "workspace:*", 40 | "@types/cors": "^2.8.16", 41 | "@types/lodash": "^4.14.197", 42 | "@types/node": "^18.17.6", 43 | "@types/ws": "^8.5.9", 44 | "cross-env": "^7.0.3", 45 | "eslint": "^8.47.0", 46 | "typescript": "^5.1.6" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/api/src/color.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { prisma } from "@rao-pics/db"; 4 | import { rgbToNumber } from "@rao-pics/utils"; 5 | 6 | import { t } from "./utils"; 7 | 8 | export const color = t.router({ 9 | upsert: t.procedure 10 | .input(z.array(z.number()).length(3).or(z.number())) 11 | .mutation(async ({ input }) => { 12 | if (typeof input === "number") { 13 | return await prisma.color.upsert({ 14 | where: { rgb: input }, 15 | create: { rgb: input }, 16 | update: { rgb: input }, 17 | }); 18 | } 19 | 20 | const rgbNumber = rgbToNumber(input); 21 | const n = Math.round(rgbNumber / 100) * 100; 22 | 23 | if (!n) throw new Error("Invalid color"); 24 | 25 | return await prisma.color.upsert({ 26 | where: { rgb: n }, 27 | create: { rgb: n }, 28 | update: { rgb: n }, 29 | }); 30 | }), 31 | 32 | delete: t.procedure 33 | .input(z.array(z.number()).length(3)) 34 | .mutation(async ({ input }) => { 35 | const n = rgbToNumberMutilple100(input); 36 | 37 | if (!n) throw new Error("Invalid color"); 38 | 39 | return await prisma.color.deleteMany({ 40 | where: { rgb: n }, 41 | }); 42 | }), 43 | 44 | deleteWithNotConnectImage: t.procedure.mutation(async () => { 45 | return await prisma.color.deleteMany({ 46 | where: { 47 | images: { 48 | none: {}, 49 | }, 50 | }, 51 | }); 52 | }), 53 | }); 54 | 55 | export const rgbToNumberMutilple100 = (input: number[]) => { 56 | const rgbNumber = rgbToNumber(input); 57 | const n = Math.round(rgbNumber / 100) * 100; 58 | 59 | return n; 60 | }; 61 | -------------------------------------------------------------------------------- /packages/api/src/config.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { prisma } from "@rao-pics/db"; 4 | 5 | import { folderCore } from "./folder"; 6 | import { t } from "./utils"; 7 | 8 | export const configInput = { 9 | upsert: z.object({ 10 | language: z.enum(["zh-cn", "en-us", "zh-tw"]).optional(), 11 | theme: z.string().optional(), 12 | color: z.string().optional(), 13 | serverPort: z.number().optional(), 14 | clientPort: z.number().optional(), 15 | clientSite: z.string().optional(), 16 | ip: z.string().optional(), 17 | pwdFolder: z.boolean().optional(), 18 | trash: z.boolean().optional(), 19 | startDiffLibrary: z.boolean().optional(), 20 | autoSync: z.boolean().optional(), 21 | }), 22 | }; 23 | 24 | export const configCore = { 25 | findUnique: async () => 26 | await prisma.config.findFirst({ where: { name: "config" } }), 27 | 28 | // pwdFolder 更新逻辑 29 | // 1. config.pwdFolder = true 30 | // 2. 修改 folder.password != null 的 folder.show = true 31 | // 2.1 如果修改的是父级 folder, 则需要同步修改子级 folder 32 | // 3. 查询 Folder 时,只需要返回 folder.show = true 的 folder 33 | upsert: async (input: z.infer) => { 34 | const { pwdFolder } = input; 35 | 36 | if (pwdFolder != undefined) { 37 | await folderCore.setPwdFolderShow(pwdFolder); 38 | } 39 | 40 | return await prisma.config.upsert({ 41 | where: { name: "config" }, 42 | update: input, 43 | create: input, 44 | }); 45 | }, 46 | }; 47 | 48 | export const config = t.router({ 49 | upsert: t.procedure 50 | .input(configInput.upsert) 51 | .mutation(({ input }) => configCore.upsert(input)), 52 | 53 | findUnique: t.procedure.query(configCore.findUnique), 54 | }); 55 | -------------------------------------------------------------------------------- /packages/api/src/folder.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { prisma } from "@rao-pics/db"; 4 | 5 | import { configCore } from "./config"; 6 | import { flatToTree } from "./sync/folder"; 7 | import { t } from "./utils"; 8 | 9 | export const folderInput = { 10 | find: z 11 | .object({ 12 | pid: z.string().optional(), 13 | }) 14 | .optional(), 15 | 16 | findUnique: z.object({ 17 | id: z.string(), 18 | include: z.enum(["children"]).array().optional(), 19 | }), 20 | 21 | upsert: z.object({ 22 | id: z.string(), 23 | name: z.string(), 24 | description: z.string().optional(), 25 | pid: z.string().optional(), 26 | password: z.string().nullish().optional(), 27 | passwordTips: z.string().optional(), 28 | show: z.boolean().default(true).optional(), 29 | }), 30 | 31 | setPwdFolderShow: z.boolean(), 32 | }; 33 | 34 | export const folderCore = { 35 | findUnique: async (input: z.infer) => { 36 | const includeFirstImage = { 37 | images: { 38 | where: { isDeleted: false }, 39 | take: 1, 40 | }, 41 | }; 42 | 43 | const folder = await prisma.folder.findUnique({ 44 | where: { id: input.id, show: true }, 45 | include: includeFirstImage, 46 | }); 47 | 48 | if (!folder) return null; 49 | 50 | const findChildren = (pid: string) => { 51 | return prisma.folder.findMany({ 52 | where: { pid: pid, show: true }, 53 | include: { 54 | images: { 55 | where: { isDeleted: false }, 56 | take: 1, 57 | }, 58 | _count: { select: { images: true } }, 59 | }, 60 | }); 61 | }; 62 | 63 | let children: Awaited> = []; 64 | 65 | if (input.include?.includes("children")) { 66 | children = await findChildren(input.id); 67 | } 68 | 69 | return { 70 | ...folder, 71 | children, 72 | }; 73 | }, 74 | 75 | find: async (input?: z.infer) => { 76 | if (input?.pid) { 77 | return await prisma.folder.findMany({ 78 | where: { pid: input.pid, show: true }, 79 | }); 80 | } 81 | 82 | return await prisma.folder.findMany({ 83 | where: { show: true }, 84 | }); 85 | }, 86 | 87 | upsert: async (input: z.infer) => { 88 | const { password } = input; 89 | const config = await configCore.findUnique(); 90 | 91 | if (password) { 92 | input.show = config?.pwdFolder ?? false; 93 | } 94 | 95 | // 更新 show, 需要同步更新子文件夹中的 show 96 | await prisma.folder.updateMany({ 97 | where: { 98 | OR: [ 99 | // 父级在 upsert 中更新 100 | // { id: input.id }, 101 | { pid: input.id }, 102 | ], 103 | }, 104 | data: { 105 | show: input.show, 106 | }, 107 | }); 108 | 109 | if (!input.password) { 110 | input.password = null; 111 | } 112 | 113 | return await prisma.folder.upsert({ 114 | where: { id: input.id }, 115 | create: input, 116 | update: input, 117 | }); 118 | }, 119 | 120 | /** 121 | * 修改所有有密码的文件夹的 show 122 | * 子级的 show 保持和父级一致 123 | * 父级有密码,子项没有,子项也会设置为 父级 相同的 show 124 | */ 125 | setPwdFolderShow: async ( 126 | input: z.infer, 127 | ) => { 128 | // 所有有密码的文件夹 129 | const pFolders = await prisma.folder.findMany({ 130 | where: { password: { not: "" } }, 131 | }); 132 | 133 | // 父级文件有密码、子级没有密码的文件夹 id 134 | const pids = pFolders.filter((f) => !f.pid).map((f) => f.id); 135 | 136 | const childFolders = await prisma.folder.findMany({ 137 | where: { 138 | pid: { 139 | in: pids, 140 | }, 141 | }, 142 | }); 143 | 144 | return await prisma.folder.updateMany({ 145 | where: { 146 | id: { 147 | in: pFolders.concat(childFolders).map((item) => item.id), 148 | }, 149 | }, 150 | data: { 151 | show: input, 152 | }, 153 | }); 154 | }, 155 | }; 156 | 157 | export const folder = t.router({ 158 | upsert: t.procedure 159 | .input(folderInput.upsert) 160 | .mutation(async ({ input }) => folderCore.upsert(input)), 161 | 162 | /** 163 | * 根据 id 查询文件夹 164 | * include: [children] 返回子文件夹的的第一个素材,包含素材总数 165 | */ 166 | findUnique: t.procedure 167 | .input(folderInput.findUnique) 168 | .query(async ({ input }) => folderCore.findUnique(input)), 169 | 170 | find: t.procedure 171 | .input(folderInput.find) 172 | .query(async ({ input }) => folderCore.find(input)), 173 | 174 | setPwdFolderShow: t.procedure 175 | .input(folderInput.setPwdFolderShow) 176 | .mutation(async ({ input }) => folderCore.setPwdFolderShow(input)), 177 | 178 | findTree: t.procedure.query(async () => { 179 | const folders = await prisma.folder.findMany({ 180 | where: { show: true }, 181 | orderBy: { id: "desc" }, 182 | select: { 183 | id: true, 184 | pid: true, 185 | name: true, 186 | description: true, 187 | _count: { select: { images: true } }, 188 | }, 189 | }); 190 | 191 | return flatToTree<(typeof folders)[number]>(folders); 192 | }), 193 | 194 | findWithPwd: t.procedure.query(async () => { 195 | return await prisma.folder.findMany({ 196 | where: { password: { not: "" } }, 197 | }); 198 | }), 199 | }); 200 | -------------------------------------------------------------------------------- /packages/api/src/log.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { LogTypeEnumZod } from "@rao-pics/constant"; 4 | import { prisma } from "@rao-pics/db"; 5 | 6 | import { t } from "./utils"; 7 | 8 | export const logInput = { 9 | upsert: z.object({ 10 | path: z.string(), 11 | type: LogTypeEnumZod, 12 | message: z.string(), 13 | }), 14 | }; 15 | 16 | export const logCore = { 17 | upsert: async (input: z.infer<(typeof logInput)["upsert"]>) => { 18 | return await prisma.log.upsert({ 19 | where: { path: input.path }, 20 | create: input, 21 | update: input, 22 | }); 23 | }, 24 | }; 25 | 26 | export const log = t.router({ 27 | upsert: t.procedure 28 | .input(logInput.upsert) 29 | .mutation(async ({ input }) => logCore.upsert(input)), 30 | 31 | delete: t.procedure.input(z.string()).mutation(async ({ input }) => { 32 | return await prisma.log.deleteMany({ where: { path: input } }); 33 | }), 34 | 35 | get: t.procedure 36 | .input( 37 | z.object({ 38 | keywords: z.string().optional(), 39 | limit: z.number().min(1).max(100).optional(), 40 | cursor: z.string().nullish(), 41 | orderBy: z.enum(["asc", "desc"]).default("asc"), 42 | }), 43 | ) 44 | .query(async ({ input }) => { 45 | const limit = input.limit ?? 50; 46 | 47 | const { cursor, keywords = "", orderBy } = input; 48 | 49 | const logs = await prisma.log.findMany({ 50 | where: { 51 | OR: [ 52 | { path: { contains: keywords } }, 53 | { type: { contains: keywords } }, 54 | ], 55 | }, 56 | take: limit + 1, 57 | cursor: cursor ? { path: cursor } : undefined, 58 | orderBy: { createdTime: orderBy }, 59 | }); 60 | 61 | let nextCursor: typeof cursor | undefined = undefined; 62 | if (logs.length > limit) { 63 | const nextLog = logs.pop(); 64 | nextCursor = nextLog!.path; 65 | } 66 | 67 | return { 68 | data: logs, 69 | nextCursor, 70 | }; 71 | }), 72 | }); 73 | -------------------------------------------------------------------------------- /packages/api/src/pending.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { PendingTypeEnumZod } from "@rao-pics/constant"; 4 | import { prisma } from "@rao-pics/db"; 5 | 6 | import { t } from "./utils"; 7 | 8 | export const pendingInput = { 9 | upsert: z.object({ 10 | path: z.string(), 11 | type: PendingTypeEnumZod, 12 | }), 13 | }; 14 | 15 | export const pendingCore = { 16 | upsert: async (input: z.infer<(typeof pendingInput)["upsert"]>) => { 17 | return await prisma.pending.upsert({ 18 | where: { path: input.path }, 19 | create: input, 20 | update: input, 21 | }); 22 | }, 23 | 24 | get: async (input?: string) => { 25 | if (input) { 26 | return await prisma.pending.findUnique({ where: { path: input } }); 27 | } 28 | 29 | return await prisma.pending.findMany(); 30 | }, 31 | 32 | delete: async (input: string) => { 33 | return await prisma.pending.delete({ where: { path: input } }); 34 | }, 35 | }; 36 | 37 | export const pending = t.router({ 38 | upsert: t.procedure 39 | .input(pendingInput.upsert) 40 | .mutation(({ input }) => pendingCore.upsert(input)), 41 | 42 | get: t.procedure 43 | .input(z.string().optional()) 44 | .query(({ input }) => pendingCore.get(input)), 45 | 46 | delete: t.procedure 47 | .input(z.string()) 48 | .mutation(({ input }) => pendingCore.delete(input)), 49 | }); 50 | -------------------------------------------------------------------------------- /packages/api/src/server/client.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 静态服务器包含 3 | * - 主题 4 | * - library 资源 5 | */ 6 | 7 | import path from "path"; 8 | import cors from "@fastify/cors"; 9 | import fastifyStatic from "@fastify/static"; 10 | import fastify from "fastify"; 11 | 12 | import { RLogger } from "@rao-pics/rlog"; 13 | 14 | import { routerCore } from "../.."; 15 | 16 | let server: ReturnType | undefined; 17 | 18 | export const startClientServer = async () => { 19 | server = fastify({ 20 | maxParamLength: 5000, 21 | }); 22 | 23 | void server.register(cors, { 24 | origin: "*", 25 | }); 26 | 27 | let config = await routerCore.config.findUnique(); 28 | if (!config) return; 29 | 30 | server.get("/common/config", async (_req, reply) => { 31 | config = await routerCore.config.findUnique(); 32 | 33 | return reply.send(config); 34 | }); 35 | 36 | await server.register(fastifyStatic, { 37 | root: path.join( 38 | process.resourcesPath, 39 | "extraResources", 40 | "themes", 41 | config.theme, 42 | ), 43 | redirect: true, 44 | }); 45 | 46 | const libray = await routerCore.library.findUnique(); 47 | 48 | if (!libray) return; 49 | 50 | await server.register(fastifyStatic, { 51 | root: path.join(libray.path, "images"), 52 | prefix: "/static/", 53 | decorateReply: false, 54 | }); 55 | 56 | server.setNotFoundHandler((_req, reply) => { 57 | return reply.sendFile("404.html"); 58 | }); 59 | 60 | await server.listen({ port: config.clientPort, host: "0.0.0.0" }); 61 | 62 | RLogger.info( 63 | `client server listening on http://${config.ip}:${config.clientPort}`, 64 | "startClientServer", 65 | ); 66 | }; 67 | 68 | export const closeClientServer = async () => { 69 | await server?.close(); 70 | server = undefined; 71 | }; 72 | 73 | export const restartClientServer = async () => { 74 | await closeClientServer(); 75 | await startClientServer(); 76 | }; 77 | -------------------------------------------------------------------------------- /packages/api/src/server/index.ts: -------------------------------------------------------------------------------- 1 | import { closeClientServer, startClientServer } from "./client"; 2 | import { closeMainServer, startMainServer } from "./main"; 3 | 4 | export * from "./main"; 5 | export * from "./client"; 6 | 7 | export const startServer = async () => { 8 | await startMainServer(); 9 | await startClientServer(); 10 | }; 11 | 12 | export const closeServer = async () => { 13 | await Promise.all([closeClientServer(), closeMainServer()]); 14 | }; 15 | -------------------------------------------------------------------------------- /packages/api/src/server/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 主要的 API 服务 3 | */ 4 | 5 | import cors from "@fastify/cors"; 6 | import ws from "@fastify/websocket"; 7 | import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify"; 8 | import fastify from "fastify"; 9 | 10 | import { RLogger } from "@rao-pics/rlog"; 11 | 12 | import { router, routerCore } from "../.."; 13 | import { createContext } from "../utils"; 14 | 15 | const server = fastify({ 16 | maxParamLength: 5000, 17 | }); 18 | 19 | void server.register(cors, { 20 | origin: "*", 21 | }); 22 | 23 | void server.register(ws); 24 | 25 | export const startMainServer = async () => { 26 | let config = await routerCore.config.findUnique(); 27 | if (!config) return; 28 | 29 | server.get("/common/config", async (_req, reply) => { 30 | config = await routerCore.config.findUnique(); 31 | return reply.send(config); 32 | }); 33 | 34 | void server.register(fastifyTRPCPlugin, { 35 | prefix: "/trpc", 36 | useWSS: true, 37 | trpcOptions: { router, createContext }, 38 | }); 39 | 40 | await server.listen({ port: config.serverPort, host: "0.0.0.0" }); 41 | 42 | RLogger.info( 43 | `api server listening on http://${config.ip}:${config.serverPort}`, 44 | "startMainServer", 45 | ); 46 | }; 47 | 48 | export const closeMainServer = () => server.close(); 49 | -------------------------------------------------------------------------------- /packages/api/src/sync/color.ts: -------------------------------------------------------------------------------- 1 | import { difference } from "lodash"; 2 | 3 | import { router } from "../.."; 4 | 5 | /** 6 | * 对比 color 找出 disconnect 和 connect 7 | * @param newColors 8 | * @param oldColors 9 | * @returns 10 | */ 11 | export const diffColor = async ( 12 | newColors: number[] = [], 13 | oldColors: number[] = [], 14 | ) => { 15 | const caller = router.createCaller({}); 16 | 17 | const disconnect = difference(oldColors, newColors); 18 | const connect = difference(newColors, oldColors).slice(0, 9); 19 | 20 | if (connect.length > 0) { 21 | for (const item of connect) { 22 | await caller.color.upsert(item); 23 | } 24 | } 25 | 26 | return { 27 | disconnect, 28 | connect, 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /packages/api/src/sync/folder.ts: -------------------------------------------------------------------------------- 1 | import { readJSONSync } from "fs-extra"; 2 | import { difference } from "lodash"; 3 | 4 | export const handleFolder = (path: string) => { 5 | const f = readJSONSync(path) as { 6 | folders: FolderTree[]; 7 | }; 8 | 9 | return treeToFlat(f.folders); 10 | }; 11 | 12 | interface FolderTree { 13 | name: string; 14 | id: string; 15 | description?: string; 16 | pid?: string; 17 | children: FolderTree[]; 18 | password?: string; 19 | passwordTips?: string; 20 | } 21 | 22 | export const treeToFlat = (folderTree: FolderTree[]) => { 23 | const flat: { 24 | name: string; 25 | id: string; 26 | description?: string; 27 | pid?: string; 28 | password?: string; 29 | passwordTips?: string; 30 | }[] = []; 31 | 32 | const callback = (folderTree: FolderTree[]) => { 33 | folderTree.forEach((folder) => { 34 | flat.push({ 35 | name: folder.name, 36 | id: folder.id, 37 | description: folder.description, 38 | pid: folder.pid, 39 | password: folder.password ?? undefined, 40 | passwordTips: folder.passwordTips ?? undefined, 41 | }); 42 | if (folder.children.length > 0) { 43 | callback( 44 | folder.children.map((child) => ({ ...child, pid: folder.id })), 45 | ); 46 | } 47 | }); 48 | }; 49 | 50 | callback(folderTree); 51 | 52 | return flat; 53 | }; 54 | 55 | export function flatToTree< 56 | T extends { id: string | number; pid: string | number | null }, 57 | >(flat: T[], parent?: string | number) { 58 | return flat.reduce( 59 | (r, item) => { 60 | if (parent == item.pid) { 61 | const obj = { ...item, children: flatToTree(flat, item.id) }; 62 | 63 | r.push(obj); 64 | } 65 | 66 | return r; 67 | }, 68 | [] as (T & { children: T[] })[], 69 | ); 70 | } 71 | 72 | /** 73 | * 对比 folder 找出 disconnect 和 connect 74 | * @param newColors 75 | * @param oldColors 76 | * @returns 77 | */ 78 | export const diffFolder = ( 79 | newFolderIds: string[] = [], 80 | oldFolderIds: string[] = [], 81 | ) => { 82 | const disconnect = difference(oldFolderIds, newFolderIds); 83 | const connect = difference(newFolderIds, oldFolderIds); 84 | 85 | return { 86 | disconnect, 87 | connect, 88 | }; 89 | }; 90 | -------------------------------------------------------------------------------- /packages/api/src/sync/image.ts: -------------------------------------------------------------------------------- 1 | import { readJsonSync, statSync } from "fs-extra"; 2 | 3 | import { EXT } from "@rao-pics/constant"; 4 | import type { Pending } from "@rao-pics/db"; 5 | 6 | import { router, routerCore } from "../.."; 7 | import { rgbToNumberMutilple100 } from "../color"; 8 | import { imageCore } from "../image"; 9 | import { diffColor } from "./color"; 10 | import { diffFolder } from "./folder"; 11 | import { syncTag } from "./tag"; 12 | 13 | /** 14 | * 检测图片是否需要更新 15 | * @param path 16 | */ 17 | export const checkedImage = async (path: string, timeout = 3000) => { 18 | const { mtime } = statSync(path); 19 | 20 | const image = await routerCore.image.findUnique({ path }); 21 | 22 | // 对比时间,如果小于3秒,不更新 23 | if (image && mtime.getTime() - image.mtime.getTime() < timeout) { 24 | return; 25 | } 26 | 27 | let data; 28 | try { 29 | data = readJsonSync(path) as Metadata; 30 | } catch (e) { 31 | throw new Error("[json-error] read json error", { 32 | cause: "sync_error", 33 | }); 34 | } 35 | 36 | if (!EXT.includes(data.ext)) { 37 | throw new Error("[unsupported-ext] not support file type", { 38 | cause: "sync_error", 39 | }); 40 | } 41 | 42 | return { 43 | ...data, 44 | mtime, 45 | }; 46 | }; 47 | 48 | export const upsertImage = async (p: Pending, timeout = 3000) => { 49 | const caller = router.createCaller({}); 50 | 51 | const newImage = await checkedImage(p.path, timeout); 52 | 53 | if (!newImage) return; 54 | 55 | const oldImage = await imageCore.findUnique({ 56 | path: p.path, 57 | includes: ["tags", "colors", "folders"], 58 | }); 59 | 60 | const tags = await syncTag( 61 | newImage.tags, 62 | oldImage?.tags.map((item) => item.name), 63 | ); 64 | 65 | const newColors = newImage.palettes 66 | .map((item) => rgbToNumberMutilple100(item.color)) 67 | .filter(Boolean); 68 | const oldColors = oldImage?.colors.map((item) => item.rgb) ?? []; 69 | const colors = await diffColor(newColors, oldColors); 70 | 71 | const oldFolderIds = oldImage?.folders.map((item) => item.id); 72 | const folders = diffFolder(newImage.folders, oldFolderIds); 73 | 74 | const image = await caller.image.upsert({ 75 | id: oldImage?.id, 76 | path: p.path, 77 | name: newImage.name, 78 | ext: newImage.ext, 79 | size: newImage.size, 80 | width: newImage.width, 81 | height: newImage.height, 82 | mtime: newImage.mtime, 83 | annotation: newImage.annotation, 84 | url: newImage.url, 85 | isDeleted: newImage.isDeleted, 86 | duration: newImage.duration, 87 | noThumbnail: newImage.noThumbnail ?? false, 88 | // 添加时间 89 | modificationTime: new Date(newImage.modificationTime), 90 | // 从 A 文件夹 移动素材到 B, 需要取消 A 文件夹的关联,添加 B 文件夹的关联 91 | folders, 92 | tags, 93 | colors, 94 | }); 95 | 96 | await caller.tag.deleteWithNotConnectImage(); 97 | await caller.color.deleteWithNotConnectImage(); 98 | 99 | return image; 100 | }; 101 | -------------------------------------------------------------------------------- /packages/api/src/sync/tag.ts: -------------------------------------------------------------------------------- 1 | import { difference } from "lodash"; 2 | 3 | import { router } from "../.."; 4 | 5 | export const syncTag = async ( 6 | newTags: string[] = [], 7 | oldTags: string[] = [], 8 | ) => { 9 | const caller = router.createCaller({}); 10 | 11 | const disconnect = difference(oldTags, newTags); 12 | const connect = difference(newTags, oldTags); 13 | 14 | if (connect.length > 0) { 15 | for (const item of connect) { 16 | await caller.tag.upsert(item); 17 | } 18 | } 19 | 20 | return { 21 | disconnect, 22 | connect, 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /packages/api/src/tag.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { prisma } from "@rao-pics/db"; 4 | 5 | import { t } from "./utils"; 6 | 7 | export const tag = t.router({ 8 | upsert: t.procedure.input(z.string()).mutation(async ({ input }) => { 9 | return await prisma.tag.upsert({ 10 | where: { name: input }, 11 | create: { name: input }, 12 | update: { name: input }, 13 | }); 14 | }), 15 | 16 | delete: t.procedure.input(z.string()).mutation(async ({ input }) => { 17 | return await prisma.tag.deleteMany({ 18 | where: { 19 | name: input, 20 | }, 21 | }); 22 | }), 23 | 24 | /** 25 | * 删除没有关联图片的标签 26 | */ 27 | deleteWithNotConnectImage: t.procedure.mutation(async () => { 28 | return await prisma.tag.deleteMany({ 29 | where: { 30 | images: { 31 | none: {}, 32 | }, 33 | }, 34 | }); 35 | }), 36 | }); 37 | -------------------------------------------------------------------------------- /packages/api/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from "@trpc/server"; 2 | import type { CreateFastifyContextOptions } from "@trpc/server/adapters/fastify"; 3 | import SuperJSON from "superjson"; 4 | 5 | export const createContext = (_: CreateFastifyContextOptions) => ({}); 6 | 7 | export type Context = Awaited>; 8 | 9 | export const t = initTRPC.context().create({ 10 | transformer: SuperJSON, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/api/test/color.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from "vitest"; 2 | 3 | import { prisma } from "@rao-pics/db"; 4 | 5 | import { router } from ".."; 6 | import { rgbToNumberMutilple100 } from "../src/color"; 7 | 8 | const caller = router.createCaller({}); 9 | 10 | describe("color module", () => { 11 | describe("upsert", () => { 12 | beforeEach(async () => { 13 | await prisma.color.deleteMany({}); 14 | }); 15 | 16 | it("should create a new color", async () => { 17 | const result = await caller.color.upsert([255, 0, 0]); 18 | expect(result).toHaveProperty("rgb", rgbToNumberMutilple100([255, 0, 0])); 19 | }); 20 | 21 | it("should update an existing color", async () => { 22 | const input = [255, 0, 0]; 23 | const result1 = await caller.color.upsert(input); 24 | const result2 = await caller.color.upsert(input); 25 | expect(result1).toHaveProperty("rgb", rgbToNumberMutilple100(input)); 26 | expect(result2).toHaveProperty("rgb", rgbToNumberMutilple100(input)); 27 | expect(result1).toEqual(result2); 28 | }); 29 | }); 30 | 31 | describe("delete", () => { 32 | beforeEach(async () => { 33 | await prisma.color.deleteMany({}); 34 | }); 35 | 36 | it("should delete an existing color", async () => { 37 | await caller.color.upsert([255, 0, 0]); 38 | const result = await caller.color.delete([255, 0, 0]); 39 | expect(result).toHaveProperty("count", 1); 40 | }); 41 | 42 | it("should return undefined for non-existing color", async () => { 43 | const result = await caller.color.delete([255, 0, 0]); 44 | expect(result).toHaveProperty("count", 0); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /packages/api/test/config.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeEach, describe, expect, it } from "vitest"; 2 | 3 | import { prisma } from "@rao-pics/db"; 4 | 5 | import { router } from ".."; 6 | 7 | const caller = router.createCaller({}); 8 | 9 | const defaultConfig = { 10 | name: "config", 11 | language: "zh-cn", 12 | color: "light", 13 | theme: "gallery", 14 | ip: "localhost", 15 | serverPort: 61122, 16 | clientPort: 61121, 17 | clientSite: null, 18 | pwdFolder: false, 19 | trash: false, 20 | startDiffLibrary: false, 21 | autoSync: false, 22 | }; 23 | 24 | describe("config module", () => { 25 | beforeEach(async () => { 26 | await prisma.config.deleteMany(); 27 | }); 28 | 29 | afterAll(async () => { 30 | await prisma.config.deleteMany(); 31 | }); 32 | 33 | describe("upsert procedure", () => { 34 | it("should update the language field in the config table", async () => { 35 | await caller.config.upsert({ 36 | language: "zh-cn", 37 | }); 38 | 39 | const res = await caller.config.findUnique(); 40 | 41 | expect(res).toEqual({ 42 | ...defaultConfig, 43 | language: "zh-cn", 44 | }); 45 | }); 46 | 47 | it("should update the ip and serverPort field in the config table", async () => { 48 | await caller.config.upsert({ 49 | ip: "0.0.0.0", 50 | serverPort: 8080, 51 | }); 52 | 53 | const res = await caller.config.findUnique(); 54 | 55 | expect(res).toEqual({ 56 | ...defaultConfig, 57 | ip: "0.0.0.0", 58 | serverPort: 8080, 59 | }); 60 | 61 | expect( 62 | await caller.config.upsert({ 63 | serverPort: 8081, 64 | }), 65 | ).toEqual({ 66 | ...defaultConfig, 67 | ip: "0.0.0.0", 68 | serverPort: 8081, 69 | }); 70 | }); 71 | 72 | it("should update the color and theme field in the config table", async () => { 73 | await caller.config.upsert({ 74 | color: "senven", 75 | }); 76 | 77 | const res = await caller.config.findUnique(); 78 | 79 | expect(res).toEqual({ 80 | ...defaultConfig, 81 | color: "senven", 82 | }); 83 | }); 84 | 85 | it("should update the theme field in the config table", async () => { 86 | await caller.config.upsert({ 87 | theme: "dark", 88 | }); 89 | 90 | const res = await caller.config.findUnique(); 91 | 92 | expect(res).toEqual({ 93 | ...defaultConfig, 94 | theme: "dark", 95 | }); 96 | }); 97 | 98 | it("language field throw errrr", () => { 99 | caller.config 100 | .upsert({ 101 | language: "en-us1" as never, 102 | }) 103 | .catch((e) => { 104 | expect(e).toHaveProperty("code", "BAD_REQUEST"); 105 | }); 106 | }); 107 | }); 108 | 109 | describe("findUnique procedure", () => { 110 | it("should return the default config", async () => { 111 | await caller.config.upsert({ 112 | language: "zh-cn", 113 | color: "light", 114 | theme: "gallery", 115 | pwdFolder: false, 116 | trash: false, 117 | }); 118 | 119 | const res = await caller.config.findUnique(); 120 | 121 | expect(res).toEqual(defaultConfig); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /packages/api/test/folder.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeEach, describe, expect, it } from "vitest"; 2 | 3 | import { prisma } from "@rao-pics/db"; 4 | 5 | import { router } from ".."; 6 | 7 | const caller = router.createCaller({}); 8 | 9 | describe("folder module", () => { 10 | beforeEach(async () => { 11 | await prisma.folder.deleteMany(); 12 | }); 13 | 14 | afterAll(async () => { 15 | await prisma.folder.deleteMany(); 16 | }); 17 | 18 | describe("upsert", () => { 19 | it("creates a new folder if it doesn't exist", async () => { 20 | const input = { 21 | id: "folder-1", 22 | name: "Folder 1", 23 | description: "This is folder 1", 24 | }; 25 | 26 | const result = await caller.folder.upsert(input); 27 | 28 | expect(result).toMatchObject(input); 29 | 30 | const dbItem = await caller.folder.findUnique({ 31 | id: input.id, 32 | }); 33 | 34 | expect(dbItem).toMatchObject(input); 35 | }); 36 | 37 | it("updates an existing folder if it exists", async () => { 38 | await caller.folder.upsert({ 39 | id: "folder-1", 40 | name: "Folder 1", 41 | description: "This is folder 1", 42 | }); 43 | 44 | const input = { 45 | id: "folder-1", 46 | name: "Updated Folder 1", 47 | description: "This is the updated folder 1", 48 | }; 49 | 50 | const result = await caller.folder.upsert(input); 51 | 52 | expect(result).toMatchObject(input); 53 | 54 | const dbItem = await caller.folder.findUnique({ id: input.id }); 55 | 56 | expect(dbItem).toMatchObject(input); 57 | }); 58 | 59 | it("throws an error if input is invalid", async () => { 60 | const input = { 61 | id: "folder-1", 62 | name: "Folder 1", 63 | description: 123, 64 | }; 65 | 66 | await expect(caller.folder.upsert(input as never)).rejects.toMatchObject({ 67 | code: "BAD_REQUEST", 68 | }); 69 | }); 70 | }); 71 | 72 | describe("find", () => { 73 | it("returns all folders if no input is provided", async () => { 74 | const input = { 75 | id: "folder-1", 76 | name: "Folder 1", 77 | description: "This is folder 1", 78 | }; 79 | 80 | await caller.folder.upsert(input); 81 | 82 | const result = await caller.folder.find(); 83 | 84 | expect(result).toMatchObject([input]); 85 | }); 86 | 87 | it("returns a folder by id if id is provided", async () => { 88 | const input = { 89 | id: "folder-1", 90 | name: "Folder 1", 91 | description: "This is folder 1", 92 | }; 93 | 94 | await caller.folder.upsert(input); 95 | 96 | const result = await caller.folder.findUnique({ id: input.id }); 97 | 98 | expect(result).toMatchObject(input); 99 | }); 100 | 101 | it("returns all folders by pid if pid is provided", async () => { 102 | const input = { 103 | id: "folder-1", 104 | name: "Folder 1", 105 | description: "This is folder 1", 106 | }; 107 | 108 | const input1 = { 109 | id: "folder-1-1", 110 | pid: "folder-1", 111 | name: "Folder 11", 112 | description: "This is folder 1", 113 | }; 114 | 115 | const input2 = { 116 | id: "folder-1-2", 117 | pid: "folder-1", 118 | name: "Folder 111", 119 | description: "This is folder 1", 120 | }; 121 | 122 | await caller.folder.upsert(input); 123 | await caller.folder.upsert(input1); 124 | await caller.folder.upsert(input2); 125 | 126 | const result = await caller.folder.find({ pid: input.id }); 127 | 128 | expect(result).toHaveLength(2); 129 | expect(result).toMatchObject([input1, input2]); 130 | }); 131 | 132 | it("returns an folder.show=false is null", async () => { 133 | const input = { 134 | id: "folder-1", 135 | name: "Folder 1", 136 | description: "This is folder 1", 137 | password: "123", 138 | }; 139 | 140 | await caller.folder.upsert(input); 141 | 142 | const result = await caller.folder.findUnique({ id: input.id }); 143 | 144 | expect(result).toBeNull(); 145 | }); 146 | }); 147 | 148 | describe("setPwdFolderShow", () => { 149 | it("sets config.pwdFolder to true", async () => { 150 | const input = { 151 | id: "folder-1", 152 | name: "Folder 1", 153 | description: "This is folder 1", 154 | password: "123", 155 | }; 156 | 157 | await caller.folder.upsert(input); 158 | 159 | await caller.folder.setPwdFolderShow(true); 160 | 161 | const result = await caller.folder.findUnique({ id: input.id }); 162 | 163 | expect(result).toMatchObject({ 164 | id: input.id, 165 | name: input.name, 166 | description: input.description, 167 | password: input.password, 168 | show: true, 169 | }); 170 | }); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /packages/api/test/library.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeEach, describe, expect, it } from "vitest"; 2 | 3 | import { prisma } from "@rao-pics/db"; 4 | 5 | import { router } from ".."; 6 | 7 | const caller = router.createCaller({}); 8 | 9 | describe("library module", () => { 10 | beforeEach(async () => { 11 | await caller.library.delete(); 12 | }); 13 | 14 | afterAll(async () => { 15 | await caller.library.delete(); 16 | }); 17 | 18 | describe("add procedure", () => { 19 | it("should create a new library when none exist", async () => { 20 | const input = "path/to/xxx.library"; 21 | const result = await caller.library.add(input); 22 | 23 | expect(result).toMatchObject({ 24 | path: input, 25 | type: "eagle", 26 | }); 27 | 28 | const libraries = await prisma.library.findMany({}); 29 | expect(libraries).toHaveLength(1); 30 | expect(libraries[0]).toMatchObject({ 31 | path: input, 32 | type: "eagle", 33 | }); 34 | }); 35 | 36 | it("should throw an error when adding a library directory", async () => { 37 | const input = "path/to/library/"; 38 | 39 | await expect(caller.library.add(input)).rejects.toMatchObject({ 40 | message: "Must be a '.library' directory.", 41 | code: "INTERNAL_SERVER_ERROR", 42 | }); 43 | 44 | const libraries = await prisma.library.findMany({}); 45 | expect(libraries).toHaveLength(0); 46 | }); 47 | 48 | it("should throw an error when adding a second library", async () => { 49 | const input1 = "path/to/xxx.library"; 50 | const input2 = "path/to/bbb.library"; 51 | await caller.library.add(input1); 52 | 53 | await expect(caller.library.add(input2)).rejects.toMatchObject({ 54 | message: "Cannot add more than one library.", 55 | code: "INTERNAL_SERVER_ERROR", 56 | }); 57 | 58 | const libraries = await prisma.library.findMany({}); 59 | expect(libraries).toHaveLength(1); 60 | expect(libraries[0]).toMatchObject({ 61 | path: input1, 62 | type: "eagle", 63 | }); 64 | }); 65 | 66 | it("should validate input using Zod schema", async () => { 67 | const input = 123; 68 | 69 | await expect(caller.library.add(input as never)).rejects.toMatchObject({ 70 | code: "BAD_REQUEST", 71 | }); 72 | 73 | const libraries = await prisma.library.findMany({}); 74 | expect(libraries).toHaveLength(0); 75 | }); 76 | }); 77 | 78 | describe("get procedure", () => { 79 | it("get library", async () => { 80 | const input = "path/to/xxx.library"; 81 | await caller.library.add(input); 82 | 83 | const result = await caller.library.findUnique(); 84 | 85 | expect(result).toMatchObject({ 86 | path: input, 87 | type: "eagle", 88 | }); 89 | }); 90 | }); 91 | 92 | describe("delete procedure", () => { 93 | it("should delete library", async () => { 94 | const input = "path/to/xxx.library"; 95 | await caller.library.add(input); 96 | await caller.pending.upsert({ 97 | path: "path/to/xxx.library", 98 | type: "create", 99 | }); 100 | await caller.image.upsert({ 101 | path: "path/to/xxx.jpg", 102 | name: "xxx", 103 | ext: "jpg", 104 | size: 123, 105 | width: 123, 106 | height: 123, 107 | mtime: new Date(), 108 | }); 109 | await caller.folder.upsert({ 110 | name: "xxx", 111 | id: "xxx", 112 | }); 113 | 114 | await caller.library.delete(); 115 | 116 | expect(await prisma.image.findMany()).toHaveLength(0); 117 | expect(await prisma.folder.findMany()).toHaveLength(0); 118 | expect(await caller.library.findUnique()).toBeNull(); 119 | expect(await caller.pending.get()).toHaveLength(0); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /packages/api/test/log.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeEach, describe, expect, it } from "vitest"; 2 | 3 | import type { LogTypeEnum } from "@rao-pics/constant"; 4 | import { prisma } from "@rao-pics/db"; 5 | 6 | import { router } from ".."; 7 | 8 | const caller = router.createCaller({}); 9 | 10 | describe("log module", () => { 11 | beforeEach(async () => { 12 | // Clear the logs table before each test 13 | await prisma.log.deleteMany(); 14 | }); 15 | afterAll(async () => { 16 | await prisma.log.deleteMany(); 17 | }); 18 | 19 | describe("upsert", () => { 20 | it("should create a new log entry if one does not exist", async () => { 21 | const input = { 22 | path: "/test", 23 | type: "json-error" as LogTypeEnum, 24 | message: "Test log message", 25 | }; 26 | 27 | const result = await caller.log.upsert(input); 28 | 29 | expect(result.path).toEqual(input.path); 30 | expect(result.type).toEqual(input.type); 31 | expect(result.message).toEqual(input.message); 32 | 33 | const dbResult = await prisma.log.findUnique({ 34 | where: { path: input.path }, 35 | }); 36 | expect(dbResult).toEqual(result); 37 | }); 38 | 39 | it("should update an existing log entry if one exists", async () => { 40 | const existingLog = await prisma.log.create({ 41 | data: { 42 | path: "/test", 43 | type: "json-error" as LogTypeEnum, 44 | message: "Original message", 45 | }, 46 | }); 47 | 48 | const input = { 49 | path: "/test", 50 | type: "json-error" as LogTypeEnum, 51 | message: "Updated message", 52 | }; 53 | 54 | const result = await caller.log.upsert(input); 55 | 56 | expect(result.path).toEqual(input.path); 57 | expect(result.type).toEqual(input.type); 58 | expect(result.message).toEqual(input.message); 59 | 60 | const dbResult = await prisma.log.findUnique({ 61 | where: { path: input.path }, 62 | }); 63 | expect(dbResult).toEqual(result); 64 | expect(dbResult?.path).toEqual(existingLog.path); 65 | }); 66 | }); 67 | 68 | describe("delete", () => { 69 | it("should delete a log entry if it exists", async () => { 70 | const existingLog = await prisma.log.create({ 71 | data: { 72 | path: "/test", 73 | type: "json-error" as LogTypeEnum, 74 | message: "Test log message", 75 | }, 76 | }); 77 | 78 | const result = await caller.log.delete(existingLog.path); 79 | 80 | expect(result.count).toEqual(1); 81 | 82 | const dbResult = await prisma.log.findUnique({ 83 | where: { path: existingLog.path }, 84 | }); 85 | expect(dbResult).toBeNull(); 86 | }); 87 | 88 | it("should not throw an error if the log entry does not exist", async () => { 89 | const result = await caller.log.delete("/nonexistent"); 90 | 91 | expect(result.count).toEqual(0); 92 | }); 93 | }); 94 | 95 | describe("get", () => { 96 | beforeEach(async () => { 97 | await prisma.log.deleteMany(); 98 | }); 99 | 100 | it("limit 20", async () => { 101 | for (let i = 0; i < 50; i++) { 102 | await caller.log.upsert({ 103 | path: `/aaa/a${i + 1}`, 104 | type: "json-error", 105 | message: "json-error", 106 | }); 107 | } 108 | 109 | const res1 = await caller.log.get({ limit: 20 }); 110 | expect(res1.data.length).toEqual(20); 111 | expect(res1.nextCursor).toEqual("/aaa/a21"); 112 | const res2 = await caller.log.get({ limit: 20, cursor: res1.nextCursor }); 113 | expect(res2.data.length).toEqual(20); 114 | expect(res2.nextCursor).toEqual("/aaa/a41"); 115 | }); 116 | 117 | it("keywords", async () => { 118 | for (let i = 0; i < 50; i++) { 119 | if (i < 20) { 120 | await caller.log.upsert({ 121 | path: `/aaa/a${i + 1}`, 122 | type: "json-error", 123 | message: "json-error", 124 | }); 125 | } else if (i >= 20 && i < 40) { 126 | await caller.log.upsert({ 127 | path: `/bbb/b${i + 1}`, 128 | type: "unknown", 129 | message: "unknown", 130 | }); 131 | } else { 132 | await caller.log.upsert({ 133 | path: `/ccc/c${i + 1}`, 134 | type: "unsupported-ext", 135 | message: "unsupported-ext", 136 | }); 137 | } 138 | } 139 | 140 | const res1 = await caller.log.get({ limit: 20, keywords: "aaa" }); 141 | expect(res1.data.length).toEqual(20); 142 | expect(res1.nextCursor).toBeUndefined(); 143 | const res2 = await caller.log.get({ limit: 20, keywords: "unknown" }); 144 | expect(res2.data.length).toEqual(20); 145 | expect(res2.nextCursor).toBeUndefined(); 146 | const res3 = await caller.log.get({ limit: 20, keywords: "22" }); 147 | expect(res3.data.length).toEqual(1); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /packages/api/test/pending.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeEach, describe, expect, it } from "vitest"; 2 | 3 | import type { PendingTypeEnum } from "@rao-pics/constant"; 4 | import { prisma } from "@rao-pics/db"; 5 | 6 | import { router } from ".."; 7 | 8 | const caller = router.createCaller({}); 9 | 10 | describe("pending module", () => { 11 | beforeEach(async () => { 12 | await prisma.pending.deleteMany({}); 13 | }); 14 | 15 | afterAll(async () => { 16 | await prisma.pending.deleteMany({}); 17 | }); 18 | 19 | describe("upsert", () => { 20 | it("creates a new pending item", async () => { 21 | const input = { 22 | path: "/api/users/123", 23 | type: "create" as PendingTypeEnum, 24 | }; 25 | 26 | const result = await caller.pending.upsert(input); 27 | 28 | expect(result).toMatchObject({ 29 | path: input.path, 30 | type: input.type, 31 | }); 32 | 33 | const dbItem = await caller.pending.get(result.path); 34 | 35 | expect(dbItem).toMatchObject({ 36 | path: input.path, 37 | type: input.type, 38 | }); 39 | }); 40 | 41 | it("throws an error if input is invalid", async () => { 42 | const input = { 43 | path: "/api/users/123", 44 | type: "invalid" as never, 45 | }; 46 | 47 | await expect(caller.library.add(input as never)).rejects.toMatchObject({ 48 | code: "BAD_REQUEST", 49 | }); 50 | }); 51 | }); 52 | 53 | describe("get", () => { 54 | beforeEach(async () => { 55 | await caller.pending.upsert({ path: "/api/users/123", type: "create" }); 56 | await caller.pending.upsert({ path: "/api/users/456", type: "update" }); 57 | await caller.pending.upsert({ path: "/api/users/789", type: "delete" }); 58 | }); 59 | 60 | it("returns all pending items if no input is provided", async () => { 61 | const result = await caller.pending.get(); 62 | 63 | expect(result).toHaveLength(3); 64 | }); 65 | 66 | it("returns only matching pending items if input is provided", async () => { 67 | const result = await caller.pending.get("/api/users/123"); 68 | 69 | expect(result).toMatchObject({ 70 | path: "/api/users/123", 71 | type: "create", 72 | }); 73 | }); 74 | }); 75 | 76 | describe("delete", () => { 77 | beforeEach(async () => { 78 | await caller.pending.upsert({ path: "/api/users/123", type: "create" }); 79 | await caller.pending.upsert({ path: "/api/users/456", type: "update" }); 80 | await caller.pending.upsert({ path: "/api/users/789", type: "delete" }); 81 | }); 82 | 83 | it("deletes a pending item", async () => { 84 | await caller.pending.delete("/api/users/123"); 85 | 86 | const result = await caller.pending.get(); 87 | 88 | expect(result).toHaveLength(2); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /packages/api/test/sync.color.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeEach, describe, expect, it } from "vitest"; 2 | 3 | import { prisma } from "@rao-pics/db"; 4 | 5 | import { diffColor } from "../src/sync/color"; 6 | 7 | describe("diffColor", () => { 8 | beforeEach(async () => { 9 | await prisma.color.deleteMany({}); 10 | }); 11 | 12 | afterAll(async () => { 13 | await prisma.color.deleteMany({}); 14 | }); 15 | 16 | it("should return the correct disconnect and connect arrays", async () => { 17 | const newColors = [1, 2, 3]; 18 | const oldColors = [1, 4]; 19 | 20 | const result = await diffColor(newColors, oldColors); 21 | 22 | expect(result).toHaveProperty("disconnect", [4]); 23 | expect(result).toHaveProperty("connect", [2, 3]); 24 | expect(await prisma.color.findMany({})).toHaveLength(2); 25 | }); 26 | 27 | it("should length 0 if new colors same as old colors", async () => { 28 | const newColors = [1, 2]; 29 | const oldColors = [1, 2]; 30 | 31 | await diffColor(newColors, oldColors); 32 | expect(await prisma.color.findMany({})).toHaveLength(0); 33 | }); 34 | 35 | it("should have length 2 if not old colors", async () => { 36 | const newColors = [1, 2]; 37 | 38 | await diffColor(newColors); 39 | expect(await prisma.color.findMany({})).toHaveLength(2); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/api/test/sync.folder.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | import folderMock from "../mocks/folder.json"; 5 | import { handleFolder, treeToFlat } from "../src/sync/folder"; 6 | 7 | const mockjson = join(__dirname, "../mocks/folder.json"); 8 | 9 | describe("handle folder by path", () => { 10 | it("should return a folder tree", () => { 11 | const flat = handleFolder(mockjson); 12 | expect(flat).toEqual([ 13 | { 14 | name: "期刊", 15 | id: "LKRVQVLHGERAX", 16 | description: "", 17 | pid: undefined, 18 | password: "", 19 | passwordTips: "", 20 | }, 21 | { 22 | name: "04", 23 | id: "LLTF4ZITJSWSS", 24 | description: "", 25 | pid: "LKRVQVLHGERAX", 26 | password: "1234", 27 | passwordTips: "1234", 28 | }, 29 | { 30 | name: "套图", 31 | id: "LLNP0K49I63ZA", 32 | description: "", 33 | pid: undefined, 34 | password: "", 35 | passwordTips: "", 36 | }, 37 | { 38 | name: "08", 39 | id: "LLWCB0OZM5GZ3", 40 | description: "", 41 | pid: "LLNP0K49I63ZA", 42 | password: "", 43 | passwordTips: "", 44 | }, 45 | ]); 46 | }); 47 | 48 | it("error path", () => { 49 | expect(() => { 50 | handleFolder("mocks/folder2.json"); 51 | }).toThrowError(); 52 | }); 53 | }); 54 | 55 | describe("folder tree to flat", () => { 56 | it("should return a flat array", () => { 57 | const flat = treeToFlat(folderMock.folders as never); 58 | expect(flat).toEqual([ 59 | { 60 | name: "期刊", 61 | id: "LKRVQVLHGERAX", 62 | description: "", 63 | pid: undefined, 64 | password: "", 65 | passwordTips: "", 66 | }, 67 | { 68 | name: "04", 69 | id: "LLTF4ZITJSWSS", 70 | description: "", 71 | pid: "LKRVQVLHGERAX", 72 | password: "1234", 73 | passwordTips: "1234", 74 | }, 75 | { 76 | name: "套图", 77 | id: "LLNP0K49I63ZA", 78 | description: "", 79 | pid: undefined, 80 | password: "", 81 | passwordTips: "", 82 | }, 83 | { 84 | name: "08", 85 | id: "LLWCB0OZM5GZ3", 86 | description: "", 87 | pid: "LLNP0K49I63ZA", 88 | password: "", 89 | passwordTips: "", 90 | }, 91 | ]); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /packages/api/test/sync.tag.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeEach, describe, expect, it } from "vitest"; 2 | 3 | import { prisma } from "@rao-pics/db"; 4 | 5 | import { syncTag } from "../src/sync/tag"; 6 | 7 | describe("syncTag", () => { 8 | beforeEach(async () => { 9 | await prisma.tag.deleteMany({}); 10 | }); 11 | 12 | afterAll(async () => { 13 | await prisma.tag.deleteMany({}); 14 | }); 15 | 16 | it("should return the correct disconnect and connect arrays", async () => { 17 | const newTags = ["tag1", "tag2", "tag3"]; 18 | const oldTags = ["tag1", "tag4"]; 19 | 20 | const result = await syncTag(newTags, oldTags); 21 | 22 | expect(result).toHaveProperty("disconnect", ["tag4"]); 23 | expect(result).toHaveProperty("connect", ["tag2", "tag3"]); 24 | expect(await prisma.tag.findMany({})).toHaveLength(2); 25 | }); 26 | 27 | it("should length 0 if new tags same as old tags", async () => { 28 | const newTags = ["tag1", "tag2"]; 29 | const oldTags = ["tag1", "tag2"]; 30 | 31 | await syncTag(newTags, oldTags); 32 | expect(await prisma.tag.findMany({})).toHaveLength(0); 33 | }); 34 | 35 | it("should have length 2 if not old tags", async () => { 36 | const newTags = ["tag1", "tag2"]; 37 | 38 | await syncTag(newTags); 39 | 40 | expect(await prisma.tag.findMany({})).toHaveLength(2); 41 | }); 42 | 43 | it("should have length 2 and disconnect length 2 if not new tags", async () => { 44 | const oldTags = ["tag1", "tag2"]; 45 | 46 | await prisma.tag.create({ data: { name: "tag1" } }); 47 | await prisma.tag.create({ data: { name: "tag2" } }); 48 | 49 | const result = await syncTag([], oldTags); 50 | 51 | expect(result).toHaveProperty("disconnect", ["tag1", "tag2"]); 52 | expect(result).toHaveProperty("connect", []); 53 | expect(await prisma.tag.findMany({})).toHaveLength(2); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/api/test/tag.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from "vitest"; 2 | 3 | import { prisma } from "@rao-pics/db"; 4 | 5 | import { router } from ".."; 6 | 7 | const caller = router.createCaller({}); 8 | 9 | describe("tag module", () => { 10 | beforeEach(async () => { 11 | await prisma.tag.deleteMany(); 12 | }); 13 | 14 | describe("upsert", () => { 15 | it("creates a new tag", async () => { 16 | const input = "test tag"; 17 | const result = await caller.tag.upsert(input); 18 | 19 | expect(result.name).toEqual(input); 20 | 21 | const dbTag = await prisma.tag.findUnique({ 22 | where: { name: input }, 23 | }); 24 | expect(dbTag).toBeDefined(); 25 | expect(dbTag!.name).toEqual(input); 26 | }); 27 | 28 | it("throws an error if the tag already exists", async () => { 29 | const input = "test tag"; 30 | await caller.tag.upsert(input); 31 | await caller.tag.upsert(input); 32 | 33 | expect(await prisma.tag.findMany({})).toHaveLength(1); 34 | }); 35 | }); 36 | 37 | describe("delete", () => { 38 | it("deletes an existing tag", async () => { 39 | const input = "test tag"; 40 | await caller.tag.upsert(input); 41 | await caller.tag.delete(input); 42 | 43 | const dbTag = await prisma.tag.findUnique({ 44 | where: { name: input }, 45 | }); 46 | expect(dbTag).toBeNull(); 47 | }); 48 | 49 | it("throws an error if the tag does not exist", async () => { 50 | const input = "test tag"; 51 | const res = await caller.tag.delete(input); 52 | expect(res.count).toEqual(0); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@rao-pics/tsconfig/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["*.ts", "src", "test"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/api/types.d.ts: -------------------------------------------------------------------------------- 1 | export interface Metadata { 2 | id: string; 3 | name: string; 4 | size: number; 5 | btime: number; 6 | mtime: number; 7 | ext: string; 8 | tags: string[]; 9 | folders: string[]; 10 | isDeleted: boolean; 11 | url: string; 12 | annotation: string; 13 | modificationTime: number; 14 | height: number; 15 | width: number; 16 | lastModified: number; 17 | palettes: Palette[]; 18 | } 19 | 20 | export interface Palette { 21 | color: number[]; 22 | ratio: number; 23 | $$hashKey?: string; 24 | } 25 | 26 | declare global { 27 | namespace NodeJS { 28 | interface Process { 29 | /** @type Electron process.resourcesPath */ 30 | resourcesPath: string; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/constant/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** 4 | * 项目名称 5 | */ 6 | export const PRODUCT_NAME = "Rao Pics"; 7 | 8 | /** 9 | * pending type 枚举 Zod 10 | */ 11 | export const PendingTypeEnumZod = z.enum(["create", "update", "delete"]); 12 | 13 | /** 14 | * pending type 枚举 15 | */ 16 | export type PendingTypeEnum = z.infer; 17 | 18 | /** 19 | * Log type 枚举 Zod 20 | */ 21 | 22 | export const LogTypeEnumZod = z.enum([ 23 | "json-error", 24 | "unsupported-ext", 25 | "unknown", 26 | ]); 27 | 28 | /** 29 | * Log type 枚举 30 | */ 31 | export type LogTypeEnum = z.infer; 32 | 33 | /** 34 | * 支持的视频格式 35 | */ 36 | export const VIDEO_EXT = [ 37 | "mp4", 38 | "avi", 39 | "mov", 40 | "wmv", 41 | "flv", 42 | "webm", 43 | "mkv", 44 | ] as const; 45 | 46 | /** 47 | * 支持的图片格式 48 | */ 49 | export const IMG_EXT = [ 50 | "jpg", 51 | "png", 52 | "jpeg", 53 | "gif", 54 | "webp", 55 | "bmp", 56 | "ico", 57 | "svg", 58 | ] as const; 59 | 60 | export const EXT = [...VIDEO_EXT, ...IMG_EXT] as const; 61 | -------------------------------------------------------------------------------- /packages/constant/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/constant", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "./index.ts", 6 | "files": [ 7 | "index.ts", 8 | "server.ts" 9 | ], 10 | "scripts": { 11 | "clean": "git clean -xdf .turbo node_modules", 12 | "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", 13 | "lint": "eslint .", 14 | "typecheck": "tsc --noEmit" 15 | }, 16 | "prettier": "@rao-pics/prettier-config", 17 | "eslintConfig": { 18 | "extends": [ 19 | "@rao-pics/eslint-config/base" 20 | ] 21 | }, 22 | "dependencies": { 23 | "zod": "^3.22.2" 24 | }, 25 | "devDependencies": { 26 | "@rao-pics/eslint-config": "workspace:*", 27 | "@rao-pics/prettier-config": "workspace:*", 28 | "@rao-pics/tsconfig": "workspace:*", 29 | "@types/node": "^18.17.6", 30 | "cross-env": "^7.0.3", 31 | "eslint": "^8.47.0", 32 | "typescript": "^5.1.6" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/constant/server.ts: -------------------------------------------------------------------------------- 1 | import { homedir } from "os"; 2 | import { join } from "path"; 3 | 4 | import { PRODUCT_NAME } from "."; 5 | 6 | /** 7 | * 是否为开发环境,不要通过 process.env.NODE_ENV 判断 8 | * 直接调用此变量 9 | */ 10 | export const IS_DEV = process.env.NODE_ENV === "development"; 11 | 12 | /** 13 | * 当前平台 14 | */ 15 | export const PLATFORM = process.platform; 16 | 17 | /** 18 | * @rao-pics/constants 中所有的目录必须调用此方法格式化 19 | * @param dir 20 | */ 21 | const formatDirPath = (dir: string) => { 22 | return dir.replace(/\s/g, ""); 23 | }; 24 | 25 | /** 26 | * App UserData 数据存放目录,不同的系统存放目录不同 27 | */ 28 | const APP_USERDATA_DIRS = { 29 | darwin: IS_DEV 30 | ? join(__dirname, "..", "db", "prisma", "migrations") 31 | : formatDirPath(join(homedir(), "Library", "Caches", PRODUCT_NAME)), 32 | win32: IS_DEV 33 | ? join(__dirname, "..", "db", "prisma", "migrations") 34 | : formatDirPath(join(homedir(), "AppData", "Local", PRODUCT_NAME)), 35 | linux: IS_DEV 36 | ? join(__dirname, "..", "db", "prisma", "migrations") 37 | : formatDirPath(join(homedir(), ".cache", PRODUCT_NAME)), 38 | } as { [key in NodeJS.Platform]: string }; 39 | 40 | /** 41 | * 当前系统的数据库文件存放路径 42 | */ 43 | export const DB_PATH = join(APP_USERDATA_DIRS[PLATFORM], "db.sqlite"); 44 | 45 | /** 46 | * 当前系统数据库版本存放路径 47 | */ 48 | export const DB_MIGRATION_VERSION_FILE = join( 49 | APP_USERDATA_DIRS[PLATFORM], 50 | ".version", 51 | ); 52 | -------------------------------------------------------------------------------- /packages/constant/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@rao-pics/tsconfig/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["*.ts", "src"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/db/index.ts: -------------------------------------------------------------------------------- 1 | import { sep } from "path"; 2 | import { PrismaClient } from "@prisma/client"; 3 | import fs from "fs-extra"; 4 | 5 | import { DB_PATH, IS_DEV } from "@rao-pics/constant/server"; 6 | 7 | /** 8 | * connection_limit=1 解决 prisma.*.delete 报错 9 | * prisma.pending.delete({ where: { path: input } }); 报错 10 | * https://www.prisma.io/docs/guides/performance-and-optimization/connection-management 11 | * https://github.com/prisma/prisma/issues/11789 12 | * FIX: https://github.com/meetqy/rao-pics/issues/552 13 | */ 14 | 15 | const _prisma: PrismaClient = new PrismaClient( 16 | !IS_DEV 17 | ? { 18 | datasources: { 19 | db: { url: `file:${DB_PATH}?connection_limit=1` }, 20 | }, 21 | } 22 | : undefined, 23 | ); 24 | 25 | /** 26 | * 创建 Db 目录, 如果不存在 27 | * @param defaultPath ...db.sqlite 28 | */ 29 | export const createDbPath = (defaultPath: string) => { 30 | if (!fs.existsSync(defaultPath)) { 31 | throw new Error(`defaultPath: ${defaultPath} not exist`); 32 | } 33 | 34 | if (fs.pathExistsSync(DB_PATH)) return; 35 | 36 | fs.ensureDirSync(DB_PATH.split(sep).slice(0, -1).join(sep)); 37 | fs.copySync(defaultPath, DB_PATH, { 38 | overwrite: false, 39 | }); 40 | }; 41 | 42 | export * from "./migrate"; 43 | export * from "@prisma/client"; 44 | 45 | export const prisma = _prisma; 46 | -------------------------------------------------------------------------------- /packages/db/migrate.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | import fs from "fs-extra"; 3 | 4 | import { DB_MIGRATION_VERSION_FILE, IS_DEV } from "@rao-pics/constant/server"; 5 | import { RLogger } from "@rao-pics/rlog"; 6 | 7 | import type { prisma } from "."; 8 | 9 | const diffMigrate = async (migratesPath: string, file: string) => { 10 | const dirs = fs.readdirSync(migratesPath); 11 | const latestVersion = dirs[dirs.length - 2]; 12 | 13 | if (!latestVersion) return; 14 | 15 | // 不存在 .version 文件,分 2 种情况 16 | // 1. 首次安装,无需执行迁移 17 | // 2. 之前安装的版本中没有 .version 文件,需要执行迁移 18 | if (!fs.existsSync(file)) { 19 | fs.writeFileSync(file, latestVersion); 20 | return runSql(migratesPath, latestVersion, file); 21 | } 22 | 23 | if (!latestVersion) return; 24 | const oldVersion = fs.readFileSync(file, "utf-8"); 25 | 26 | if (oldVersion === latestVersion) return; 27 | 28 | return await runSql(migratesPath, latestVersion, file); 29 | }; 30 | 31 | const runSql = async ( 32 | migratesPath: string, 33 | latestVersion: string, 34 | file: string, 35 | ) => { 36 | const path = join(migratesPath, latestVersion, "migration.sql"); 37 | 38 | if (!fs.existsSync(path)) { 39 | RLogger.info(`migrate: ${path} not exists`); 40 | return; 41 | } 42 | 43 | const sqls = fs 44 | .readFileSync(join(migratesPath, latestVersion, "migration.sql"), "utf-8") 45 | .split(";\n") 46 | .map((sql) => sql.replace(/(\n)?--.*?\n/, "")) 47 | .filter(Boolean) 48 | .map( 49 | (sql) => 50 | eval(`prisma.$executeRaw\`${sql};\``) as ReturnType< 51 | typeof prisma.$executeRaw 52 | >, 53 | ); 54 | 55 | fs.writeFileSync(file, latestVersion); 56 | 57 | for (const sql of sqls) { 58 | try { 59 | await sql; 60 | } catch (e) { 61 | if (e instanceof Error) { 62 | if (e.message.includes("already exists")) { 63 | return RLogger.warning(e.message, "runSql"); 64 | } 65 | 66 | throw e; 67 | } 68 | 69 | throw e; 70 | } 71 | } 72 | }; 73 | 74 | // TODO: 跨多个版本的迁移,暂未实现 75 | /** 76 | * 迁移 77 | * @param appMigrationsPath 打包后的 miggrations 目录 78 | * @returns 79 | */ 80 | export const migrate = async (appMigrationsPath?: string) => { 81 | try { 82 | if (IS_DEV) { 83 | const migratesPath = join( 84 | __dirname, 85 | "../../../../", 86 | "packages", 87 | "db", 88 | "prisma", 89 | "migrations", 90 | ); 91 | 92 | return await diffMigrate(migratesPath, join(migratesPath, ".version")); 93 | } else { 94 | if (appMigrationsPath) { 95 | return await diffMigrate(appMigrationsPath, DB_MIGRATION_VERSION_FILE); 96 | } 97 | 98 | throw new Error("migrate error: appMigrationsPath is undefined"); 99 | } 100 | } catch (e) { 101 | if (e instanceof Error) { 102 | if (e.message.includes("duplicate column name")) { 103 | return RLogger.warning(e.message, "migrate"); 104 | } 105 | } 106 | 107 | throw e; 108 | } 109 | }; 110 | -------------------------------------------------------------------------------- /packages/db/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/db", 3 | "version": "0.1.0", 4 | "private": true, 5 | "exports": { 6 | ".": "./index.ts" 7 | }, 8 | "main": "./index.ts", 9 | "scripts": { 10 | "build": "pnpm reset", 11 | "clean": "rm -rf .turbo node_modules", 12 | "dev": "prisma studio -b none", 13 | "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", 14 | "postinstall": "pnpm push", 15 | "lint": "eslint .", 16 | "push": "prisma db push", 17 | "reset": "pnpm push --force-reset", 18 | "typecheck": "tsc --noEmit" 19 | }, 20 | "prettier": "@rao-pics/prettier-config", 21 | "eslintConfig": { 22 | "extends": [ 23 | "@rao-pics/eslint-config/base" 24 | ] 25 | }, 26 | "dependencies": { 27 | "@prisma/client": "5.7.0", 28 | "@rao-pics/constant": "workspace:^", 29 | "@rao-pics/rlog": "workspace:^", 30 | "fs-extra": "^11.1.1" 31 | }, 32 | "devDependencies": { 33 | "@rao-pics/eslint-config": "workspace:*", 34 | "@rao-pics/prettier-config": "workspace:*", 35 | "@rao-pics/tsconfig": "workspace:*", 36 | "@types/node": "^18.17.6", 37 | "cross-env": "^7.0.3", 38 | "eslint": "^8.47.0", 39 | "prisma": "^5.6.0", 40 | "typescript": "^5.1.6" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/db/prisma/migrations/20231115085510_1_0_0_alpha_9/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Config" ( 3 | "name" TEXT NOT NULL PRIMARY KEY DEFAULT 'config', 4 | "language" TEXT NOT NULL DEFAULT 'zh-cn', 5 | "color" TEXT NOT NULL DEFAULT 'light', 6 | "theme" TEXT NOT NULL DEFAULT 'gallery', 7 | "serverPort" INTEGER NOT NULL DEFAULT 61121, 8 | "clientPort" INTEGER NOT NULL DEFAULT 61122, 9 | "ip" TEXT NOT NULL DEFAULT 'localhost', 10 | "trash" BOOLEAN NOT NULL DEFAULT false, 11 | "pwdFolder" BOOLEAN NOT NULL DEFAULT false, 12 | "startDiffLibrary" BOOLEAN NOT NULL DEFAULT false, 13 | "autoSync" BOOLEAN NOT NULL DEFAULT false 14 | ); 15 | 16 | -- CreateTable 17 | CREATE TABLE "Library" ( 18 | "path" TEXT NOT NULL PRIMARY KEY, 19 | "type" TEXT NOT NULL, 20 | "createdTime" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 21 | "updatedTime" DATETIME NOT NULL, 22 | "lastSyncTime" DATETIME 23 | ); 24 | 25 | -- CreateTable 26 | CREATE TABLE "Pending" ( 27 | "path" TEXT NOT NULL PRIMARY KEY, 28 | "type" TEXT NOT NULL 29 | ); 30 | 31 | -- CreateTable 32 | CREATE TABLE "Image" ( 33 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 34 | "path" TEXT NOT NULL, 35 | "name" TEXT NOT NULL, 36 | "size" INTEGER NOT NULL, 37 | "createdTime" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 38 | "updateTime" DATETIME NOT NULL, 39 | "mtime" DATETIME NOT NULL, 40 | "modificationTime" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 41 | "ext" TEXT NOT NULL, 42 | "width" INTEGER NOT NULL, 43 | "height" INTEGER NOT NULL, 44 | "isDeleted" BOOLEAN NOT NULL DEFAULT false, 45 | "duration" REAL, 46 | "annotation" TEXT, 47 | "url" TEXT, 48 | "blurDataURL" TEXT, 49 | "noThumbnail" BOOLEAN NOT NULL DEFAULT false 50 | ); 51 | 52 | -- CreateTable 53 | CREATE TABLE "Tag" ( 54 | "name" TEXT NOT NULL PRIMARY KEY 55 | ); 56 | 57 | -- CreateTable 58 | CREATE TABLE "Color" ( 59 | "rgb" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT 60 | ); 61 | 62 | -- CreateTable 63 | CREATE TABLE "Folder" ( 64 | "id" TEXT NOT NULL PRIMARY KEY, 65 | "name" TEXT NOT NULL, 66 | "pid" TEXT, 67 | "description" TEXT, 68 | "password" TEXT, 69 | "passwordTips" TEXT, 70 | "show" BOOLEAN NOT NULL DEFAULT true 71 | ); 72 | 73 | -- CreateTable 74 | CREATE TABLE "Log" ( 75 | "path" TEXT NOT NULL PRIMARY KEY, 76 | "type" TEXT NOT NULL, 77 | "message" TEXT NOT NULL, 78 | "createdTime" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 79 | "updatedTime" DATETIME NOT NULL 80 | ); 81 | 82 | -- CreateTable 83 | CREATE TABLE "_ImageToTag" ( 84 | "A" INTEGER NOT NULL, 85 | "B" TEXT NOT NULL, 86 | CONSTRAINT "_ImageToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Image" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 87 | CONSTRAINT "_ImageToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag" ("name") ON DELETE CASCADE ON UPDATE CASCADE 88 | ); 89 | 90 | -- CreateTable 91 | CREATE TABLE "_ColorToImage" ( 92 | "A" INTEGER NOT NULL, 93 | "B" INTEGER NOT NULL, 94 | CONSTRAINT "_ColorToImage_A_fkey" FOREIGN KEY ("A") REFERENCES "Color" ("rgb") ON DELETE CASCADE ON UPDATE CASCADE, 95 | CONSTRAINT "_ColorToImage_B_fkey" FOREIGN KEY ("B") REFERENCES "Image" ("id") ON DELETE CASCADE ON UPDATE CASCADE 96 | ); 97 | 98 | -- CreateTable 99 | CREATE TABLE "_FolderToImage" ( 100 | "A" TEXT NOT NULL, 101 | "B" INTEGER NOT NULL, 102 | CONSTRAINT "_FolderToImage_A_fkey" FOREIGN KEY ("A") REFERENCES "Folder" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 103 | CONSTRAINT "_FolderToImage_B_fkey" FOREIGN KEY ("B") REFERENCES "Image" ("id") ON DELETE CASCADE ON UPDATE CASCADE 104 | ); 105 | 106 | -- CreateIndex 107 | CREATE UNIQUE INDEX "Config_name_key" ON "Config"("name"); 108 | 109 | -- CreateIndex 110 | CREATE UNIQUE INDEX "Library_path_key" ON "Library"("path"); 111 | 112 | -- CreateIndex 113 | CREATE UNIQUE INDEX "Pending_path_key" ON "Pending"("path"); 114 | 115 | -- CreateIndex 116 | CREATE UNIQUE INDEX "Image_id_key" ON "Image"("id"); 117 | 118 | -- CreateIndex 119 | CREATE UNIQUE INDEX "Image_path_key" ON "Image"("path"); 120 | 121 | -- CreateIndex 122 | CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name"); 123 | 124 | -- CreateIndex 125 | CREATE UNIQUE INDEX "Color_rgb_key" ON "Color"("rgb"); 126 | 127 | -- CreateIndex 128 | CREATE UNIQUE INDEX "Folder_id_key" ON "Folder"("id"); 129 | 130 | -- CreateIndex 131 | CREATE UNIQUE INDEX "Log_path_key" ON "Log"("path"); 132 | 133 | -- CreateIndex 134 | CREATE UNIQUE INDEX "_ImageToTag_AB_unique" ON "_ImageToTag"("A", "B"); 135 | 136 | -- CreateIndex 137 | CREATE INDEX "_ImageToTag_B_index" ON "_ImageToTag"("B"); 138 | 139 | -- CreateIndex 140 | CREATE UNIQUE INDEX "_ColorToImage_AB_unique" ON "_ColorToImage"("A", "B"); 141 | 142 | -- CreateIndex 143 | CREATE INDEX "_ColorToImage_B_index" ON "_ColorToImage"("B"); 144 | 145 | -- CreateIndex 146 | CREATE UNIQUE INDEX "_FolderToImage_AB_unique" ON "_FolderToImage"("A", "B"); 147 | 148 | -- CreateIndex 149 | CREATE INDEX "_FolderToImage_B_index" ON "_FolderToImage"("B"); 150 | -------------------------------------------------------------------------------- /packages/db/prisma/migrations/20231206011741_1_0_0_alpha_10/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_Config" ( 4 | "name" TEXT NOT NULL PRIMARY KEY DEFAULT 'config', 5 | "language" TEXT NOT NULL DEFAULT 'zh-cn', 6 | "color" TEXT NOT NULL DEFAULT 'light', 7 | "theme" TEXT NOT NULL DEFAULT 'gallery', 8 | "serverPort" INTEGER NOT NULL DEFAULT 61122, 9 | "clientPort" INTEGER NOT NULL DEFAULT 61121, 10 | "clientSite" TEXT, 11 | "ip" TEXT NOT NULL DEFAULT 'localhost', 12 | "trash" BOOLEAN NOT NULL DEFAULT false, 13 | "pwdFolder" BOOLEAN NOT NULL DEFAULT false, 14 | "startDiffLibrary" BOOLEAN NOT NULL DEFAULT false, 15 | "autoSync" BOOLEAN NOT NULL DEFAULT false 16 | ); 17 | INSERT INTO "new_Config" ("autoSync", "clientPort", "color", "ip", "language", "name", "pwdFolder", "serverPort", "startDiffLibrary", "theme", "trash") SELECT "autoSync", "clientPort", "color", "ip", "language", "name", "pwdFolder", "serverPort", "startDiffLibrary", "theme", "trash" FROM "Config"; 18 | DROP TABLE "Config"; 19 | ALTER TABLE "new_Config" RENAME TO "Config"; 20 | CREATE UNIQUE INDEX "Config_name_key" ON "Config"("name"); 21 | PRAGMA foreign_key_check; 22 | PRAGMA foreign_keys=ON; 23 | -------------------------------------------------------------------------------- /packages/db/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /packages/db/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | binaryTargets = ["darwin", "darwin-arm64", "windows", "native"] 4 | } 5 | 6 | datasource db { 7 | provider = "sqlite" 8 | url = "file:./db.sqlite?connection_limit=1" 9 | } 10 | 11 | model Config { 12 | // config 13 | name String @id @unique @default("config") 14 | // zh-cn、en-us、zh-tw 15 | language String @default("zh-cn") 16 | // 配色 17 | color String @default("light") 18 | // 主题 19 | theme String @default("gallery") 20 | // 服务器 API 端口 21 | serverPort Int @default(61122) 22 | // Library 资源 / 主题端口 23 | clientPort Int @default(61121) 24 | // 客户端网址 25 | clientSite String? 26 | // 当前 IP 27 | ip String @default("localhost") 28 | // 显示回收站 29 | trash Boolean @default(false) 30 | // 显示加密文件夹 31 | pwdFolder Boolean @default(false) 32 | // 启动时对比资源库 33 | startDiffLibrary Boolean @default(false) 34 | // 自动同步 35 | autoSync Boolean @default(false) 36 | } 37 | 38 | model Library { 39 | // 路径 40 | path String @id @unique 41 | // eagle | billfish | pixcall 42 | type String 43 | // created time 44 | createdTime DateTime @default(now()) 45 | // updated time 46 | updatedTime DateTime @updatedAt 47 | // 最后同步时间 48 | lastSyncTime DateTime? 49 | } 50 | 51 | // 等待同步的文件 52 | model Pending { 53 | path String @id @unique 54 | // create | update | delete 55 | type String 56 | } 57 | 58 | model Image { 59 | id Int @id @unique @default(autoincrement()) 60 | // 完整地址 61 | path String @unique 62 | // 名字 63 | name String 64 | // 文件大小 65 | size Int 66 | // 创建时间 67 | createdTime DateTime @default(now()) 68 | // 修改时间 69 | updateTime DateTime @updatedAt 70 | // 文件的 mtime 71 | mtime DateTime 72 | // 添加时间 73 | modificationTime DateTime @default(now()) 74 | // 扩展名 75 | ext String 76 | // 宽度 77 | width Int 78 | // 高度 79 | height Int 80 | // 是否删除 81 | isDeleted Boolean @default(false) 82 | // 视频时长 83 | duration Float? 84 | // 描述 85 | annotation String? 86 | // 链接 87 | url String? 88 | // 颜色 89 | colors Color[] 90 | // 文件夹 91 | folders Folder[] 92 | // 标签 93 | tags Tag[] 94 | // blurDataURL Base64 95 | blurDataURL String? 96 | // 没有缩略图 97 | noThumbnail Boolean @default(false) 98 | } 99 | 100 | model Tag { 101 | name String @id @unique 102 | images Image[] 103 | } 104 | 105 | model Color { 106 | // 16 进制数字 107 | rgb Int @id @unique 108 | images Image[] 109 | } 110 | 111 | model Folder { 112 | id String @id @unique @default(uuid()) 113 | name String 114 | pid String? 115 | description String? 116 | images Image[] 117 | password String? 118 | passwordTips String? 119 | show Boolean @default(true) 120 | } 121 | 122 | model Log { 123 | path String @id @unique 124 | // json-error | unsupported-ext | deleted | unknown 125 | // unknown 如果确认了错误,可以从 unknown 改为其他类型 126 | type String 127 | // 具体的错误信息 128 | message String 129 | // 创建时间 130 | createdTime DateTime @default(now()) 131 | updatedTime DateTime @updatedAt 132 | } 133 | -------------------------------------------------------------------------------- /packages/db/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import "chai"; 2 | 3 | import { join } from "path"; 4 | import fs from "fs-extra"; 5 | import { afterAll, beforeEach, describe, expect, it } from "vitest"; 6 | 7 | import { DB_PATH } from "@rao-pics/constant/server"; 8 | 9 | import { createDbPath } from ".."; 10 | 11 | describe("createDbPath", () => { 12 | const defaultPath = join(__dirname, "..", "prisma", "db.sqlite"); 13 | 14 | beforeEach(() => { 15 | fs.removeSync(DB_PATH); 16 | }); 17 | 18 | afterAll(() => { 19 | fs.removeSync(DB_PATH); 20 | }); 21 | 22 | it("should throw an error if the default path does not exist", () => { 23 | expect(() => { 24 | createDbPath("./test/nonexistent.sqlite"); 25 | }).to.throw(Error, "defaultPath: ./test/nonexistent.sqlite not exist"); 26 | }); 27 | 28 | it("should create the db directory if it does not exist", () => { 29 | createDbPath(defaultPath); 30 | expect(fs.existsSync(DB_PATH)).to.be.true; 31 | }); 32 | 33 | it("should not overwrite the existing db file by default", () => { 34 | fs.ensureDirSync(DB_PATH); 35 | fs.writeFileSync(`${DB_PATH}/db.sqlite`, "existing data"); 36 | 37 | expect(createDbPath(defaultPath)).toBeUndefined(); 38 | expect(fs.readFileSync(`${DB_PATH}/db.sqlite`, "utf-8")).to.equal( 39 | "existing data", 40 | ); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/db/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@rao-pics/tsconfig/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["*.ts", "src", "test"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/rlog/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src'; -------------------------------------------------------------------------------- /packages/rlog/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/rlog", 3 | "version": "0.1.0", 4 | "private": true, 5 | "exports": { 6 | ".": "./index.ts" 7 | }, 8 | "main": "./index.ts", 9 | "scripts": { 10 | "clean": "git clean -xdf .turbo node_modules", 11 | "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", 12 | "lint": "eslint .", 13 | "typecheck": "tsc --noEmit" 14 | }, 15 | "prettier": "@rao-pics/prettier-config", 16 | "eslintConfig": { 17 | "extends": [ 18 | "@rao-pics/eslint-config/base" 19 | ] 20 | }, 21 | "dependencies": { 22 | "chalk": "^5.3.0", 23 | "fs-extra": "^11.1.1" 24 | }, 25 | "devDependencies": { 26 | "@rao-pics/eslint-config": "workspace:*", 27 | "@rao-pics/prettier-config": "workspace:*", 28 | "@rao-pics/tsconfig": "workspace:*", 29 | "cross-env": "^7.0.3", 30 | "eslint": "^8.47.0", 31 | "typescript": "^5.1.6" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/rlog/src/index.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | 3 | chalk.level = 1; 4 | 5 | /** 6 | * RAO.PICS Logger 单例 7 | * @returns rollbar 8 | */ 9 | export const RLogger = { 10 | info: (message: string, type?: string) => { 11 | const t = type ? `[${type}]` : ""; 12 | console.log(chalk.blue(`ℹ ${t} ${chalk.dim(message)}`)); 13 | }, 14 | 15 | /** 16 | * 需要上报使用 error,否则使用 warning 17 | * waring 表示错误已经知道,所以无需上报 18 | * 只有未知错误需要上报 19 | * @param e 20 | */ 21 | error: function ( 22 | e: string | Error | T, 23 | type?: string, 24 | callback?: (type: string, msg: string) => void, 25 | ) { 26 | const t = type ? `[${type}]` : ""; 27 | const message = e instanceof Error ? e.message : JSON.stringify(e); 28 | 29 | console.log(chalk.red(`✖ ${t} ${message}`)); 30 | 31 | callback?.(t, message); 32 | }, 33 | 34 | warning: function (e: string | Error | T, type?: string) { 35 | const message = e instanceof Error ? e.message : JSON.stringify(e); 36 | const t = type ? `[${type}]` : ""; 37 | 38 | console.log(chalk.yellow(`⚠ ${t} ${message}`)); 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /packages/rlog/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@rao-pics/tsconfig/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["*.ts", "src", "test"], 7 | "exclude": ["node_modules", "coverage"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/trpc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/trpc", 3 | "version": "0.0.1", 4 | "main": "./src/index.ts", 5 | "scripts": { 6 | "clean": "git clean -xdf .turbo node_modules" 7 | }, 8 | "prettier": "@rao-pics/prettier-config", 9 | "eslintConfig": { 10 | "extends": [ 11 | "@rao-pics/eslint-config/base", 12 | "@rao-pics/eslint-config/nextjs", 13 | "@rao-pics/eslint-config/react" 14 | ], 15 | "root": true 16 | }, 17 | "dependencies": { 18 | "@tanstack/react-query": "^5.8.3", 19 | "@trpc/client": "next", 20 | "@trpc/react-query": "next", 21 | "react": "18.2.0", 22 | "react-dom": "18.2.0" 23 | }, 24 | "devDependencies": { 25 | "@rao-pics/api": "workspace:*", 26 | "@rao-pics/eslint-config": "workspace:*", 27 | "@rao-pics/prettier-config": "workspace:*", 28 | "@rao-pics/tsconfig": "workspace:*", 29 | "@types/node": "^18.17.6", 30 | "@types/react": "^18.2.20" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/trpc/src/TRPCReactProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 3 | import { createWSClient, httpLink, splitLink, wsLink } from "@trpc/client"; 4 | import SuperJSON from "superjson"; 5 | 6 | import { trpc } from "./trpc"; 7 | 8 | export const TRPCReactProvider = (props: { 9 | children: React.ReactNode; 10 | /** 只需要在主题(客户端)使用自定义域名,electron 中不需要 */ 11 | client?: boolean; 12 | }) => { 13 | const [site, setSite] = useState(); 14 | /** 15 | * Fetch 配置信息 16 | * @param url 17 | * @returns 18 | */ 19 | const fetchConfig = useCallback( 20 | (url: string) => 21 | fetch(url) 22 | // Prod 23 | .then((res) => res.json()) 24 | .then( 25 | (data: { ip: string; serverPort: number; clientSite?: string }) => { 26 | const { ip, serverPort, clientSite } = data; 27 | if (clientSite && props.client) return setSite(clientSite); 28 | 29 | setSite(`http://${ip}:${serverPort}`); 30 | }, 31 | ), 32 | [props.client], 33 | ); 34 | 35 | useEffect(() => { 36 | const getConfig = () => { 37 | // Prod 38 | fetchConfig("/common/config").catch(() => { 39 | // Dev 40 | fetchConfig(`http://localhost:61122/common/config`).catch(() => { 41 | const { hostname } = window.location; 42 | 43 | // 开发环境 IP 访问 44 | void fetchConfig(`http://${hostname}:61122/common/config`); 45 | }); 46 | }); 47 | }; 48 | 49 | void getConfig(); 50 | }, [fetchConfig]); 51 | 52 | return site && {props.children}; 53 | }; 54 | 55 | function Core({ children, site }: { children: React.ReactNode; site: string }) { 56 | const [queryClient] = useState(() => new QueryClient()); 57 | 58 | // 匹配 http:// 或 https:// 59 | const host = site.replace(/https?:\/\//g, ""); 60 | 61 | const [wsClient] = useState(() => 62 | createWSClient({ url: `ws://${host}/trpc` }), 63 | ); 64 | 65 | const [trpcClient] = useState(() => 66 | trpc.createClient({ 67 | transformer: SuperJSON, 68 | links: [ 69 | splitLink({ 70 | condition: (op) => op.type === "subscription", 71 | true: wsLink({ client: wsClient }), 72 | false: httpLink({ url: `${site}/trpc` }), 73 | }), 74 | ], 75 | }), 76 | ); 77 | 78 | return ( 79 | 80 | {children} 81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /packages/trpc/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./TRPCReactProvider"; 2 | export * from "./trpc"; 3 | -------------------------------------------------------------------------------- /packages/trpc/src/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCReact } from "@trpc/react-query"; 2 | 3 | import type { AppRouter } from "@rao-pics/api"; 4 | 5 | export const trpc = createTRPCReact(); 6 | -------------------------------------------------------------------------------- /packages/trpc/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@rao-pics/tsconfig/base.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./src"; 2 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/utils", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "./index.ts", 6 | "files": [ 7 | "index.ts", 8 | "server.ts" 9 | ], 10 | "scripts": { 11 | "clean": "git clean -xdf .turbo node_modules", 12 | "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", 13 | "lint": "eslint .", 14 | "typecheck": "tsc --noEmit" 15 | }, 16 | "prettier": "@rao-pics/prettier-config", 17 | "eslintConfig": { 18 | "extends": [ 19 | "@rao-pics/eslint-config/base" 20 | ] 21 | }, 22 | "devDependencies": { 23 | "@rao-pics/eslint-config": "workspace:*", 24 | "@rao-pics/prettier-config": "workspace:*", 25 | "@rao-pics/tsconfig": "workspace:*", 26 | "cross-env": "^7.0.3", 27 | "electron": "25.9.7", 28 | "eslint": "^8.47.0", 29 | "typescript": "^5.1.6" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/utils/src/color.ts: -------------------------------------------------------------------------------- 1 | export const hexToNumber = (hex: string) => 2 | parseInt(hex.replace("#", "").padEnd(6, "0"), 16); 3 | 4 | export const rgbToHex = (r: number, g: number, b: number) => { 5 | r = r > 255 ? 255 : r < 0 ? 0 : r; 6 | g = g > 255 ? 255 : g < 0 ? 0 : g; 7 | b = b > 255 ? 255 : b < 0 ? 0 : b; 8 | 9 | const red = r.toString(16).padStart(2, "0"); 10 | const green = g.toString(16).padStart(2, "0"); 11 | const blue = b.toString(16).padStart(2, "0"); 12 | return `#${red}${green}${blue}`; 13 | }; 14 | 15 | export const numberToHex = (num: number) => { 16 | if (num < 0) return "#000000"; 17 | 18 | const hex = num.toString(16); 19 | return `#${hex.padEnd(6, "f")}`; 20 | }; 21 | 22 | export const rgbToNumber = (rgb: number[]) => { 23 | const [r = 0, g = 0, b = 0] = rgb; 24 | return hexToNumber(rgbToHex(r, g, b)); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/utils/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./color"; 2 | -------------------------------------------------------------------------------- /packages/utils/test/color.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { hexToNumber, numberToHex, rgbToHex, rgbToNumber } from "../src"; 4 | 5 | describe("hexToNumber", () => { 6 | it("should convert a hex string to a number", () => { 7 | expect(hexToNumber("#ff0000")).toBe(16711680); 8 | expect(hexToNumber("#00ff00")).toBe(65280); 9 | expect(hexToNumber("#0000ff")).toBe(255); 10 | }); 11 | 12 | it("should handle lowercase hex strings", () => { 13 | expect(hexToNumber("#ff00ff")).toBe(16711935); 14 | expect(hexToNumber("#00ffff")).toBe(65535); 15 | expect(hexToNumber("#000000")).toBe(0); 16 | expect(hexToNumber("000000")).toBe(0); 17 | }); 18 | 19 | it("should handle invalid hex strings", () => { 20 | expect(hexToNumber("#gggggg")).toBeNaN(); 21 | }); 22 | }); 23 | 24 | describe("rgbToHex", () => { 25 | it("should convert an RGB color to a hex string", () => { 26 | expect(rgbToHex(255, 0, 0)).toBe("#ff0000"); 27 | expect(rgbToHex(0, 255, 0)).toBe("#00ff00"); 28 | expect(rgbToHex(0, 0, 255)).toBe("#0000ff"); 29 | }); 30 | 31 | it("should handle values outside the 0-255 range", () => { 32 | expect(rgbToHex(300, 0, 0)).toBe("#ff0000"); 33 | expect(rgbToHex(0, -50, 0)).toBe("#000000"); 34 | expect(rgbToHex(0, 0, 500)).toBe("#0000ff"); 35 | }); 36 | 37 | it("should handle single-digit hex values", () => { 38 | expect(rgbToHex(16, 32, 48)).toBe("#102030"); 39 | expect(rgbToHex(255, 255, 0)).toBe("#ffff00"); 40 | expect(rgbToHex(0, 0, 0)).toBe("#000000"); 41 | }); 42 | }); 43 | 44 | describe("numberToHex", () => { 45 | it("should convert a number to a hex string", () => { 46 | expect(numberToHex(255)).toBe("#ffffff"); 47 | expect(numberToHex(0)).toBe("#0fffff"); 48 | expect(numberToHex(128)).toBe("#80ffff"); 49 | }); 50 | 51 | it("should handle values outside the 0-255 range", () => { 52 | expect(numberToHex(-1)).toBe("#000000"); 53 | expect(numberToHex(256)).toBe("#100fff"); 54 | expect(numberToHex(500)).toBe("#1f4fff"); 55 | }); 56 | }); 57 | 58 | describe("rgbToNumber", () => { 59 | it("should convert an RGB color to a number", () => { 60 | expect(rgbToNumber([255, 0, 0])).toBe(16711680); 61 | expect(rgbToNumber([0, 255, 0])).toBe(65280); 62 | expect(rgbToNumber([0, 0, 255])).toBe(255); 63 | }); 64 | 65 | it("should handle missing values", () => { 66 | expect(rgbToNumber([255, 0])).toBe(16711680); 67 | expect(rgbToNumber([0])).toBe(0); 68 | expect(rgbToNumber([])).toBe(0); 69 | }); 70 | 71 | it("should handle values outside the 0-255 range", () => { 72 | expect(rgbToNumber([300, 0, 0])).toBe(16711680); 73 | expect(rgbToNumber([0, -50, 0])).toBe(0); 74 | expect(rgbToNumber([0, 0, 500])).toBe(255); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@rao-pics/tsconfig/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["*.ts", "src", "test"], 7 | "exclude": ["node_modules", "coverage"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/watch/index.ts: -------------------------------------------------------------------------------- 1 | import chokidar from "chokidar"; 2 | 3 | import { router } from "@rao-pics/api"; 4 | 5 | const caller = router.createCaller({}); 6 | 7 | let watcher: chokidar.FSWatcher; 8 | 9 | /** 10 | * 监听 library 变化 11 | * @param path 监听的 library 路径 12 | * @returns 13 | */ 14 | export const watchLibrary = (path: string) => { 15 | let conut = 0; 16 | 17 | if (watcher) return Promise.resolve(conut); 18 | 19 | watcher = chokidar.watch(path); 20 | 21 | return new Promise((reslove, reject) => { 22 | watcher 23 | .on("add", (path) => { 24 | conut++; 25 | void caller.pending.upsert({ path, type: "create" }); 26 | }) 27 | .on("change", (path) => { 28 | void caller.pending.upsert({ path, type: "update" }); 29 | }) 30 | .on("unlink", (path) => { 31 | void caller.pending.upsert({ path, type: "delete" }); 32 | }) 33 | .on("error", reject) 34 | .on("ready", () => { 35 | reslove(conut); 36 | }); 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/watch/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/watch", 3 | "version": "0.1.0", 4 | "private": true, 5 | "exports": { 6 | ".": "./index.ts" 7 | }, 8 | "main": "./index.ts", 9 | "scripts": { 10 | "clean": "git clean -xdf .turbo node_modules", 11 | "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", 12 | "lint": "eslint .", 13 | "typecheck": "tsc --noEmit" 14 | }, 15 | "prettier": "@rao-pics/prettier-config", 16 | "eslintConfig": { 17 | "extends": [ 18 | "@rao-pics/eslint-config/base" 19 | ] 20 | }, 21 | "dependencies": { 22 | "@rao-pics/api": "workspace:^", 23 | "chokidar": "^3.5.3" 24 | }, 25 | "devDependencies": { 26 | "@rao-pics/eslint-config": "workspace:*", 27 | "@rao-pics/prettier-config": "workspace:*", 28 | "@rao-pics/tsconfig": "workspace:*", 29 | "cross-env": "^7.0.3", 30 | "eslint": "^8.47.0", 31 | "typescript": "^5.1.6" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/watch/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@rao-pics/tsconfig/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["*.ts", "src", "test"], 7 | "exclude": ["node_modules", "coverage"] 8 | } 9 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - apps/* 3 | - packages/* 4 | - tooling/* 5 | - themes/* 6 | -------------------------------------------------------------------------------- /privacy.md: -------------------------------------------------------------------------------- 1 | # 隐私权政策 2 | 3 | 您在 Rao Pics 软件上的所内容都保存在您设备的硬盘上,Rao Pics 永远不会将您的内容上传到网络上。 4 | 5 | 匿名应用进程使用数据 6 | 7 | 我们使用 Sentry 来分析软件错误数据,收集的数据仅用于帮助我们改进 Rao Pics 和我们的服务。 8 | 9 | 我们不会收集任何个人身份数据,也不会收集您网站上存储的任何内容。 如果您对我们收集的匿名使用数据有任何疑问,请与我们联系,我们很乐意回答您的任何问题。请注意,由于我们收集的所有数据都不能用于个人识别,因此我们无法处理任何删除之前可能已提交的数据的请求,因为我们无法识别任何单一用户数据。 10 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /themes/gallery/next.config.mjs: -------------------------------------------------------------------------------- 1 | // Importing env files here to validate on build 2 | import "./src/env.mjs"; 3 | 4 | /** @type {import("next").NextConfig} */ 5 | const config = { 6 | reactStrictMode: true, 7 | /** Enables hot reloading for local packages without a build step */ 8 | transpilePackages: ["@rao-pics/api"], 9 | /** We already do linting and typechecking as separate tasks in CI */ 10 | eslint: { ignoreDuringBuilds: true }, 11 | typescript: { ignoreBuildErrors: true }, 12 | output: "export", 13 | images: { unoptimized: true }, 14 | trailingSlash: true, 15 | }; 16 | 17 | export default config; 18 | -------------------------------------------------------------------------------- /themes/gallery/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "pnpm with-env next build", 7 | "clean": "git clean -xdf .next .turbo node_modules", 8 | "dev": "pnpm with-env next dev -H 0.0.0.0", 9 | "format": "prettier --check . --ignore-path ../../.gitignore", 10 | "lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint", 11 | "start": "pnpm with-env next start", 12 | "typecheck": "tsc --noEmit", 13 | "with-env": "dotenv -e ../../.env --" 14 | }, 15 | "prettier": "@rao-pics/prettier-config", 16 | "eslintConfig": { 17 | "extends": [ 18 | "@rao-pics/eslint-config/base", 19 | "@rao-pics/eslint-config/nextjs", 20 | "@rao-pics/eslint-config/react" 21 | ], 22 | "root": true 23 | }, 24 | "dependencies": { 25 | "@heroicons/react": "^2.0.18", 26 | "@rao-pics/constant": "workspace:^", 27 | "@rao-pics/trpc": "workspace:^", 28 | "@rao-pics/utils": "workspace:^", 29 | "@react-hook/debounce": "^4.0.0", 30 | "@react-hook/event": "^1.2.6", 31 | "@t3-oss/env-nextjs": "^0.7.1", 32 | "@tanstack/react-query": "^5.8.1", 33 | "@tanstack/react-query-devtools": "^5.8.1", 34 | "@trpc/client": "next", 35 | "@trpc/react-query": "next", 36 | "@trpc/server": "next", 37 | "justified-layout": "^4.1.0", 38 | "masonic": "^3.7.0", 39 | "next": "^14.0.2", 40 | "photoswipe": "^5.4.0", 41 | "react": "18.2.0", 42 | "react-dom": "18.2.0", 43 | "recoil": "^0.7.7", 44 | "zod": "^3.22.2" 45 | }, 46 | "devDependencies": { 47 | "@rao-pics/api": "workspace:*", 48 | "@rao-pics/eslint-config": "workspace:*", 49 | "@rao-pics/prettier-config": "workspace:*", 50 | "@rao-pics/tailwind-config": "workspace:*", 51 | "@rao-pics/tsconfig": "workspace:*", 52 | "@types/justified-layout": "^4.1.1", 53 | "@types/node": "^18.18.9", 54 | "@types/react": "^18.2.37", 55 | "@types/react-dom": "^18.2.15", 56 | "dotenv-cli": "^7.3.0", 57 | "eslint": "^8.53.0", 58 | "prettier": "^3.0.3", 59 | "tailwindcss": "3.3.6", 60 | "typescript": "^5.2.2" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /themes/gallery/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /themes/gallery/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meetqy/rao-pics/c86d164c408d9594dda480835ec2ca15087f2d0e/themes/gallery/public/favicon.ico -------------------------------------------------------------------------------- /themes/gallery/src/app/_components/ChildFolderCardList.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import Image from "next/legacy/image"; 3 | import { useRouter } from "next/navigation"; 4 | import { LightBulbIcon } from "@heroicons/react/24/outline"; 5 | import { useRecoilValue } from "recoil"; 6 | 7 | import { settingSelector } from "~/states/setting"; 8 | import { trpc } from "~/utils/trpc"; 9 | import Error from "./Error"; 10 | 11 | const ChildFolderCardList = ({ folderId }: { folderId: string }) => { 12 | const router = useRouter(); 13 | const setting = useRecoilValue(settingSelector); 14 | 15 | const { layout } = setting; 16 | 17 | const { data: folder } = trpc.folder.findUnique.useQuery({ 18 | id: folderId, 19 | include: ["children"], 20 | }); 21 | 22 | const { data: config } = trpc.config.findUnique.useQuery(); 23 | 24 | const children = folder?.children ?? []; 25 | 26 | return children.length > 0 ? ( 27 |
28 | {children.map((child) => { 29 | const Card = (props: { children?: ReactNode }) => { 30 | return ( 31 |
{ 33 | void router.push(`/${layout}?m=${child.id}`); 34 | }} 35 | aria-hidden 36 | key={child.id} 37 | className="group relative flex aspect-square w-full cursor-pointer items-end justify-center overflow-hidden rounded-box border border-base-content/10 bg-base-200/30 p-2" 38 | > 39 |
40 |

{child.name}

41 |

42 | {child._count.images 43 | ? `${child._count.images} 个文件` 44 | : "查看子文件夹"} 45 |

46 |
47 | 48 | {props.children} 49 |
50 | ); 51 | }; 52 | 53 | const image = child.images?.[0]; 54 | if (!image || !config) { 55 | return ( 56 | 57 |
58 | 59 |
60 |
61 | ); 62 | } 63 | 64 | const id = image.path.split(/\/|\\/).slice(-2)[0]; 65 | const host = `http://${config.ip}:${config.clientPort}`; 66 | const src = `${host}/static/${id}/${image.name}.${image.ext}`; 67 | const thumbnailPath = image.noThumbnail 68 | ? src 69 | : `${host}/static/${id}/${image.name}_thumbnail.png`; 70 | 71 | return ( 72 | 73 | 78 | 79 | ); 80 | })} 81 |
82 | ) : ( 83 | 88 | ); 89 | }; 90 | 91 | export default ChildFolderCardList; 92 | -------------------------------------------------------------------------------- /themes/gallery/src/app/_components/Error.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/navigation"; 2 | 3 | interface Props { 4 | statusCode: number; 5 | statusMessage: string; 6 | btnText: string; 7 | onClick?: () => void; 8 | } 9 | 10 | export default function Error(props: Props) { 11 | const router = useRouter(); 12 | 13 | return ( 14 |
15 |
16 |

17 | R 18 | a 19 | o. 20 | P 21 | i 22 | c 23 | s 24 |

25 |

26 | {props.statusCode}. 27 | 28 | {props.statusMessage} 29 | 30 |

31 |

32 | 44 |

45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /themes/gallery/src/app/_components/LayoutWrapper.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { RecoilRoot } from "recoil"; 4 | 5 | import { TRPCReactProvider } from "@rao-pics/trpc"; 6 | 7 | export const RecoilRootWrapper = ({ 8 | children, 9 | }: { 10 | children: React.ReactNode; 11 | }) => { 12 | return {children}; 13 | }; 14 | 15 | export const TRPCReactProviderWrapper = ({ 16 | children, 17 | }: { 18 | children: React.ReactNode; 19 | }) => { 20 | return {children}; 21 | }; 22 | -------------------------------------------------------------------------------- /themes/gallery/src/app/_components/Masonry.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRef } from "react"; 4 | import Image from "next/legacy/image"; 5 | import { 6 | MasonryScroller, 7 | useContainerPosition, 8 | useInfiniteLoader, 9 | usePositioner, 10 | } from "masonic"; 11 | import PhotoSwipeLightbox from "photoswipe/lightbox"; 12 | 13 | import type { EXT } from "@rao-pics/constant"; 14 | 15 | import initLightboxVideoPlugin from "~/utils/photoswipe-video"; 16 | 17 | import "photoswipe/style.css"; 18 | 19 | import { useWindowSize } from "~/hooks/useWindowSize"; 20 | 21 | interface Props { 22 | images?: { 23 | id: number; 24 | src: string; 25 | thumbnailPath: string; 26 | bgColor: string; 27 | width: number; 28 | height: number; 29 | ext: typeof EXT; 30 | type: string; 31 | msrc: string; 32 | }[]; 33 | 34 | onLoadMore: () => void; 35 | 36 | children?: React.ReactNode; 37 | } 38 | 39 | function Masonry({ children, onLoadMore, images }: Props) { 40 | const limit = 50; 41 | const containerRef = useRef(null); 42 | const [windowWidth, windowHeight] = useWindowSize(); 43 | 44 | const { offset, width } = useContainerPosition(containerRef, [ 45 | windowWidth, 46 | windowHeight, 47 | ]); 48 | 49 | const positioner = usePositioner( 50 | { 51 | width, 52 | columnGutter: windowWidth < 768 ? 8 : 12, 53 | rowGutter: windowWidth < 768 ? 8 : 12, 54 | columnWidth: windowWidth < 768 ? windowWidth / 3 : 224, 55 | }, 56 | [images], 57 | ); 58 | 59 | const onRender = useInfiniteLoader(onLoadMore, { minimumBatchSize: limit }); 60 | 61 | const lightbox: PhotoSwipeLightbox | null = new PhotoSwipeLightbox({ 62 | pswpModule: () => import("photoswipe"), 63 | loop: false, 64 | }); 65 | 66 | initLightboxVideoPlugin(lightbox); 67 | lightbox.init(); 68 | 69 | return ( 70 |
71 | data.id} 79 | render={({ data, width: w, index }) => { 80 | const m = data.width / w; 81 | const h = data.height / m; 82 | 83 | return ( 84 | { 94 | e.preventDefault(); 95 | 96 | lightbox.loadAndOpen(index, images ?? []); 97 | }} 98 | > 99 | 104 | 105 | ); 106 | }} 107 | /> 108 | 109 | {children} 110 |
111 | ); 112 | } 113 | 114 | export default Masonry; 115 | -------------------------------------------------------------------------------- /themes/gallery/src/app/_components/Responsive.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMemo, useRef } from "react"; 4 | import Image from "next/legacy/image"; 5 | import justifyLayout from "justified-layout"; 6 | import { 7 | MasonryScroller, 8 | useContainerPosition, 9 | useInfiniteLoader, 10 | usePositioner, 11 | } from "masonic"; 12 | import PhotoSwipeLightbox from "photoswipe/lightbox"; 13 | 14 | import { VIDEO_EXT } from "@rao-pics/constant"; 15 | import type { EXT } from "@rao-pics/constant"; 16 | 17 | import initLightboxVideoPlugin from "~/utils/photoswipe-video"; 18 | 19 | import "photoswipe/style.css"; 20 | 21 | import { useWindowSize } from "~/hooks/useWindowSize"; 22 | 23 | type JustifyLayoutResult = ReturnType; 24 | 25 | interface Props { 26 | images?: { 27 | id: number; 28 | src: string; 29 | thumbnailPath: string; 30 | bgColor: string; 31 | width: number; 32 | height: number; 33 | ext: typeof EXT; 34 | type: string; 35 | msrc: string; 36 | }[]; 37 | 38 | onLoadMore: () => void; 39 | 40 | children?: React.ReactNode; 41 | } 42 | 43 | function Responsive({ children, images, onLoadMore }: Props) { 44 | const limit = 50; 45 | const containerRef = useRef(null); 46 | const [windowWidth, windowHeight] = useWindowSize(); 47 | 48 | const { offset, width } = useContainerPosition(containerRef, [ 49 | windowWidth, 50 | windowHeight, 51 | ]); 52 | 53 | const items = useMemo(() => { 54 | if (images && width) { 55 | const results: JustifyLayoutResult[] = []; 56 | const imageTemp = JSON.parse(JSON.stringify(images)) as typeof images; 57 | const imageResult: (typeof images)[] = []; 58 | 59 | while (imageTemp.length > 0) { 60 | const result = justifyLayout(imageTemp, { 61 | maxNumRows: 1, 62 | containerWidth: width, 63 | containerPadding: 0, 64 | boxSpacing: 12, 65 | targetRowHeight: 240, 66 | }); 67 | 68 | imageResult.push(imageTemp.splice(0, result.boxes.length)); 69 | results.push(result); 70 | } 71 | 72 | return { 73 | justify: results, 74 | images: imageResult, 75 | }; 76 | } 77 | 78 | return null; 79 | }, [images, width]); 80 | 81 | const positioner = usePositioner( 82 | { 83 | width: width, 84 | columnGutter: windowWidth < 768 ? 8 : 12, 85 | columnCount: 1, 86 | }, 87 | [images], 88 | ); 89 | 90 | const onRender = useInfiniteLoader(onLoadMore, { 91 | minimumBatchSize: limit, 92 | threshold: 3, 93 | }); 94 | 95 | const lightbox: PhotoSwipeLightbox | null = new PhotoSwipeLightbox({ 96 | pswpModule: () => import("photoswipe"), 97 | loop: false, 98 | }); 99 | 100 | initLightboxVideoPlugin(lightbox); 101 | lightbox.init(); 102 | 103 | return ( 104 |
105 | { 113 | const itemImages = items?.images[index]; 114 | return ( 115 |
121 | {data.boxes.map((box, i) => { 122 | const image = itemImages?.[i]; 123 | 124 | return ( 125 | image && ( 126 | { 146 | e.preventDefault(); 147 | 148 | const curIndex = images?.findIndex( 149 | (item) => item.id === image.id, 150 | ); 151 | 152 | lightbox.loadAndOpen(curIndex ?? 0, images ?? []); 153 | }} 154 | > 155 | 156 | 157 | ) 158 | ); 159 | })} 160 |
161 | ); 162 | }} 163 | /> 164 | 165 | {children} 166 |
167 | ); 168 | } 169 | 170 | export default Responsive; 171 | -------------------------------------------------------------------------------- /themes/gallery/src/app/_components/Setting/FolderTree.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter, useSearchParams } from "next/navigation"; 2 | import { FolderMinusIcon, FolderOpenIcon } from "@heroicons/react/24/outline"; 3 | import { useRecoilState } from "recoil"; 4 | 5 | import { settingSelector } from "~/states/setting"; 6 | 7 | interface Folder { 8 | name: string; 9 | id: string; 10 | pid: string | null; 11 | description: string | null; 12 | children?: Folder[]; 13 | _count: { 14 | images: number; 15 | }; 16 | } 17 | 18 | interface FileTreeProps { 19 | data: Folder[]; 20 | } 21 | 22 | // 递归计算文件夹总数 23 | function countFolder(data: Folder[]): number { 24 | let count = 0; 25 | data.forEach((item) => { 26 | count += item._count.images; 27 | if (item.children?.length) { 28 | count += countFolder(item.children); 29 | } 30 | }); 31 | return count; 32 | } 33 | 34 | function FolderTree({ data }: FileTreeProps) { 35 | const [setting, setSetting] = useRecoilState(settingSelector); 36 | const router = useRouter(); 37 | 38 | const { layout, openFolderIds } = setting; 39 | 40 | const search = useSearchParams(); 41 | const folderId = search.get("f") ?? ""; 42 | 43 | const Document = ({ data }: { data: Folder }) => { 44 | return ( 45 |
  • 46 | 67 |
  • 68 | ); 69 | }; 70 | 71 | // 父级文件夹 72 | const FolderRoot = ({ data }: { data: Folder }) => { 73 | const childCount = countFolder(data.children ?? []); 74 | const allCount = data._count.images + (childCount ?? 0); 75 | 76 | const open = openFolderIds?.includes(data.id); 77 | 78 | return ( 79 |
  • 80 | 127 |
  • 128 | ); 129 | }; 130 | 131 | return ( 132 | <> 133 |
      134 | {data.map((item) => { 135 | if (!item.children?.length) { 136 | return ; 137 | } else { 138 | return ; 139 | } 140 | })} 141 |
    142 | 143 | ); 144 | } 145 | 146 | export default FolderTree; 147 | -------------------------------------------------------------------------------- /themes/gallery/src/app/_components/Setting/index.module.css: -------------------------------------------------------------------------------- 1 | .row { 2 | @apply flex h-12 items-center justify-between px-1 text-base; 3 | } 4 | 5 | .rowTitle { 6 | @apply flex items-center; 7 | } 8 | -------------------------------------------------------------------------------- /themes/gallery/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "~/styles/globals.css"; 2 | 3 | import type { Metadata } from "next"; 4 | 5 | import { 6 | RecoilRootWrapper, 7 | TRPCReactProviderWrapper, 8 | } from "./_components/LayoutWrapper"; 9 | 10 | export const metadata: Metadata = { 11 | title: "Gallery", 12 | description: "RAO.PICS 默认主题", 13 | manifest: "/manifest.json", 14 | // IOS 状态栏颜色 15 | other: { 16 | "mobile-web-app-capable": "yes", 17 | "apple-mobile-web-app-status-bar-style": "black-translucent", 18 | }, 19 | }; 20 | 21 | export default function Layout(props: { children: React.ReactNode }) { 22 | return ( 23 | 24 | 25 | 26 | {props.children} 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /themes/gallery/src/app/manifest.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from "next"; 2 | 3 | export default function manifest(): MetadataRoute.Manifest { 4 | return { 5 | name: "Gallery 主题", 6 | short_name: "Gallery", 7 | description: "RAO.PICS 默认主题", 8 | start_url: "/", 9 | display: "standalone", 10 | icons: [ 11 | { 12 | src: "/icon_192x192.png", 13 | sizes: "192x192", 14 | type: "image/png", 15 | }, 16 | { 17 | src: "/icon_512x512.png", 18 | sizes: "512x512", 19 | type: "image/png", 20 | }, 21 | ], 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /themes/gallery/src/app/masonry/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Layout = (props: { children: React.ReactNode }) => { 4 | return
    {props.children}
    ; 5 | }; 6 | 7 | export default Layout; 8 | -------------------------------------------------------------------------------- /themes/gallery/src/app/masonry/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useMemo } from "react"; 4 | 5 | import { VIDEO_EXT } from "@rao-pics/constant"; 6 | import type { EXT } from "@rao-pics/constant"; 7 | import { numberToHex } from "@rao-pics/utils"; 8 | 9 | import { trpc } from "~/utils/trpc"; 10 | 11 | import "photoswipe/style.css"; 12 | 13 | import { useSearchParams } from "next/navigation"; 14 | import { useRecoilValue } from "recoil"; 15 | 16 | import { settingSelector } from "~/states/setting"; 17 | import { getImageQuery } from "~/utils/get-image-query"; 18 | import ChildFolderCardList from "../_components/ChildFolderCardList"; 19 | import Masonry from "../_components/Masonry"; 20 | 21 | function Home() { 22 | const setting = useRecoilValue(settingSelector); 23 | const { data: config } = trpc.config.findUnique.useQuery(); 24 | 25 | const search = useSearchParams(); 26 | const m = search.get("m"); 27 | 28 | const imageQuery = useCallback( 29 | () => getImageQuery(m, setting.orderBy, setting.shuffle), 30 | [setting.orderBy, m, setting.shuffle], 31 | )(); 32 | 33 | const pages = imageQuery.data?.pages; 34 | const count = imageQuery.data?.pages[0]?.count; 35 | 36 | const images = useMemo(() => { 37 | if (!config) return []; 38 | 39 | const result = pages?.map((page) => { 40 | return page.data.map((image) => { 41 | const id = image.path.split(/\/|\\/).slice(-2)[0]; 42 | const host = `http://${config.ip}:${config.clientPort}`; 43 | const src = `${host}/static/${id}/${image.name}.${image.ext}`; 44 | const thumbnailPath = image.noThumbnail 45 | ? src 46 | : `${host}/static/${id}/${image.name}_thumbnail.png`; 47 | 48 | return { 49 | id: image.id, 50 | src, 51 | thumbnailPath, 52 | msrc: thumbnailPath, 53 | bgColor: numberToHex(image.colors?.[0]?.rgb ?? 0), 54 | width: image.width, 55 | height: image.height, 56 | ext: image.ext as unknown as typeof EXT, 57 | type: VIDEO_EXT.includes(image.ext) ? "video" : "image", 58 | }; 59 | }); 60 | }); 61 | 62 | return result?.flat(); 63 | }, [config, pages]); 64 | 65 | const onLoadMore = async () => { 66 | if (imageQuery.hasNextPage) { 67 | await imageQuery.fetchNextPage(); 68 | } 69 | }; 70 | 71 | const ChildFolder = useCallback(() => { 72 | if (!m) return null; 73 | if (m === "trash") return null; 74 | if (count) return null; 75 | 76 | return ; 77 | }, [count, m]); 78 | 79 | return ( 80 | 81 | 82 | 83 | ); 84 | } 85 | 86 | export default Home; 87 | -------------------------------------------------------------------------------- /themes/gallery/src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Error from "./_components/Error"; 4 | 5 | export default function NotFound() { 6 | return ( 7 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /themes/gallery/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import { useRecoilValue } from "recoil"; 6 | 7 | import { settingSelector } from "~/states/setting"; 8 | 9 | export default function Page() { 10 | const setting = useRecoilValue(settingSelector); 11 | const router = useRouter(); 12 | 13 | useEffect(() => { 14 | void router.replace("/" + setting.layout); 15 | }, [router, setting.layout]); 16 | } 17 | -------------------------------------------------------------------------------- /themes/gallery/src/app/responsive/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Layout = (props: { children: React.ReactNode }) => { 4 | return
    {props.children}
    ; 5 | }; 6 | 7 | export default Layout; 8 | -------------------------------------------------------------------------------- /themes/gallery/src/app/responsive/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useMemo } from "react"; 4 | 5 | import { VIDEO_EXT } from "@rao-pics/constant"; 6 | import type { EXT } from "@rao-pics/constant"; 7 | import { numberToHex } from "@rao-pics/utils"; 8 | 9 | import { trpc } from "~/utils/trpc"; 10 | 11 | import "photoswipe/style.css"; 12 | 13 | import { useSearchParams } from "next/navigation"; 14 | import { useRecoilValue } from "recoil"; 15 | 16 | import { settingSelector } from "~/states/setting"; 17 | import { getImageQuery } from "~/utils/get-image-query"; 18 | import ChildFolderCardList from "../_components/ChildFolderCardList"; 19 | import Responsive from "../_components/Responsive"; 20 | 21 | function Page() { 22 | const setting = useRecoilValue(settingSelector); 23 | 24 | const { data: config } = trpc.config.findUnique.useQuery(); 25 | const search = useSearchParams(); 26 | const m = search.get("m"); 27 | 28 | const imageQuery = useCallback( 29 | () => getImageQuery(m, setting.orderBy, setting.shuffle), 30 | [setting.orderBy, m, setting.shuffle], 31 | )(); 32 | 33 | const pages = imageQuery.data?.pages; 34 | const count = imageQuery.data?.pages[0]?.count; 35 | 36 | const images = useMemo(() => { 37 | if (!config) return []; 38 | 39 | const result = pages?.map((page) => { 40 | return page.data.map((image) => { 41 | const id = image.path.split(/\/|\\/).slice(-2)[0]; 42 | const host = `http://${config.ip}:${config.clientPort}`; 43 | const src = `${host}/static/${id}/${image.name}.${image.ext}`; 44 | const thumbnailPath = image.noThumbnail 45 | ? src 46 | : `${host}/static/${id}/${image.name}_thumbnail.png`; 47 | 48 | return { 49 | id: image.id, 50 | src, 51 | thumbnailPath, 52 | bgColor: numberToHex(image.colors?.[0]?.rgb ?? 0), 53 | width: image.width, 54 | height: image.height, 55 | ext: image.ext as unknown as typeof EXT, 56 | type: VIDEO_EXT.includes(image.ext) ? "video" : "image", 57 | msrc: thumbnailPath, 58 | }; 59 | }); 60 | }); 61 | 62 | return result?.flat(); 63 | }, [config, pages]); 64 | 65 | const onLoadMore = async () => { 66 | if (imageQuery.hasNextPage) { 67 | await imageQuery.fetchNextPage(); 68 | } 69 | }; 70 | 71 | const ChildFolder = useCallback(() => { 72 | if (!m) return null; 73 | if (m === "trash") return null; 74 | if (count) return null; 75 | 76 | return ; 77 | }, [count, m]); 78 | 79 | return ( 80 | 81 | 82 | 83 | ); 84 | } 85 | 86 | export default Page; 87 | -------------------------------------------------------------------------------- /themes/gallery/src/app/template.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { useRecoilState } from "recoil"; 5 | 6 | import { trpc } from "@rao-pics/trpc"; 7 | 8 | import type { SettingType } from "~/states/setting"; 9 | import { defaultSetting, settingSelector } from "~/states/setting"; 10 | import Setting from "./_components/Setting"; 11 | 12 | export default function Template({ children }: { children: React.ReactNode }) { 13 | const [, setSetting] = useRecoilState(settingSelector); 14 | 15 | const { data: lib } = trpc.library.findUnique.useQuery(); 16 | const { data: config } = trpc.config.findUnique.useQuery(); 17 | 18 | useEffect(() => { 19 | if (config) { 20 | document.querySelector("html")?.setAttribute("data-theme", config.color); 21 | } 22 | }, [config]); 23 | 24 | useEffect(() => { 25 | const local = localStorage.getItem("setting"); 26 | const setting = (local ? JSON.parse(local) : defaultSetting) as SettingType; 27 | 28 | setSetting({ 29 | ...setting, 30 | trashCount: lib?.trashCount ?? 0, 31 | count: lib?.syncCount ?? 0, 32 | }); 33 | }, [setSetting, lib]); 34 | 35 | return ( 36 | <> 37 | {children} 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /themes/gallery/src/env.mjs: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import { z } from "zod"; 3 | 4 | export const env = createEnv({ 5 | shared: { 6 | PORT: z.coerce.number().default(3000), 7 | }, 8 | /** 9 | * Specify your client-side environment variables schema here. 10 | * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`. 11 | */ 12 | client: { 13 | // NEXT_PUBLIC_CLIENTVAR: z.string(), 14 | }, 15 | /** 16 | * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. 17 | */ 18 | runtimeEnv: { 19 | PORT: process.env.PORT, 20 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, 21 | }, 22 | skipValidation: 23 | !!process.env.CI || 24 | !!process.env.SKIP_ENV_VALIDATION || 25 | process.env.npm_lifecycle_event === "lint", 26 | }); 27 | -------------------------------------------------------------------------------- /themes/gallery/src/hooks/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useDebounce } from "@react-hook/debounce"; 3 | import useEvent from "@react-hook/event"; 4 | 5 | const emptyObj = {}; 6 | 7 | export interface DebouncedWindowSizeOptions { 8 | initialWidth?: number; 9 | initialHeight?: number; 10 | wait?: number; 11 | leading?: boolean; 12 | } 13 | 14 | const win = typeof window === "undefined" ? null : window; 15 | const wv = 16 | win && typeof win.visualViewport !== "undefined" ? win.visualViewport : null; 17 | 18 | const getSize = () => 19 | [ 20 | document.documentElement.clientWidth, 21 | document.documentElement.clientHeight, 22 | ] as const; 23 | 24 | export const useWindowSize = ( 25 | options: DebouncedWindowSizeOptions = emptyObj, 26 | ): readonly [number, number] => { 27 | const { wait, leading, initialWidth = 0, initialHeight = 0 } = options; 28 | const [size, setDebouncedSize] = useDebounce( 29 | typeof document === "undefined" ? [initialWidth, initialHeight] : getSize, 30 | wait, 31 | leading, 32 | ); 33 | 34 | const [wn, setWn] = useState(getSize()[0]); 35 | 36 | const setSize = (): void => { 37 | const s = getSize(); 38 | if (s[0] !== wn) { 39 | setWn(s[0]); 40 | setDebouncedSize(s); 41 | } 42 | }; 43 | 44 | useEvent(win, "resize", setSize); 45 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 46 | // @ts-expect-error 47 | useEvent(wv, "resize", setSize); 48 | useEvent(win, "orientationchange", setSize); 49 | 50 | return size; 51 | }; 52 | 53 | export const useWindowHeight = ( 54 | options?: Omit, 55 | ): number => useWindowSize(options)[1]; 56 | 57 | export const useWindowWidth = ( 58 | options?: Omit, 59 | ): number => useWindowSize(options)[0]; 60 | -------------------------------------------------------------------------------- /themes/gallery/src/icons/Icon-Shuffle.tsx: -------------------------------------------------------------------------------- 1 | export const IconShuffle = (props: { className?: string }) => { 2 | return ( 3 | 9 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /themes/gallery/src/states/setting.ts: -------------------------------------------------------------------------------- 1 | import { atom, selector } from "recoil"; 2 | 3 | export interface SettingType { 4 | layout: "masonry" | "responsive"; 5 | orderBy: { 6 | mtime?: "asc" | "desc"; 7 | modificationTime?: "asc" | "desc"; 8 | }; 9 | openFolderIds: string[]; 10 | count: number; 11 | trashCount: number; 12 | shuffle?: boolean; 13 | } 14 | 15 | export const defaultSetting: SettingType = { 16 | layout: "masonry", 17 | orderBy: { 18 | modificationTime: "desc", 19 | }, 20 | openFolderIds: [], 21 | count: 0, 22 | trashCount: 0, 23 | shuffle: false, 24 | }; 25 | 26 | const settingAtom = atom({ 27 | key: "settingState", 28 | default: defaultSetting, 29 | }); 30 | 31 | export const settingSelector = selector({ 32 | key: "settingSelector", 33 | get: ({ get }) => { 34 | return get(settingAtom); 35 | }, 36 | set: ({ set }, newSetting) => { 37 | localStorage.setItem("setting", JSON.stringify(newSetting)); 38 | set(settingAtom, newSetting); 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /themes/gallery/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .pswp__img { 6 | @apply !bg-transparent; 7 | } 8 | 9 | @supports (-webkit-touch-callout: none) { 10 | .h-screen { 11 | height: -webkit-fill-available; 12 | } 13 | } 14 | 15 | body { 16 | @apply scrollbar-thin scrollbar-track-base-200 scrollbar-thumb-primary/50 scrollbar-track-rounded-box scrollbar-thumb-rounded-box; 17 | } 18 | -------------------------------------------------------------------------------- /themes/gallery/src/utils/get-image-query.ts: -------------------------------------------------------------------------------- 1 | import { trpc } from "@rao-pics/trpc"; 2 | 3 | import type { SettingType } from "~/states/setting"; 4 | 5 | /** 6 | * 获取 trpc Query 7 | * @param m search Param m 8 | * @param orderBy setting.orderBy 9 | */ 10 | export const getImageQuery = ( 11 | m: string | null, 12 | orderBy: SettingType["orderBy"], 13 | random?: boolean, 14 | ) => { 15 | const limit = 50; 16 | 17 | let find: ReturnType | undefined; 18 | let findTrash: 19 | | ReturnType 20 | | ReturnType 21 | | undefined; 22 | let findByFolderId: 23 | | ReturnType 24 | | undefined; 25 | 26 | if (!m) { 27 | if (!find) { 28 | if (random) { 29 | find = trpc.image.findShuffle.useInfiniteQuery( 30 | { limit, includes: ["colors"], orderBy }, 31 | { 32 | getNextPageParam: (lastPage) => lastPage.nextCursor, 33 | }, 34 | ); 35 | } else { 36 | find = trpc.image.find.useInfiniteQuery( 37 | { limit, includes: ["colors"], orderBy }, 38 | { 39 | getNextPageParam: (lastPage) => lastPage.nextCursor, 40 | }, 41 | ); 42 | } 43 | } 44 | 45 | return find; 46 | } 47 | 48 | if (m === "trash") { 49 | if (!findTrash) { 50 | findTrash = trpc.image.findTrash.useInfiniteQuery( 51 | { limit, includes: ["colors"], orderBy }, 52 | { 53 | getNextPageParam: (lastPage) => lastPage.nextCursor, 54 | }, 55 | ); 56 | } 57 | 58 | return findTrash; 59 | } 60 | 61 | if (!findByFolderId) { 62 | findByFolderId = trpc.image.findByFolderId.useInfiniteQuery( 63 | { limit, includes: ["colors"], orderBy, id: m }, 64 | { 65 | getNextPageParam: (lastPage) => lastPage.nextCursor, 66 | }, 67 | ); 68 | } 69 | 70 | return findByFolderId; 71 | }; 72 | -------------------------------------------------------------------------------- /themes/gallery/src/utils/photoswipe-video.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type PhotoSwipeLightbox from "photoswipe/dist/types/lightbox/lightbox"; 4 | import type Content from "photoswipe/dist/types/slide/content"; 5 | 6 | interface VideoContent extends Content { 7 | videoElement: HTMLVideoElement; 8 | } 9 | 10 | const initLightboxVideoPlugin = (lightbox: PhotoSwipeLightbox) => { 11 | lightbox.on("contentLoad", (e) => { 12 | const content = e.content as VideoContent; 13 | if (content.type.includes("video")) { 14 | // stop default content load 15 | e.preventDefault(); 16 | 17 | const video = document.createElement("video"); 18 | video.controls = true; 19 | video.playsInline = true; 20 | video.preload = "auto"; 21 | video.loop = true; 22 | video.setAttribute("poster", content.data.msrc ?? ""); 23 | video.src = content.data.src!; 24 | 25 | content.videoElement = video; 26 | content.element = document.createElement("div"); 27 | content.element.appendChild(video); 28 | } 29 | }); 30 | 31 | lightbox.on("contentActivate", (e) => { 32 | const content = e.content as VideoContent; 33 | if (content.videoElement) { 34 | void content.videoElement.play(); 35 | } 36 | }); 37 | 38 | lightbox.on("contentDeactivate", (e) => { 39 | const content = e.content as VideoContent; 40 | if (content.videoElement) { 41 | content.videoElement.pause(); 42 | } 43 | }); 44 | }; 45 | 46 | export default initLightboxVideoPlugin; 47 | -------------------------------------------------------------------------------- /themes/gallery/src/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCReact } from "@trpc/react-query"; 2 | 3 | import type { AppRouter } from "@rao-pics/api"; 4 | 5 | export const trpc = createTRPCReact(); 6 | -------------------------------------------------------------------------------- /themes/gallery/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | import baseConfig from "@rao-pics/tailwind-config"; 4 | 5 | export default { 6 | content: ["./src/**/*.{ts,tsx}"], 7 | presets: [baseConfig], 8 | } satisfies Config; 9 | -------------------------------------------------------------------------------- /themes/gallery/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@rao-pics/tsconfig/base.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "~/*": ["./src/*"] 7 | }, 8 | "plugins": [ 9 | { 10 | "name": "next" 11 | } 12 | ], 13 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 14 | }, 15 | "include": [".", ".next/types/**/*.ts"], 16 | "exclude": ["node_modules", "out", ".next"] 17 | } 18 | -------------------------------------------------------------------------------- /themes/gallery/types/photoswiper.d.ts: -------------------------------------------------------------------------------- 1 | declare module "photoswipe/lightbox" { 2 | import PhotoSwipeLightBox from "photoswipe/dist/types/lightbox/lightbox"; 3 | export default PhotoSwipeLightBox; 4 | } 5 | -------------------------------------------------------------------------------- /tooling/eslint/base.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | extends: [ 4 | "turbo", 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended-type-checked", 7 | "plugin:@typescript-eslint/stylistic-type-checked", 8 | "prettier", 9 | ], 10 | env: { 11 | es2022: true, 12 | node: true, 13 | }, 14 | parser: "@typescript-eslint/parser", 15 | parserOptions: { 16 | project: true, 17 | }, 18 | plugins: ["@typescript-eslint", "import"], 19 | rules: { 20 | "turbo/no-undeclared-env-vars": "off", 21 | "@typescript-eslint/no-unused-vars": [ 22 | "error", 23 | { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 24 | ], 25 | "@typescript-eslint/consistent-type-imports": [ 26 | "warn", 27 | { prefer: "type-imports", fixStyle: "separate-type-imports" }, 28 | ], 29 | "@typescript-eslint/no-misused-promises": [ 30 | 2, 31 | { checksVoidReturn: { attributes: false } }, 32 | ], 33 | "import/consistent-type-specifier-style": ["error", "prefer-top-level"], 34 | }, 35 | ignorePatterns: [ 36 | "**/*.cjs", 37 | ".next", 38 | "dist", 39 | "pnpm-lock.yaml", 40 | "coverage", 41 | "out", 42 | "releases", 43 | ], 44 | reportUnusedDisableDirectives: true, 45 | }; 46 | 47 | module.exports = config; 48 | -------------------------------------------------------------------------------- /tooling/eslint/nextjs.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | const config = { 3 | extends: ["plugin:@next/next/recommended"], 4 | rules: { 5 | "@next/next/no-html-link-for-pages": "off", 6 | }, 7 | }; 8 | 9 | module.exports = config; 10 | -------------------------------------------------------------------------------- /tooling/eslint/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/eslint-config", 3 | "version": "0.2.0", 4 | "private": true, 5 | "files": [ 6 | "./base.js", 7 | "./nextjs.js", 8 | "./react.js" 9 | ], 10 | "scripts": { 11 | "clean": "rm -rf .turbo node_modules", 12 | "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", 13 | "lint": "eslint .", 14 | "typecheck": "tsc --noEmit" 15 | }, 16 | "prettier": "@rao-pics/prettier-config", 17 | "eslintConfig": { 18 | "extends": [ 19 | "./base.js" 20 | ], 21 | "root": true 22 | }, 23 | "dependencies": { 24 | "@next/eslint-plugin-next": "^14.0.0", 25 | "@types/eslint": "^8.44.2", 26 | "@typescript-eslint/eslint-plugin": "^6.3.0", 27 | "@typescript-eslint/parser": "^6.3.0", 28 | "eslint-config-prettier": "^9.0.0", 29 | "eslint-config-turbo": "^1.10.12", 30 | "eslint-plugin-import": "^2.28.1", 31 | "eslint-plugin-jsx-a11y": "^6.7.1", 32 | "eslint-plugin-react": "^7.33.2", 33 | "eslint-plugin-react-hooks": "^4.6.0" 34 | }, 35 | "devDependencies": { 36 | "@rao-pics/prettier-config": "workspace:*", 37 | "@rao-pics/tsconfig": "workspace:*", 38 | "eslint": "^8.47.0", 39 | "typescript": "^5.1.6" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tooling/eslint/react.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | const config = { 3 | extends: [ 4 | "plugin:react/recommended", 5 | "plugin:react-hooks/recommended", 6 | "plugin:jsx-a11y/recommended", 7 | ], 8 | rules: { 9 | "react/prop-types": "off", 10 | "react-hooks/rules-of-hooks": "error", 11 | "react-hooks/exhaustive-deps": [ 12 | "warn", 13 | { 14 | additionalHooks: "useRecoilCallback", 15 | }, 16 | ], 17 | }, 18 | globals: { 19 | React: "writable", 20 | }, 21 | settings: { 22 | react: { 23 | version: "detect", 24 | }, 25 | }, 26 | env: { 27 | browser: true, 28 | }, 29 | }; 30 | 31 | module.exports = config; 32 | -------------------------------------------------------------------------------- /tooling/eslint/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@rao-pics/tsconfig/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["."], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /tooling/prettier/index.mjs: -------------------------------------------------------------------------------- 1 | /** @typedef {import("prettier").Config} PrettierConfig */ 2 | /** @typedef {import("prettier-plugin-tailwindcss").PluginOptions} TailwindConfig */ 3 | /** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig */ 4 | 5 | /** @type { PrettierConfig | SortImportsConfig | TailwindConfig } */ 6 | const config = { 7 | plugins: [ 8 | "@ianvs/prettier-plugin-sort-imports", 9 | "prettier-plugin-tailwindcss", 10 | "prettier-plugin-packagejson", 11 | ], 12 | // tailwindConfig: "../../tooling/tailwind", 13 | importOrder: [ 14 | "^(react/(.*)$)|^(react$)|^(react-native(.*)$)", 15 | "^(next/(.*)$)|^(next$)", 16 | "^(electron(.*)$)|^(electron$)", 17 | "", 18 | "", 19 | "^@rao-pics/(.*)$", 20 | "", 21 | "^~/", 22 | "^[../]", 23 | "^[./]", 24 | ], 25 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 26 | importOrderTypeScriptVersion: "4.4.0", 27 | }; 28 | 29 | export default config; 30 | -------------------------------------------------------------------------------- /tooling/prettier/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/prettier-config", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "index.mjs", 6 | "scripts": { 7 | "clean": "rm -rf .turbo node_modules", 8 | "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", 9 | "typecheck": "tsc --noEmit" 10 | }, 11 | "prettier": "@rao-pics/prettier-config", 12 | "dependencies": { 13 | "@ianvs/prettier-plugin-sort-imports": "^4.1.0", 14 | "prettier": "^3.0.2", 15 | "prettier-plugin-packagejson": "^2.4.5", 16 | "prettier-plugin-tailwindcss": "^0.5.3" 17 | }, 18 | "devDependencies": { 19 | "@rao-pics/tsconfig": "workspace:*", 20 | "typescript": "^5.1.6" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tooling/prettier/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@rao-pics/tsconfig/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["."], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /tooling/tailwind/index.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | import plugin from "tailwindcss/plugin"; 3 | 4 | export default { 5 | content: [""], 6 | theme: { 7 | extend: {}, 8 | }, 9 | daisyui: { 10 | themes: [ 11 | "light", 12 | "dark", 13 | "cupcake", 14 | "bumblebee", 15 | "emerald", 16 | "corporate", 17 | "synthwave", 18 | "retro", 19 | "cyberpunk", 20 | "valentine", 21 | "halloween", 22 | "garden", 23 | "forest", 24 | "aqua", 25 | "lofi", 26 | "pastel", 27 | "fantasy", 28 | "wireframe", 29 | "black", 30 | "luxury", 31 | "dracula", 32 | "cmyk", 33 | "autumn", 34 | "business", 35 | "acid", 36 | "lemonade", 37 | "night", 38 | "coffee", 39 | "winter", 40 | "dim", 41 | "nord", 42 | "sunset", 43 | ], 44 | }, 45 | plugins: [ 46 | require("daisyui"), 47 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-var-requires 48 | require("tailwind-scrollbar")({ nocompatible: true }), 49 | // drag-none 50 | plugin(function ({ addUtilities }) { 51 | addUtilities({ 52 | ".drag-none": { 53 | "-webkit-user-drag": "none", 54 | "-khtml-user-drag": "none", 55 | "-moz-user-drag": "none", 56 | "-o-user-drag": "none", 57 | "user-drag": "none", 58 | }, 59 | }); 60 | }), 61 | ], 62 | } satisfies Config; 63 | -------------------------------------------------------------------------------- /tooling/tailwind/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/tailwind-config", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "index.ts", 6 | "files": [ 7 | "index.ts", 8 | "postcss.js" 9 | ], 10 | "scripts": { 11 | "clean": "rm -rf .turbo node_modules", 12 | "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", 13 | "lint": "eslint .", 14 | "typecheck": "tsc --noEmit" 15 | }, 16 | "prettier": "@rao-pics/prettier-config", 17 | "eslintConfig": { 18 | "extends": [ 19 | "@rao-pics/eslint-config/base" 20 | ], 21 | "root": true 22 | }, 23 | "dependencies": { 24 | "autoprefixer": "^10.4.15", 25 | "daisyui": "^4.4.0", 26 | "postcss": "8.4.32", 27 | "postcss-nesting": "^12.0.1", 28 | "tailwindcss": "3.3.6" 29 | }, 30 | "devDependencies": { 31 | "@rao-pics/eslint-config": "workspace:*", 32 | "@rao-pics/prettier-config": "workspace:*", 33 | "@rao-pics/tsconfig": "workspace:*", 34 | "eslint": "^8.47.0", 35 | "prettier": "^3.0.2", 36 | "tailwind-scrollbar": "^3.0.5", 37 | "typescript": "^5.1.6" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tooling/tailwind/postcss.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "tailwindcss/nesting": {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /tooling/tailwind/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@rao-pics/tsconfig/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["."], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /tooling/typescript/base.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | interface ProcessEnv { 5 | /** 6 | * App 版本号 (apps/electron => package.json 中的 version) 7 | */ 8 | APP_VERSION: string; 9 | } 10 | 11 | interface ReadonlyArray { 12 | /** 13 | * Determines whether an array includes a certain element, returning true or false as appropriate. 14 | * @param searchElement The element to search for. 15 | * @param fromIndex The position in this array at which to begin searching for searchElement. 16 | */ 17 | includes( 18 | searchElement: any, 19 | fromIndex?: number, 20 | ): searchElement is ReadonlyArray[number]; 21 | } 22 | 23 | type ObjectKeys = `${Exclude}`; 24 | 25 | interface Metadata { 26 | id: string; 27 | name: string; 28 | size: number; 29 | btime: number; 30 | mtime: number; 31 | ext: string; 32 | tags: string[]; 33 | folders: string[]; 34 | isDeleted: boolean; 35 | url: string; 36 | annotation: string; 37 | modificationTime: number; 38 | height: number; 39 | width: number; 40 | duration: number; 41 | lastModified: number; 42 | noThumbnail?: boolean; 43 | palettes: MetadataPalette[]; 44 | } 45 | 46 | interface MetadataPalette { 47 | color: number[]; 48 | ratio: number; 49 | $$hashKey?: string; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tooling/typescript/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "ES2017", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "checkJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "noUncheckedIndexedAccess": true 20 | }, 21 | "files": ["./base.d.ts"], 22 | "exclude": [ 23 | "node_modules", 24 | "build", 25 | "dist", 26 | "releases", 27 | ".next", 28 | ".expo", 29 | "out", 30 | "**/*.cjs" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /tooling/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/tsconfig", 3 | "version": "0.1.0", 4 | "private": true, 5 | "types": "./base.d.ts", 6 | "files": [ 7 | "base.json" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "globalDependencies": ["**/.env"], 4 | "remoteCache": { 5 | "signature": true 6 | }, 7 | "pipeline": { 8 | "topo": { 9 | "dependsOn": ["^topo"] 10 | }, 11 | "releases": { 12 | "cache": false 13 | }, 14 | "build": { 15 | "cache": false, 16 | "dependsOn": ["^build"], 17 | "outputs": [".next/**", "!.next/cache/**", "next-env.d.ts", ".expo/**"] 18 | }, 19 | "dev": { 20 | "persistent": true, 21 | "cache": false 22 | }, 23 | "format": { 24 | "outputs": ["node_modules/.cache/.prettiercache"], 25 | "outputMode": "new-only" 26 | }, 27 | "lint": { 28 | "outputs": ["node_modules/.cache/.eslintcache"] 29 | }, 30 | "typecheck": { 31 | "outputs": ["node_modules/.cache/tsbuildinfo.json"] 32 | }, 33 | "clean": { 34 | "cache": false 35 | }, 36 | "//#clean": { 37 | "cache": false 38 | }, 39 | "test": { 40 | "cache": false 41 | } 42 | }, 43 | "globalEnv": [] 44 | } 45 | -------------------------------------------------------------------------------- /turbo/generators/config.ts: -------------------------------------------------------------------------------- 1 | 2 | export default function generator(plop) { 3 | plop.setGenerator("init", { 4 | description: "Generate a new package for the Acme Monorepo", 5 | prompts: [ 6 | { 7 | type: "input", 8 | name: "name", 9 | message: 10 | "What is the name of the package? (You can skip the `@rao-pics/` prefix)", 11 | }, 12 | { 13 | type: "input", 14 | name: "deps", 15 | message: 16 | "Enter a space separated list of dependencies you would like to install", 17 | }, 18 | ], 19 | actions: [ 20 | (answers) => { 21 | if ("name" in answers && typeof answers.name === "string") { 22 | if (answers.name.startsWith("@rao-pics/")) { 23 | answers.name = answers.name.replace("@rao-pics/", ""); 24 | } 25 | } 26 | return "Config sanitized"; 27 | }, 28 | { 29 | type: "add", 30 | path: "packages/{{ name }}/package.json", 31 | templateFile: "templates/package.json.hbs", 32 | }, 33 | { 34 | type: "add", 35 | path: "packages/{{ name }}/tsconfig.json", 36 | templateFile: "templates/tsconfig.json.hbs", 37 | }, 38 | { 39 | type: "add", 40 | path: "packages/{{ name }}/index.ts", 41 | template: "export * from './src';", 42 | }, 43 | { 44 | type: "add", 45 | path: "packages/{{ name }}/src/index.ts", 46 | template: "export const name = '{{ name }}';", 47 | }, 48 | { 49 | type: "modify", 50 | path: "packages/{{ name }}/package.json", 51 | async transform(content, answers) { 52 | const pkg = JSON.parse(content); 53 | for (const dep of answers.deps.split(" ").filter(Boolean)) { 54 | const version = await fetch( 55 | `https://registry.npmjs.org/-/package/${dep}/dist-tags`, 56 | ) 57 | .then((res) => res.json()) 58 | .then((json) => json.latest); 59 | pkg.dependencies[dep] = `^${version}`; 60 | } 61 | return JSON.stringify(pkg, null, 2); 62 | }, 63 | }, 64 | async (answers) => { 65 | return "Package scaffolded"; 66 | }, 67 | ], 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /turbo/generators/templates/package.json.hbs: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rao-pics/{{ name }}", 3 | "private": true, 4 | "version": "0.1.0", 5 | "exports": { 6 | ".": "./index.ts" 7 | }, 8 | "main": "./index.ts", 9 | "scripts": { 10 | "clean": "git clean -xdf .turbo node_modules", 11 | "lint": "eslint .", 12 | "format": "prettier --check \"**/*.{mjs,ts,md,json}\"", 13 | "typecheck": "tsc --noEmit", 14 | }, 15 | "dependencies": { 16 | }, 17 | "devDependencies": { 18 | "@rao-pics/eslint-config": "workspace:*", 19 | "@rao-pics/prettier-config": "workspace:*", 20 | "@rao-pics/tsconfig": "workspace:*", 21 | "eslint": "^8.47.0", 22 | "typescript": "^5.1.6", 23 | }, 24 | "eslintConfig": { 25 | "extends": [ 26 | "@rao-pics/eslint-config/base" 27 | ] 28 | }, 29 | "prettier": "@rao-pics/prettier-config" 30 | } 31 | -------------------------------------------------------------------------------- /turbo/generators/templates/tsconfig.json.hbs: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@rao-pics/tsconfig/base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" 5 | }, 6 | "include": ["*.ts", "src", "test"], 7 | "exclude": ["node_modules", "coverage"] 8 | } 9 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "silent": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ["test/**/*.test.ts"], 6 | poolOptions: { 7 | threads: { 8 | singleThread: true, 9 | }, 10 | }, 11 | coverage: { 12 | all: false, 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkspace } from "vitest/config"; 2 | 3 | export default defineWorkspace([ 4 | "packages/*", 5 | { 6 | extends: "./vite.config.ts", 7 | }, 8 | ]); 9 | --------------------------------------------------------------------------------