diff --git a/apps/frontend/web/app/components/Resources/index.ts b/apps/frontend/web/app/components/Resources/index.ts
new file mode 100644
index 00000000..9da37fdc
--- /dev/null
+++ b/apps/frontend/web/app/components/Resources/index.ts
@@ -0,0 +1,3 @@
+import Resources from './table';
+
+export default Resources;
diff --git a/apps/frontend/web/app/components/Resources/table.tsx b/apps/frontend/web/app/components/Resources/table.tsx
new file mode 100644
index 00000000..235b3e5d
--- /dev/null
+++ b/apps/frontend/web/app/components/Resources/table.tsx
@@ -0,0 +1,5 @@
+export interface ResourcesTableProps { }
+
+export default function ResourcesTable(props: ResourcesTableProps) {
+ return
+}
\ No newline at end of file
diff --git a/apps/frontend/web/app/components/Search/Search.tsx b/apps/frontend/web/app/components/Search/Search.tsx
new file mode 100644
index 00000000..f75edc38
--- /dev/null
+++ b/apps/frontend/web/app/components/Search/Search.tsx
@@ -0,0 +1,3 @@
+export default function Search() {
+ return
+}
\ No newline at end of file
diff --git a/apps/frontend/web/app/components/Search/index.ts b/apps/frontend/web/app/components/Search/index.ts
new file mode 100644
index 00000000..517d0ee8
--- /dev/null
+++ b/apps/frontend/web/app/components/Search/index.ts
@@ -0,0 +1,3 @@
+import Search from './Search';
+
+export default Search;
diff --git a/apps/frontend/web/app/components/Search/utils.ts b/apps/frontend/web/app/components/Search/utils.ts
new file mode 100644
index 00000000..3f7550ec
--- /dev/null
+++ b/apps/frontend/web/app/components/Search/utils.ts
@@ -0,0 +1,231 @@
+import { findFansub, parseSearchURL, ResourceType, stringifySearchURL } from 'animegarden';
+
+import { DisplayType } from '~/constant';
+
+export const DMHY_RE = /(?:https:\/\/share.dmhy.org\/topics\/view\/)?(\d+_[a-zA-Z0-9_\-]+\.html)/;
+
+export function parseSearch(input: string) {
+ function splitWords(search: string) {
+ const matchQuotes = {
+ '"': ['"'],
+ "'": ["'"],
+ '“': ['”'],
+ '”': ['“']
+ };
+
+ const words: string[] = [];
+ let i = 0;
+ while (i < search.length) {
+ // Skip whitespaces
+ while (i < search.length && /\s/.test(search[i])) i++;
+ if (i >= search.length) break;
+
+ let j = i;
+ let word = '';
+ while (j < search.length && !/\s/.test(search[j])) {
+ if (Object.keys(matchQuotes).includes(search[j])) {
+ // Split by quote "..." or '...'
+ const quote = matchQuotes[search[j] as keyof typeof matchQuotes];
+ j++;
+ let k = j;
+ while (k < search.length) {
+ if (quote.includes(search[k])) {
+ break;
+ } else if (search[k] === '\\' && k + 1 < search.length) {
+ word += search[++k];
+ } else {
+ word += search[k];
+ }
+ k++;
+ }
+ // j -> quote
+ j = k;
+ } else if (search[j] === '\\' && j + 1 < search.length) {
+ // \"
+ j++;
+ word += search[j];
+ } else {
+ // otherwise
+ word += search[j];
+ }
+
+ j++;
+ }
+
+ words.push(word);
+ i = j;
+ }
+ return words;
+ }
+
+ const splitted = splitWords(input);
+
+ const search: string[] = [];
+ const include: string[] = [];
+ const keywords: string[] = [];
+ const exclude: string[] = [];
+
+ const fansub: string[] = [];
+ const type: string[] = [];
+ const after: Date[] = [];
+ const before: Date[] = [];
+
+ const handlers: Record void> = {
+ 'title:,标题:,匹配:': (word) => {
+ include.push(word);
+ },
+ '+,include:,包含:': (word) => {
+ keywords.push(word);
+ },
+ '!,!,-,exclude:,排除:': (word) => {
+ exclude.push(word);
+ },
+ 'fansub:,字幕:,字幕组:': (word) => {
+ if (/^\d+$/.test(word)) {
+ fansub.push(word);
+ } else {
+ const found = findFansub('dmhy', word, { fuzzy: true });
+ if (found) {
+ fansub.push(found.providerId);
+ }
+ }
+ },
+ 'after:,开始:,晚于:': (word) => {
+ after.push(new Date(word));
+ },
+ 'before:,结束:,早于:': (word) => {
+ before.push(new Date(word));
+ },
+ '类型:,type:': (word) => {
+ type.push(word);
+ }
+ };
+
+ for (const word of splitted) {
+ let found = false;
+ for (const [keys, handler] of Object.entries(handlers)) {
+ for (const key of keys.split(',')) {
+ if (word.startsWith(key) || word.startsWith(key.replace(':', ':'))) {
+ const text = word.slice(key.length);
+ if (text.length > 0) {
+ handler(text);
+ found = true;
+ break;
+ }
+ }
+ }
+ if (found) break;
+ }
+ if (!found) {
+ search.push(word.replace(/\+/g, '%2b'));
+ }
+ }
+
+ if (include.length > 0 || keywords.length > 0 || exclude.length > 0) {
+ include.push(...search);
+ search.splice(0, search.length);
+ }
+
+ return {
+ search,
+ include,
+ keywords,
+ exclude,
+ fansubId: fansub,
+ after: after.at(-1),
+ before: before.at(-1),
+ type: type.at(-1)
+ };
+}
+
+export function stringifySearch(search: URLSearchParams) {
+ const filter = parseSearchURL(search, { pageSize: 80 });
+ const content: string[] = [];
+
+ if (filter.search) {
+ content.push(...filter.search.map((f) => wrap(f)));
+ } else {
+ if (filter.include && filter.include.length > 0) {
+ content.push(...filter.include.map((f) => '标题:' + wrap(f)));
+ }
+ if (filter.keywords) {
+ content.push(...filter.keywords.map((t) => '包含:' + wrap(t)));
+ }
+ if (filter.exclude) {
+ content.push(...filter.exclude.map((t) => '排除:' + wrap(t)));
+ }
+ }
+ if (filter.fansubId) {
+ content.push(...filter.fansubId.map((f) => '字幕组:' + (findFansub('dmhy', f)?.name ?? f)));
+ }
+ if (filter.fansubName) {
+ content.push(...filter.fansubName.map((f) => '字幕组:' + f));
+ }
+ if (filter.after) {
+ content.push('开始:' + formatDate(filter.after));
+ }
+ if (filter.before) {
+ content.push('结束:' + formatDate(filter.before));
+ }
+ if (filter.type) {
+ const type =
+ filter.type in DisplayType ? DisplayType[filter.type as ResourceType] : filter.type;
+ content.push('类型:' + type);
+ }
+
+ return content.map((c) => c).join(' ');
+
+ function formatDate(d: Date) {
+ const t = d.toISOString();
+ if (t.endsWith('T00:00:00.000Z')) return t.replace('T00:00:00.000Z', '');
+ return t;
+ }
+
+ function wrap(t: string) {
+ if (t.indexOf(' ') !== -1) return `"${dewrap(t).replace(/"/g, '\\"')}"`;
+ else return dewrap(t);
+ }
+
+ function dewrap(t: string) {
+ if (t.at(0) === '"' && t.at(-1) === '"') {
+ return t.slice(1, t.length - 1);
+ } else {
+ return t;
+ }
+ }
+}
+
+export function resolveSearchURL(search: string) {
+ if (search.startsWith(location.origin)) {
+ return search.slice(location.origin.length);
+ } else if (search.startsWith(location.host)) {
+ return search.slice(location.host.length);
+ } else {
+ const match = DMHY_RE.exec(search);
+ if (match) {
+ return `/detail/dmhy/${match[1]}`;
+ } else {
+ const url = stringifySearchURL(location.origin, parseSearch(search));
+ return `${url.pathname}${url.search}`;
+ }
+ }
+}
+
+export function goToSearch(search: string) {
+ return goTo(resolveSearchURL(search));
+}
+
+export function goTo(href: string) {
+ // TODO: navigate
+ // navigate(href, { history: 'push' });
+}
+
+export function debounce void>(fn: T, time = 1000): T {
+ let timestamp: any;
+ return ((...args: any[]) => {
+ clearTimeout(timestamp);
+ timestamp = setTimeout(() => {
+ fn(...args);
+ }, time);
+ }) as T;
+}
diff --git a/apps/frontend/web/app/constant.ts b/apps/frontend/web/app/constant.ts
new file mode 100644
index 00000000..6bf5e56d
--- /dev/null
+++ b/apps/frontend/web/app/constant.ts
@@ -0,0 +1,184 @@
+import type { ResourceType } from 'animegarden';
+
+export const types = [
+ '动画',
+ '季度全集',
+ '音乐',
+ '动漫音乐',
+ '同人音乐',
+ '流行音乐',
+ '日剧',
+ 'RAW',
+ '其他',
+ '漫画',
+ '港台原版',
+ '日文原版',
+ '游戏',
+ '电脑游戏',
+ '主机游戏',
+ '掌机游戏',
+ '网络游戏 ',
+ '游戏周边',
+ '特摄'
+];
+
+export const QueryType: Record = {
+ 动画: '動畫',
+ 季度全集: '季度全集',
+ 音乐: '音樂',
+ 动漫音乐: '動漫音樂',
+ 同人音乐: '同人音樂',
+ 流行音乐: '流行音樂',
+ 日剧: '日劇',
+ RAW: 'RAW',
+ 其他: '其他',
+ 漫画: '漫畫',
+ 港台原版: '港台原版',
+ 日文原版: '日文原版',
+ 游戏: '遊戲',
+ 电脑游戏: '電腦遊戲',
+ 主机游戏: '電視遊戲',
+ 掌机游戏: '掌機遊戲',
+ '网络游戏 ': '網絡遊戲',
+ 游戏周边: '遊戲周邊',
+ 特摄: '特攝'
+};
+
+export const DisplayType: Record = {
+ 動畫: '动画',
+ 季度全集: '季度全集',
+ 音樂: '音乐',
+ 動漫音樂: '动漫音乐',
+ 同人音樂: '同人音乐',
+ 流行音樂: '流行音乐',
+ 日劇: '日剧',
+ RAW: 'RAW',
+ 其他: '其他',
+ 漫畫: '漫画',
+ 港台原版: '港台原版',
+ 日文原版: '日文原版',
+ 遊戲: '游戏',
+ 電腦遊戲: '电脑游戏',
+ 電視遊戲: '主机游戏',
+ 掌機遊戲: '掌机游戏',
+ 網絡遊戲: '网络游戏 ',
+ 遊戲周邊: '游戏周边',
+ 特攝: '特摄'
+};
+
+// @unocss-include
+export const DisplayTypeColor: Record = {
+ 動畫: 'text-red-600',
+ 季度全集: 'text-[#ff0000]',
+ 漫畫: 'text-green-600',
+ 港台原版: 'text-green-600',
+ 日文原版: 'text-green-600',
+ 音樂: 'text-purple-600',
+ 動漫音樂: 'text-purple-600',
+ 同人音樂: 'text-purple-600',
+ 流行音樂: 'text-purple-600',
+ 日劇: 'text-blue-600',
+ RAW: 'text-[#ffa500]',
+ 遊戲: 'text-[#0eb9e7]',
+ 電腦遊戲: 'text-[#0eb9e7]',
+ 電視遊戲: 'text-[#0eb9e7]',
+ 掌機遊戲: 'text-[#0eb9e7]',
+ 網絡遊戲: 'text-[#0eb9e7]',
+ 遊戲周邊: 'text-[#0eb9e7]',
+ 特攝: 'text-[#a52a2a]',
+ 其他: 'text-base-800'
+};
+
+export const fansubs = [
+ { id: 619, name: '桜都字幕组' },
+ { id: 833, name: '北宇治字幕组' },
+ { id: 185, name: '极影字幕社' },
+ { id: 669, name: '喵萌奶茶屋' },
+ { id: 151, name: '悠哈C9字幕社' },
+ { id: 657, name: 'LoliHouse' },
+ { id: 803, name: 'Lilith-Raws' },
+ { id: 767, name: '天月動漫&發佈組' },
+ { id: 283, name: '千夏字幕组' },
+ { id: 816, name: 'ANi' },
+ { id: 813, name: 'MingYSub' },
+ { id: 650, name: 'SweetSub' },
+ { id: 47, name: '爱恋字幕社' },
+ { id: 303, name: '动漫国字幕组' },
+ { id: 241, name: '幻樱字幕组' },
+ { id: 390, name: '天使动漫论坛' },
+ { id: 804, name: '霜庭云花Sub' },
+ { id: 731, name: '星空字幕组' },
+ { id: 321, name: '轻之国度' },
+ { id: 764, name: 'MCE汉化组' },
+ { id: 604, name: 'c.c动漫' },
+ { id: 288, name: '诸神kamigami字幕组' },
+ { id: 438, name: '白恋字幕组' },
+ { id: 834, name: '氢气烤肉架' },
+ { id: 837, name: '六道我大鸽汉化组' },
+ { id: 838, name: '云歌字幕组' },
+ { id: 840, name: '成子坂地下室' },
+ { id: 841, name: '失眠搬运组' },
+ { id: 842, name: 'SRVFI-Raws' },
+ { id: 843, name: 'Pharos of MyGO' },
+ { id: 823, name: '拨雪寻春' },
+ // 特摄
+ { id: 648, name: '魔星字幕团' },
+ { id: 805, name: 'DBD制作组' },
+ { id: 228, name: 'KRL字幕组' },
+ // 其它
+ { id: 550, name: '萝莉社活动室' },
+ { id: 755, name: 'GMTeam' },
+ { id: 454, name: '风车字幕组' },
+ { id: 37, name: '雪飄工作室(FLsnow)' },
+ { id: 488, name: '丸子家族' },
+ { id: 574, name: '梦蓝字幕组' },
+ { id: 504, name: 'LoveEcho!' },
+ { id: 576, name: '银色子弹字幕组' },
+ { id: 75, name: '柯南事务所' },
+ { id: 630, name: '枫叶字幕组' },
+ { id: 665, name: 'YWCN字幕组' },
+ // 日剧
+ { id: 749, name: '幻月字幕组' },
+ { id: 649, name: '云光字幕组' },
+ { id: 520, name: '豌豆字幕组' },
+ { id: 626, name: '驯兽师联盟' },
+ { id: 666, name: '中肯字幕組' },
+ { id: 781, name: 'SW字幕组' },
+ { id: 434, name: '风之圣殿' },
+ { id: 49, name: '华盟字幕社' },
+ { id: 627, name: '波洛咖啡厅' },
+ { id: 88, name: '动音漫影' },
+ { id: 581, name: 'VCB-Studio' },
+ { id: 407, name: 'DHR動研字幕組' },
+ { id: 719, name: '80v08' },
+ { id: 732, name: '肥猫压制' },
+ { id: 680, name: 'Little字幕组' },
+ { id: 613, name: 'AI-Raws' },
+ { id: 806, name: '离谱Sub' },
+ { id: 812, name: '虹咲学园烤肉同好会' },
+ { id: 636, name: 'ARIA吧汉化组' },
+ { id: 821, name: '百冬練習組' },
+ { id: 641, name: '冷番补完字幕组' },
+ { id: 765, name: '爱咕字幕组' },
+ { id: 822, name: '極彩字幕组' },
+ { id: 592, name: '未央阁联盟' },
+ { id: 703, name: '届恋字幕组' },
+ { id: 808, name: '夜莺家族' },
+ { id: 734, name: 'TD-RAWS' },
+ { id: 447, name: '夢幻戀櫻' },
+ { id: 790, name: 'WBX-SUB' },
+ { id: 814, name: 'Amor字幕组' }
+ // 停更
+ // { id: 217, name: 'AQUA工作室' },
+ // { id: 832, name: 'Sakura' },
+ // { id: 817, name: 'EMe' },
+ // { id: 818, name: 'Alchemist' }
+ // { id: 479, name: 'Little Subbers!' }
+ // { id: 835, name: '小白GM' },
+ // { id: 819, name: '黑岩射手吧字幕组' },
+ // { id: 807, name: 'Liella!の烧烤摊' },
+ // { id: 801, name: 'NC-Raws' },
+ // { id: 772, name: 'IET字幕組' },
+ // { id: 117, name: '動漫花園' },
+ // { id: 836, name: 'MSB制作組' }
+];
diff --git a/apps/frontend/web/app/hooks/focus.ts b/apps/frontend/web/app/hooks/focus.ts
new file mode 100644
index 00000000..fded4a64
--- /dev/null
+++ b/apps/frontend/web/app/hooks/focus.ts
@@ -0,0 +1,26 @@
+import { useEffect, useState } from 'react';
+
+export const useActiveElement = () => {
+ const [listenersReady, setListenersReady] = useState(false);
+ const [activeElement, setActiveElement] = useState(document.activeElement);
+
+ useEffect(() => {
+ const onFocus = (event: FocusEvent) => setActiveElement(event.target as any);
+ const onBlur = (event: FocusEvent) => setActiveElement(null);
+
+ window.addEventListener('focus', onFocus, true);
+ window.addEventListener('blur', onBlur, true);
+
+ setListenersReady(true);
+
+ return () => {
+ window.removeEventListener('focus', onFocus);
+ window.removeEventListener('blur', onBlur);
+ };
+ }, []);
+
+ return {
+ active: activeElement,
+ ready: listenersReady
+ };
+};
diff --git a/apps/frontend/web/app/hooks/index.ts b/apps/frontend/web/app/hooks/index.ts
index ef84bb0c..4a45e210 100644
--- a/apps/frontend/web/app/hooks/index.ts
+++ b/apps/frontend/web/app/hooks/index.ts
@@ -1,3 +1,5 @@
export * from './document';
export * from './scroll';
+
+export * from './focus';
diff --git a/apps/frontend/web/app/layouts/Layout.tsx b/apps/frontend/web/app/layouts/Layout.tsx
index 804bd787..4abf18d9 100644
--- a/apps/frontend/web/app/layouts/Layout.tsx
+++ b/apps/frontend/web/app/layouts/Layout.tsx
@@ -1,3 +1,4 @@
+import Search from '@/components/Search';
import { NavLink } from '@remix-run/react';
import { atom, useAtom } from 'jotai';
import { useEffect, useRef, useState } from 'react';
@@ -50,7 +51,9 @@ function Hero(props: { height: number, paddingTop: number; paddingBottom: number
🌸 Anime Garden
diff --git a/apps/frontend/web/app/utils/index.ts b/apps/frontend/web/app/utils/index.ts
new file mode 100644
index 00000000..707ec23f
--- /dev/null
+++ b/apps/frontend/web/app/utils/index.ts
@@ -0,0 +1,10 @@
+export function removeQuote(words: string[]) {
+ return words.map((w) => w.replace(/^(\+|-)?"([^"]*)"$/, '$1$2'));
+}
+
+export function getPikPakUrlChecker(magnet: string) {
+ const url = magnet.split('&')[0];
+ return 'https://keepshare.org/gv78k1oi/' + encodeURIComponent(url);
+ // const replaced = prefix.replace(/^magnet:\?xt/, 'magnet:?xt.1');
+ // return `https://mypikpak.com/drive/url-checker?url=${replaced}`;
+}
diff --git a/apps/frontend/web/package.json b/apps/frontend/web/package.json
index cd7e3115..52c50405 100644
--- a/apps/frontend/web/package.json
+++ b/apps/frontend/web/package.json
@@ -20,6 +20,8 @@
"@remix-run/react": "^2.12.0",
"@remix-run/serve": "^2.12.0",
"@remix-run/server-runtime": "^2.12.0",
+ "animegarden": "workspace:*",
+ "anitomy": "0.0.35",
"hono": "^4.6.1",
"isbot": "^4.1.0",
"jotai": "^2.9.3",
diff --git a/apps/frontend/web/reset.d.ts b/apps/frontend/web/reset.d.ts
new file mode 100644
index 00000000..12bd3edc
--- /dev/null
+++ b/apps/frontend/web/reset.d.ts
@@ -0,0 +1 @@
+import '@total-typescript/ts-reset';
diff --git a/apps/frontend/web/tsconfig.json b/apps/frontend/web/tsconfig.json
index 1cf27d2d..c686ed07 100644
--- a/apps/frontend/web/tsconfig.json
+++ b/apps/frontend/web/tsconfig.json
@@ -23,6 +23,9 @@
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
+ "@/*": [
+ "./app/*"
+ ],
"~/*": [
"./app/*"
]
diff --git a/apps/frontend/web/vite.config.ts b/apps/frontend/web/vite.config.ts
index ba8a7f21..0f9ed119 100644
--- a/apps/frontend/web/vite.config.ts
+++ b/apps/frontend/web/vite.config.ts
@@ -1,6 +1,7 @@
-import { vitePlugin as remix } from '@remix-run/dev';
-import { defineConfig } from 'vite';
+import path from 'path';
import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vite';
+import { vitePlugin as remix } from '@remix-run/dev';
import UnoCSS from 'unocss/vite';
import Info from 'unplugin-info/vite';
@@ -15,7 +16,8 @@ export default defineConfig({
}
},
resolve: {
- mainFields: ['browser', 'module', 'main']
+ mainFields: ['browser', 'module', 'main'],
+ alias: { '@': path.resolve(__dirname, './app') }
},
build: {
minify: true
diff --git a/package.json b/package.json
index a88983a1..f089a28b 100644
--- a/package.json
+++ b/package.json
@@ -6,13 +6,13 @@
"scripts": {
"animegarden": "tsx packages/cli/src/cli.ts",
"build": "turbo run build",
- "build:web": "turbo run build --filter @animegarden/web...",
"build:cli": "turbo run build --filter @animegarden/cli...",
"build:server": "turbo run build --filter @animegarden/server...",
+ "build:web": "turbo run build --filter @animegarden/web...",
"build:worker": "turbo run build --filter @animegarden/worker...",
"dev": "turbo run dev --parallel",
- "dev:web": "pnpm -C apps/frontend/web dev",
"dev:server": "pnpm -C packages/server dev",
+ "dev:web": "pnpm -C apps/frontend/web dev",
"dev:worker": "pnpm -C packages/worker dev",
"format": "turbo run format --parallel",
"release": "bumpp package.json packages/*/package.json --commit --push --tag && pnpm -r publish --access public",
@@ -25,6 +25,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240909.0",
+ "@total-typescript/ts-reset": "^0.6.1",
"@types/fs-extra": "^11.0.4",
"@types/node": "^20.16.5",
"breadc": "^0.9.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 66a09c56..c0f58555 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,9 @@ importers:
'@cloudflare/workers-types':
specifier: ^4.20240909.0
version: 4.20240909.0
+ '@total-typescript/ts-reset':
+ specifier: ^0.6.1
+ version: 0.6.1
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
@@ -83,6 +86,12 @@ importers:
'@remix-run/server-runtime':
specifier: ^2.12.0
version: 2.12.0(typescript@5.6.2)
+ animegarden:
+ specifier: workspace:*
+ version: link:../../../packages/animegarden
+ anitomy:
+ specifier: 0.0.35
+ version: 0.0.35
hono:
specifier: ^4.6.1
version: 4.6.1