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