feature/search-prototype #1

Merged
bivashy merged 10 commits from feature/search-prototype into main 2025-07-19 18:05:52 +00:00
25 changed files with 1861 additions and 173 deletions
Showing only changes of commit ef68e4a84d - Show all commits

View File

@ -7,18 +7,20 @@
"@nuxt/eslint": "1.4.1",
"@nuxt/image": "1.10.0",
"@tailwindcss/vite": "^4.1.7",
"@vueuse/core": "^13.2.0",
"@videojs-player/vue": "^1.0.0",
"@vueuse/core": "^13.4.0",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^9.0.0",
"lucide-vue-next": "^0.511.0",
"nuxt": "^3.17.4",
"reka-ui": "^2.2.1",
"reka-ui": "^2.3.1",
"shadcn-nuxt": "2.1.0",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.7",
"tw-animate-css": "^1.3.0",
"video.js": "^8.23.3",
"vue": "^3.5.14",
"vue-router": "^4.5.1",
"vue-sonner": "^2.0.0",
@ -89,6 +91,8 @@
"@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg=="],
"@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.27.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.1", "@babel/parser": "^7.27.1", "@babel/template": "^7.27.1", "@babel/types": "^7.27.1", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg=="],
@ -569,6 +573,8 @@
"@types/urijs": ["@types/urijs@1.19.25", "", {}, "sha512-XOfUup9r3Y06nFAZh3WvO0rBU4OtlfPB/vgxpjg+NRdGU6CN6djdc6OEiH+PcqHCY6eFLo9Ista73uarf4gnBg=="],
"@types/video.js": ["@types/video.js@7.3.58", "", {}, "sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
@ -627,6 +633,14 @@
"@vercel/nft": ["@vercel/nft@0.29.3", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^10.4.5", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-aVV0E6vJpuvImiMwU1/5QKkw2N96BRFE7mBYGS7FhXUoS6V7SarQ+8tuj33o7ofECz8JtHpmQ9JW+oVzOoB7MA=="],
"@videojs-player/vue": ["@videojs-player/vue@1.0.0", "", { "peerDependencies": { "@types/video.js": "7.x", "video.js": "7.x", "vue": "3.x" } }, "sha512-WonTezRfKu3fYdQLt/ta+nuKH6gMZUv8l40Jke/j4Lae7IqeO/+lLAmBnh3ni88bwR+vkFXIlZ2Ci7VKInIYJg=="],
"@videojs/http-streaming": ["@videojs/http-streaming@3.17.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/vhs-utils": "^4.1.1", "aes-decrypter": "^4.0.2", "global": "^4.4.0", "m3u8-parser": "^7.2.0", "mpd-parser": "^1.3.1", "mux.js": "7.1.0", "video.js": "^7 || ^8" } }, "sha512-Ch1P3tvvIEezeZXyK11UfWgp4cWKX4vIhZ30baN/lRinqdbakZ5hiAI3pGjRy3d+q/Epyc8Csz5xMdKNNGYpcw=="],
"@videojs/vhs-utils": ["@videojs/vhs-utils@4.1.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "global": "^4.4.0" } }, "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA=="],
"@videojs/xhr": ["@videojs/xhr@2.7.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "global": "~4.4.0", "is-function": "^1.0.1" } }, "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
"@vitejs/plugin-vue-jsx": ["@vitejs/plugin-vue-jsx@4.2.0", "", { "dependencies": { "@babel/core": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1", "@rolldown/pluginutils": "^1.0.0-beta.9", "@vue/babel-plugin-jsx": "^1.4.0" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.0.0" } }, "sha512-DSTrmrdLp+0LDNF77fqrKfx7X0ErRbOcUAgJL/HbSesqQwoUvUQ4uYQqaex+rovqgGcoPqVk+AwUh3v9CuiYIw=="],
@ -665,11 +679,11 @@
"@vue/shared": ["@vue/shared@3.5.14", "", {}, "sha512-oXTwNxVfc9EtP1zzXAlSlgARLXNC84frFYkS0HHz0h3E4WZSP9sywqjqzGCP9Y34M8ipNmd380pVgmMuwELDyQ=="],
"@vueuse/core": ["@vueuse/core@13.2.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.2.0", "@vueuse/shared": "13.2.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-n5TZoIAxbWAQ3PqdVPDzLgIRQOujFfMlatdI+f7ditSmoEeNpPBvp7h2zamzikCmrhFIePAwdEQB6ENccHr7Rg=="],
"@vueuse/core": ["@vueuse/core@13.4.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.4.0", "@vueuse/shared": "13.4.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-OnK7zW3bTq/QclEk17+vDFN3tuAm8ONb9zQUIHrYQkkFesu3WeGUx/3YzpEp+ly53IfDAT9rsYXgGW6piNZC5w=="],
"@vueuse/metadata": ["@vueuse/metadata@13.2.0", "", {}, "sha512-kPpzuQCU0+D8DZCzK0iPpIcXI+6ufWSgwnjJ6//GNpEn+SHViaCtR+XurzORChSgvpHO9YC8gGM97Y1kB+UabA=="],
"@vueuse/metadata": ["@vueuse/metadata@13.4.0", "", {}, "sha512-CPDQ/IgOeWbqItg1c/pS+Ulum63MNbpJ4eecjFJqgD/JUCJ822zLfpw6M9HzSvL6wbzMieOtIAW/H8deQASKHg=="],
"@vueuse/shared": ["@vueuse/shared@13.2.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-vx9ZPDF5HcU9up3Jgt3G62dMUfZEdk6tLyBAHYAG4F4n73vpaA7J5hdncDI/lS9Vm7GA/FPlbOmh9TrDZROTpg=="],
"@vueuse/shared": ["@vueuse/shared@13.4.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-+AxuKbw8R1gYy5T21V5yhadeNM7rJqb4cPaRI9DdGnnNl3uqXh+unvQ3uCaA2DjYLbNr1+l7ht/B4qEsRegX6A=="],
"@whatwg-node/disposablestack": ["@whatwg-node/disposablestack@0.0.6", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" } }, "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw=="],
@ -681,6 +695,8 @@
"@whatwg-node/server": ["@whatwg-node/server@0.9.71", "", { "dependencies": { "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/fetch": "^0.10.5", "@whatwg-node/promise-helpers": "^1.2.2", "tslib": "^2.6.3" } }, "sha512-ueFCcIPaMgtuYDS9u0qlUoEvj6GiSsKrwnOLPp9SshqjtcRaR1IEHRjoReq3sXNydsF5i0ZnmuYgXq9dV53t0g=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.10", "", {}, "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw=="],
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
@ -691,6 +707,8 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"aes-decrypter": ["aes-decrypter@4.0.2", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/vhs-utils": "^4.1.1", "global": "^4.4.0", "pkcs7": "^1.0.4" } }, "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw=="],
"agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
@ -995,6 +1013,8 @@
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"dom-walk": ["dom-walk@0.1.2", "", {}, "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
@ -1231,6 +1251,8 @@
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"global": ["global@4.4.0", "", { "dependencies": { "min-document": "^2.19.0", "process": "^0.11.10" } }, "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w=="],
"global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="],
"globals": ["globals@16.1.0", "", {}, "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g=="],
@ -1343,6 +1365,8 @@
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-function": ["is-function@1.0.2", "", {}, "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ=="],
"is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
@ -1543,6 +1567,8 @@
"luxon": ["luxon@3.6.1", "", {}, "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ=="],
"m3u8-parser": ["m3u8-parser@7.2.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/vhs-utils": "^4.1.1", "global": "^4.4.0" } }, "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ=="],
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
"magic-string-ast": ["magic-string-ast@0.7.1", "", { "dependencies": { "magic-string": "^0.30.17" } }, "sha512-ub9iytsEbT7Yw/Pd29mSo/cNQpaEu67zR1VVcXDiYjSFwzeBxNdTd0FMnSslLQXiRj8uGPzwsaoefrMD5XAmdw=="],
@ -1579,6 +1605,8 @@
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"min-document": ["min-document@2.19.0", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
@ -1601,10 +1629,14 @@
"module-definition": ["module-definition@5.0.1", "", { "dependencies": { "ast-module-types": "^5.0.0", "node-source-walk": "^6.0.1" }, "bin": { "module-definition": "bin/cli.js" } }, "sha512-kvw3B4G19IXk+BOXnYq/D/VeO9qfHaapMeuS7w7sNUqmGaA6hywdFHMi+VWeR9wUScXM7XjoryTffCZ5B0/8IA=="],
"mpd-parser": ["mpd-parser@1.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/vhs-utils": "^4.0.0", "@xmldom/xmldom": "^0.8.3", "global": "^4.4.0" }, "bin": { "mpd-to-m3u8-json": "bin/parse.js" } }, "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mux.js": ["mux.js@7.1.0", "", { "dependencies": { "@babel/runtime": "^7.11.2", "global": "^4.4.0" }, "bin": { "muxjs-transmux": "bin/transmux.js" } }, "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanotar": ["nanotar@0.2.0", "", {}, "sha512-9ca1h0Xjvo9bEkE4UOxgAzLV0jHKe6LMaxo37ND2DAhhAtd0j8pR1Wxz+/goMrZO8AEZTWCmyaOsFI/W5AdpCQ=="],
@ -1765,6 +1797,8 @@
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"pkcs7": ["pkcs7@1.0.4", "", { "dependencies": { "@babel/runtime": "^7.5.5" }, "bin": { "pkcs7": "bin/cli.js" } }, "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ=="],
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
@ -1905,7 +1939,7 @@
"regjsparser": ["regjsparser@0.12.0", "", { "dependencies": { "jsesc": "~3.0.2" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ=="],
"reka-ui": ["reka-ui@2.2.1", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^12.5.0", "@vueuse/shared": "^12.5.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-oLHiyBn6gTIQGnTnv8G5LQuFp9j8HuUNl0qdnW3XPhFb/07hrxzFpjo2kt/jxOZive+n/XWDbOjSj2h9Hih3qA=="],
"reka-ui": ["reka-ui@2.3.1", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^12.5.0", "@vueuse/shared": "^12.5.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-2SjGeybd7jvD8EQUkzjgg7GdOQdf4cTwdVMq/lDNTMqneUFNnryGO43dg8WaM/jaG9QpSCZBvstfBFWlDdb2Zg=="],
"remove-trailing-separator": ["remove-trailing-separator@1.1.0", "", {}, "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw=="],
@ -2223,6 +2257,14 @@
"validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="],
"video.js": ["video.js@8.23.3", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/http-streaming": "^3.17.0", "@videojs/vhs-utils": "^4.1.1", "@videojs/xhr": "2.7.0", "aes-decrypter": "^4.0.2", "global": "4.4.0", "m3u8-parser": "^7.2.0", "mpd-parser": "^1.3.1", "mux.js": "^7.0.1", "videojs-contrib-quality-levels": "4.1.0", "videojs-font": "4.2.0", "videojs-vtt.js": "0.15.5" } }, "sha512-Toe0VLlDZcUhiaWfcePS1OEdT3ATfktm0hk/PELfD7zUoPDHeT+cJf/wZmCy5M5eGVwtGUg25RWPCj1L/1XufA=="],
"videojs-contrib-quality-levels": ["videojs-contrib-quality-levels@4.1.0", "", { "dependencies": { "global": "^4.4.0" }, "peerDependencies": { "video.js": "^8" } }, "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA=="],
"videojs-font": ["videojs-font@4.2.0", "", {}, "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ=="],
"videojs-vtt.js": ["videojs-vtt.js@0.15.5", "", { "dependencies": { "global": "^4.3.1" } }, "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ=="],
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
"vite-dev-rpc": ["vite-dev-rpc@1.0.7", "", { "dependencies": { "birpc": "^2.0.19", "vite-hot-client": "^2.0.4" }, "peerDependencies": { "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1" } }, "sha512-FxSTEofDbUi2XXujCA+hdzCDkXFG1PXktMjSk1efq9Qb5lOYaaM9zNSvKvPPF7645Bak79kSp1PTooMW2wktcA=="],

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
data-slot="dialog"
v-bind="forwarded"
>
<slot />
</DialogRoot>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import { DialogClose, type DialogCloseProps } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import DialogOverlay from './DialogOverlay.vue'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'], close?: () => void }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'close')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const handleClose = () => {
if (props.close) {
props.close()
}
}
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent data-slot="dialog-content" @escape-key-down="handleClose" @interact-outside="handleClose"
@pointer-down-outside="handleClose" v-bind="forwarded" :class="cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)">
<slot />
<DialogClose @click="handleClose"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription, type DialogDescriptionProps, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DialogDescription>
</template>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogOverlay, type DialogOverlayProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
>
<slot />
</DialogOverlay>
</template>

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="forwarded"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle, type DialogTitleProps, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg leading-none font-semibold', props.class)"
>
<slot />
</DialogTitle>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import { DialogTrigger, type DialogTriggerProps } from 'reka-ui'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>

View File

@ -0,0 +1,10 @@
export { default as Dialog } from './Dialog.vue'
export { default as DialogClose } from './DialogClose.vue'
export { default as DialogContent } from './DialogContent.vue'
export { default as DialogDescription } from './DialogDescription.vue'
export { default as DialogFooter } from './DialogFooter.vue'
export { default as DialogHeader } from './DialogHeader.vue'
export { default as DialogOverlay } from './DialogOverlay.vue'
export { default as DialogScrollContent } from './DialogScrollContent.vue'
export { default as DialogTitle } from './DialogTitle.vue'
export { default as DialogTrigger } from './DialogTrigger.vue'

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Label, type LabelProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@ -0,0 +1 @@
export { default as Label } from './Label.vue'

1
openapi/extractor.json Normal file
View File

@ -0,0 +1 @@
{"openapi":"3.1.0","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:8081","description":"Generated server url"}],"paths":{"/metadata/shikimori":{"get":{"tags":["metadata-controller"],"operationId":"shikimori","parameters":[{"name":"id","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/KodikMetadata"}}}}}}},"/metadata/kodik":{"get":{"tags":["metadata-controller"],"operationId":"kodik","parameters":[{"name":"id","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/KodikMetadata"}}}}}}},"/extract/video":{"get":{"tags":["extract-controller"],"operationId":"video","parameters":[{"name":"translationDTO","in":"query","required":true,"schema":{"$ref":"#/components/schemas/KodikTranslationDTO"}},{"name":"quality","in":"query","required":true,"schema":{"type":"string"}},{"name":"episode","in":"query","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/KodikVideoLinks"}}}}}}}},"components":{"schemas":{"KodikMetadata":{"type":"object","properties":{"title":{"type":"string"},"translations":{"type":"array","items":{"$ref":"#/components/schemas/KodikTranslation"}}}},"KodikTranslation":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"mediaId":{"type":"string"},"mediaHash":{"type":"string"},"mediaType":{"type":"string"},"translationType":{"type":"string"},"episodeCount":{"type":"integer","format":"int32"}}},"KodikTranslationDTO":{"type":"object","properties":{"mediaType":{"type":"string"},"mediaId":{"type":"string"},"mediaHash":{"type":"string"}}},"KodikVideoLinks":{"type":"object","properties":{"links":{"type":"object","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/Link"}}}}},"Link":{"type":"object","properties":{"type":{"type":"string"},"src":{"type":"string"}}}}}}

96
openapi/extractor.ts Normal file
View File

@ -0,0 +1,96 @@
/**
* Generated by orval v7.10.0 🍺
* Do not edit manually.
* OpenAPI definition
* OpenAPI spec version: v0
*/
import axios from 'axios';
import type {
AxiosRequestConfig,
AxiosResponse
} from 'axios';
export interface KodikMetadata {
title?: string;
translations?: KodikTranslation[];
}
export interface KodikTranslation {
id?: string;
title?: string;
mediaId?: string;
mediaHash?: string;
mediaType?: string;
translationType?: string;
episodeCount?: number;
}
export interface KodikTranslationDTO {
mediaType?: string;
mediaId?: string;
mediaHash?: string;
}
export type KodikVideoLinksLinks = { [key: string]: Link[] };
export interface KodikVideoLinks {
links?: KodikVideoLinksLinks;
}
export interface Link {
type?: string;
src?: string;
}
export type ShikimoriParams = {
id: string;
};
export type KodikParams = {
id: string;
};
export type VideoParams = {
mediaType: string;
mediaId: string;
mediaHash: string;
quality: string;
episode: number;
};
export const shikimori = <TData = AxiosResponse<KodikMetadata>>(
params: ShikimoriParams, options?: AxiosRequestConfig
): Promise<TData> => {
return axios.get(
`http://localhost:8081/metadata/shikimori`, {
...options,
params: { ...params, ...options?.params },
}
);
}
export const kodik = <TData = AxiosResponse<KodikMetadata>>(
params: KodikParams, options?: AxiosRequestConfig
): Promise<TData> => {
return axios.get(
`http://localhost:8081/metadata/kodik`, {
...options,
params: { ...params, ...options?.params },
}
);
}
export const video = <TData = AxiosResponse<KodikVideoLinks>>(
params: VideoParams, options?: AxiosRequestConfig
): Promise<TData> => {
return axios.get(
`http://localhost:8081/extract/video`, {
...options,
params: { ...params, ...options?.params },
}
);
}
export type ShikimoriResult = AxiosResponse<KodikMetadata>
export type KodikResult = AxiosResponse<KodikMetadata>
export type VideoResult = AxiosResponse<KodikVideoLinks>

View File

@ -5,3 +5,11 @@ export const search = {
baseUrl: 'http://localhost:8080'
},
};
export const extractor = {
input: './openapi/extractor.json',
output: {
target: './openapi/extractor.ts',
baseUrl: 'http://localhost:8081'
},
}

View File

@ -13,18 +13,20 @@
"@nuxt/eslint": "1.4.1",
"@nuxt/image": "1.10.0",
"@tailwindcss/vite": "^4.1.7",
"@vueuse/core": "^13.2.0",
"@videojs-player/vue": "^1.0.0",
"@vueuse/core": "^13.4.0",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^9.0.0",
"lucide-vue-next": "^0.511.0",
"nuxt": "^3.17.4",
"reka-ui": "^2.2.1",
"reka-ui": "^2.3.1",
"shadcn-nuxt": "2.1.0",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.7",
"tw-animate-css": "^1.3.0",
"video.js": "^8.23.3",
"vue": "^3.5.14",
"vue-router": "^4.5.1",
"vue-sonner": "^2.0.0"

71
pages/anime/[slug].vue Normal file
View File

@ -0,0 +1,71 @@
<script setup lang="ts">
import { kodik, type KodikTranslation } from '~/openapi/extractor';
const route = useRoute();
const animeId = ref(route.params.slug as string)
const results = ref<KodikTranslation[]>([])
const isLoading = ref(false)
const error = ref<unknown>(null)
const isSelectingEpisode = ref(false)
const currentSelectionItem = ref<KodikTranslation | null>(null)
watchEffect(async () => {
if (!animeId.value) return
try {
isLoading.value = true
error.value = null
const response = await kodik({ id: animeId.value })
results.value = response?.data?.translations || []
} catch (err) {
error.value = err
console.error('Failed to fetch anime details:', err)
} finally {
isLoading.value = false
}
})
function episodesSelect(item: KodikTranslation) {
currentSelectionItem.value = item
isSelectingEpisode.value = true
}
function closeDialog() {
console.log('Dialog closed')
isSelectingEpisode.value = false
}
</script>
<template>
<div v-for="item in results" :key="item.id" class="flex w-[32rem]">
<div @click="episodesSelect(item)">
<span>{{ item.title }}</span>
<span>{{ item.episodeCount }}</span>
</div>
</div>
<p>{{ $route.params.slug }}</p>
<Dialog v-bind:open="isSelectingEpisode">
<DialogTrigger as-child>
<Button variant="outline">
Select episode
</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto" :close="closeDialog">
<DialogHeader>
<DialogTitle>Select episode</DialogTitle>
</DialogHeader>
<div class="grid gap-4 py-4">
<div v-for="n in currentSelectionItem?.episodeCount || 0" :key="n"
class="flex items-center justify-between">
<NuxtLink
:to="{ path: '/watch', query: { mediaType: currentSelectionItem?.mediaType, mediaId: currentSelectionItem?.mediaId, mediaHash: currentSelectionItem?.mediaHash, episode: n } }"
class="flex items-center justify-between gap-2 w-full">
<span>Episode {{ n }}</span>
<Button variant="outline" @click="closeDialog">Watch</Button>
</NuxtLink>
</div>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -3,13 +3,13 @@
import kodikImage from "assets/img/kodik.png";
import shikimoriImage from "assets/img/shikimori.png";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue
} from "~/components/ui/select";
import { toast } from "vue-sonner";
import { Toaster } from "~/components/ui/sonner";
@ -17,63 +17,71 @@ import { Navbar } from "~/components/ui/navbar";
import 'vue-sonner/style.css'
import { Icon } from "#components";
const router = useRouter()
const searchProvider = ref('kodik')
const displaySearchProvider = computed(() => {
let label = '';
let image;
if (searchProvider.value === 'kodik') {
label = 'Kodik';
image = kodikImage;
}
if (searchProvider.value === 'shikimori') {
label = 'Shikimori';
image = shikimoriImage;
}
return {
label,
image
}
let label = '';
let image;
if (searchProvider.value === 'kodik') {
label = 'Kodik';
image = kodikImage;
}
if (searchProvider.value === 'shikimori') {
label = 'Shikimori';
image = shikimoriImage;
}
return {
label,
image
}
})
const search = defineModel<string>("")
function querySearch() {
if (!search.value || search.value.trim() === "") {
toast('Please enter a value');
return;
}
if (!search.value || search.value.trim() === "") {
toast('Please enter a value');
return;
}
router.push({ path: '/search', query: { title: search.value, provider: searchProvider.value } })
.catch(err => {
console.error('Navigation error:', err);
toast.error('Failed to navigate to search results');
});
}
</script>
<template>
<div>
<Toaster />
<Navbar />
<div class="bg-background h-lvh w-screen flex justify-center items-center gap-1">
<Select v-model="searchProvider">
<SelectTrigger>
<SelectValue placeholder="Select an provider">
<img :src="displaySearchProvider.image" alt="" class="w-5 h-5 rounded border border-gray-300">
<span>{{ displaySearchProvider.label }}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Providers</SelectLabel>
<SelectItem value="kodik">
<img :src="kodikImage" alt="" class="w-5 h-5 rounded border border-gray-300">
<span>Kodik</span>
</SelectItem>
<SelectItem value="shikimori">
<img :src="shikimoriImage" alt="" class="w-5 h-5 rounded border border-gray-300">
Shikimori
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Input v-model="search" class="w-64" type="text" placeholder="Search anime..." />
<Button @click="querySearch">
<Icon name="heroicons:magnifying-glass-16-solid" class="w-6 h-6" />
</Button>
</div>
<div>
<Toaster />
<Navbar />
<div class="bg-background h-lvh w-screen flex justify-center items-center gap-1">
<form @submit.prevent="querySearch" class="flex items-center gap-2">
<Select v-model="searchProvider">
<SelectTrigger>
<SelectValue placeholder="Select an provider">
<img :src="displaySearchProvider.image" alt="" class="w-5 h-5 rounded border border-gray-300">
<span>{{ displaySearchProvider.label }}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Providers</SelectLabel>
<SelectItem value="kodik">
<img :src="kodikImage" alt="" class="w-5 h-5 rounded border border-gray-300">
<span>Kodik</span>
</SelectItem>
<SelectItem value="shikimori">
<img :src="shikimoriImage" alt="" class="w-5 h-5 rounded border border-gray-300">
Shikimori
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Input v-model="search" class="w-64" type="text" placeholder="Search anime..." />
<Button type="submit">
<Icon name="heroicons:magnifying-glass-16-solid" class="w-6 h-6" />
</Button>
</form>
</div>
</div>
</template>

View File

@ -1,132 +1,58 @@
<!-- pages/search.vue -->
<script setup lang="ts">
import { search, type Result } from '~/openapi/search'
// Define route query params
const route = useRoute()
const searchQuery = ref(route.query.title as string || '')
// Search results state
const results = ref<Result[]>([])
const isLoading = ref(false)
const error = ref<unknown>(null)
// Perform search when query changes
watchEffect(async () => {
if (!searchQuery.value) return
if (!searchQuery.value) return
try {
isLoading.value = true
error.value = null
const response = await search({ title: searchQuery.value })
results.value = response.data.results || []
} catch (err) {
error.value = err
console.error('Search failed:', err)
} finally {
isLoading.value = false
}
try {
isLoading.value = true
error.value = null
const response = await search({ title: searchQuery.value })
results.value = response.data.results || []
} catch (err) {
error.value = err
console.error('Search failed:', err)
} finally {
isLoading.value = false
}
})
</script>
<template>
<div class="search-page">
<div class="search-box">
<input v-model="searchQuery" type="text" placeholder="Search anime..."
@keyup.enter="navigateTo({ path: '/search', query: { title: searchQuery } })">
</div>
<!-- Loading State -->
<div v-if="isLoading" class="loading">
Loading results...
</div>
<!-- Error State -->
<div v-if="error" class="error">
Error loading results: {{ error.message }}
</div>
<!-- Results List -->
<div v-if="results.length > 0" class="results-grid">
<div v-for="item in results" :key="item.id" class="result-card">
<NuxtLink :to="`/anime/${item.id}`">
<img v-if="item.material_data?.anime_poster_url" :src="item.material_data.anime_poster_url"
:alt="item.title" class="poster">
<div class="details">
<h3 className="scroll-m-20 text-2xl font-semibold tracking-tight">{{ item.title }}</h3>
<p v-if="item.material_data?.year">Year: {{ item.material_data.year }}</p>
<p v-if="item.material_data?.anime_kind">Type: {{ item.material_data.anime_kind }}</p>
<p v-if="item.material_data?.episodes_total">Episodes: {{ item.material_data.episodes_total }}
</p>
</div>
</NuxtLink>
</div>
</div>
<!-- No Results -->
<div v-if="!isLoading && !error && results.length === 0 && searchQuery" class="no-results">
No results found for "{{ searchQuery }}"
</div>
<div>
<div v-if="isLoading">
Loading results...
</div>
<div v-if="error">
Error loading results: {{ error }}
</div>
<div v-if="results.length > 0" class="grid grid-cols-[repeat(auto-fill,32rem)] justify-around gap-8 grid-flow-row">
<div v-for="item in results" :key="item.id" class="flex w-[32rem]">
<NuxtLink :to="`/anime/${item.id}`">
<img v-if="item.material_data?.anime_poster_url" :src="item.material_data.anime_poster_url" :alt="item.title"
class="rounded-md">
<div>
<h3 className="text-2xl font-semibold tracking-tight">{{ item.title }}</h3>
<p v-if="item.material_data?.year">Year: {{ item.material_data.year }}</p>
<p v-if="item.material_data?.anime_kind">Type: {{ item.material_data.anime_kind }}</p>
<p v-if="item.material_data?.episodes_total">Episodes: {{ item.material_data.episodes_total }}
</p>
</div>
</NuxtLink>
</div>
</div>
<div v-if="!isLoading && !error && results.length === 0 && searchQuery">
No results found for "{{ searchQuery }}"
</div>
</div>
</template>
<style scoped>
.search-page {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.search-box {
margin-bottom: 30px;
}
.search-box input {
width: 100%;
padding: 12px;
font-size: 16px;
border: 2px solid #ddd;
border-radius: 4px;
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.result-card {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
}
.result-card:hover {
transform: translateY(-5px);
}
.poster {
width: 100%;
height: 350px;
object-fit: cover;
}
.details {
padding: 15px;
}
.details p {
margin: 5px 0;
font-size: 14px;
color: #666;
}
.loading,
.no-results,
.error {
text-align: center;
padding: 40px;
font-size: 18px;
}
</style>

125
pages/watch.vue Normal file
View File

@ -0,0 +1,125 @@
<template>
<div>
<h1>Watch Page</h1>
<div v-if="mediaId">
<p>Media Type: {{ mediaType }}</p>
<p>Media ID: {{ mediaId }}</p>
<p>Media Hash: {{ mediaHash }}</p>
<p>Episode: {{ episode }}</p>
<!-- Video Player Container -->
<div class="video-container">
<video-player v-if="hlsUrl" ref="videoPlayer" :options="playerOptions" class="vjs-big-play-centered" />
</div>
<!-- Loading and Error States -->
<div v-if="isLoading" class="loading">Loading video...</div>
<div v-if="error" class="error">Error loading video: {{ error }}</div>
</div>
<div v-else>
<p>No media selected.</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { video, type KodikTranslationDTO, type KodikVideoLinks, type VideoParams } from '~/openapi/extractor'
import { VideoPlayer } from '@videojs-player/vue'
import 'video.js/dist/video-js.css'
const route = useRoute()
const mediaType = route.query.mediaType
const mediaId = route.query.mediaId
const mediaHash = route.query.mediaHash
const episode = route.query.episode
const results = ref<KodikVideoLinks | null>(null)
const isLoading = ref(false)
const error = ref<unknown>(null)
const hlsUrl = ref<string | null>(null)
const playerOptions = ref({
autoplay: false,
controls: true,
responsive: true,
fluid: true,
sources: [{
src: hlsUrl.value,
type: 'application/x-mpegURL'
}]
})
watchEffect(async () => {
if (!mediaType || !mediaId || !mediaHash || !episode) return
try {
isLoading.value = true
error.value = null
const translationDto: KodikTranslationDTO = {
mediaType: mediaType as string,
mediaId: mediaId as string,
mediaHash: mediaHash as string,
}
const videoParams: VideoParams = {
mediaType: mediaType as string,
mediaId: mediaId as string,
mediaHash: mediaHash as string,
episode: Number(episode as string),
quality: '720',
}
const response = await video(videoParams)
results.value = response?.data || null
// Extract HLS URL from response
if (results.value?.links) {
// Find the first available quality
const qualities = Object.keys(results.value.links)
const bestQuality = qualities.includes('720') ? '720' :
qualities.includes('480') ? '480' :
qualities[0]
// Find the first HLS link
const hlsLink = results.value.links[bestQuality]?.find(link =>
link.type?.includes('hls') || link.src?.includes('.m3u8')
)
if (hlsLink?.src) {
hlsUrl.value = hlsLink.src
playerOptions.value.sources[0].src = hlsLink.src
} else {
throw new Error('No HLS stream found in response')
}
}
} catch (err) {
error.value = err
console.error('Failed to fetch video:', err)
} finally {
isLoading.value = false
}
})
</script>
<style scoped>
.video-container {
max-width: 800px;
margin: 20px auto;
}
.loading,
.error {
padding: 20px;
text-align: center;
background: #f8f8f8;
margin: 20px 0;
border-radius: 4px;
}
.error {
color: #d32f2f;
background: #ffebee;
}
</style>