feature/search-prototype #1
54
bun.lock
54
bun.lock
@ -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=="],
|
||||
|
17
components/ui/dialog/Dialog.vue
Normal file
17
components/ui/dialog/Dialog.vue
Normal 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>
|
14
components/ui/dialog/DialogClose.vue
Normal file
14
components/ui/dialog/DialogClose.vue
Normal 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>
|
47
components/ui/dialog/DialogContent.vue
Normal file
47
components/ui/dialog/DialogContent.vue
Normal 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>
|
22
components/ui/dialog/DialogDescription.vue
Normal file
22
components/ui/dialog/DialogDescription.vue
Normal 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>
|
15
components/ui/dialog/DialogFooter.vue
Normal file
15
components/ui/dialog/DialogFooter.vue
Normal 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>
|
17
components/ui/dialog/DialogHeader.vue
Normal file
17
components/ui/dialog/DialogHeader.vue
Normal 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>
|
20
components/ui/dialog/DialogOverlay.vue
Normal file
20
components/ui/dialog/DialogOverlay.vue
Normal 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>
|
56
components/ui/dialog/DialogScrollContent.vue
Normal file
56
components/ui/dialog/DialogScrollContent.vue
Normal 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>
|
22
components/ui/dialog/DialogTitle.vue
Normal file
22
components/ui/dialog/DialogTitle.vue
Normal 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>
|
14
components/ui/dialog/DialogTrigger.vue
Normal file
14
components/ui/dialog/DialogTrigger.vue
Normal 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>
|
10
components/ui/dialog/index.ts
Normal file
10
components/ui/dialog/index.ts
Normal 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'
|
25
components/ui/label/Label.vue
Normal file
25
components/ui/label/Label.vue
Normal 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>
|
1
components/ui/label/index.ts
Normal file
1
components/ui/label/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Label } from './Label.vue'
|
1
openapi/extractor.json
Normal file
1
openapi/extractor.json
Normal 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
96
openapi/extractor.ts
Normal 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>
|
@ -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'
|
||||
},
|
||||
}
|
||||
|
@ -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
71
pages/anime/[slug].vue
Normal 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>
|
120
pages/index.vue
120
pages/index.vue
@ -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>
|
||||
|
154
pages/search.vue
154
pages/search.vue
@ -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
125
pages/watch.vue
Normal 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>
|
Reference in New Issue
Block a user