├── .commitlintrc ├── .editorconfig ├── .github └── FUNDING.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierignore ├── .prettierrc ├── .stylelintignore ├── .stylelintrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── app ├── (pages) │ ├── (layout) │ │ ├── footer.tsx │ │ ├── footerLink.tsx │ │ └── navbar.tsx │ ├── about │ │ ├── changeLog.tsx │ │ ├── introduction.tsx │ │ ├── layout.tsx │ │ ├── legalStatement.tsx │ │ └── page.tsx │ ├── beverages │ │ ├── @preferences │ │ │ ├── (..)preferences │ │ │ │ └── page.tsx │ │ │ └── default.tsx │ │ ├── content.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── clothes │ │ ├── @preferences │ │ │ ├── (..)preferences │ │ │ │ └── page.tsx │ │ │ └── default.tsx │ │ ├── content.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── cookers │ │ ├── @preferences │ │ │ ├── (..)preferences │ │ │ │ └── page.tsx │ │ │ └── default.tsx │ │ ├── content.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── currencies │ │ ├── @preferences │ │ │ ├── (..)preferences │ │ │ │ └── page.tsx │ │ │ └── default.tsx │ │ ├── content.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── customer-normal │ │ ├── @preferences │ │ │ ├── (..)preferences │ │ │ │ └── page.tsx │ │ │ └── default.tsx │ │ ├── beverageTabContent.tsx │ │ ├── constants.tsx │ │ ├── customerCard.tsx │ │ ├── customerTabContent.tsx │ │ ├── infoButton.tsx │ │ ├── ingredientTabContent.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── recipeTabContent.tsx │ │ ├── resultCard.tsx │ │ ├── savedMealCard.tsx │ │ ├── tagGroup.tsx │ │ └── types.d.ts │ ├── customer-rare │ │ ├── @preferences │ │ │ ├── (..)preferences │ │ │ │ └── page.tsx │ │ │ └── default.tsx │ │ ├── beverageTabContent.tsx │ │ ├── constants.tsx │ │ ├── customerCard.tsx │ │ ├── customerTabContent.tsx │ │ ├── infoButton.tsx │ │ ├── infoButtonBase.tsx │ │ ├── ingredientTabContent.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── recipeTabContent.tsx │ │ ├── resultCard.tsx │ │ ├── savedMealCard.tsx │ │ ├── tagGroup.tsx │ │ └── types.d.ts │ ├── ingredients │ │ ├── @preferences │ │ │ ├── (..)preferences │ │ │ │ └── page.tsx │ │ │ └── default.tsx │ │ ├── content.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── layouts.tsx │ ├── ornaments │ │ ├── @preferences │ │ │ ├── (..)preferences │ │ │ │ └── page.tsx │ │ │ └── default.tsx │ │ ├── content.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── partners │ │ ├── @preferences │ │ │ ├── (..)preferences │ │ │ │ └── page.tsx │ │ │ └── default.tsx │ │ ├── content.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── preferences │ │ ├── content.tsx │ │ ├── dataManager.tsx │ │ ├── layout.tsx │ │ ├── modal.tsx │ │ ├── page.tsx │ │ └── switchItem.tsx │ └── recipes │ │ ├── @preferences │ │ ├── (..)preferences │ │ │ └── page.tsx │ │ └── default.tsx │ │ ├── content.tsx │ │ ├── layout.tsx │ │ └── page.tsx ├── actions │ └── backup │ │ ├── db.ts │ │ ├── file.ts │ │ └── index.ts ├── api │ └── backup │ │ ├── check │ │ └── [code] │ │ │ └── route.ts │ │ ├── cleanup │ │ └── [secret] │ │ │ └── route.ts │ │ ├── delete │ │ └── [code] │ │ │ └── route.ts │ │ ├── download │ │ └── [code] │ │ │ └── route.ts │ │ └── upload │ │ └── route.ts ├── components │ ├── analytics.tsx │ ├── compatibleBrowser.tsx │ ├── customerRareTutorial.tsx │ ├── errorBoundary.tsx │ ├── fontAwesomeIconButton.tsx │ ├── fontAwesomeIconLink.tsx │ ├── heading.tsx │ ├── itemCard.tsx │ ├── itemPage.tsx │ ├── itemPopoverCard.tsx │ ├── ol.tsx │ ├── placeholder.tsx │ ├── pressElement.tsx │ ├── price.tsx │ ├── qrCode.tsx │ ├── rednote.tsx │ ├── sideButtonGroup.tsx │ ├── sideFilterIconButton.tsx │ ├── sidePinyinSortIconButton.tsx │ ├── sideSearchIconButton.tsx │ ├── sprite.tsx │ ├── tachie.tsx │ ├── tags.tsx │ ├── themeSwitcher.tsx │ └── timeAgo.tsx ├── configs │ ├── index.ts │ └── site │ │ ├── index.ts │ │ └── types.d.ts ├── data │ ├── beverages │ │ ├── data.ts │ │ ├── index.ts │ │ └── types.d.ts │ ├── clothes │ │ ├── data.ts │ │ ├── index.ts │ │ └── types.d.ts │ ├── constant.ts │ ├── cookers │ │ ├── data.ts │ │ ├── index.ts │ │ └── types.d.ts │ ├── currencies │ │ ├── data.ts │ │ ├── index.ts │ │ └── types.d.ts │ ├── customer_normal │ │ ├── data.ts │ │ ├── index.ts │ │ └── types.d.ts │ ├── customer_rare │ │ ├── data.ts │ │ ├── index.ts │ │ └── types.d.ts │ ├── index.ts │ ├── ingredients │ │ ├── data.ts │ │ ├── index.ts │ │ └── types.d.ts │ ├── ornaments │ │ ├── data.ts │ │ ├── index.ts │ │ └── types.d.ts │ ├── partners │ │ ├── data.ts │ │ ├── index.ts │ │ └── types.d.ts │ ├── recipes │ │ ├── data.ts │ │ ├── index.ts │ │ └── types.d.ts │ └── types.d.ts ├── design │ ├── hooks │ │ ├── index.ts │ │ └── use-theme │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── styles.scss │ │ │ ├── themeScript.tsx │ │ │ ├── types.d.ts │ │ │ └── useTheme.ts │ ├── theme │ │ ├── colors │ │ │ ├── constants │ │ │ │ ├── backgroundColors.ts │ │ │ │ ├── black.ts │ │ │ │ ├── blue.ts │ │ │ │ ├── brown.ts │ │ │ │ ├── green.ts │ │ │ │ ├── index.ts │ │ │ │ ├── orange.ts │ │ │ │ ├── pink.ts │ │ │ │ └── purple.ts │ │ │ ├── index.ts │ │ │ ├── rating │ │ │ │ ├── index.ts │ │ │ │ └── types.d.ts │ │ │ ├── semantic.ts │ │ │ ├── types.d.ts │ │ │ └── utils.ts │ │ ├── index.ts │ │ ├── styles │ │ │ ├── fontFamily.ts │ │ │ ├── index.ts │ │ │ └── rating │ │ │ │ ├── index.ts │ │ │ │ └── types.d.ts │ │ └── types.d.ts │ ├── ui │ │ ├── components │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── constant.ts │ │ │ ├── dropdown.tsx │ │ │ ├── index.ts │ │ │ ├── link.tsx │ │ │ ├── pagination.tsx │ │ │ ├── popover.tsx │ │ │ ├── scrollShadow.tsx │ │ │ ├── snippet.tsx │ │ │ ├── switch.tsx │ │ │ └── tooltip.tsx │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── useMotionProps.ts │ │ │ └── useReducedMotion.ts │ │ └── utils │ │ │ ├── cn.ts │ │ │ ├── generateRatingVariants.ts │ │ │ └── index.ts │ └── utils │ │ ├── addSafeMediaQueryEventListener.ts │ │ └── index.ts ├── global-error.tsx ├── globals.scss ├── hooks │ ├── index.ts │ ├── useFilteredData.ts │ ├── useItemPopoverState.ts │ ├── useMounted.ts │ ├── useOpenedItemPopover.ts │ ├── useParams.ts │ ├── usePathname.ts │ ├── usePinyinSortConfig.ts │ ├── useSearchConfig.ts │ ├── useSearchResult.ts │ ├── useSkipProcessItemData.ts │ ├── useSortedData.ts │ ├── useThrottle.ts │ ├── useVibrate.ts │ └── useViewInNewWindow.ts ├── layout.tsx ├── lib │ └── db │ │ ├── constant.ts │ │ ├── db.ts │ │ ├── index.ts │ │ ├── types.d.ts │ │ └── utils │ │ ├── getTableColumns.ts │ │ └── index.ts ├── loading.tsx ├── manifest.ts ├── not-found.tsx ├── page.tsx ├── polyfills.tsx ├── providers.tsx ├── robots.ts ├── sitemap.ts ├── stores │ ├── beverages.ts │ ├── clothes.ts │ ├── cookers.ts │ ├── currencies.ts │ ├── customer-normal.ts │ ├── customer-rare.ts │ ├── global.ts │ ├── index.ts │ ├── ingredients.ts │ ├── middlewares │ │ ├── index.ts │ │ ├── persist.ts │ │ └── sync.ts │ ├── ornaments.ts │ ├── partners.ts │ ├── recipes.ts │ ├── types.d.ts │ └── utils │ │ ├── getAllItemNames.ts │ │ ├── index.ts │ │ ├── keepLastTag.ts │ │ ├── reverseDirection.ts │ │ └── reverseVisibilityState.ts ├── types │ ├── element.d.ts │ ├── environment.d.ts │ ├── evaluation.d.ts │ ├── improve.d.ts │ ├── index.ts │ ├── meal.d.ts │ ├── popularTrend.d.ts │ └── reset.d.ts ├── utilities │ ├── array │ │ ├── check.ts │ │ ├── covert.ts │ │ ├── generateRange.ts │ │ ├── index.ts │ │ ├── intersection.ts │ │ ├── removeLastElement.ts │ │ ├── union.ts │ │ └── without.ts │ ├── checkA11yConfirmKey.ts │ ├── checkDomReady.ts │ ├── getPageTitle.ts │ ├── index.ts │ ├── memoize.ts │ ├── object │ │ ├── cloneJsonObject.ts │ │ ├── covertCollection.ts │ │ └── index.ts │ ├── pinyin │ │ ├── getPinyin.ts │ │ ├── index.ts │ │ └── processPinyin.ts │ ├── processJsonFile.ts │ ├── setScriptUrlTag.ts │ ├── sort │ │ ├── index.ts │ │ ├── numberSort.ts │ │ └── pinyinSort.ts │ ├── string │ │ ├── index.ts │ │ ├── pxToRem.ts │ │ └── remToPx.ts │ └── toggleBoolean.ts └── utils │ ├── customer │ ├── base.ts │ ├── customer_normal │ │ ├── evaluateMeal.ts │ │ └── index.ts │ ├── customer_rare │ │ ├── evaluateMeal.ts │ │ └── index.ts │ ├── index.ts │ └── types.d.ts │ ├── food │ ├── base.ts │ ├── beverages.ts │ ├── index.ts │ ├── ingredients.ts │ ├── recipes.ts │ └── types.d.ts │ ├── index.ts │ ├── item │ ├── base.ts │ ├── clothes.ts │ ├── cooker.ts │ ├── currency.ts │ ├── index.ts │ ├── ornament.ts │ ├── partner.ts │ └── types.d.ts │ ├── sprite │ ├── index.ts │ └── types.d.ts │ └── types.d.ts ├── eslint.config.mjs ├── lint-staged.config.mjs ├── next.config.ts ├── package.json ├── patches ├── @heroui__accordion.patch ├── @heroui__autocomplete.patch ├── @heroui__button.patch ├── @heroui__select.patch ├── @heroui__tabs.patch ├── @heroui__theme.patch ├── @heroui__tooltip.patch ├── @heroui__use-aria-button.patch ├── @heroui__use-aria-link.patch └── next.patch ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── postcss.config.mjs ├── public ├── SmoothScroll.min.js ├── assets │ ├── app-qa.png │ ├── icon.png │ ├── loading.gif │ ├── mystia.png │ ├── sprites │ │ ├── beverage.png │ │ ├── beverage.webp │ │ ├── clothes.png │ │ ├── clothes.webp │ │ ├── cooker.png │ │ ├── cooker.webp │ │ ├── currency.png │ │ ├── currency.webp │ │ ├── customer_normal.png │ │ ├── customer_normal.webp │ │ ├── customer_rare.png │ │ ├── customer_rare.webp │ │ ├── ingredient.png │ │ ├── ingredient.webp │ │ ├── original_1x │ │ │ ├── beverage.png │ │ │ ├── clothes.png │ │ │ ├── cooker.png │ │ │ ├── currency.png │ │ │ ├── ingredient.png │ │ │ ├── ornament.png │ │ │ └── recipe.png │ │ ├── ornament.png │ │ ├── ornament.webp │ │ ├── partner.png │ │ ├── partner.webp │ │ ├── recipe.png │ │ └── recipe.webp │ └── tachies │ │ ├── clothes │ │ ├── dangaoqun.png │ │ ├── dongjishuishoufu.png │ │ ├── fangwenzhuohefu.png │ │ ├── fanzhangfu.png │ │ ├── haidaofu.png │ │ ├── haitandujiazhuang.png │ │ ├── heisetaozhuang.gif │ │ ├── huadebaoen.png │ │ ├── huakuiyuyi.png │ │ ├── jinxiuzhongguowawa.png │ │ ├── junyueduilifu.png │ │ ├── monvfu.png │ │ ├── ouxiangfu.png │ │ ├── pengkeyanchufu.png │ │ ├── quejiuwugongzuozhuang.png │ │ ├── shengdanjietedianwanzhuang.png │ │ ├── shuishoufu.png │ │ ├── shuiyi.png │ │ ├── tuisedewunvfu.png │ │ ├── wanshengjietedianwanzhuang.png │ │ ├── xiannvfu.png │ │ ├── xingchenpifengtaozhuang.gif │ │ ├── yequefu.png │ │ ├── zhishifu.png │ │ └── zhonghuafengxiaofu.png │ │ ├── customer_rare │ │ ├── ailian.png │ │ ├── ailisi.png │ │ ├── baitianaqiu.png │ │ ├── bayunzi.png │ │ ├── bengbengtiaotiaodesanyaojing.png │ │ ├── binamingjutianzi.png │ │ ├── bolilingmeng.png │ │ ├── cheng.png │ │ ├── cimuhuashan.png │ │ ├── cunshashuimi.png │ │ ├── dongfengguzaomiao.png │ │ ├── duoduoliangxiaosan.png │ │ ├── eryantuancang.png │ │ ├── fengjianyouxiang.png │ │ ├── fengshouye.png │ │ ├── guirenzhengxie.png │ │ ├── gumingdijue.png │ │ ├── gumingdilian.png │ │ ├── hechenghequ.png │ │ ├── heigushannv.png │ │ ├── hongmeiling.png │ │ ├── hunpoyaomeng.png │ │ ├── huoqinge.png │ │ ├── huoyanmaolin.png │ │ ├── huyuelin.png │ │ ├── jinquanyinglang.png │ │ ├── leimiliya.png │ │ ├── ligelu.png │ │ ├── likongxi.png │ │ ├── lingwulukong.png │ │ ├── lingxian.png │ │ ├── lumiya.png │ │ ├── luyizi.png │ │ ├── meidixin.png │ │ ├── meimo.png │ │ ├── mengchengguo.png │ │ ├── mianyuefengji.png │ │ ├── mianyueyiji.png │ │ ├── paqiuli.png │ │ ├── penglaishanhuiye.png │ │ ├── qilunuo.png │ │ ├── quanzouhua.png │ │ ├── senjinlinzhizhu.png │ │ ├── shangbaizehuiyin.png │ │ ├── shaomingzhenmiaowan.png │ │ ├── shemingwanwen.png │ │ ├── shitiansichengmei.png │ │ ├── shiyanyou.png │ │ ├── shuiqiaopaluxi.png │ │ ├── suwotuzigu.png │ │ ├── taotieyoumo.png │ │ ├── tengyuanmeihong.png │ │ ├── wububudou.png │ │ ├── wuyumolisha.png │ │ ├── xingxiongyongyi.png │ │ ├── xixingsiyouyouzi.png │ │ ├── yichuicuixiang.png │ │ └── yinfandi.png │ │ └── partners │ │ ├── benjuxiaoling.png │ │ ├── chimanqi.png │ │ ├── duolaimi.png │ │ ├── gaoliyeahong.png │ │ ├── gonggufangxiang.png │ │ ├── hunpoyaomeng.png │ │ ├── jianshanchu.png │ │ ├── laerwa.png │ │ ├── lingxian.png │ │ ├── mengzi.png │ │ ├── qisimei.png │ │ ├── sala.png │ │ ├── shiliuyexiaoye.png │ │ ├── xiaoyezhongxiaoting.png │ │ ├── youguxiangzi.png │ │ └── yunjuyilun.png ├── favicon.ico └── icons │ ├── apple-touch-icon.png │ ├── pwa-icon-192.png │ └── pwa-icon-512.png ├── scripts ├── babelTransformFile.mjs ├── generateOfflineZip.mjs ├── generateServiceWorker.mjs ├── offline-template.zip ├── registerServiceWorker-template.js ├── serviceWorker-template.js └── utils.mjs ├── tailwind.config.ts ├── tsconfig.json ├── types.d.ts └── vercel.json /.commitlintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | tab_width = 4 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.{yml,yaml}] 13 | indent_size = 2 14 | indent_style = space 15 | 16 | [public/*.js] 17 | insert_final_newline = false 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://url.izakaya.cc/HI9lxP"] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .*cache 3 | 4 | # next.js 5 | /.next/ 6 | /out/ 7 | 8 | # production 9 | /public/registerServiceWorker.js 10 | /public/serviceWorker.js 11 | /*offline*.zip 12 | 13 | # server-side files 14 | /upload 15 | /sqlite.db 16 | 17 | # local env files 18 | .env*.local 19 | 20 | # vercel 21 | .vercel 22 | 23 | # typescript 24 | *.tsbuildinfo 25 | next-env.d.ts 26 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit ${1} 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | public/*.js 3 | *.yml 4 | *.yaml 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"], 3 | "tailwindAttributes": ["className", "classNames", "itemClasses", "tw"], 4 | "tailwindFunctions": ["cn"], 5 | "bracketSpacing": false, 6 | "printWidth": 120, 7 | "quoteProps": "as-needed", 8 | "singleQuote": true, 9 | "tabWidth": 4, 10 | "trailingComma": "es5", 11 | "useTabs": true 12 | } 13 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["stylelint-scss"], 3 | "extends": ["stylelint-config-standard-scss"], 4 | "rules": { 5 | "at-rule-no-unknown": null, 6 | "scss/at-rule-no-unknown": [ 7 | true, 8 | { 9 | "ignoreAtRules": ["tailwind"] 10 | } 11 | ], 12 | "selector-class-pattern": null 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bradlc.vscode-tailwindcss", 4 | "dbaeumer.vscode-eslint", 5 | "EditorConfig.EditorConfig", 6 | "esbenp.prettier-vscode", 7 | "mgmcdermott.vscode-language-babel", 8 | "redhat.vscode-yaml", 9 | "streetsidesoftware.code-spell-checker", 10 | "stylelint.vscode-stylelint", 11 | "syler.sass-indented" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # touhou-mystia-izakaya-assistant 2 | 3 | ## 项目介绍 4 | 5 | [东方夜雀食堂小助手](https://izakaya.cc)(英语:Touhou Mystia's Izakaya Assistant;简称:夜雀助手)是为游戏《[东方夜雀食堂](https://store.steampowered.com/app/1584090/__Touhou_Mystias_Izakaya)》所打造的辅助工具,提供顾客图鉴(包括羁绊奖励和符卡效果查询)、搭配稀客和普客的料理套餐,以及料理(食谱)、酒水、食材、厨具、摆件、衣服和伙伴查询等功能,旨在为玩家的游玩过程提供帮助。 6 | 7 | ### 更新日志 8 | 9 | 功能概览和更新摘要[见此](https://izakaya.cc/about),完整的提交日志[见此](https://github.com/AnYiEE/touhou-mystia-izakaya-assistant/commits)。 10 | 11 | ### 相关视频 12 | 13 | - [【东方夜雀食堂】用小助手来辅助你搭配料理吧!](https://www.bilibili.com/video/BV1SphBe8EZM/) 14 | - [【东方夜雀食堂】小助手使用教程+游戏中演示](https://www.bilibili.com/video/BV12bbWeGELA/) 15 | 16 | ## 许可证 17 | 18 | [AGPL-3.0-only](https://github.com/AnYiEE/touhou-mystia-izakaya-assistant/blob/master/LICENSE),详细法律说明[见此](https://izakaya.cc/about)。 19 | -------------------------------------------------------------------------------- /app/(pages)/(layout)/footerLink.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {type PropsWithChildren, memo} from 'react'; 4 | 5 | import {type ILinkProps, type ITooltipProps, Link, Tooltip, cn} from '@/design/ui/components'; 6 | 7 | interface IFooterLinkProps extends Pick { 8 | content?: ReactNodeWithoutBoolean; 9 | } 10 | 11 | export const FooterLink = memo>(function FooterLink({ 12 | children, 13 | content, 14 | href = '#', 15 | isExternal = true, 16 | title, 17 | }) { 18 | return ( 19 | 30 | {children} 31 | 32 | ); 33 | }); 34 | 35 | interface IFooterLinkWithTooltipProps extends IFooterLinkProps, Pick { 36 | content: ReactNodeWithoutBoolean; 37 | } 38 | 39 | export const FooterLinkWithTooltip = memo>( 40 | function FooterLinkWithTooltip({classNames, ...props}) { 41 | return ( 42 | 53 | 54 | 55 | 56 | 57 | ); 58 | } 59 | ); 60 | -------------------------------------------------------------------------------- /app/(pages)/about/layout.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from 'next'; 2 | 3 | import {getPageTitle} from '@/utilities'; 4 | 5 | export const metadata: Metadata = { 6 | title: getPageTitle('/about'), 7 | }; 8 | 9 | export {default} from '@/(pages)/layouts'; 10 | -------------------------------------------------------------------------------- /app/(pages)/about/page.tsx: -------------------------------------------------------------------------------- 1 | import ChangeLog from './changeLog'; 2 | import Introduction from './introduction'; 3 | import LegalStatement from './legalStatement'; 4 | 5 | export default function About() { 6 | return ( 7 |
8 | 9 | 10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/(pages)/beverages/@preferences/(..)preferences/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export {default} from '@/(pages)/preferences/modal'; 4 | -------------------------------------------------------------------------------- /app/(pages)/beverages/@preferences/default.tsx: -------------------------------------------------------------------------------- 1 | export {PreferencesModalDefault as default} from '@/(pages)/preferences/modal'; 2 | -------------------------------------------------------------------------------- /app/(pages)/beverages/layout.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from 'next'; 2 | 3 | import {siteConfig} from '@/configs'; 4 | import {getPageTitle, toArray} from '@/utilities'; 5 | import {Beverage} from '@/utils'; 6 | 7 | const {description, keywords} = siteConfig; 8 | 9 | const beverages = Beverage.getInstance().getNames(10); 10 | const title = getPageTitle('/beverages'); 11 | 12 | export const metadata: Metadata = { 13 | title, 14 | 15 | description: `本页面可以查询${beverages.join('、')}等${title}的详情。${description}`, 16 | keywords: toArray(keywords.slice(0, 18), beverages), 17 | }; 18 | 19 | export {WithPreference as default} from '@/(pages)/layouts'; 20 | -------------------------------------------------------------------------------- /app/(pages)/clothes/@preferences/(..)preferences/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export {default} from '@/(pages)/preferences/modal'; 4 | -------------------------------------------------------------------------------- /app/(pages)/clothes/@preferences/default.tsx: -------------------------------------------------------------------------------- 1 | export {PreferencesModalDefault as default} from '@/(pages)/preferences/modal'; 2 | -------------------------------------------------------------------------------- /app/(pages)/clothes/layout.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from 'next'; 2 | 3 | import {siteConfig} from '@/configs'; 4 | import {getPageTitle, toArray} from '@/utilities'; 5 | import {Clothes} from '@/utils'; 6 | 7 | const {description, keywords} = siteConfig; 8 | 9 | const clothes = Clothes.getInstance().getNames(10); 10 | const title = getPageTitle('/clothes'); 11 | 12 | export const metadata: Metadata = { 13 | title, 14 | 15 | description: `本页面可以查询${clothes.join('、')}等${title}的详情。${description}`, 16 | keywords: toArray(keywords.slice(0, 18), clothes), 17 | }; 18 | 19 | export {WithPreference as default} from '@/(pages)/layouts'; 20 | -------------------------------------------------------------------------------- /app/(pages)/cookers/@preferences/(..)preferences/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export {default} from '@/(pages)/preferences/modal'; 4 | -------------------------------------------------------------------------------- /app/(pages)/cookers/@preferences/default.tsx: -------------------------------------------------------------------------------- 1 | export {PreferencesModalDefault as default} from '@/(pages)/preferences/modal'; 2 | -------------------------------------------------------------------------------- /app/(pages)/cookers/layout.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from 'next'; 2 | 3 | import {siteConfig} from '@/configs'; 4 | import {getPageTitle, toArray} from '@/utilities'; 5 | import {Cooker} from '@/utils'; 6 | 7 | const {description, keywords} = siteConfig; 8 | 9 | const cookers = Cooker.getInstance().getNames(10); 10 | const title = getPageTitle('/cookers'); 11 | 12 | export const metadata: Metadata = { 13 | title, 14 | 15 | description: `本页面可以查询${cookers.join('、')}等${title}的详情。${description}`, 16 | keywords: toArray(keywords.slice(0, 18), cookers), 17 | }; 18 | 19 | export {WithPreference as default} from '@/(pages)/layouts'; 20 | -------------------------------------------------------------------------------- /app/(pages)/currencies/@preferences/(..)preferences/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export {default} from '@/(pages)/preferences/modal'; 4 | -------------------------------------------------------------------------------- /app/(pages)/currencies/@preferences/default.tsx: -------------------------------------------------------------------------------- 1 | export {PreferencesModalDefault as default} from '@/(pages)/preferences/modal'; 2 | -------------------------------------------------------------------------------- /app/(pages)/currencies/layout.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from 'next'; 2 | 3 | import {siteConfig} from '@/configs'; 4 | import {getPageTitle, toArray} from '@/utilities'; 5 | import {Currency} from '@/utils'; 6 | 7 | const {description, keywords} = siteConfig; 8 | 9 | const currencies = Currency.getInstance().getNames(10); 10 | const title = getPageTitle('/currencies'); 11 | 12 | export const metadata: Metadata = { 13 | title, 14 | 15 | description: `本页面可以查询${currencies.join('、')}等${title}的详情。${description}`, 16 | keywords: toArray(keywords.slice(0, 18), currencies), 17 | }; 18 | 19 | export {WithPreference as default} from '@/(pages)/layouts'; 20 | -------------------------------------------------------------------------------- /app/(pages)/currencies/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {usePinyinSortConfig, useSearchConfig, useSearchResult, useSortedData, useThrottle} from '@/hooks'; 4 | 5 | import Content from './content'; 6 | import ItemPage from '@/components/itemPage'; 7 | import SideButtonGroup from '@/components/sideButtonGroup'; 8 | import SidePinyinSortIconButton from '@/components/sidePinyinSortIconButton'; 9 | import SideSearchIconButton from '@/components/sideSearchIconButton'; 10 | 11 | import {currenciesStore as store} from '@/stores'; 12 | import {checkEmpty} from '@/utilities'; 13 | 14 | export default function Currencies() { 15 | const instance = store.instance.get(); 16 | 17 | const allNames = store.names.use(); 18 | 19 | const pinyinSortState = store.persistence.pinyinSortState.use(); 20 | const searchValue = store.persistence.searchValue.use(); 21 | 22 | const throttledSearchValue = useThrottle(searchValue); 23 | const searchResult = useSearchResult(instance, throttledSearchValue); 24 | 25 | const sortedData = useSortedData(instance, searchResult, pinyinSortState); 26 | 27 | const pinyinSortConfig = usePinyinSortConfig(pinyinSortState, store.persistence.pinyinSortState.set); 28 | 29 | const searchConfig = useSearchConfig({ 30 | label: '选择或输入货币名称', 31 | searchItems: allNames, 32 | searchValue, 33 | setSearchValue: store.persistence.searchValue.set, 34 | spriteTarget: 'currency', 35 | }); 36 | 37 | return ( 38 | 42 | 43 | 44 | 45 | } 46 | > 47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/(pages)/customer-normal/@preferences/(..)preferences/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export {default} from '@/(pages)/customer-rare/@preferences/(..)preferences/page'; 4 | -------------------------------------------------------------------------------- /app/(pages)/customer-normal/@preferences/default.tsx: -------------------------------------------------------------------------------- 1 | export {default} from '@/(pages)/customer-rare/@preferences/default'; 2 | -------------------------------------------------------------------------------- /app/(pages)/customer-normal/constants.tsx: -------------------------------------------------------------------------------- 1 | export { 2 | beverageTableColumns, 3 | customerTabStyleMap, 4 | ingredientTabStyleMap, 5 | recipeTableColumns, 6 | tabVisibilityStateMap, 7 | tachieBreakPointMap, 8 | } from '@/(pages)/customer-rare/constants'; 9 | -------------------------------------------------------------------------------- /app/(pages)/customer-normal/layout.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from 'next'; 2 | 3 | import {siteConfig} from '@/configs'; 4 | import {getPageTitle, toArray} from '@/utilities'; 5 | import {CustomerNormal} from '@/utils'; 6 | 7 | const {description, keywords} = siteConfig; 8 | 9 | const customers = CustomerNormal.getInstance().getNames(10); 10 | const title = getPageTitle('/customer-normal'); 11 | 12 | export const metadata: Metadata = { 13 | title, 14 | 15 | description: `本页面可以为${customers.join('、')}等${title}搭配料理套餐。${description}`, 16 | keywords: toArray(keywords.slice(0, 18), customers), 17 | }; 18 | 19 | export {default} from '@/(pages)/customer-rare/layout'; 20 | -------------------------------------------------------------------------------- /app/(pages)/customer-normal/tagGroup.tsx: -------------------------------------------------------------------------------- 1 | export {default} from '@/(pages)/customer-rare/tagGroup'; 2 | -------------------------------------------------------------------------------- /app/(pages)/customer-normal/types.d.ts: -------------------------------------------------------------------------------- 1 | import {type TRecipeTag} from '@/data'; 2 | import type {TRecipe} from '@/utils/types'; 3 | 4 | interface IRecipeSuitability { 5 | matchedPositiveTags: TRecipeTag[]; 6 | suitability: number; 7 | } 8 | 9 | export type TRecipeWithSuitability = Prettify; 10 | export type TRecipesWithSuitability = TRecipeWithSuitability[]; 11 | 12 | export type { 13 | ICustomerTabStyle, 14 | TBeverageWithSuitability, 15 | TBeveragesWithSuitability, 16 | TTab, 17 | TTabVisibilityState, 18 | } from '@/(pages)/customer-rare/types'; 19 | -------------------------------------------------------------------------------- /app/(pages)/customer-rare/@preferences/(..)preferences/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export {default} from '@/(pages)/preferences/modal'; 4 | -------------------------------------------------------------------------------- /app/(pages)/customer-rare/@preferences/default.tsx: -------------------------------------------------------------------------------- 1 | export {PreferencesModalDefault as default} from '@/(pages)/preferences/modal'; 2 | -------------------------------------------------------------------------------- /app/(pages)/customer-rare/layout.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from 'next'; 2 | 3 | import {siteConfig} from '@/configs'; 4 | import {getPageTitle, toArray} from '@/utilities'; 5 | import {CustomerRare} from '@/utils'; 6 | 7 | const {description, keywords} = siteConfig; 8 | 9 | const customers = CustomerRare.getInstance().getNames(10); 10 | const title = getPageTitle('/customer-rare'); 11 | 12 | export const metadata: Metadata = { 13 | title, 14 | 15 | description: `本页面可以为${customers.join('、')}等${title}搭配料理套餐或查询羁绊奖励和符卡效果。${description}`, 16 | keywords: toArray(keywords.slice(0, 18), customers), 17 | }; 18 | 19 | export {WithPreference as default} from '@/(pages)/layouts'; 20 | -------------------------------------------------------------------------------- /app/(pages)/customer-rare/tagGroup.tsx: -------------------------------------------------------------------------------- 1 | import {type PropsWithChildren, memo} from 'react'; 2 | 3 | import {cn} from '@/design/ui/components'; 4 | 5 | interface IProps extends Pick {} 6 | 7 | export default memo>(function TagGroup({children, className}) { 8 | return
{children}
; 9 | }); 10 | -------------------------------------------------------------------------------- /app/(pages)/customer-rare/types.d.ts: -------------------------------------------------------------------------------- 1 | import {type SortDescriptor} from '@heroui/table'; 2 | 3 | import {type tabVisibilityStateMap} from './constants'; 4 | import {type TBeverageTag, type TRecipeTag} from '@/data'; 5 | import type {TBeverage, TRecipe} from '@/utils/types'; 6 | 7 | export type TTabVisibilityState = ExtractCollectionValue; 8 | 9 | export interface ICustomerTabStyle { 10 | ariaLabel: string; 11 | buttonNode: ReactNodeWithoutBoolean; 12 | classNames: { 13 | content: string; 14 | sideButtonGroup: string; 15 | }; 16 | } 17 | 18 | export type TCustomerTabStyleMap = Record; 19 | 20 | export interface IIngredientsTabStyle { 21 | ariaLabel: string; 22 | buttonNode: ReactNodeWithoutBoolean; 23 | classNames: { 24 | content: string; 25 | sideButtonGroup: string; 26 | }; 27 | } 28 | 29 | export type TIngredientsTabStyleMap = Record; 30 | 31 | export interface ITableColumn { 32 | key: T; 33 | label: string; 34 | sortable: boolean; 35 | } 36 | 37 | export interface ITableSortDescriptor extends SortDescriptor { 38 | column?: T; 39 | direction?: SortDescriptor['direction']; 40 | lastColumn?: T; 41 | time?: number; 42 | } 43 | 44 | interface IBeverageSuitability { 45 | matchedTags: TBeverageTag[]; 46 | suitability: number; 47 | } 48 | 49 | export type TBeverageWithSuitability = Prettify; 50 | export type TBeveragesWithSuitability = TBeverageWithSuitability[]; 51 | 52 | interface IRecipeSuitability { 53 | matchedNegativeTags: TRecipeTag[]; 54 | matchedPositiveTags: TRecipeTag[]; 55 | suitability: number; 56 | } 57 | 58 | export type TRecipeWithSuitability = Prettify; 59 | export type TRecipesWithSuitability = TRecipeWithSuitability[]; 60 | 61 | export type TTab = 'beverage' | 'customer' | 'ingredient' | 'recipe'; 62 | -------------------------------------------------------------------------------- /app/(pages)/ingredients/@preferences/(..)preferences/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export {default} from '@/(pages)/preferences/modal'; 4 | -------------------------------------------------------------------------------- /app/(pages)/ingredients/@preferences/default.tsx: -------------------------------------------------------------------------------- 1 | export {PreferencesModalDefault as default} from '@/(pages)/preferences/modal'; 2 | -------------------------------------------------------------------------------- /app/(pages)/ingredients/layout.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from 'next'; 2 | 3 | import {siteConfig} from '@/configs'; 4 | import {getPageTitle, toArray} from '@/utilities'; 5 | import {Ingredient} from '@/utils'; 6 | 7 | const {description, keywords} = siteConfig; 8 | 9 | const ingredients = Ingredient.getInstance().getNames(10); 10 | const title = getPageTitle('/ingredients'); 11 | 12 | export const metadata: Metadata = { 13 | title, 14 | 15 | description: `本页面可以查询${ingredients.join('、')}等${title}的详情。${description}`, 16 | keywords: toArray(keywords.slice(0, 18), ingredients), 17 | }; 18 | 19 | export {WithPreference as default} from '@/(pages)/layouts'; 20 | -------------------------------------------------------------------------------- /app/(pages)/layouts.tsx: -------------------------------------------------------------------------------- 1 | import {type ReactNode} from 'react'; 2 | 3 | export default function Basic({ 4 | children, 5 | }: Readonly<{ 6 | children: ReactNode; 7 | }>) { 8 | return children; 9 | } 10 | 11 | export function WithPreference({ 12 | children, 13 | preferences, 14 | }: Readonly<{ 15 | children: ReactNode; 16 | preferences: ReactNode; 17 | }>) { 18 | return ( 19 | <> 20 | {children} 21 | {preferences} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/(pages)/ornaments/@preferences/(..)preferences/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export {default} from '@/(pages)/preferences/modal'; 4 | -------------------------------------------------------------------------------- /app/(pages)/ornaments/@preferences/default.tsx: -------------------------------------------------------------------------------- 1 | export {PreferencesModalDefault as default} from '@/(pages)/preferences/modal'; 2 | -------------------------------------------------------------------------------- /app/(pages)/ornaments/layout.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from 'next'; 2 | 3 | import {siteConfig} from '@/configs'; 4 | import {getPageTitle, toArray} from '@/utilities'; 5 | import {Ornament} from '@/utils'; 6 | 7 | const {description, keywords} = siteConfig; 8 | 9 | const ornaments = Ornament.getInstance().getNames(10); 10 | const title = getPageTitle('/ornaments'); 11 | 12 | export const metadata: Metadata = { 13 | title, 14 | 15 | description: `本页面可以查询${ornaments.join('、')}等${title}的详情。${description}`, 16 | keywords: toArray(keywords.slice(0, 18), ornaments), 17 | }; 18 | 19 | export {WithPreference as default} from '@/(pages)/layouts'; 20 | -------------------------------------------------------------------------------- /app/(pages)/partners/@preferences/(..)preferences/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export {default} from '@/(pages)/preferences/modal'; 4 | -------------------------------------------------------------------------------- /app/(pages)/partners/@preferences/default.tsx: -------------------------------------------------------------------------------- 1 | export {PreferencesModalDefault as default} from '@/(pages)/preferences/modal'; 2 | -------------------------------------------------------------------------------- /app/(pages)/partners/layout.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from 'next'; 2 | 3 | import {siteConfig} from '@/configs'; 4 | import {getPageTitle, toArray} from '@/utilities'; 5 | import {Partner} from '@/utils'; 6 | 7 | const {description, keywords} = siteConfig; 8 | 9 | const partners = Partner.getInstance().getNames(10); 10 | const title = getPageTitle('/partners'); 11 | 12 | export const metadata: Metadata = { 13 | title, 14 | 15 | description: `本页面可以查询${partners.join('、')}等${title}的详情。${description}`, 16 | keywords: toArray(keywords.slice(0, 18), partners), 17 | }; 18 | 19 | export {WithPreference as default} from '@/(pages)/layouts'; 20 | -------------------------------------------------------------------------------- /app/(pages)/preferences/layout.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from 'next'; 2 | 3 | import {getPageTitle} from '@/utilities'; 4 | 5 | export const metadata: Metadata = { 6 | title: getPageTitle('/preferences'), 7 | 8 | robots: { 9 | index: false, 10 | }, 11 | }; 12 | 13 | export {default} from '@/(pages)/layouts'; 14 | -------------------------------------------------------------------------------- /app/(pages)/preferences/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {useMounted} from '@/hooks'; 4 | 5 | import Content from './content'; 6 | import Loading from '@/loading'; 7 | 8 | export default function Preferences() { 9 | const isMounted = useMounted(); 10 | 11 | if (!isMounted) { 12 | return ; 13 | } 14 | 15 | return ; 16 | } 17 | -------------------------------------------------------------------------------- /app/(pages)/preferences/switchItem.tsx: -------------------------------------------------------------------------------- 1 | import {type PropsWithChildren, memo} from 'react'; 2 | 3 | import {type ISwitchProps, Switch, cn} from '@/design/ui/components'; 4 | 5 | interface IProps { 6 | 'aria-label': NonNullable; 7 | className?: NonNullable; 8 | isSelected: NonNullable; 9 | onValueChange: NonNullable; 10 | } 11 | 12 | export default memo>(function SwitchItem({ 13 | children, 14 | className, 15 | isSelected, 16 | onValueChange, 17 | ...props 18 | }) { 19 | return ( 20 |
21 | {children} 22 | 关} 24 | startContent={} 25 | isSelected={isSelected} 26 | size="sm" 27 | onValueChange={onValueChange} 28 | classNames={{ 29 | endContent: 'leading-none', 30 | startContent: 'leading-none', 31 | }} 32 | {...props} 33 | /> 34 |
35 | ); 36 | }); 37 | -------------------------------------------------------------------------------- /app/(pages)/recipes/@preferences/(..)preferences/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export {default} from '@/(pages)/preferences/modal'; 4 | -------------------------------------------------------------------------------- /app/(pages)/recipes/@preferences/default.tsx: -------------------------------------------------------------------------------- 1 | export {PreferencesModalDefault as default} from '@/(pages)/preferences/modal'; 2 | -------------------------------------------------------------------------------- /app/(pages)/recipes/layout.tsx: -------------------------------------------------------------------------------- 1 | import {type Metadata} from 'next'; 2 | 3 | import {siteConfig} from '@/configs'; 4 | import {getPageTitle, toArray} from '@/utilities'; 5 | import {Recipe} from '@/utils'; 6 | 7 | const {description, keywords} = siteConfig; 8 | 9 | const recipes = Recipe.getInstance().getNames(10); 10 | const title = getPageTitle('/recipes'); 11 | 12 | export const metadata: Metadata = { 13 | title, 14 | 15 | description: `本页面可以查询${recipes.join('、')}等${title}的详情。${description}`, 16 | keywords: toArray(keywords.slice(0, 18), recipes), 17 | }; 18 | 19 | export {WithPreference as default} from '@/(pages)/layouts'; 20 | -------------------------------------------------------------------------------- /app/actions/backup/file.ts: -------------------------------------------------------------------------------- 1 | import {access, mkdir, readFile, unlink, writeFile} from 'node:fs/promises'; 2 | import {join} from 'node:path'; 3 | import {cwd} from 'node:process'; 4 | 5 | import type {TBackupFileRecord} from '@/lib/db/types'; 6 | 7 | const dir = join(cwd(), 'upload/backups'); 8 | 9 | function generateFilePath(code: TBackupFileRecord['code']) { 10 | return join(dir, `${code}.json`); 11 | } 12 | 13 | export async function deleteFile(code: TBackupFileRecord['code']) { 14 | const filePath = generateFilePath(code); 15 | 16 | try { 17 | await unlink(filePath); 18 | } catch { 19 | /* empty */ 20 | } 21 | } 22 | 23 | export async function getFile(code: TBackupFileRecord['code']) { 24 | const filePath = generateFilePath(code); 25 | 26 | try { 27 | return await readFile(filePath, 'utf8'); 28 | } catch { 29 | return null; 30 | } 31 | } 32 | 33 | export async function saveFile(code: TBackupFileRecord['code'], content: string) { 34 | try { 35 | await access(dir); 36 | } catch { 37 | await mkdir(dir, { 38 | recursive: true, 39 | }); 40 | } 41 | 42 | const filePath = generateFilePath(code); 43 | 44 | await writeFile(filePath, content, 'utf8'); 45 | } 46 | -------------------------------------------------------------------------------- /app/actions/backup/index.ts: -------------------------------------------------------------------------------- 1 | export * from './db'; 2 | export * from './file'; 3 | -------------------------------------------------------------------------------- /app/api/backup/check/[code]/route.ts: -------------------------------------------------------------------------------- 1 | import {NextRequest, NextResponse} from 'next/server'; 2 | import {validate} from 'uuid'; 3 | 4 | import {getRecord} from '@/actions/backup'; 5 | 6 | export async function GET( 7 | _request: NextRequest, 8 | { 9 | params, 10 | }: { 11 | params: Promise<{ 12 | code: string; 13 | }>; 14 | } 15 | ) { 16 | const {code} = await params; 17 | if (!validate(code)) { 18 | return NextResponse.json({message: 'Invalid code'}, {status: 400}); 19 | } 20 | 21 | const record = await getRecord(code); 22 | if (record.status === 404) { 23 | return NextResponse.json({message: 'The file record does not exist or has been deleted'}, {status: 404}); 24 | } 25 | 26 | const {created_at, last_accessed} = record; 27 | 28 | return NextResponse.json({created_at, last_accessed}); 29 | } 30 | -------------------------------------------------------------------------------- /app/api/backup/cleanup/[secret]/route.ts: -------------------------------------------------------------------------------- 1 | import {NextRequest, NextResponse} from 'next/server'; 2 | import {env} from 'node:process'; 3 | 4 | import {deleteFile, deleteRecord, getExpiredRecords} from '@/actions/backup'; 5 | 6 | export async function DELETE( 7 | _request: NextRequest, 8 | { 9 | params, 10 | }: { 11 | params: Promise<{ 12 | secret: string; 13 | }>; 14 | } 15 | ) { 16 | const {secret} = await params; 17 | 18 | if (secret !== env.CLEANUP_SECRET) { 19 | return NextResponse.json({message: 'Invalid secret'}, {status: 401}); 20 | } 21 | 22 | const now = Date.now(); 23 | const sixMonthsAgo = now - 180 * 24 * 60 * 60 * 1000; 24 | 25 | const records = await getExpiredRecords(sixMonthsAgo); 26 | 27 | let deletedCount = 0; 28 | await Promise.all( 29 | records.map(async ({code}) => { 30 | deletedCount++; 31 | await deleteRecord(code); 32 | await deleteFile(code); 33 | }) 34 | ); 35 | 36 | return NextResponse.json({ 37 | deletedCount, 38 | deletedFiles: records.map(({code}) => code), 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /app/api/backup/delete/[code]/route.ts: -------------------------------------------------------------------------------- 1 | import {NextRequest, NextResponse} from 'next/server'; 2 | import {validate} from 'uuid'; 3 | 4 | import {deleteFile, deleteRecord, getRecord} from '@/actions/backup'; 5 | 6 | export async function DELETE( 7 | _request: NextRequest, 8 | { 9 | params, 10 | }: { 11 | params: Promise<{ 12 | code: string; 13 | }>; 14 | } 15 | ) { 16 | const {code} = await params; 17 | if (!validate(code)) { 18 | return NextResponse.json({message: 'Invalid code'}, {status: 400}); 19 | } 20 | 21 | const {status} = await getRecord(code); 22 | if (status === 404) { 23 | return NextResponse.json({message: 'The file record does not exist or has been deleted'}, {status: 404}); 24 | } 25 | 26 | await deleteRecord(code); 27 | await deleteFile(code); 28 | 29 | return NextResponse.json({message: 'The file record has been deleted'}); 30 | } 31 | -------------------------------------------------------------------------------- /app/api/backup/download/[code]/route.ts: -------------------------------------------------------------------------------- 1 | import {NextRequest, NextResponse} from 'next/server'; 2 | import {validate} from 'uuid'; 3 | 4 | import {getFile, getRecord, updateRecordTimeout} from '@/actions/backup'; 5 | import {FILE_TYPE_JSON} from '@/utilities'; 6 | 7 | export async function GET( 8 | _request: NextRequest, 9 | { 10 | params, 11 | }: { 12 | params: Promise<{ 13 | code: string; 14 | }>; 15 | } 16 | ) { 17 | const {code} = await params; 18 | if (!validate(code)) { 19 | return NextResponse.json({message: 'Invalid code'}, {status: 400}); 20 | } 21 | 22 | const {status} = await getRecord(code); 23 | if (status === 404) { 24 | return NextResponse.json({message: 'The file record does not exist or has been deleted'}, {status: 404}); 25 | } 26 | 27 | await updateRecordTimeout(code, Date.now()); 28 | 29 | const fileContent = await getFile(code); 30 | 31 | const isFileExisted = fileContent !== null; 32 | if (!isFileExisted) { 33 | return NextResponse.json({message: 'The file does not exist or has been deleted'}, {status: 404}); 34 | } 35 | 36 | return new NextResponse(fileContent, { 37 | headers: { 38 | 'Content-Type': FILE_TYPE_JSON, 39 | }, 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /app/components/fontAwesomeIconButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {memo} from 'react'; 4 | 5 | import {FontAwesomeIcon, type FontAwesomeIconProps} from '@fortawesome/react-fontawesome'; 6 | 7 | import {Button, type IButtonProps} from '@/design/ui/components'; 8 | 9 | interface IProps extends Omit, Pick {} 10 | 11 | export default memo(function FontAwesomeIconButton({icon, radius = 'full', size = 'sm', ...props}) { 12 | return ( 13 | 16 | ); 17 | }); 18 | 19 | export type {IProps as IFontAwesomeIconButtonProps}; 20 | -------------------------------------------------------------------------------- /app/components/fontAwesomeIconLink.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {memo} from 'react'; 4 | 5 | import {FontAwesomeIcon, type FontAwesomeIconProps} from '@fortawesome/react-fontawesome'; 6 | 7 | import {type ILinkProps, Link} from '@/design/ui/components'; 8 | 9 | interface IProps extends Omit, Pick {} 10 | 11 | export default memo(function FontAwesomeIconLink({icon, size = '1x', ...props}) { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | }); 18 | 19 | export type {IProps as IFontAwesomeIconLinkProps}; 20 | -------------------------------------------------------------------------------- /app/components/heading.tsx: -------------------------------------------------------------------------------- 1 | import {type PropsWithChildren, memo, useMemo} from 'react'; 2 | 3 | import {cn} from '@/design/ui/components'; 4 | 5 | type THeadingClassName = Pick['className']; 6 | type TSpanClassName = Pick['className']; 7 | 8 | interface IProps { 9 | as?: 'h1' | 'h2' | 'h3'; 10 | className?: THeadingClassName; 11 | classNames?: Partial<{ 12 | title: THeadingClassName; 13 | subTitle: TSpanClassName; 14 | }>; 15 | isFirst?: boolean; 16 | subTitle?: ReactNodeWithoutBoolean; 17 | } 18 | 19 | export default memo>(function Heading({ 20 | as: Component = 'h1', 21 | children, 22 | className, 23 | classNames, 24 | isFirst, 25 | subTitle, 26 | }) { 27 | const headingClassName = useMemo(() => { 28 | switch (Component) { 29 | case 'h1': 30 | return cn('mb-4 text-2xl font-bold', !isFirst && 'mt-8', className, classNames?.title); 31 | case 'h2': 32 | return cn('mb-3 text-xl font-semibold', !isFirst && 'mt-6', className, classNames?.title); 33 | case 'h3': 34 | return cn('mb-3 text-large font-medium', !isFirst && 'mt-4', className, classNames?.title); 35 | } 36 | }, [Component, className, classNames?.title, isFirst]); 37 | 38 | const subTitleClassName = useMemo(() => { 39 | switch (Component) { 40 | case 'h1': 41 | return cn('-mt-4 mb-4 block text-foreground-500', classNames?.subTitle); 42 | case 'h2': 43 | case 'h3': 44 | return cn('-mt-3 mb-3 block text-small text-foreground-500', classNames?.subTitle); 45 | } 46 | }, [Component, classNames?.subTitle]); 47 | 48 | return ( 49 | <> 50 | {children} 51 | {subTitle !== undefined && {subTitle}} 52 | 53 | ); 54 | }); 55 | -------------------------------------------------------------------------------- /app/components/itemCard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {memo} from 'react'; 4 | 5 | import {Card, type ICardProps, cn} from '@/design/ui/components'; 6 | 7 | import {globalStore as store} from '@/stores'; 8 | 9 | interface IProps extends Omit { 10 | name: ReactNodeWithoutBoolean; 11 | description?: ReactNodeWithoutBoolean; 12 | image: ReactNodeWithoutBoolean; 13 | } 14 | 15 | export default memo(function ItemCard({description, image, name, ...props}) { 16 | const isHighAppearance = store.persistence.highAppearance.use(); 17 | 18 | return ( 19 | 30 |
31 |
{image}
32 |
33 |

{name}

34 | {description !== undefined &&

{description}

} 35 |
36 |
37 |
38 | ); 39 | }); 40 | -------------------------------------------------------------------------------- /app/components/itemPage.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {type PropsWithChildren, memo} from 'react'; 4 | 5 | import {useMounted, useSkipProcessItemData} from '@/hooks'; 6 | 7 | import {cn} from '@/design/ui/components'; 8 | 9 | import Loading from '@/loading'; 10 | import Placeholder from '@/components/placeholder'; 11 | 12 | interface IProps { 13 | isEmpty: boolean; 14 | sideButton: ReactNodeWithoutBoolean; 15 | } 16 | 17 | export default memo>(function ItemPage({children, isEmpty, sideButton}) { 18 | const isMounted = useMounted(); 19 | const shouldSkipProcessData = useSkipProcessItemData(); 20 | 21 | if (!isMounted) { 22 | return ; 23 | } 24 | 25 | return ( 26 |
34 | {!shouldSkipProcessData && sideButton} 35 | {isEmpty ? 数据为空 : children} 36 |
37 | ); 38 | }); 39 | -------------------------------------------------------------------------------- /app/components/ol.tsx: -------------------------------------------------------------------------------- 1 | import {type PropsWithChildren, memo} from 'react'; 2 | 3 | import {cn} from '@/design/ui/components'; 4 | 5 | interface ILiProps extends Pick {} 6 | 7 | const Li = memo>(function Li({children, className}) { 8 | return ( 9 |
  • 10 | {children} 11 |
  • 12 | ); 13 | }); 14 | 15 | interface IOlProps extends Pick {} 16 | 17 | const OlComponent = memo>(function Ol({children, className}) { 18 | return
      {children}
    ; 19 | }); 20 | 21 | const Ol = OlComponent as typeof OlComponent & { 22 | Li: typeof Li; 23 | }; 24 | 25 | Ol.Li = Li; 26 | 27 | export default Ol; 28 | -------------------------------------------------------------------------------- /app/components/placeholder.tsx: -------------------------------------------------------------------------------- 1 | import {type PropsWithChildren, memo} from 'react'; 2 | 3 | import {cn} from '@/design/ui/components'; 4 | 5 | interface IProps extends Pick {} 6 | 7 | export default memo>(function Placeholder({children, className}) { 8 | return ( 9 |
    15 | {children} 16 |
    17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /app/components/pressElement.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {type ElementType, type HTMLAttributes, memo, useCallback} from 'react'; 4 | 5 | import {checkA11yConfirmKey} from '@/utilities'; 6 | 7 | type HTMLElementClickEventHandler = HTMLAttributes['onClick']; 8 | type HTMLElementKeyPressEventHandler = HTMLAttributes['onKeyDown']; 9 | 10 | export type HTMLElementClickEvent = Parameters>>[0]; 11 | export type HTMLElementKeyDownEvent = Parameters< 12 | NonNullable> 13 | >[0]; 14 | 15 | type HTMLElementPressEventHandler = HTMLElementClickEventHandler & 16 | HTMLElementKeyPressEventHandler; 17 | type HTMLElementPressEvent = HTMLElementClickEvent & HTMLElementKeyDownEvent; 18 | 19 | export interface IPressProp { 20 | onPress: HTMLElementPressEventHandler; 21 | } 22 | 23 | interface IProps extends HTMLAttributes, IPressProp { 24 | as: ElementType; 25 | } 26 | 27 | export default memo(function PressElement({ 28 | as: Component = 'span', 29 | onClick, 30 | onKeyDown, 31 | onPress, 32 | ...props 33 | }: IProps) { 34 | const handleClick = useCallback( 35 | (event: HTMLElementPressEvent) => { 36 | onClick?.(event); 37 | onPress?.(event); 38 | }, 39 | [onClick, onPress] 40 | ); 41 | 42 | const handleKeyDown = useCallback( 43 | (event: HTMLElementPressEvent) => { 44 | if (onKeyDown !== undefined) { 45 | checkA11yConfirmKey(onKeyDown)(event); 46 | } 47 | if (onPress !== undefined) { 48 | checkA11yConfirmKey(onPress)(event); 49 | } 50 | }, 51 | [onKeyDown, onPress] 52 | ); 53 | 54 | return ; 55 | }); 56 | -------------------------------------------------------------------------------- /app/components/price.tsx: -------------------------------------------------------------------------------- 1 | import {Fragment, type PropsWithChildren, memo} from 'react'; 2 | 3 | interface IProps { 4 | showSymbol?: boolean; 5 | } 6 | 7 | export default memo>(function Price({children, showSymbol = true}) { 8 | const Component = showSymbol ? 'span' : Fragment; 9 | 10 | return ( 11 | 12 | {showSymbol && ¥} 13 | {children} 14 | 15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /app/components/qrCode.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {type PropsWithChildren, memo} from 'react'; 4 | 5 | import {useQRCode} from 'next-qrcode'; 6 | 7 | import {cn} from '@/design/ui/components'; 8 | 9 | import {type IQRCode} from 'next-qrcode/dist/useQRCode'; 10 | 11 | interface IProps extends Omit, Pick {} 12 | 13 | export default memo>(function QRCode({children, className, options, text}) { 14 | const {SVG} = useQRCode(); 15 | 16 | return ( 17 |
    18 |
    19 | 33 |
    34 | {children !== undefined &&

    {children}

    } 35 |
    36 | ); 37 | }); 38 | -------------------------------------------------------------------------------- /app/components/sideButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import {type PropsWithChildren, memo} from 'react'; 2 | 3 | import {cn} from '@/design/ui/components'; 4 | 5 | interface IProps extends Pick {} 6 | 7 | export default memo>(function SideButtonGroup({children, className}) { 8 | return ( 9 |
    10 |
    11 |
    {children}
    12 |
    13 |
    14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /app/components/tachie.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {memo} from 'react'; 4 | 5 | import {Image, type ImageProps} from '@heroui/image'; 6 | 7 | import {cn, useReducedMotion} from '@/design/ui/components'; 8 | 9 | interface IProps extends Pick {} 10 | 11 | export default memo(function Tachie({alt, className, src, width, ...props}) { 12 | const isReducedMotion = useReducedMotion(); 13 | 14 | return ( 15 | {alt} 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /app/components/timeAgo.tsx: -------------------------------------------------------------------------------- 1 | import {memo, useEffect, useState} from 'react'; 2 | 3 | function formatTimeAgo(pastTimestamp: number, nowTimestamp = Date.now()) { 4 | const diff = nowTimestamp - pastTimestamp; 5 | 6 | const minutes = Math.floor(diff / (1000 * 60)); 7 | const hours = Math.floor(diff / (1000 * 60 * 60)); 8 | const days = Math.floor(diff / (1000 * 60 * 60 * 24)); 9 | 10 | if (days > 0) { 11 | return `${days}天前`; 12 | } else if (hours > 0) { 13 | return `${hours}小时前`; 14 | } else if (minutes > 0) { 15 | return `${minutes}分钟前`; 16 | } 17 | 18 | return '刚刚'; 19 | } 20 | 21 | interface IProps extends HTMLSpanElementAttributes, RefProps { 22 | timestamp: number; 23 | } 24 | 25 | export default memo(function TimeAgo({timestamp, ...props}) { 26 | const [timeAgo, setTimeAgo] = useState(''); 27 | 28 | useEffect(() => { 29 | const update = () => { 30 | setTimeAgo(formatTimeAgo(timestamp)); 31 | }; 32 | 33 | update(); 34 | 35 | const interval = setInterval(update, 60 * 1000); 36 | 37 | return () => { 38 | clearInterval(interval); 39 | }; 40 | }, [timestamp]); 41 | 42 | return {timeAgo}; 43 | }); 44 | -------------------------------------------------------------------------------- /app/configs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './site'; 2 | -------------------------------------------------------------------------------- /app/configs/site/types.d.ts: -------------------------------------------------------------------------------- 1 | import type {TSpriteTarget} from '@/utils/sprite/types'; 2 | 3 | export interface ILink { 4 | label: string; 5 | href: T; 6 | } 7 | 8 | export type TNavItem = 9 | | ILink 10 | | Record< 11 | string, 12 | Array< 13 | Prettify< 14 | ILink & { 15 | sprite: TSpriteTarget | null; 16 | spriteIndex: number | null; 17 | } 18 | > 19 | > 20 | >; 21 | 22 | export interface ISiteConfig { 23 | domain: string; 24 | id: string; 25 | name: string; 26 | enName: string; 27 | shortName: string; 28 | author: { 29 | name: string; 30 | url: string; 31 | }; 32 | description: string; 33 | keywords: string[]; 34 | /** @see {@link https://www.heroui.com/docs/api-references/heroui-provider} */ 35 | locale: string; 36 | version: string; 37 | navItems: TNavItem[]; 38 | navMenuItems: ILink[]; 39 | links: Record; 40 | cdnUrl: string; 41 | analyticsApiUrl: string; 42 | analyticsScriptUrl: string; 43 | analyticsSiteId: string; 44 | isAnalytics: boolean; 45 | isIcpFiling: boolean; 46 | nodeEnv: NodeJS.ProcessEnv['NODE_ENV']; 47 | vercelEnv: NodeJS.ProcessEnv['NODE_ENV'] | undefined; 48 | vercelSha: string | undefined; 49 | isOffline: boolean; 50 | isProduction: boolean; 51 | isSelfHosted: boolean; 52 | isVercel: boolean; 53 | } 54 | 55 | export type TSiteConfig = typeof import('./index').siteConfig; 56 | 57 | type ExtractNestedHref = T extends {href: infer U} ? U : {[K in keyof T]: ExtractNestedHref}[keyof T]; 58 | export type TSitePath = ExtractStringTypes>; 59 | -------------------------------------------------------------------------------- /app/data/beverages/index.ts: -------------------------------------------------------------------------------- 1 | import type {ITagStyle} from '@/data/types'; 2 | import type {ISpriteConfig} from '@/utils/sprite/types'; 3 | 4 | export const BEVERAGE_SPRITE_CONFIG = { 5 | col: 10, 6 | row: 5, 7 | 8 | height: 520, 9 | width: 1040, 10 | } as const satisfies ISpriteConfig; 11 | 12 | export const BEVERAGE_TAG_STYLE = { 13 | positive: { 14 | backgroundColor: '#b0cfd7', 15 | borderColor: '#6f929b', 16 | color: '#a45c22', 17 | }, 18 | } as const satisfies ITagStyle; 19 | 20 | export * from './data'; 21 | -------------------------------------------------------------------------------- /app/data/beverages/types.d.ts: -------------------------------------------------------------------------------- 1 | import type {IFoodBase} from '@/data/types'; 2 | 3 | type TTag = 4 | | '无酒精' 5 | | '低酒精' 6 | | '中酒精' 7 | | '高酒精' 8 | | '可加冰' 9 | | '可加热' 10 | | '烧酒' 11 | | '清酒' 12 | | '鸡尾酒' 13 | | '西洋酒' 14 | | '利口酒' 15 | | '啤酒' 16 | | '直饮' 17 | | '水果' 18 | | '甘' 19 | | '辛' 20 | | '苦' 21 | | '气泡' 22 | | '古典' 23 | | '现代' 24 | | '提神'; 25 | 26 | type TFromBase = IFoodBase['from']; 27 | 28 | interface IFrom extends Omit { 29 | /** @description Initial beverages. */ 30 | self: boolean; 31 | } 32 | 33 | export interface IBeverage extends IFoodBase { 34 | tags: TTag[]; 35 | from: Partial; 36 | } 37 | 38 | export type TBeverages = typeof import('./data').BEVERAGE_LIST; 39 | 40 | export type TBeverageName = TBeverages[number]['name']; 41 | -------------------------------------------------------------------------------- /app/data/clothes/index.ts: -------------------------------------------------------------------------------- 1 | import type {ISpriteConfig} from '@/utils/sprite/types'; 2 | 3 | export const CLOTHES_SPRITE_CONFIG = { 4 | col: 10, 5 | row: 3, 6 | 7 | height: 312, 8 | width: 1040, 9 | } as const satisfies ISpriteConfig; 10 | 11 | export * from './data'; 12 | -------------------------------------------------------------------------------- /app/data/clothes/types.d.ts: -------------------------------------------------------------------------------- 1 | import {type TCurrencyName, type TCustomerRareName} from '@/data'; 2 | import type {IItemBase, TMerchant} from '@/data/types'; 3 | 4 | export interface IClothes extends IItemBase { 5 | /** @description Whether the tachie image of the clothes is a gif. */ 6 | gif: boolean; 7 | /** @description Whether the clothes will change the izakaya skin. */ 8 | izakaya: boolean; 9 | from: Array< 10 | | string 11 | | Partial<{ 12 | bond: TCustomerRareName; 13 | buy: { 14 | name: TMerchant; 15 | price: { 16 | currency: TCurrencyName; 17 | amount: number; 18 | }; 19 | }; 20 | /** @description Initial clothes. */ 21 | self: true; 22 | }> 23 | >; 24 | } 25 | 26 | export type TClothes = typeof import('./data').CLOTHES_LIST; 27 | 28 | export type TClothesName = TClothes[number]['name']; 29 | -------------------------------------------------------------------------------- /app/data/cookers/index.ts: -------------------------------------------------------------------------------- 1 | import type {ISpriteConfig} from '@/utils/sprite/types'; 2 | 3 | export const COOKER_SPRITE_CONFIG = { 4 | col: 10, 5 | row: 5, 6 | 7 | height: 520, 8 | width: 1040, 9 | } as const satisfies ISpriteConfig; 10 | 11 | export * from './data'; 12 | -------------------------------------------------------------------------------- /app/data/cookers/types.d.ts: -------------------------------------------------------------------------------- 1 | import {type TCurrencyName, type TCustomerRareName} from '@/data'; 2 | import type {IItemBase, TDescription, TMerchant} from '@/data/types'; 3 | 4 | type TCategory = 'DLC' | '初始' | '夜雀' | '超' | '极' | '核能' | '可疑' | '月见'; 5 | 6 | type TType = '煮锅' | '烧烤架' | '油锅' | '蒸锅' | '料理台'; 7 | 8 | export interface ICooker extends IItemBase { 9 | type: TType | TType[]; 10 | category: TCategory; 11 | /** @description If it is an array, the first element represents the effect, and the second element represents whether it is a mystia only effect. */ 12 | effect: TDescription | [TDescription, boolean] | null; 13 | from: Array< 14 | | string 15 | | Partial<{ 16 | bond: TCustomerRareName; 17 | buy: { 18 | name: TMerchant; 19 | price: Array< 20 | | number 21 | | { 22 | currency: TCurrencyName; 23 | amount: number; 24 | } 25 | >; 26 | }; 27 | /** @description Initial cookers. */ 28 | self: true; 29 | }> 30 | >; 31 | } 32 | 33 | export type TCookers = typeof import('./data').COOKER_LIST; 34 | 35 | export type TCookerName = TCookers[number]['name']; 36 | export type TCookerCategory = TCookers[number]['category']; 37 | export type TCookerType = FlatArray; 38 | -------------------------------------------------------------------------------- /app/data/currencies/data.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | import type {ICurrency} from './types'; 3 | 4 | export const CURRENCY_LIST = [ 5 | { 6 | id: 3, 7 | name: '奇怪的石头', 8 | description: '兽道散落的奇形怪状的石头,还有点儿重。', 9 | dlc: 0, 10 | from: [ 11 | { 12 | task: '妖怪兽道', 13 | }, 14 | ], 15 | }, 16 | { 17 | id: 4, 18 | name: '古朴的铜钱', 19 | description: '人间之里散落的有些年代感的铜钱,似乎已经不再流通。', 20 | dlc: 0, 21 | from: [ 22 | { 23 | task: '人间之里', 24 | }, 25 | ], 26 | }, 27 | { 28 | id: 5, 29 | name: '破损的符咒', 30 | description: '博丽神社散落的破损的符咒,拼拼凑凑似乎也能得到点儿信息。', 31 | dlc: 0, 32 | from: [ 33 | { 34 | task: '博丽神社', 35 | }, 36 | ], 37 | }, 38 | { 39 | id: 6, 40 | name: '红色的宝石', 41 | description: '红魔馆散落的红色的宝石,在幻想乡宝石和石头也没什么区别。', 42 | dlc: 0, 43 | from: [ 44 | { 45 | task: '红魔馆', 46 | }, 47 | ], 48 | }, 49 | { 50 | id: 7, 51 | name: '发光的竹子', 52 | description: '迷途竹林偶尔看到的发光的竹子,不知道里面有什么呢。', 53 | dlc: 0, 54 | from: [ 55 | { 56 | task: '迷途竹林', 57 | }, 58 | ], 59 | }, 60 | { 61 | id: 29, 62 | name: '银色的青蛙硬币', 63 | description: '从守矢小神社中摇出的银色青蛙硬币。持有一百枚时将自动获得衣服【水手服】和物品【金色的青蛙硬币】。', 64 | dlc: 0, 65 | from: [ 66 | '地区【博丽神社】西侧守矢分社处祈愿', 67 | { 68 | buy: { 69 | name: '【魔界】蓬松松爱莲♡魔法店', 70 | price: { 71 | currency: '蓬松松糖果', 72 | amount: 4, 73 | }, 74 | }, 75 | }, 76 | ], 77 | }, 78 | { 79 | id: 5011, 80 | name: '蓬松松糖果', 81 | description: 82 | '“蓬松松爱莲♡魔法店”发行的糖果型货币,是爱莲分享给大家的甜蜜。可以在“蓬松松爱莲♡魔法店”里换购商品。', 83 | dlc: 0, 84 | from: [ 85 | '【爱莲】奖励符卡', 86 | { 87 | buy: { 88 | name: '【魔界】蓬松松爱莲♡魔法店', 89 | price: { 90 | currency: '银色的青蛙硬币', 91 | amount: 4, 92 | }, 93 | }, 94 | }, 95 | ], 96 | }, 97 | ] as const satisfies ICurrency[]; 98 | -------------------------------------------------------------------------------- /app/data/currencies/index.ts: -------------------------------------------------------------------------------- 1 | import type {ISpriteConfig} from '@/utils/sprite/types'; 2 | 3 | export const CURRENCY_SPRITE_CONFIG = { 4 | col: 7, 5 | row: 1, 6 | 7 | height: 104, 8 | width: 728, 9 | } as const satisfies ISpriteConfig; 10 | 11 | export * from './data'; 12 | -------------------------------------------------------------------------------- /app/data/currencies/types.d.ts: -------------------------------------------------------------------------------- 1 | import {type TPlace} from '@/data'; 2 | import type {IItemBase, TMerchant} from '@/data/types'; 3 | 4 | export interface ICurrency extends IItemBase { 5 | from: Array< 6 | | string 7 | | Partial<{ 8 | buy: { 9 | name: TMerchant; 10 | price: { 11 | currency: string; // TCurrencyName 12 | amount: number; 13 | }; 14 | }; 15 | task: TPlace; 16 | }> 17 | >; 18 | } 19 | 20 | export type TCurrencies = typeof import('./data').CURRENCY_LIST; 21 | 22 | export type TCurrencyName = TCurrencies[number]['name']; 23 | -------------------------------------------------------------------------------- /app/data/customer_normal/index.ts: -------------------------------------------------------------------------------- 1 | import {BEVERAGE_TAG_STYLE} from '@/data/beverages'; 2 | import {RECIPE_TAG_STYLE} from '@/data/recipes'; 3 | import type {ITagStyle} from '@/data/types'; 4 | import type {ISpriteConfig} from '@/utils/sprite/types'; 5 | 6 | export const CUSTOMER_NORMAL_SPRITE_CONFIG = { 7 | col: 10, 8 | row: 5, 9 | 10 | height: 885, 11 | width: 1330, 12 | } as const satisfies ISpriteConfig; 13 | 14 | export const CUSTOMER_NORMAL_TAG_STYLE = { 15 | beverage: BEVERAGE_TAG_STYLE.positive, 16 | positive: RECIPE_TAG_STYLE.positive, 17 | } as const satisfies Omit; 18 | 19 | export * from './data'; 20 | -------------------------------------------------------------------------------- /app/data/customer_normal/types.d.ts: -------------------------------------------------------------------------------- 1 | import type {ICustomerBase} from '@/data/types'; 2 | 3 | export interface ICustomerNormal extends ICustomerBase {} 4 | 5 | export type TCustomerNormals = typeof import('./data').CUSTOMER_NORMAL_LIST; 6 | 7 | export type TCustomerNormalName = TCustomerNormals[number]['name']; 8 | -------------------------------------------------------------------------------- /app/data/customer_rare/types.d.ts: -------------------------------------------------------------------------------- 1 | import {type TEvaluationKey, type TRecipeTag} from '@/data'; 2 | import type {ICustomerBase, TDescription} from '@/data/types'; 3 | 4 | interface ISpellCard { 5 | name: string; 6 | description: TDescription; 7 | /** @todo {type: string} */ 8 | } 9 | 10 | interface ISpellCards { 11 | negative: ISpellCard[]; 12 | positive: ISpellCard[]; 13 | } 14 | 15 | export interface ICustomerRare extends ICustomerBase { 16 | negativeTags: TRecipeTag[]; 17 | collection: boolean; 18 | evaluation: Record; 19 | spellCards: Partial; 20 | positiveTagMapping: Partial>; 21 | price: `${number}-${number}`; 22 | } 23 | 24 | export type TCustomerRares = typeof import('./data').CUSTOMER_RARE_LIST; 25 | 26 | export type TCustomerRareName = TCustomerRares[number]['name']; 27 | -------------------------------------------------------------------------------- /app/data/ingredients/index.ts: -------------------------------------------------------------------------------- 1 | import type {ITagStyle} from '@/data/types'; 2 | import type {ISpriteConfig} from '@/utils/sprite/types'; 3 | 4 | export const INGREDIENT_SPRITE_CONFIG = { 5 | col: 10, 6 | row: 7, 7 | 8 | height: 728, 9 | width: 1040, 10 | } as const satisfies ISpriteConfig; 11 | 12 | export const INGREDIENT_TAG_STYLE = { 13 | positive: { 14 | backgroundColor: '#efe0a6', 15 | borderColor: '#a1904e', 16 | color: '#90611b', 17 | }, 18 | } as const satisfies ITagStyle; 19 | 20 | export * from './data'; 21 | -------------------------------------------------------------------------------- /app/data/ingredients/types.d.ts: -------------------------------------------------------------------------------- 1 | import {type DYNAMIC_TAG_MAP} from '@/data/constant'; 2 | import type {IFoodBase} from '@/data/types'; 3 | 4 | type TTag = 5 | | '肉' 6 | | '水产' 7 | | '素' 8 | | '家常' 9 | | '高级' 10 | | '传说' 11 | | '重油' 12 | | '清淡' 13 | | '下酒' 14 | | '饱腹' 15 | | '山珍' 16 | | '海味' 17 | | '西式' 18 | | '咸' 19 | | '鲜' 20 | | '甜' 21 | | '生' 22 | | (typeof DYNAMIC_TAG_MAP)['signature'] 23 | | '适合拍照' 24 | | '凉爽' 25 | | '猎奇' 26 | | '文化底蕴' 27 | | '菌类' 28 | | '不可思议' 29 | | '小巧' 30 | | '梦幻' 31 | | '特产' 32 | | '果味' 33 | | '辣' 34 | | '酸' 35 | | '毒' 36 | | '天罚'; 37 | 38 | type TType = '肉类' | '海鲜' | '蔬菜' | '其他'; 39 | 40 | export interface IIngredient extends IFoodBase { 41 | type: TType; 42 | tags: TTag[]; 43 | } 44 | 45 | export type TIngredients = typeof import('./data').INGREDIENT_LIST; 46 | 47 | export type TIngredientName = TIngredients[number]['name']; 48 | export type TIngredientType = TIngredients[number]['type']; 49 | -------------------------------------------------------------------------------- /app/data/ornaments/index.ts: -------------------------------------------------------------------------------- 1 | import type {ISpriteConfig} from '@/utils/sprite/types'; 2 | 3 | export const ORNAMENT_SPRITE_CONFIG = { 4 | col: 10, 5 | row: 2, 6 | 7 | height: 208, 8 | width: 1040, 9 | } as const satisfies ISpriteConfig; 10 | 11 | export * from './data'; 12 | -------------------------------------------------------------------------------- /app/data/ornaments/types.d.ts: -------------------------------------------------------------------------------- 1 | import {type TCustomerRareName} from '@/data'; 2 | import type {IItemBase, TDescription} from '@/data/types'; 3 | 4 | export interface IOrnament extends IItemBase { 5 | effect: TDescription; 6 | from: 7 | | TDescription 8 | | { 9 | bond: TCustomerRareName; 10 | level: number; 11 | }; 12 | } 13 | 14 | export type TOrnaments = typeof import('./data').ORNAMENT_LIST; 15 | 16 | export type TOrnamentName = TOrnaments[number]['name']; 17 | -------------------------------------------------------------------------------- /app/data/partners/index.ts: -------------------------------------------------------------------------------- 1 | import type {ISpriteConfig} from '@/utils/sprite/types'; 2 | 3 | export const PARTNER_SPRITE_CONFIG = { 4 | col: 10, 5 | row: 2, 6 | 7 | height: 368, 8 | width: 1840, 9 | } as const satisfies ISpriteConfig; 10 | 11 | export * from './data'; 12 | -------------------------------------------------------------------------------- /app/data/partners/types.d.ts: -------------------------------------------------------------------------------- 1 | import {type TCustomerRareName, type TPlace, type TSpeed} from '@/data'; 2 | import type {IItemBase, TDescription} from '@/data/types'; 3 | 4 | export interface IPartner extends IItemBase { 5 | belong: TCustomerRareName[] | null; 6 | effect: TDescription | null; 7 | from: 8 | | TDescription 9 | | Partial<{ 10 | /** @description Partners by maximize all rare customers bond level in the place. */ 11 | place: TPlace; 12 | /** @description Initial partners. */ 13 | self: true; 14 | /** @description Partners by complete the main quests in the place. */ 15 | task: TPlace; 16 | }>; 17 | pay: number; 18 | speed: { 19 | moving: TSpeed; 20 | working: Exclude; 21 | }; 22 | } 23 | 24 | export type TPartners = typeof import('./data').PARTNER_LIST; 25 | 26 | export type TPartnerName = TPartners[number]['name']; 27 | -------------------------------------------------------------------------------- /app/data/recipes/index.ts: -------------------------------------------------------------------------------- 1 | import type {ITagStyle} from '@/data/types'; 2 | import type {ISpriteConfig} from '@/utils/sprite/types'; 3 | 4 | export const RECIPE_SPRITE_CONFIG = { 5 | col: 10, 6 | row: 17, 7 | 8 | height: 1768, 9 | width: 1040, 10 | } as const satisfies ISpriteConfig; 11 | 12 | export const RECIPE_TAG_STYLE = { 13 | negative: { 14 | backgroundColor: '#5d453a', 15 | borderColor: '#000000', 16 | color: '#e6b4a6', // The contrast of the tag color #e40d0d in the game is too low. 17 | }, 18 | positive: { 19 | backgroundColor: '#e6b4a6', 20 | borderColor: '#9d5437', 21 | color: '#830000', 22 | }, 23 | } as const satisfies ITagStyle; 24 | 25 | export * from './data'; 26 | -------------------------------------------------------------------------------- /app/data/recipes/types.d.ts: -------------------------------------------------------------------------------- 1 | import {type TCookerName, type TCurrencyName, type TCustomerRareName, type TIngredientName, type TPlace} from '@/data'; 2 | import {type DARK_MATTER_META_MAP, type DYNAMIC_TAG_MAP} from '@/data/constant'; 3 | import type {IFoodBase, TMerchant} from '@/data/types'; 4 | 5 | type TTag = 6 | | (typeof DARK_MATTER_META_MAP)['positiveTag'] 7 | | (typeof DYNAMIC_TAG_MAP)['largePartition'] 8 | | '肉' 9 | | '水产' 10 | | '素' 11 | | '家常' 12 | | '高级' 13 | | '传说' 14 | | '重油' 15 | | '清淡' 16 | | '下酒' 17 | | '饱腹' 18 | | '山珍' 19 | | '海味' 20 | | '和风' 21 | | '西式' 22 | | '中华' 23 | | '咸' 24 | | '鲜' 25 | | '甜' 26 | | '生' 27 | | (typeof DYNAMIC_TAG_MAP)['signature'] 28 | | '适合拍照' 29 | | '凉爽' 30 | | '灼热' 31 | | '力量涌现' 32 | | '猎奇' 33 | | '文化底蕴' 34 | | '菌类' 35 | | '不可思议' 36 | | '小巧' 37 | | '梦幻' 38 | | '特产' 39 | | '果味' 40 | | '汤羹' 41 | | '烧烤' 42 | | '辣' 43 | | '燃起来了' 44 | | '酸' 45 | | '毒'; 46 | 47 | export interface IRecipe extends IFoodBase { 48 | /** @description If the value is `-1`, it means there is no corresponding recipe. */ 49 | recipeId: number; 50 | ingredients: TIngredientName[]; 51 | positiveTags: TTag[]; 52 | negativeTags: TTag[]; 53 | cooker: TCookerName; 54 | max: number; 55 | min: number; 56 | from: 57 | | string 58 | | Partial<{ 59 | bond: { 60 | name: TCustomerRareName; 61 | level: number; 62 | }; 63 | buy: { 64 | name: TMerchant; 65 | price: { 66 | currency: TCurrencyName; 67 | amount: number; 68 | }; 69 | }; 70 | /** @description Recipes by levelup. */ 71 | levelup: [number, TPlace | null]; 72 | /** @description Initial recipes. */ 73 | self: true; 74 | }>; 75 | } 76 | 77 | export type TRecipes = typeof import('./data').RECIPE_LIST; 78 | 79 | export type TRecipeName = TRecipes[number]['name']; 80 | -------------------------------------------------------------------------------- /app/design/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-theme'; 2 | -------------------------------------------------------------------------------- /app/design/hooks/use-theme/constants.ts: -------------------------------------------------------------------------------- 1 | import {defaultBackgrounds} from '@/design/theme/colors/constants/backgroundColors'; 2 | 3 | export const COLOR_MAP = { 4 | DARK: defaultBackgrounds.dark, 5 | LIGHT: defaultBackgrounds.light, 6 | LIGHT_THEME: defaultBackgrounds.lightTheme, 7 | } as const; 8 | 9 | export const MEDIA = '(prefers-color-scheme: dark)'; 10 | 11 | export const THEME_MAP = { 12 | DARK: 'dark', 13 | LIGHT: 'light', 14 | SYSTEM: 'system', 15 | } as const; 16 | 17 | export const STORAGE_KEY = 'theme'; 18 | -------------------------------------------------------------------------------- /app/design/hooks/use-theme/index.ts: -------------------------------------------------------------------------------- 1 | export {COLOR_MAP, THEME_MAP} from './constants'; 2 | export {default as ThemeScript} from './themeScript'; 3 | export * from './useTheme'; 4 | 5 | export type * from './types'; 6 | -------------------------------------------------------------------------------- /app/design/hooks/use-theme/styles.scss: -------------------------------------------------------------------------------- 1 | body { 2 | @apply transition-colors motion-reduce:transition-none; 3 | } 4 | -------------------------------------------------------------------------------- /app/design/hooks/use-theme/themeScript.tsx: -------------------------------------------------------------------------------- 1 | import {COLOR_MAP, MEDIA, STORAGE_KEY, THEME_MAP} from './constants'; 2 | import {TTheme} from './types'; 3 | 4 | const script = ( 5 | media: typeof MEDIA, 6 | storageKey: typeof STORAGE_KEY, 7 | themeMap: typeof THEME_MAP, 8 | colors: typeof COLOR_MAP 9 | ) => { 10 | try { 11 | const systemTheme = globalThis.matchMedia(media).matches ? themeMap.DARK : themeMap.LIGHT; 12 | 13 | let storedTheme; 14 | try { 15 | storedTheme = (localStorage.getItem(storageKey) as TTheme | null) ?? systemTheme; 16 | } catch { 17 | storedTheme = systemTheme; 18 | } 19 | 20 | const currentTheme = storedTheme === themeMap.SYSTEM ? systemTheme : storedTheme; 21 | const isDarkTheme = currentTheme === themeMap.DARK; 22 | 23 | document.documentElement.classList.remove(...Object.values(themeMap)); 24 | document.documentElement.classList.add(currentTheme); 25 | document.documentElement.style.colorScheme = currentTheme; 26 | 27 | const metaElement = document.createElement('meta'); 28 | metaElement.content = isDarkTheme ? colors.DARK : colors.LIGHT_THEME; 29 | metaElement.name = 'theme-color'; 30 | 31 | document.head.append(metaElement); 32 | } catch (error) { 33 | console.error('[design/hooks/use-theme]:', error); 34 | } 35 | }; 36 | 37 | export default function ThemeScript() { 38 | const scriptArgs = JSON.stringify([MEDIA, STORAGE_KEY, THEME_MAP, COLOR_MAP]).slice(1, -1); 39 | 40 | return ( 41 |