Compare commits

...

211 Commits

Author SHA1 Message Date
336d9fde03 Remove deprecated ListPage component 2026-03-10 23:50:38 +01:00
4ed311f059 Update home page and components to integrate with discover navigation 2026-03-10 23:50:32 +01:00
df9db461e5 Add discover and section page routes with interfaces 2026-03-10 23:50:25 +01:00
0e11649e04 Update navigation to use IconDiscover for discover route 2026-03-10 23:50:19 +01:00
dea1efb57c Add discover icon (compass navigation) for main navigation 2026-03-10 23:50:11 +01:00
8344209191 Add icons for discover page categories (spotlights, theater, compass, calendar, star) 2026-03-10 23:50:05 +01:00
a2c7d1c850 Add section page component for browsing discover categories 2026-03-10 23:49:58 +01:00
d95b19a2de Add discover page components with category showcase 2026-03-10 23:49:49 +01:00
c8262a3bda Feat: Misc improvements (#107)
* Expand SCSS variables for improved theming

* Redesign 404 page with dynamic movie quotes

* Add password generator page

* Add missing Plex authentication page

* Improve torrent table and torrents page

* Enhance toast notification component

* Enhance popup components

* Refine UI components and remove DarkmodeToggle

* Add user profile component for settings

* Update autocomplete dropdown component

* Update register page

* Redesign signin and register pages with improved UX

* Improve torrent table with sort toggle and highlight colors

* eslint & prettier fixes
2026-03-09 00:01:05 +01:00
cb90281e5e Feat: Activity page enhancements (#106)
* Add activity page components and Tautulli stats integration

- Add StatsOverview component for watch statistics display
- Add WatchHistory component for recent watch activity
- Add useTautulliStats composable for Tautulli API integration
- Components display total plays, watch time, movies/episodes watched
- Support for fetching home stats and last watched content

* Enhance Graph component with improved styling and options

- Add wrapper div for better layout control
- Update color scheme with modern palette (Indigo, Amber, Emerald)
- Add Filler plugin for filled area charts
- Improve bar chart styling with rounded corners
- Add proper lifecycle cleanup with onBeforeUnmount
- Enhance tooltip formatting for time and number values
- Add deep watch for reactive data updates
- Better TypeScript type safety with Chart.js types

* Refactor ActivityPage with enhanced stats and visualizations

- Integrate StatsOverview component for at-a-glance metrics
- Add WatchHistory component for recent watch activity
- Add hourly viewing patterns chart
- Modernize UI with card-based layout
- Improve controls styling with better labels and input handling
- Remove authentication dependency (now handled by route guards)
- Use useTautulliStats composable for data fetching
- Add comprehensive watch statistics (total plays, hours, by media type)
- Support for both plays and duration view modes

* Improve Plex authentication check with cookie fallback

- Add usePlexAuth composable import to routes
- Enhance hasPlexAccount() to check cookies when Vuex store is empty
- Fixes authentication check after page refreshes
- Ensures activity page remains accessible with valid Plex auth
2026-03-08 21:38:22 +01:00
0cd2a73a8b Feat: Command palette (#105)
* Add command palette with smart navigation and usage tracking

- Add CommandPalette.vue component with keyboard shortcut (Cmd+K/Ctrl+K)
- Implement smart route navigation with parameter input support
- Add content search integration via Elasticsearch
- Create commandTracking.ts utility for usage analytics
- Track command frequency and recency with scoring algorithm
- Support for all application routes with metadata and icons
- Includes badge system for auth requirements and shortcuts

* Integrate CommandPalette into main application

- Add CommandPalette component to App.vue
- Enable global keyboard shortcut (Cmd+K/Ctrl+K)
- Command palette is now accessible from anywhere in the app
2026-03-08 21:29:07 +01:00
c309016299 Feat/settings page redesign (#104)
* include credentials on login fetch requests, allows set header response

* Add theme composable and utility improvements

- Create useTheme composable for centralized theme management
- Update main.ts to use useTheme for initialization
- Generalize getCookie utility in user module
- Add utility functions for data formatting

* Add Plex integration composables and icons

- Create usePlexAuth composable for Plex OAuth flow
- Create usePlexApi composable for Plex API interactions
- Create useRandomWords composable for password generation
- Add Plex-related icons (IconPlex, IconServer, IconSync)
- Add Plex helper utilities
- Update API with Plex-related endpoints

* Add storage management components for data & privacy section

- Create StorageManager component for browser storage overview
- Create StorageSectionBrowser for localStorage/sessionStorage/cookies
- Create StorageSectionServer for server-side data (mock)
- Create ExportSection for data export functionality
- Refactor DataExport component with modular sections
- Add storage icons (IconCookie, IconDatabase, IconTimer)
- Implement collapsible sections with visual indicators
- Add colored borders per storage type
- Display item counts and total size in headers

* Add theme, password, and security settings components

- Create ThemePreferences with visual theme selector
- Create PasswordGenerator with passphrase and random modes
- Create SecuritySettings wrapper for password management
- Update ChangePassword to work with new layout
- Implement improved slider UX with visual feedback
- Add theme preview cards with gradients
- Standardize component styling and typography

* Add Plex settings and authentication components

- Create PlexSettings component for Plex account management
- Create PlexAuthButton with improved OAuth flow
- Create PlexServerInfo for server details display
- Use icon components instead of inline SVGs
- Add sync and unlink functionality
- Implement user-friendly authentication flow

* Redesign settings page with two-column layout and ProfileHero

- Create ProfileHero component with avatar and user info
- Create RequestHistory component for Plex requests (placeholder)
- Redesign SettingsPage with modern two-column grid layout
- Add shared-settings.scss for consistent styling
- Organize sections: Appearance, Security, Integrations, Data & Privacy
- Implement responsive mobile layout
- Standardize typography (h2: 1.5rem, 700 weight)
- Add compact modifier for tighter sections
2026-03-08 21:16:36 +01:00
081240c83e include credentials on login fetch requests, allows set header response 2026-02-24 22:22:22 +01:00
eac12748db mobile improvements
tries to setup layout for success with safari iso 26 bottom navigation
bar and having content appear behind it instead of having a fat lip of
background color.

Also fixes where main content was not taking full width on mobile & text
alignment on torrent search results.
2026-02-24 18:43:26 +01:00
426b376d05 Feat: Dynamic colors (#101)
* On every route change, update local variables from query params

* ResultSection is keyed to query to force re-render

* Feat: vite & upgraded dependencies (#100)

* On every route change, update local variables from query params

* ResultSection is keyed to query to force re-render

* Resolved lint warnings

* replace webpack w/ vite

* update all imports with alias @ and scss

* vite environment variables, also typed

* upgraded eslint, defined new rules & added ignore comments

* resolved linting issues

* moved index.html to project root

* updated dockerfile w/ build stage before runtime image definition

* sign drone config

* dynamic colors from poster for popup bg & text colors

* more torrents nav button now link elem & better for darker bg

* make list item title clickable

* removed extra no-shadow eslint rule definitions

* fixed movie import

* adhere to eslint rules & package.json clean command

* remove debounce autocomplete search, track & hault on failure
2026-02-24 00:22:51 +01:00
1238cf50cc Feat: Caddy webserver (#102)
* describe Caddyfile & update Dockerfile runtime image

* remove nginx config - replaced by caddy
2026-02-24 00:22:31 +01:00
8e586811ec Feat: vite & upgraded dependencies (#100)
* On every route change, update local variables from query params

* ResultSection is keyed to query to force re-render

* Resolved lint warnings

* replace webpack w/ vite

* update all imports with alias @ and scss

* vite environment variables, also typed

* upgraded eslint, defined new rules & added ignore comments

* resolved linting issues

* moved index.html to project root

* updated dockerfile w/ build stage before runtime image definition

* sign drone config
2026-02-23 20:53:19 +01:00
fb3b4c8f7d updated elastic autocomplete to include persons, also adds debounce & clearer handling of response 2025-01-11 14:07:07 +01:00
25da19eaf5 Removed import stmt defineEmits & defineProps as they are compiler macros 2024-02-25 16:28:11 +01:00
28a559727f Fixes table sort for file size (#86)
* Fixed algorithm to de-humanize size string to bytes

* Resolved linting issues
2023-01-16 22:50:25 +01:00
dependabot[bot]
ba3cb6486e Bump loader-utils from 1.4.0 to 1.4.2 (#81)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 1.4.0 to 1.4.2.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v1.4.2/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v1.4.0...v1.4.2)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-30 01:26:34 +01:00
dependabot[bot]
f819120cde Bump decode-uri-component from 0.2.0 to 0.2.2 (#82)
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-30 01:26:06 +01:00
Snyk bot
2f48656454 fix: Dockerfile to reduce vulnerabilities (#83)
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-DEBIAN11-CURL-3065656
- https://snyk.io/vuln/SNYK-DEBIAN11-LIBTASN16-3061097
- https://snyk.io/vuln/SNYK-DEBIAN11-LIBXML2-3059797
- https://snyk.io/vuln/SNYK-DEBIAN11-LIBXML2-3059801
- https://snyk.io/vuln/SNYK-DEBIAN11-TIFF-3113871
2022-12-30 01:25:47 +01:00
4a128044bf Defined request interface & updated expected request status response 2022-11-03 21:56:46 +01:00
dec15194e4 Fix: Search query reload (#79)
* On every route change, update local variables from query params

* ResultSection is keyed to query to force re-render

* Resolved lint warnings
2022-08-27 11:58:30 +02:00
2fed03a882 fix: updated plex_userid to camelcase 2022-08-19 10:49:16 +02:00
74d5868a5c Tried setting backdrop too fast, tries after mount & api response (#78)
* Tried setting backdrop too fast, tries after mount & api response

* Resolved linting issues
2022-08-15 21:12:50 +02:00
609ebc3940 Merge pull request #76 from KevinMidboe/feat/vue-3-typescripted
Feat: Vue 3 typescripted
2022-08-15 20:39:12 +02:00
5d2e667ceb Only build and publish docker image to github when pushing master 2022-08-15 20:34:59 +02:00
5786f55e78 Updated seasoned server ip address 2022-08-15 20:25:05 +02:00
ba888fb303 Moved github files into folder 2022-08-15 20:17:37 +02:00
5f942848aa Only search when query has length 2022-08-14 23:44:52 +02:00
3a58e77da0 Some icons look better using stroke over fill 2022-08-14 23:40:24 +02:00
8d03ea5eec Remove old htaccess file 2022-08-14 23:11:42 +02:00
2f4c6e2543 Also read SEASONED_DOMAIN from drone secret during build 2022-08-14 21:50:05 +02:00
7829ad7298 Removed console log, env variables from drone secrets seem to work 2022-08-14 21:32:57 +02:00
6642b2531e Test node_modules caching 2022-08-14 21:25:09 +02:00
abf005fd8d Read any matching secrets as env variable during build 2022-08-14 21:15:54 +02:00
c6cff7a0c4 If .env is not defined use sane defaults from .env.example 2022-08-14 21:05:45 +02:00
5f1de791c0 Only merge dotenv if .env file exists 2022-08-14 20:01:51 +02:00
91c75198de Removed node server to be replaced with nginx through docker 2022-08-14 19:50:25 +02:00
3b98faeddd Replaced config w/ dotenv. Hydrate docker nginx using env. Updated readme 2022-08-14 19:49:48 +02:00
cbf400c118 Implement simpler Dockerfile & use previous ci build steps 2022-08-14 01:36:38 +02:00
c49f0816c8 Test simplified Dockerfile, removed internal build 2022-08-14 01:24:29 +02:00
9cf2bb9bd8 Start nginx service on container boot 2022-08-14 00:51:43 +02:00
6ceb7861de God damn, not apt but apk 2022-08-14 00:26:14 +02:00
97ed8a491e Nginx install and simple config 2022-08-14 00:22:07 +02:00
cf0bd9aa84 Container source repo label 2022-08-13 13:57:17 +02:00
a62de038a4 FCN for repo when using ghcr 2022-08-13 13:48:17 +02:00
eeac27370b Joined docker build & publish steps 2022-08-13 13:31:13 +02:00
88edc03b8b Fixed tag syntax & added username & password from secret 2022-08-13 13:08:22 +02:00
4a5dddac75 Split build & publish steps & added github registry 2022-08-13 12:53:39 +02:00
fee26fb9e1 COPY with more than one src file, the dest must be a dir and end with a / 2022-08-13 12:39:07 +02:00
9ee9eff8a3 Docker build step uses image plugins/docker 2022-08-13 12:34:12 +02:00
fda353f746 Project dockerfile & added build as ci step 2022-08-13 12:20:42 +02:00
762eb6fe79 Build ts, ts-projects & webpack in seperate commands 2022-08-13 12:14:55 +02:00
335155eb8f Converted server to ts w/ its own tsconfig 2022-08-13 12:14:22 +02:00
577a64a32f Safer popup params to object logic 2022-08-13 00:40:20 +02:00
b7ac8bce83 Added lint setup to drone ci 2022-08-12 23:47:14 +02:00
3594b18872 Resolved ALL eslint issues for project 2022-08-12 23:46:55 +02:00
29dfe55974 Renamed 404 and home with Page suffix 2022-08-12 23:45:47 +02:00
3111513458 eslint config and required packages
eslint packages/plugins:
  - vue
  - typescript
  - prettier
2022-08-12 23:45:00 +02:00
67686095a5 Run drone build for all pushes & PR 2022-08-11 19:08:59 +02:00
e7a0e08938 Increased width of activity days page input 2022-08-11 18:43:43 +02:00
41067aae84 Resolved all ts lint errors on build 2022-08-11 18:37:33 +02:00
f7fe582200 rm Credit- & ListTypes, added enum for genre & production status
Removed types refering to other interfaces, most times we just want to
use MediaTypes which is enum values for supported types.
Also expanded on IMovie & IShow to match api response
2022-08-11 18:34:24 +02:00
09a25e0f37 Added package install & build step in drone config 2022-08-09 01:32:07 +02:00
d061ca06e2 Call store user/loggedIn to get value 2022-08-09 01:17:45 +02:00
2b8d9868b9 Vue shim module declaration for *.vue files 2022-08-09 01:09:39 +02:00
fe86bbae40 Landing banner height 25vh for all devices 2022-08-09 01:06:04 +02:00
81bead113f Fixed chartjs breaking changes 2022-08-09 01:05:29 +02:00
0015588f9c Upgraded all node modules & update lock file 2022-08-09 01:04:31 +02:00
132dd2803e set prettierrc rule vueIndentScriptAndStyle to true 2022-08-09 01:04:02 +02:00
f8196b162e Removed console log & debug message 2022-08-09 01:03:32 +02:00
fde8fd9259 Minor fixes formatting document & table styling 2022-08-08 18:45:03 +02:00
dc69b4086c Split activity graph into component & typed 2022-08-08 18:44:07 +02:00
2a893f5871 Merge pull request #72 from KevinMidboe/feat/refactor
Feat Refactor
2022-08-08 14:11:44 +02:00
96c412ca49 Upgraded entries, plugins, router & webpack to vue 3 & typescript 2022-08-06 16:14:44 +02:00
d279298dec New interfaces defined 2022-08-06 16:12:47 +02:00
d13d883db9 Added more icon components & raw svg files 2022-08-06 16:11:31 +02:00
b7e7fe9c55 Ugraded all pages to vue 3 & typescript 2022-08-06 16:10:37 +02:00
d12dfc3c8e Upgraded all components to vue 3 & typescript 2022-08-06 16:10:13 +02:00
890d0c428d v1.22.17 2022-07-27 00:41:54 +02:00
105fb02378 Updated lock file 2022-07-26 23:17:21 +02:00
7478016384 Removed window eventhub, replaced w/ store 2022-07-26 23:12:39 +02:00
8216502eeb Convert store to typescript w/ matching interfaces 2022-07-26 23:00:58 +02:00
5eadb0b47a Removed storage.js, long ago replaced by store 2022-07-26 22:14:48 +02:00
a4a669e774 Converted utils & api to typescript. Webpack setup 2022-07-26 22:09:41 +02:00
8308a7231a Fixes clicking outside popup not closing on mobile 2022-07-26 20:44:37 +02:00
d585af2193 🧹 moved files around 2022-07-26 20:30:29 +02:00
fe162eb081 Removed unused component 2022-07-26 19:53:04 +02:00
ae3b228cf5 Updated app & header grid layout 2022-07-26 19:51:19 +02:00
023b2cd86e Renamed/moved files around 2022-07-26 19:49:54 +02:00
1f51cead5c Moved build image proxy url to utils file 2022-07-26 17:23:41 +02:00
be29242cd3 Intersection observer threshold set to 0 & hide button on load 2022-07-26 17:14:07 +02:00
742caad102 Reset preventPushState after a replace 2022-07-26 00:32:33 +02:00
eae632d0da Some border radius, increased font size & bold title 2022-07-26 00:31:16 +02:00
7f68d2bf79 Border radius & prevent display result count before vaule 2022-07-26 00:21:37 +02:00
df0432a99f Reduce size of list header 2022-07-26 00:20:43 +02:00
92a9ccd470 only copy of index.html is in /src after webpack update 2022-06-29 01:29:23 +02:00
f5ff2ba44c Remove all host urls, use webpack server proxy 2022-06-29 01:03:53 +02:00
6c80fdff86 postbuild and clean script
webpack config compiles all matching rules and html-webpack-plugin into
/dist output. Use postbuild to move file out from dist into public
directory.

clean tries to remove build files in /dist and index.html from
postbuild.
2022-06-29 00:58:37 +02:00
f425055a53 html-webpack-loader makes server/build use same src/index.html file
Added option for dev proxy by passing env variable: proxyhost.
File definitions and paths defined by variables instead of rewriting
string
2022-06-29 00:58:20 +02:00
1ddaf25150 Add code-splitting of all node_modules 2022-03-07 08:09:59 +01:00
5cacbec11a Movie popup should clear torrent count on mount 2022-03-07 00:16:12 +01:00
728e3a5406 Removed default torrent count & syntax error 2022-03-07 00:12:33 +01:00
8a75fd7c22 WIP torrent search list 2022-03-07 00:05:20 +01:00
baf16f2a55 Center load less button inside a container 2022-03-07 00:02:52 +01:00
a9c06a6aaf Try improve history navigation by pushing popup changes 2022-03-06 23:56:39 +01:00
9bb5211b4e If home and click logo reload page 2022-03-06 23:51:32 +01:00
8c28f7d5f3 Only show No results when not loading 2022-03-06 23:27:46 +01:00
d658d90d18 Fade poster in with a before element with background -> transparent
Fixes issue where mobile had some image flickering
2022-03-06 23:26:15 +01:00
19366f29a9 Only show minutes of hours is < 1 2022-03-06 23:25:45 +01:00
3628b2bfaa Only show production status if something else than Released 2022-03-06 23:25:17 +01:00
0085daec61 Show genres only if length > 0 2022-03-06 23:24:57 +01:00
e45dffcfbe If seasonedURL undefined use location & prefix all api urls at func call 2022-03-06 20:21:24 +01:00
7e24829300 Prevent load-button to take space when not visible 2022-03-06 12:18:47 +01:00
b3266af6bc Api functions for getting credits by type, movie & show 2022-03-06 12:07:46 +01:00
79893c4652 Rule if lineClass = fullwidth & removed horizontal margin 2022-03-06 12:06:30 +01:00
23a1fe5f7f Top prop for setting margin top 2022-03-06 12:05:51 +01:00
ca873f14c7 Loading placeholder for person fields. 2022-03-06 12:05:25 +01:00
80ce96d6b2 Also handle setting credits from info object if ?credits=true 2022-03-06 12:05:07 +01:00
333314fa69 Person's credits are converted to movie & show so can check type attr 2022-03-06 12:03:44 +01:00
7c969b55dc Don't show load more if results.length is zero 2022-03-06 12:01:56 +01:00
eb253609d5 Fetch credits async in separated call from info 2022-03-06 12:01:33 +01:00
06a48e738d Type is defined in person response so can handle more consistent 2022-03-06 12:00:36 +01:00
15b6206b05 Updated lock file 2022-03-05 20:41:41 +01:00
2c3de34b22 Updated tagline to italics & 80% alpha 2022-03-05 20:41:23 +01:00
1bfdb8629f Increased known for text & reduced color to 80% alpha 2022-03-05 20:40:30 +01:00
afa3c21c99 Read title of list from props when settings document title 2022-03-05 20:37:22 +01:00
1161a25c97 Set max-height of cast image 2022-03-05 20:36:22 +01:00
7dd2d3ee82 Try focus on username input on mount 2022-03-05 20:36:00 +01:00
1d2e88749c Separate endpoint for fetching person credits 2022-03-05 18:45:47 +01:00
d39e02cb56 Calulate max-height to only show n rows 2022-03-05 18:45:21 +01:00
21ff5f22a7 Simple handling of movie or show items 2022-03-05 18:44:41 +01:00
fca123e26d Show tagline, human readable runtime & updated to grid layout 2022-03-05 18:24:43 +01:00
b5c56a62de New design for Person popup 2022-03-05 18:23:38 +01:00
dc98f9ced2 Linting 2022-03-05 18:23:18 +01:00
394cd71e44 Fixed sort where loadMore never got past 10 2022-03-05 18:23:06 +01:00
b7db3fec62 Popup box has margin top relative to view height 2022-03-05 18:01:37 +01:00
80a65f1940 Lists can have duplicates so add index to list item key 2022-03-05 18:00:44 +01:00
a6dbb2ba59 Line-height css variable. 2022-03-05 17:59:56 +01:00
4dd51dc4cd Parent sets wrapping so it can also set padding-right 2022-03-05 17:59:31 +01:00
caa8dffc87 New webpack config, scripts & moved dist, favicons & assets to /public 2022-03-05 13:10:21 +01:00
25dd8bea9e Updated and removed unused packages 2022-03-05 13:06:38 +01:00
da99616086 Now that we get auth token from cookie don't need to build authorization header 2022-03-05 13:05:47 +01:00
28950a974c New background variables 80, 90 opacity 2022-03-05 13:05:06 +01:00
8f454b54d8 New icons, updated assets url & action texts 2022-03-05 13:04:06 +01:00
982d8c353c Moved all header info & logic to component. 2022-03-05 12:57:47 +01:00
03bbb5781a CastPerson fallback image large text no-image 2022-03-05 12:57:20 +01:00
0433f8c910 Moved expand click to button & some more animations to icon 2022-03-05 12:56:55 +01:00
f21d879af0 Updated resultslist to grid layout and added No results text 2022-03-05 12:55:24 +01:00
c180bdf98a Autoload results after clicking loadmore, also enabled loadLess again 2022-03-05 12:52:08 +01:00
bf44668a12 Replaced no-image with svg 2022-03-05 12:50:45 +01:00
acc9bda292 Removed unused styles 2022-03-05 12:49:57 +01:00
bb834e7c2e Linting 2022-03-05 12:48:12 +01:00
2d58cca30d Removed unused parameter 2022-03-05 12:47:57 +01:00
a5a4bd2641 Better centering of elements and lazy load image 2022-03-05 12:47:11 +01:00
38813229c9 Transition everything 2022-03-05 11:18:37 +01:00
d58504cde3 Some weird fill & transition-duration hacks for color animation. 2022-03-05 09:06:42 +01:00
04c9e019d3 Linting 2022-03-05 08:59:35 +01:00
d24a318de8 Updated all scss imports to be relative from src alias
Alias defined in webpack.config.js
2022-03-05 08:46:18 +01:00
3b0039b51b Removed all icon references from index and linted 2022-03-04 18:41:54 +01:00
5dd3509466 If id is string convert to number 2022-03-04 18:41:41 +01:00
dbde8bc00b Re-did cast elements. Renamed CastPersons so Person can be popup comp for person. 2022-03-04 18:39:48 +01:00
7449650b64 Shortlist moved to resultsSection & sizing for item not that have grid 2022-03-04 18:36:59 +01:00
a0810fbee1 Linting 2022-03-04 18:34:48 +01:00
a614974a35 Fixed input when it has icon & refactored signin/register to reflect
changes in store
2022-03-04 18:33:16 +01:00
b24b091a3e Simplified home, now send apiFunction to child to make the requests 2022-03-04 18:32:22 +01:00
3aefb4c4ac Added new profile icon to show if user is logged in or not. 2022-03-04 18:28:12 +01:00
2e2ca59334 Updated requests route in navigation icons 2022-03-04 18:27:47 +01:00
6463d5ef4c Linting 2022-03-04 18:27:28 +01:00
4432d8e604 Decrease font size a bit on mobile & remove margin for last element 2022-03-04 18:26:57 +01:00
7ded50ea84 New icons, changed how we color them 2022-03-04 18:26:34 +01:00
d49285f1e2 Some style tweaks to toggle input. Also added a v-key to children 2022-03-04 18:25:38 +01:00
b9f39e690d Renamed variable to make more sense 2022-03-04 18:24:53 +01:00
fc2b139653 New icons need different styling, updated. 2022-03-04 18:24:37 +01:00
3ceb2d7a6f New rolling animation for search result elements. 2022-03-04 18:23:43 +01:00
95ad74a1b5 Authorization is now a cookie so removed localStorage code
Update some structure in how we request and save settings. Updated
Settings & Profile to reflect these changes.
2022-03-04 18:20:50 +01:00
ca4d87b315 Increased height to 30vh & added expand/collapse icon on hover 2022-03-04 18:13:43 +01:00
86efb04eb8 Moved App.vue entry component styles to main.scss 2022-03-04 18:12:15 +01:00
67de2a91fe Requests should also have list route prefix 2022-03-04 18:11:24 +01:00
df388b929a Updated and added new icons from Lindua 2022-03-04 18:10:35 +01:00
9083b0a5d0 Ghost element needed 10px to max-width to be consistent
Also updated expand icon that requires updates to height, width & color
attribute.
2022-03-03 23:13:41 +01:00
dbc225a41c If plexId updates, reload graph 2022-02-03 20:53:18 +01:00
18a0acfe19 Popup now handles person. Updated all dependencies. 2022-01-28 20:03:02 +01:00
4488e53ff2 getMovie & getShow should also request cast data 2022-01-14 17:13:17 +01:00
7bced50952 Don't send auth token to elastic 2022-01-14 17:12:55 +01:00
824a2143ef Replaced searchInput with local icon 2022-01-14 17:09:45 +01:00
5c1b9a00f4 Removed unused Search component (replace w/ SearchPage) 2022-01-14 17:05:02 +01:00
aaef8a6107 Cast has more css shadows and animations. 2022-01-14 17:04:28 +01:00
9f3745b71c Moved hamburger logic to store & auto hide on route change 2022-01-14 17:02:00 +01:00
5431b5be40 Tried simplifying and spliting some of Movie component.
Simplified sidebar element to use props.
Replaced icons with feather icons.

Description gets it's own component & tries it best at figuring out if
description should be truncated or not. Now it adds a element at bottom
of body with the same description and compares the height to default
truncated text. If the dummy element is taller we show the truncate
button.
2022-01-14 17:00:54 +01:00
acfa3e9d54 Renamed icon request to inbox. 2022-01-14 15:40:11 +01:00
3c72bdf3c2 Activity page subscribes to store & more css variables 2022-01-13 00:27:09 +01:00
dc2359ff6a Movie now subscribes to store. Added cast to info panel. 2022-01-13 00:25:38 +01:00
0b6398cc4c Refactored search and autocomplete
Now with more icons, much simpler dropdown and a smooth open animation.
Filter is moved to the searchPage instead of baking in the search
dropdown.
2022-01-13 00:24:40 +01:00
d3a3160cf8 Split navigation icons/header into more components, fixed svg transition
Split more out into `Hamburger` & `NavigationIcon` components.
2022-01-13 00:17:43 +01:00
b021882013 Refactored user store & moved popup logic from App to store
Cleaned up bits of all the components that use these stores.

User store now focuses around keeping track of the authorization token
and the response from /login. When a sucessfull login request is made we
save our new token and username & admin data to the with login(). Since
cookies aren't implemented yet we keep track of the auth_token to make
authroized requests back to the api later.
The username and admin data from within the body of the token is saved
and only cleared on logout().
Since we haven't implemented cookies we persist storage with
localStorage. Whenever we successfully decode and save a token body we
also save the token to localStorage. This is later used by
initFromLocalStorage() to hydrate the store on first page load.

Popup module is for opening and closing the popup, and now moved away
from a inline plugin in App entry. Now handles loading from &
updating query parameters type=movie | show.
The route listens checks if open every navigation and closes popup if it
is.
2022-01-13 00:14:36 +01:00
d1cbbfffd8 Fixes broken functions and bugs
- Mobile can now click behind movie popup to dismiss
- Link to /activity instead of /profile?activity=true
- Remove fill from icons that color using stroke
- Add border to navigation icons
- Darkmode now toggles correctly when load in light/default mode.
- Only show load previous button when loading is false
- Switched to new SearchPage over Search.vue
2022-01-10 18:33:16 +01:00
5104df0af0 Width fix for password inputs 2022-01-10 01:25:18 +01:00
5e330861ca Let navigatino elements grow to natural height 2022-01-10 01:06:02 +01:00
4d27fdb25a Popover should take all height 2022-01-10 01:03:20 +01:00
2ab1609bd9 Profile has both activity and settings inline 2022-01-10 00:51:14 +01:00
aa7e6a2a53 Fullwidth property for seasoned button 2022-01-10 00:50:47 +01:00
2937e7b974 Linting 2022-01-10 00:50:09 +01:00
2371907f54 Set search params when popup movie & check for and read on load 2022-01-10 00:49:57 +01:00
6615827b29 Re-did list components 2022-01-10 00:48:15 +01:00
97c23fa895 Re implemented header navigation 2022-01-10 00:46:26 +01:00
39930428a9 Banner has some more images to cycle between 2022-01-10 00:43:37 +01:00
83b14e0744 Moved icons from html file to separate icons/ components 2022-01-10 00:42:12 +01:00
318 changed files with 23059 additions and 15043 deletions

View File

@@ -7,38 +7,106 @@ platform:
os: linux
arch: amd64
volumes:
- name: cache
host:
path: /tmp/cache
steps:
- name: frontend_install
image: node:13.6.0
commands:
- node -v
- yarn --version
- name: deploy
image: appleboy/drone-ssh
pull: true
secrets:
- ssh_key
when:
event:
- push
branch:
- master
- drone-test
status: success
settings:
host: 10.0.0.114
username: root
key:
from_secret: ssh_key
command_timeout: 600s
script:
- /home/kevin/deploy/seasoned.sh
- name: Load cached frontend packages
image: sinlead/drone-cache:1.0.0
settings:
action: load
key: yarn.lock
mount: node_modules
prefix: yarn-modules-seasoned
volumes:
- name: cache
path: /cache
- name: Frontend install
image: node:24.13.1
commands:
- node -v
- yarn --version
- yarn
- name: Cache frontend packages
image: sinlead/drone-cache:1.0.0
settings:
action: save
key: yarn.lock
mount: node_modules
prefix: yarn-modules-seasoned
volumes:
- name: cache
path: /cache
- name: Lint project using eslint
image: node:24.13.1
commands:
- yarn lint
failure: ignore
- name: Frontend build
image: node:24.13.1
commands:
- yarn build
environment:
ELASTIC:
from_secret: ELASTIC
ELASTIC_INDEX:
from_secret: ELASTIC_INDEX
SEASONED_API:
from_secret: SEASONED_API
SEASONED_DOMAIN:
from_secret: SEASONED_DOMAIN
- name: Build and publish docker image
image: plugins/docker
settings:
registry: ghcr.io
repo: ghcr.io/kevinmidboe/seasoned
dockerfile: Dockerfile
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GITHUB_PASSWORD
tags: latest
when:
event:
- push
branch:
- master
- name: deploy
image: appleboy/drone-ssh
pull: true
secrets:
- ssh_key
when:
event:
- push
branch:
- master
- drone-test
status: success
settings:
host: 10.0.0.54
username: root
key:
from_secret: ssh_key
command_timeout: 600s
script:
- /home/kevin/deploy/seasoned.sh
trigger:
branch:
- master
event:
include:
- pull_request
- push
# - pull_request
---
kind: signature
hmac: 6f10b2871d2bd6b5cd26ddf72796325991ba211ba1eb62b657baf993e9d549c8
...

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
SEASONED_API=http://localhost:31459
ELASTIC_URL=http://elastic.local:9200/tmdb-movies-shows
ELASTIC_API_KEY=

2
.gitignore vendored
View File

@@ -1,8 +1,10 @@
# config file - copy config.json.example
src/config.json
.env
# Build directory
dist/
lib/
# Node packages
node_modules/

View File

@@ -1,8 +0,0 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>

View File

@@ -5,6 +5,6 @@
"singleQuote": false,
"bracketSpacing": true,
"arrowParens": "avoid",
"vueIndentScriptAndStyle": false,
"vueIndentScriptAndStyle": true,
"trailingComma": "none"
}

31
Caddyfile Normal file
View File

@@ -0,0 +1,31 @@
{
# Disable automatic HTTPS
auto_https off
}
:8080 {
root * {$DIST_PATH:/usr/share/caddy}
file_server
encode gzip zstd
try_files {path} {path}/ /index.html
# Cache favicons aggressively
@favicons path /favicons/*
header @favicons Cache-Control "public, max-age=31536000, immutable"
# Cache static assets based on MIME type
@static {
header Content-Type application/javascript*
header Content-Type text/css*
header Content-Type image/*
header Content-Type font/*
header Content-Type application/font-*
header Content-Type application/woff*
header Content-Type application/json*
}
header @static Cache-Control "public, max-age=2592000, immutable"
}

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
FROM node:24.13.1 AS build
# Set the working directory for the build stage
WORKDIR /app
# Install dependencies
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Copy source files that the build depends on
COPY index.html .
COPY public/ public/
COPY src/ src/
COPY tsconfig.json vite.config.ts ./
ARG SEASONED_API=http://localhost:31459
ENV VITE_SEASONED_API=$SEASONED_API
ARG ELASTIC_URL=http://elastic.local:9200/tmdb-movies-shows
ENV VITE_ELASTIC_URL=$ELASTIC_URL
ARG ELASTIC_API_KEY=
ENV VITE_ELASTIC_API_KEY=$ELASTIC_API_KEY
RUN yarn build
FROM caddy:2.11-alpine
COPY Caddyfile /etc/caddy/Caddyfile
# Copy static files
COPY public /usr/share/caddy
# Copy the static build from the previous stage
COPY --from=build /app/dist /usr/share/caddy
EXPOSE 8080
LABEL org.opencontainers.image.source https://github.com/kevinmidboe/seasoned

View File

@@ -1,45 +1,72 @@
# The Movie Database App
# Seasoned Request
A Vue.js project.
Seasoned request is frontend vue application for searching, requesting and viewing account watch activity.
![](https://github.com/dmtrbrl/tmdb-app/blob/master/docs/demo.gif)
## Demo
[TMDB Vue App](https://tmdb-vue-app.herokuapp.com/)
## Config setup
Set seasonedShows api endpoint and/or elastic.
- SeasonedShows [can be found here](https://github.com/kevinmidboe/seasonedshows) and is the matching backend to fetch tmdb search results, tmdb lists, request new content, check plex status and lets owner search and add torrents to download.
- Elastic is optional and can be used for a instant search feature for all movies and shows registered in tmdb.
```json
{
"SEASONED_URL": "http://localhost:31459/api",
"ELASTIC_URL": "http://localhost:9200"
}
```bash
# make copy of example environment file
cp .env.example .env
```
*Set ELASTIC_URL to undefined or false to disable*
## Build Setup
```bash
# .env sane default values
SEASONED_API=
ELASTIC=
ELASTIC_INDEX=shows,movies
SEASONED_DOMAIN=
```
``` bash
- Leave SEASONED_API empty to request `/api` from same origin and proxy passed by nginx, set if hosting [seasonedShows backend api](https://github.com/KevinMidboe/seasonedShows) locally.
- Elastic is optional and can be used for a instant search feature for all movies and shows registered in tmdb, leave empty to disable.
```bash
# .env example values
SEASONED_API=http://localhost:31459
ELASTIC=http://localhost:9200
ELASTIC_INDEX=shows,movies
SEASONED_DOMAIN=request.movie
```
## Build Steps
```bash
# install dependencies
npm install
yarn
# serve with hot reload at localhost:8080
npm run dev
# build vue project using webpack
yarn build
# build for production with minification
npm run build
# test or host built files using docker, might require sudo:
docker build -t seasoned .
docker run -d -p 5000:5000 --name seasoned-request --env-file .env seasoned
```
For detailed explanation on how things work, consult the [docs for vue-loader](http://vuejs.github.io/vue-loader).
This app uses [history mode](https://router.vuejs.org/en/essentials/history-mode.html)
## Development Steps
```bash
# serve project with hot reloading at localhost:8080
yarn dev
```
To proxy requests to `/api` either update `SEASONED_API` in `.env` or run set environment variable, e.g.:
```bash
# export and run
export SEASONED_API=http://localhost:31459
yarn dev
# or run with environment variable inline
SEASONED_API=http://localhost:31459 yarn dev
```
## Documentation
All api functions are documented in `/docs` and [found here](docs/api.md).
[html version also available](http://htmlpreview.github.io/?https://github.com/KevinMidboe/seasoned/blob/release/v2/docs/api/index.html)
## License
[MIT](https://github.com/dmtrbrl/tmdb-app/blob/master/LICENSE)

66
eslint.config.mjs Normal file
View File

@@ -0,0 +1,66 @@
import path from "node:path";
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import { defineConfig } from "eslint/config";
import { configs, plugins } from "eslint-config-airbnb-extended";
import { rules as prettierConfigRules } from "eslint-config-prettier";
import prettierPlugin from "eslint-plugin-prettier";
const CUSTOM_RULES = {
"vue/no-v-model-argument": "off",
"no-underscore-dangle": "off",
"vue/multi-word-component-names": "off"
};
const gitignorePath = path.resolve(".", ".gitignore");
// ESLint recommended config
const jsConfig = defineConfig([
{
name: "js/config",
...js.configs.recommended
},
plugins.stylistic,
plugins.importX,
...configs.base.recommended // Airbnb base recommended config
]);
// Node & Airbnb recommended config
const nodeConfig = defineConfig([plugins.node, ...configs.node.recommended]);
// Typescript & Airbnb base TS config
const typescriptConfig = defineConfig([
plugins.typescriptEslint,
...configs.base.typescript
// rules.typescript.typescriptEslintStrict
]);
// Prettier config
const prettierConfig = defineConfig([
{
name: "prettier/plugin/config",
plugins: {
prettier: prettierPlugin
}
},
{
name: "prettier/config",
rules: {
...prettierConfigRules,
"prettier/prettier": "error"
}
}
]);
export default defineConfig([
// Ignore files and folders listed in .gitignore
includeIgnoreFile(gitignorePath),
...jsConfig,
...nodeConfig,
...typescriptConfig,
...prettierConfig,
{
rules: CUSTOM_RULES
}
]);

File diff suppressed because one or more lines are too long

View File

@@ -1,45 +1,34 @@
{
"name": "seasoned-request",
"description": "seasoned request app",
"version": "1.0.0",
"version": "1.22.17",
"author": "Kevin Midboe",
"private": true,
"scripts": {
"dev": "cross-env NODE_ENV=development webpack-dev-server --hot",
"build": "cross-env NODE_ENV=production webpack --progress --hide-modules",
"start": "node server.js",
"docs": "documentation build src/api.js -f html -o docs/api && documentation build src/api.js -f md -o docs/api.md"
"dev": "NODE_ENV=development vite",
"build": "yarn vite build",
"lint": "eslint src; prettier -c src",
"clean": "rm -rf dist/ yarn-*.log 2>/dev/null",
"docs": "documentation build src/api.ts -f html -o docs/api && documentation build src/api.ts -f md -o docs/api.md"
},
"dependencies": {
"axios": "^0.18.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"chart.js": "^2.9.2",
"connect-history-api-fallback": "^1.3.0",
"express": "^4.16.1",
"vue": "^2.5.2",
"vue-axios": "^1.2.2",
"vue-data-tablee": "^0.12.1",
"vue-js-modal": "^1.3.16",
"vue-router": "^3.0.1",
"vuex": "^3.1.0"
"chart.js": "3.9.1",
"vue": "3.5.28",
"vue-router": "5.0.3",
"vuex": "4.1.0"
},
"devDependencies": {
"@babel/core": "^7.4.5",
"@babel/plugin-transform-runtime": "^7.4.4",
"@babel/preset-env": "^7.4.5",
"@babel/runtime": "^7.4.5",
"babel-loader": "^8.0.6",
"cross-env": "^3.0.0",
"css-loader": "^3.4.2",
"documentation": "^11.0.0",
"file-loader": "^0.9.0",
"node-sass": "^4.5.0",
"sass-loader": "^5.0.1",
"schema-utils": "^2.4.1",
"vue-loader": "^10.0.0",
"vue-svg-inline-loader": "^1.3.1",
"vue-template-compiler": "2.6.10",
"webpack": "^2.2.0",
"webpack-dev-server": "^2.2.0"
"@eslint/compat": "^2.0.2",
"@eslint/js": "^10.0.1",
"@types/node": "^25.3.0",
"@vitejs/plugin-vue": "^5.2.1",
"eslint": "^10.0.1",
"eslint-config-airbnb-extended": "^3.0.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"prettier": "^3.8.1",
"sass": "1.54.3",
"typescript": "5.9.3",
"vite": "^6.0.3"
}
}

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

BIN
public/assets/dune.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 275 KiB

View File

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 423 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 889 B

After

Width:  |  Height:  |  Size: 889 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,23 +0,0 @@
var express = require('express');
var path = require('path');
const compression = require('compression')
var history = require('connect-history-api-fallback');
app = express();
app.use(compression())
app.use('/dist', express.static(path.join(__dirname + "/dist")));
app.use('/dist', express.static(path.join(__dirname + "/dist/")));
app.use('/favicons', express.static(path.join(__dirname + "/favicons")));
app.use(history({
index: '/'
}));
var port = process.env.PORT || 5000;
app.get('/', function(req, res) {
res.sendFile(path.join(__dirname + '/index.html'));
});
app.listen(port);

View File

@@ -1,159 +1,73 @@
<template>
<div id="app">
<div id="content">
<!-- Header and hamburger navigation -->
<navigation></navigation>
<NavigationHeader class="header" />
<!-- Header with search field -->
<!-- TODO move this to the navigation component -->
<header class="header">
<search-input v-model="query"></search-input>
</header>
<!-- Movie popup that will show above existing rendered content -->
<movie-popup v-if="moviePopupIsVisible" :id="popupID" :type="popupType"></movie-popup>
<darkmode-toggle />
<div class="navigation-icons-gutter desktop-only">
<NavigationIcons />
</div>
<!-- Display the component assigned to the given route (default: home) -->
<router-view class="content" :key="$route.fullPath"></router-view>
<router-view :key="router.currentRoute.value.path" class="content" />
<!-- Popup that will show above existing rendered content -->
<popup />
<!-- Command Palette for quick navigation -->
<command-palette />
</div>
</template>
<script>
import Vue from 'vue'
import Navigation from '@/components/Navigation'
import MoviePopup from '@/components/MoviePopup'
import SearchInput from '@/components/SearchInput'
import DarkmodeToggle from '@/components/ui/darkmodeToggle'
<script setup lang="ts">
import { useRouter } from "vue-router";
import NavigationHeader from "@/components/header/NavigationHeader.vue";
import NavigationIcons from "@/components/header/NavigationIcons.vue";
import Popup from "@/components/Popup.vue";
import CommandPalette from "@/components/ui/CommandPalette.vue";
export default {
name: 'app',
components: {
Navigation,
MoviePopup,
SearchInput,
DarkmodeToggle
},
data() {
return {
query: '',
moviePopupIsVisible: false,
popupID: 0,
popupType: 'movie'
}
},
created(){
let that = this
Vue.prototype.$popup = {
get isOpen() {
return that.moviePopupIsVisible
},
open: (id, type) => {
this.popupID = id || this.popupID
this.popupType = type || this.popupType
this.moviePopupIsVisible = true
console.log('opened')
},
close: () => {
this.moviePopupIsVisible = false
console.log('closed')
}
}
console.log('MoviePopup registered at this.$popup and has state: ', this.$popup.isOpen)
}
}
const router = useRouter();
</script>
<style lang="scss" scoped>
@import "./src/scss/media-queries";
@import "./src/scss/variables";
.content {
@include tablet-min{
width: calc(100% - 95px);
margin-top: $header-size;
margin-left: 95px;
position: relative;
}
}
</style>
<style lang="scss">
// @import "./src/scss/main";
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "scss/main";
@import "scss/media-queries";
*{
box-sizing: border-box;
}
html {
height: 100%;
}
body{
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
line-height: 1.6;
background: $background-color;
color: $text-color;
transition: background-color .5s ease, color .5s ease;
&.hidden{
overflow: hidden;
#content {
display: grid;
grid-template-rows: var(--header-size);
grid-template-columns: var(--header-size) 100%;
@include mobile {
grid-template-columns: 1fr;
}
.header {
position: fixed;
top: 0;
width: 100%;
z-index: 15;
}
.navigation-icons-gutter {
position: fixed;
height: 100vh;
margin: 0;
top: var(--header-size);
width: var(--header-size);
background-color: var(--background-color-secondary);
}
.content {
display: grid;
grid-column: 2 / 3;
width: calc(100% - var(--header-size));
grid-row: 2;
z-index: 5;
@include mobile {
grid-column: 1 / 3;
width: 100%;
}
}
}
}
h1,h2,h3 {
transition: color .5s ease;
}
a:any-link {
color: inherit;
}
input, textarea, button{
font-family: 'Roboto', sans-serif;
}
figure{
padding: 0;
margin: 0;
}
img{
display: block;
// max-width: 100%;
height: auto;
}
.no-scroll {
overflow: hidden;
}
.wrapper{
position: relative;
}
.header{
position: fixed;
z-index: 15;
display: flex;
flex-direction: column;
@include tablet-min{
width: calc(100% - 170px);
margin-left: 95px;
border-top: 0;
border-bottom: 0;
top: 0;
}
}
// router view transition
.fade-enter-active, .fade-leave-active {
transition-property: opacity;
transition-duration: 0.25s;
}
.fade-enter-active {
transition-delay: 0.25s;
}
.fade-enter, .fade-leave-active {
opacity: 0
}
</style>

View File

@@ -1,490 +0,0 @@
import axios from 'axios'
import storage from '@/storage'
import config from '@/config.json'
import path from 'path'
import store from '@/store'
const SEASONED_URL = config.SEASONED_URL
const ELASTIC_URL = config.ELASTIC_URL
const ELASTIC_INDEX = config.ELASTIC_INDEX
// TODO
// - Move autorization token and errors here?
const checkStatusAndReturnJson = (response) => {
if (!response.ok) {
throw resp
}
return response.json()
}
// - - - TMDB - - -
/**
* Fetches tmdb movie by id. Can optionally include cast credits in result object.
* @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getMovie = (id, checkExistance=false, credits=false, release_dates=false) => {
const url = new URL('v2/movie', SEASONED_URL)
url.pathname = path.join(url.pathname, id.toString())
if (checkExistance) {
url.searchParams.append('check_existance', true)
}
if (credits) {
url.searchParams.append('credits', true)
}
if(release_dates) {
url.searchParams.append('release_dates', true)
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => { console.error(`api error getting movie: ${id}`); throw error })
}
/**
* Fetches tmdb show by id. Can optionally include cast credits in result object.
* @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getShow = (id, checkExistance=false, credits=false) => {
const url = new URL('v2/show', SEASONED_URL)
url.pathname = path.join(url.pathname, id.toString())
if (checkExistance) {
url.searchParams.append('check_existance', true)
}
if (credits) {
url.searchParams.append('credits', true)
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => { console.error(`api error getting show: ${id}`); throw error })
}
/**
* Fetches tmdb person by id. Can optionally include cast credits in result object.
* @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getPerson = (id, credits=false) => {
const url = new URL('v2/person', SEASONED_URL)
url.pathname = path.join(url.pathname, id.toString())
if (credits) {
url.searchParams.append('credits', true)
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => { console.error(`api error getting person: ${id}`); throw error })
}
/**
* Fetches tmdb list by name.
* @param {string} name List the fetch
* @param {number} [page=1]
* @returns {object} Tmdb list response
*/
const getTmdbMovieListByName = (name, page=1) => {
const url = new URL('v2/movie/' + name, SEASONED_URL)
url.searchParams.append('page', page)
const headers = { authorization: storage.token }
return fetch(url.href, { headers: headers })
.then(resp => resp.json())
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error })
}
/**
* Fetches requested items.
* @param {number} [page=1]
* @returns {object} Request response
*/
const getRequests = (page=1) => {
const url = new URL('v2/request', SEASONED_URL)
url.searchParams.append('page', page)
const headers = { authorization: storage.token }
return fetch(url.href, { headers: headers })
.then(resp => resp.json())
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error })
}
const getUserRequests = (page=1) => {
const url = new URL('v1/user/requests', SEASONED_URL)
url.searchParams.append('page', page)
const headers = { authorization: localStorage.getItem('token') }
return fetch(url.href, { headers })
.then(resp => resp.json())
}
/**
* Fetches tmdb movies and shows by query.
* @param {string} query
* @param {number} [page=1]
* @returns {object} Tmdb response
*/
const searchTmdb = (query, page=1, adult=false, mediaType=null) => {
const url = new URL('v2/search', SEASONED_URL)
if (mediaType != null && ['movie', 'show', 'person'].includes(mediaType)) {
url.pathname += `/${mediaType}`
}
url.searchParams.append('query', query)
url.searchParams.append('page', page)
url.searchParams.append('adult', adult)
const headers = { authorization: localStorage.getItem('token') }
return fetch(url.href, { headers })
.then(resp => resp.json())
.catch(error => { console.error(`api error searching: ${query}, page: ${page}`); throw error })
}
// - - - Torrents - - -
/**
* Search for torrents by query
* @param {string} query
* @param {boolean} credits Include credits
* @returns {object} Torrent response
*/
const searchTorrents = (query, authorization_token) => {
const url = new URL('/api/v1/pirate/search', SEASONED_URL)
url.searchParams.append('query', query)
const headers = { authorization: storage.token }
return fetch(url.href, { headers: headers })
.then(resp => resp.json())
.catch(error => { console.error(`api error searching torrents: ${query}`); throw error })
}
/**
* Add magnet to download queue.
* @param {string} magnet Magnet link
* @param {boolean} name Name of torrent
* @param {boolean} tmdb_id
* @returns {object} Success/Failure response
*/
const addMagnet = (magnet, name, tmdb_id) => {
const url = new URL('v1/pirate/add', SEASONED_URL)
const body = JSON.stringify({
magnet: magnet,
name: name,
tmdb_id: tmdb_id
})
const headers = {
'Content-Type': 'application/json',
authorization: storage.token
}
return fetch(url.href, {
method: 'POST',
headers,
body
})
.then(resp => resp.json())
.catch(error => { console.error(`api error adding magnet: ${name} ${error}`); throw error })
}
// - - - Plex/Request - - -
/**
* Request a movie or show from id. If authorization token is included the user will be linked
* to the requested item.
* @param {number} id Movie or show id
* @param {string} type Movie or show type
* @param {string} [authorization_token] To identify the requesting user
* @returns {object} Success/Failure response
*/
const request = (id, type, authorization_token=undefined) => {
const url = new URL('v2/request', SEASONED_URL)
// url.pathname = path.join(url.pathname, id.toString())
// url.searchParams.append('type', type)
const headers = {
'Authorization': authorization_token,
'Content-Type': 'application/json'
}
const body = {
id: id,
type: type
}
return fetch(url.href, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
})
.then(resp => resp.json())
.catch(error => { console.error(`api error requesting: ${id}, type: ${type}`); throw error })
}
/**
* Check request status by tmdb id and type
* @param {number} tmdb id
* @param {string} type
* @returns {object} Success/Failure response
*/
const getRequestStatus = (id, type, authorization_token=undefined) => {
const url = new URL('v2/request', SEASONED_URL)
url.pathname = path.join(url.pathname, id.toString())
url.searchParams.append('type', type)
return fetch(url.href)
.then(resp => {
const status = resp.status;
if (status === 200) { return true }
else if (status === 404) { return false }
else {
console.error(`api error getting request status for id ${id} and type ${type}`)
}
})
.catch(err => Promise.reject(err))
}
const watchLink = (title, year, authorization_token=undefined) => {
const url = new URL('v1/plex/watch-link', SEASONED_URL)
url.searchParams.append('title', title)
url.searchParams.append('year', year)
const headers = {
'Authorization': authorization_token,
'Content-Type': 'application/json'
}
return fetch(url.href, { headers })
.then(resp => resp.json())
.then(response => response.link)
}
// - - - Seasoned user endpoints - - -
const register = (username, password) => {
const url = new URL('v1/user', SEASONED_URL)
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
}
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error('Unexpected error occured before receiving response. Error:', error)
// TODO log to sentry the issue here
throw error
})
}
const login = (username, password, throwError=false) => {
const url = new URL('v1/user/login', SEASONED_URL)
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
}
return fetch(url.href, options)
.then(resp => {
if (resp.status == 200)
return resp.json();
if (throwError)
throw resp;
else
console.error("Error occured when trying to sign in.\nError:", resp);
})
}
const getSettings = () => {
const settingsExists = (value) => {
if (value instanceof Object && value.hasOwnProperty('settings'))
return value;
throw "Settings does not exist in response object.";
}
const commitSettingsToStore = (response) => {
store.dispatch('userModule/setSettings', response.settings)
return response
}
const url = new URL('v1/user/settings', SEASONED_URL)
const authorization_token = localStorage.getItem('token')
const headers = authorization_token ? {
'Authorization': authorization_token,
'Content-Type': 'application/json'
} : {}
return fetch(url.href, { headers })
.then(resp => resp.json())
.then(settingsExists)
.then(commitSettingsToStore)
.then(response => response.settings)
.catch(error => { console.log('api error getting user settings'); throw error })
}
const updateSettings = (settings) => {
const url = new URL('v1/user/settings', SEASONED_URL)
const authorization_token = localStorage.getItem('token')
const headers = authorization_token ? {
'Authorization': authorization_token,
'Content-Type': 'application/json'
} : {}
return fetch(url.href, {
method: 'PUT',
headers,
body: JSON.stringify(settings)
})
.then(resp => resp.json())
.catch(error => { console.log('api error updating user settings'); throw error })
}
// - - - Authenticate with plex - - -
const linkPlexAccount = (username, password) => {
const url = new URL('v1/user/link_plex', SEASONED_URL)
const body = { username, password }
const headers = {
'Content-Type': 'application/json',
authorization: storage.token
}
return fetch(url.href, {
method: 'POST',
headers,
body: JSON.stringify(body)
})
.then(resp => resp.json())
.catch(error => { console.error(`api error linking plex account: ${username}`); throw error })
}
const unlinkPlexAccount = (username, password) => {
const url = new URL('v1/user/unlink_plex', SEASONED_URL)
const headers = {
'Content-Type': 'application/json',
authorization: storage.token
}
return fetch(url.href, {
method: 'POST',
headers
})
.then(resp => resp.json())
.catch(error => { console.error(`api error unlinking plex account: ${username}`); throw error })
}
// - - - User graphs - - -
const fetchChart = (urlPath, days, chartType) => {
const url = new URL('v1/user' + urlPath, SEASONED_URL)
url.searchParams.append('days', days)
url.searchParams.append('y_axis', chartType)
const authorization_token = localStorage.getItem('token')
const headers = authorization_token ? {
'Authorization': authorization_token,
'Content-Type': 'application/json'
} : {}
return fetch(url.href, { headers })
.then(resp => resp.json())
.catch(error => { console.log('api error fetching chart'); throw error })
}
// - - - Random emoji - - -
const getEmoji = () => {
const url = new URL('v1/emoji', SEASONED_URL)
return fetch(url.href)
.then(resp => resp.json())
.catch(error => { console.log('api error getting emoji'); throw error })
}
// - - - ELASTIC SEARCH - - -
// This elastic index contains titles mapped to ids. Lightning search
// used for autocomplete
/**
* Search elastic indexes movies and shows by query. Doc includes Tmdb daily export of Movies and
* Tv Shows. See tmdb docs for more info: https://developers.themoviedb.org/3/getting-started/daily-file-exports
* @param {string} query
* @returns {object} List of movies and shows matching query
*/
const elasticSearchMoviesAndShows = (query) => {
const url = new URL(path.join(ELASTIC_INDEX, '/_search'), ELASTIC_URL)
const headers = {
'Content-Type': 'application/json'
}
const body = {
"sort" : [
{ "popularity" : {"order" : "desc"}},
"_score"
],
"query": {
"bool": {
"should": [{
"match_phrase_prefix": {
"original_name": query
}
},
{
"match_phrase_prefix": {
"original_title": query
}
}]
}
},
"size": 6
}
return fetch(url.href, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
})
.then(resp => resp.json())
.catch(error => { console.log(`api error searching elasticsearch: ${query}`); throw error })
}
export {
getMovie,
getShow,
getPerson,
getTmdbMovieListByName,
searchTmdb,
getUserRequests,
getRequests,
searchTorrents,
addMagnet,
request,
watchLink,
getRequestStatus,
linkPlexAccount,
unlinkPlexAccount,
register,
login,
getSettings,
updateSettings,
fetchChart,
getEmoji,
elasticSearchMoviesAndShows
}

587
src/api.ts Normal file
View File

@@ -0,0 +1,587 @@
/* eslint-disable n/no-unsupported-features/node-builtins */
import {
IList,
IMediaCredits,
IPersonCredits,
MediaTypes
} from "./interfaces/IList";
import type {
IRequestStatusResponse,
IRequestSubmitResponse
} from "./interfaces/IRequestResponse";
const API_HOSTNAME = import.meta.env.VITE_SEASONED_API;
const ELASTIC_URL = import.meta.env.VITE_ELASTIC_URL;
const ELASTIC_API_KEY = import.meta.env.VITE_ELASTIC_API_KEY;
// - - - TMDB - - -
interface GetMediaOpts {
checkExistance: boolean;
credits: boolean;
releaseDates?: boolean;
}
const getMovie = async (id: number, opts: GetMediaOpts) => {
const url = new URL("/api/v2/movie", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
const { checkExistance, credits, releaseDates } = opts;
if (checkExistance) {
url.searchParams.append("check_existance", "true");
}
if (credits) {
url.searchParams.append("credits", "true");
}
if (releaseDates) {
url.searchParams.append("release_dates", "true");
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting movie: ${id}`); // eslint-disable-line no-console
throw error;
});
};
// Fetches tmdb show by id. Can optionally include cast credits in result object.
const getShow = async (id: number, opts: GetMediaOpts) => {
const url = new URL("/api/v2/show", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
const { checkExistance, credits, releaseDates } = opts;
if (checkExistance) {
url.searchParams.append("check_existance", "true");
}
if (credits) {
url.searchParams.append("credits", "true");
}
if (releaseDates) {
url.searchParams.append("release_dates", "true");
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting show: ${id}`); // eslint-disable-line no-console
throw error;
});
};
// Fetches tmdb person by id. Can optionally include cast credits in result object.
const getPerson = async (id: number, credits = false) => {
const url = new URL("/api/v2/person", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
if (credits) {
url.searchParams.append("credits", "true");
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting person: ${id}`); // eslint-disable-line no-console
throw error;
});
};
// Fetches tmdb movie credits by id.
const getMovieCredits = async (id: number): Promise<IMediaCredits> => {
const url = new URL("/api/v2/movie", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}/credits`;
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting movie: ${id}`); // eslint-disable-line no-console
throw error;
});
};
// Fetches tmdb show credits by id.
const getShowCredits = async (id: number): Promise<IMediaCredits> => {
const url = new URL("/api/v2/show", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}/credits`;
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting show: ${id}`); // eslint-disable-line no-console
throw error;
});
};
// Fetches tmdb person credits by id.
const getPersonCredits = async (id: number): Promise<IPersonCredits> => {
const url = new URL("/api/v2/person", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}/credits`;
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting person: ${id}`); // eslint-disable-line no-console
throw error;
});
};
// Fetches tmdb list by name.
const getTmdbMovieListByName = async (
name: string,
page = 1
): Promise<IList> => {
const url = new URL(`/api/v2/movie/${name}`, API_HOSTNAME);
url.searchParams.append("page", page.toString());
return fetch(url.href).then(resp => resp.json());
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console
};
// Fetches requested items.
const getRequests = async (page = 1) => {
const url = new URL("/api/v2/request", API_HOSTNAME);
url.searchParams.append("page", page.toString());
return fetch(url.href).then(resp => resp.json());
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console
};
const getUserRequests = async (page = 1) => {
const url = new URL("/api/v1/user/requests", API_HOSTNAME);
url.searchParams.append("page", page.toString());
const options: RequestInit = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options).then(resp => resp.json());
};
// Fetches tmdb movies and shows by query.
const searchTmdb = async (
query: string,
page = 1,
adult = false,
mediaType = null
) => {
const url = new URL("/api/v2/search", API_HOSTNAME);
if (mediaType != null && ["movie", "show", "person"].includes(mediaType)) {
url.pathname += `/${mediaType}`;
}
url.searchParams.append("query", query);
url.searchParams.append("page", page.toString());
url.searchParams.append("adult", adult.toString());
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error searching: ${query}, page: ${page}`); // eslint-disable-line no-console
throw error;
});
};
// - - - Torrents - - -
// Search for torrents by query
const searchTorrents = async (query: string) => {
const url = new URL("/api/v1/pirate/search", API_HOSTNAME);
url.searchParams.append("query", query);
const options: RequestInit = {
credentials: "include"
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error searching torrents: ${query}`); // eslint-disable-line no-console
throw error;
});
};
// Add magnet to download queue.
const addMagnet = async (
magnet: string,
name: string,
tmdbId: number | null
) => {
const url = new URL("/api/v1/pirate/add", API_HOSTNAME);
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
magnet,
name,
tmdb_id: tmdbId
})
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error adding magnet: ${name} ${error}`); // eslint-disable-line no-console
throw error;
});
};
// - - - Plex/Request - - -
// Request a movie or show from id. If authorization token is included the user will be linked
const request = async (
id: number,
type: MediaTypes.Movie | MediaTypes.Show
): Promise<IRequestSubmitResponse> => {
const url = new URL("/api/v2/request", API_HOSTNAME);
const options = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, type })
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error requesting: ${id}, type: ${type}`); // eslint-disable-line no-console
throw error;
});
};
// Check request status by tmdb id and type
const getRequestStatus = async (
id: number,
type = null
): Promise<IRequestStatusResponse> => {
const url = new URL("/api/v2/request", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
url.searchParams.append("type", type);
return fetch(url.href)
.then(resp => resp.json())
.catch(err => Promise.reject(err));
};
const watchLink = async (title: string, year: string) => {
const url = new URL("/api/v1/plex/watch-link", API_HOSTNAME);
url.searchParams.append("title", title);
url.searchParams.append("year", year);
const options: RequestInit = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options)
.then(resp => resp.json())
.then(response => response.link);
};
/*
const movieImages = id => {
const url = new URL(`v2/movie/${id}/images`, API_HOSTNAME);
return fetch(url.href).then(resp => resp.json());
};
*/
// - - - Seasoned user endpoints - - -
const register = async (username: string, password: string) => {
const url = new URL("/api/v1/user", API_HOSTNAME);
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password })
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
const errorMessage =
"Unexpected error occured before receiving response. Error:";
// eslint-disable-next-line no-console
console.error(errorMessage, error);
// TODO log to sentry the issue here
throw error;
});
};
const login = async (
username: string,
password: string,
throwError = false
) => {
const url = new URL("/api/v1/user/login", API_HOSTNAME);
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password })
};
return fetch(url.href, options).then(resp => {
if (resp.status === 200) return resp.json();
if (throwError) return Promise.reject(resp.text().then(t => new Error(t)));
console.error("Error occured when trying to sign in.\nError:", resp); // eslint-disable-line no-console
return Promise.reject(resp);
});
};
const logout = async (throwError = false) => {
const url = new URL("/api/v1/user/logout", API_HOSTNAME);
const options: RequestInit = { method: "POST", credentials: "include" };
return fetch(url.href, options).then(resp => {
if (resp.status === 200) return resp.json();
if (throwError) return Promise.reject(resp.text().then(t => new Error(t)));
console.error("Error occured when trying to log out.\nError:", resp); // eslint-disable-line no-console
return Promise.reject(resp);
});
};
const getSettings = async () => {
const url = new URL("/api/v1/user/settings", API_HOSTNAME);
const options: RequestInit = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.log("api error getting user settings"); // eslint-disable-line no-console
throw error;
});
};
const updateSettings = async (settings: any) => {
const url = new URL("/api/v1/user/settings", API_HOSTNAME);
const options: RequestInit = {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(settings)
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.log("api error updating user settings"); // eslint-disable-line no-console
throw error;
});
};
// - - - Authenticate with plex - - -
const linkPlexAccount = async (authToken: string) => {
const url = new URL("/api/v1/user/link_plex", API_HOSTNAME);
const body = { authToken };
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body)
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error("api error linking plex account"); // eslint-disable-line no-console
throw error;
});
};
const unlinkPlexAccount = async () => {
const url = new URL("/api/v1/user/unlink_plex", API_HOSTNAME);
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error unlinking your plex account`); // eslint-disable-line no-console
throw error;
});
};
const plexRecentlyAddedInLibrary = async (id: number) => {
const url = new URL(`/api/v2/plex/recently_added/${id}`, API_HOSTNAME);
const options: RequestInit = {
credentials: "include"
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error fetch plex recently added`); // eslint-disable-line no-console
throw error;
});
};
// - - - User graphs - - -
const fetchGraphData = async (
urlPath: string,
days: number,
chartType: string
) => {
const url = new URL(`/api/v1/user/${urlPath}`, API_HOSTNAME);
url.searchParams.append("days", String(days));
url.searchParams.append("y_axis", chartType);
const options: RequestInit = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options).then(resp => {
if (!resp.ok) {
console.log("DAMN WE FAILED!", resp); // eslint-disable-line no-console
throw Error(resp.statusText);
}
return resp.json();
});
};
// - - - Random emoji - - -
const getEmoji = async () => {
const url = new URL("/api/v1/emoji", API_HOSTNAME);
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.log("api error getting emoji"); // eslint-disable-line no-console
throw error;
});
};
// - - - ELASTIC SEARCH - - -
// This elastic index contains titles mapped to ids. Lightning search
// used for autocomplete
interface TimeoutRequestInit extends RequestInit {
timeout: number;
}
async function fetchWithTimeout(url: string, options: TimeoutRequestInit) {
const { timeout = 2000 } = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timer);
return response;
}
/**
* Search elastic indexes movies and shows by query. Doc includes Tmdb daily export of Movies and
* Tv Shows. See tmdb docs for more info: https://developers.themoviedb.org/3/getting-started/daily-file-exports
*/
const elasticSearchMoviesAndShows = async (query: string, count = 22) => {
const url = new URL(`${ELASTIC_URL}/_search`);
const body = {
sort: [{ popularity: { order: "desc" } }, "_score"],
size: count,
query: {
multi_match: {
query,
fields: ["name", "original_title", "original_name"],
type: "phrase_prefix",
tie_breaker: 0.3
}
},
suggest: {
text: query,
"person-suggest": {
prefix: query,
completion: {
field: "name.completion",
fuzzy: {
fuzziness: "AUTO"
}
}
},
"movie-suggest": {
prefix: query,
completion: {
field: "original_title.completion",
fuzzy: {
fuzziness: "AUTO"
}
}
},
"show-suggest": {
prefix: query,
completion: {
field: "original_name.completion",
fuzzy: {
fuzziness: "AUTO"
}
}
}
}
};
const options = {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `ApiKey ${ELASTIC_API_KEY}`
},
body: JSON.stringify(body),
timeout: 1000
};
return fetchWithTimeout(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.log(`api error searching elasticsearch: ${query}`); // eslint-disable-line no-console
throw error;
});
};
export {
API_HOSTNAME,
getMovie,
getShow,
getPerson,
getMovieCredits,
getShowCredits,
getPersonCredits,
getTmdbMovieListByName,
searchTmdb,
getUserRequests,
getRequests,
searchTorrents,
addMagnet,
request,
getRequestStatus,
linkPlexAccount,
unlinkPlexAccount,
plexRecentlyAddedInLibrary,
register,
login,
logout,
getSettings,
updateSettings,
fetchGraphData,
watchLink,
getEmoji,
elasticSearchMoviesAndShows
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,75 +0,0 @@
<template>
<div>
<section class="not-found">
<h1 class="not-found__title">Page Not Found</h1>
</section>
<seasoned-button class="button" @click="goBack">go back to previous page</seasoned-button>
</div>
</template>
<script>
import store from '@/store'
import SeasonedButton from '@/components/ui/SeasonedButton'
export default {
components: { SeasonedButton },
methods: {
goBack() {
this.$router.go(-1)
}
},
created() {
if (this.$popup.isOpen == true)
this.$popup.close()
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.button {
font-size: 1.2rem;
position: fixed;
top: 50%;
left: calc(50% + 46px);
transform: translate(-50%, -50%);
@include mobile {
top: 60%;
left: 50%;
font-size: 1rem;
width: content;
}
}
.not-found {
display: flex;
height: calc(100vh - var(--header-size));
background: url('~assets/pulp-fiction.jpg') no-repeat 50% 50%;
background-size: cover;
align-items: center;
flex-direction: column;
&::before {
content: "";
position: absolute;
height: calc(100vh - var(--header-size));
width: 100%;
pointer-events: none;
background: $background-40;
}
&__title {
margin-top: 30vh;
font-size: 2.5rem;
font-weight: 500;
color: $text-color;
position: relative;
@include tablet-min {
font-size: 3.5rem;
}
}
}
</style>

View File

@@ -1,316 +0,0 @@
<template>
<div class="wrapper" v-if="hasPlexUser">
<h1>Your watch activity</h1>
<div class="filter">
<h2>Filter</h2>
<div class="filter-item">
<label class="desktop-only">Days:</label>
<input class="dayinput"
v-model="days"
placeholder="number of days"
type="number"
pattern="[0-9]*"
:style="{maxWidth: `${3 + (0.5 * days.length)}rem`}"/>
<!-- <datalist id="days">
<option v-for="index in 1500" :value="index" :key="index"></option>
</datalist> -->
</div>
<toggle-button class="filter-item" :options="chartTypes" :selected.sync="selectedChartDataType" />
</div>
<div class="chart-section">
<h3 class="chart-header">Activity per day:</h3>
<div class="chart">
<canvas ref="activityCanvas"></canvas>
</div>
<h3 class="chart-header">Activity per day of week:</h3>
<div class="chart">
<canvas ref="playsByDayOfWeekCanvas"></canvas>
</div>
</div>
</div>
<div v-else>
<h1>Must be authenticated</h1>
</div>
</template>
<script>
import store from '@/store'
import ToggleButton from '@/components/ui/ToggleButton';
import { fetchChart } from '@/api'
var Chart = require('chart.js');
Chart.defaults.global.elements.point.radius = 0
Chart.defaults.global.elements.point.hitRadius = 10
Chart.defaults.global.elements.point.pointHoverRadius = 10
Chart.defaults.global.elements.point.hoverBorderWidth = 4
export default {
components: { ToggleButton },
data() {
return {
days: 30,
selectedChartDataType: 'plays',
charts: [{
name: 'Watch activity',
ref: 'activityCanvas',
data: null,
urlPath: '/plays_by_day',
graphType: 'line'
}, {
name: 'Plays by day of week',
ref: 'playsByDayOfWeekCanvas',
data: null,
urlPath: '/plays_by_dayofweek',
graphType: 'bar'
}],
chartData: [{
type: 'plays',
tooltipLabel: 'Play count',
},{
type: 'duration',
tooltipLabel: 'Watched duration',
valueConvertFunction: this.convertSecondsToHumanReadable
}],
gridColor: getComputedStyle(document.documentElement).getPropertyValue('--text-color-5')
}
},
computed: {
hasPlexUser() {
return store.getters['userModule/plex_userid'] != null ? true : false
},
chartTypes() {
return this.chartData.map(chart => chart.type)
},
selectedChartType() {
return this.chartData.filter(data => data.type == this.selectedChartDataType)[0]
}
},
watch: {
hasPlexUser(newValue, oldValue) {
if (newValue != oldValue && newValue == true) {
this.fetchChartData(this.charts)
}
},
days(newValue) {
if (newValue !== '') {
this.fetchChartData(this.charts)
}
},
selectedChartDataType(selectedChartDataType) {
this.fetchChartData(this.charts)
}
},
beforeMount() {
if (typeof(this.days) == 'number') {
this.days = this.days.toString()
}
},
methods: {
fetchChartData(charts) {
if (this.hasPlexUser == false) {
return
}
for (let chart of charts) {
fetchChart(chart.urlPath, this.days, this.selectedChartType.type)
.then(data => {
this.series = data.data.series.filter(group => group.name === 'TV')[0].data; // plays pr date in groups (movie/tv/music)
this.categories = data.data.categories; // dates
const x_labels = data.data.categories.map(date => {
if (date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
const [year, month, day] = date.split('-')
return `${day}.${month}`
}
return date
})
let y_activityMovies = data.data.series.filter(group => group.name === 'Movies')[0].data
let y_activityTV = data.data.series.filter(group => group.name === 'TV')[0].data
const datasets = [{
label: `Movies watch last ${ this.days } days`,
data: y_activityMovies,
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
},
{
label: `Shows watch last ${ this.days } days`,
data: y_activityTV,
backgroundColor: 'rgba(255, 159, 64, 0.2)',
borderColor: 'rgba(255, 159, 64, 1)',
borderWidth: 1
}
]
if (chart.data == null) {
this.generateChart(chart, x_labels, datasets)
} else {
chart.data.clear();
chart.data.data.labels = x_labels;
chart.data.data.datasets = datasets;
chart.data.update();
}
})
}
},
generateChart(chart, labels, datasets) {
const chartInstance = new Chart(this.$refs[chart.ref], {
type: chart.graphType,
data: {
labels: labels,
datasets: datasets
},
options: {
// hitRadius: 8,
maintainAspectRatio: false,
tooltips: {
callbacks: {
title: (tooltipItem, data) => `Watch date: ${tooltipItem[0].label}`,
label: (tooltipItem, data) => {
let label = data.datasets[tooltipItem.datasetIndex].label
let value = tooltipItem.value;
let text = 'Duration watched'
const context = label.split(' ')[0]
if (context) {
text = `${context} ${this.selectedChartType.tooltipLabel.toLowerCase()}`
}
if (this.selectedChartType.valueConvertFunction) {
value = this.selectedChartType.valueConvertFunction(tooltipItem.value)
}
return ` ${text}: ${value}`
}
}
},
scales: {
yAxes: [{
gridLines: {
color: this.gridColor
},
stacked: chart.graphType === 'bar',
ticks: {
// suggestedMax: 10000,
callback: (value, index, values) => {
if (this.selectedChartType.valueConvertFunction) {
return this.selectedChartType.valueConvertFunction(value, values)
}
return value
},
beginAtZero: true
}
}],
xAxes: [{
stacked: chart.graphType === 'bar',
gridLines: {
display: false,
}
}]
}
}
});
chart.data = chartInstance;
},
convertSecondsToHumanReadable(value, values=null) {
const highestValue = values ? values[0] : value;
// minutes
if (highestValue < 3600) {
const minutes = Math.floor(value / 60);
value = `${minutes} m`
}
// hours and minutes
else if (highestValue > 3600 && highestValue < 86400) {
const hours = Math.floor(value / 3600);
const minutes = Math.floor(value % 3600 / 60);
value = hours != 0 ? `${hours} h ${minutes} m` : `${minutes} m`
}
// days and hours
else if (highestValue > 86400 && highestValue < 31557600) {
const days = Math.floor(value / 86400);
const hours = Math.floor(value % 86400 / 3600);
value = days != 0 ? `${days} d ${hours} h` : `${hours} h`
}
// years and days
else if (highestValue > 31557600) {
const years = Math.floor(value / 31557600);
const days = Math.floor(value % 31557600 / 86400);
value = years != 0 ? `${years} y ${days} d` : `${days} d`
}
return value
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
.wrapper {
padding: 2rem;
@include mobile-only {
padding: 0 0.8rem;
}
}
.filter {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
margin-bottom: 2rem;
h2 {
margin-bottom: 0.5rem;
width: 100%;
font-weight: 400;
}
&-item:not(:first-of-type) {
margin-left: 1rem;
}
.dayinput {
font-size: 1.2rem;
max-width: 3rem;
background-color: $background-ui;
color: $text-color;
}
}
.chart-section {
display: flex;
flex-wrap: wrap;
.chart {
position: relative;
height: 35vh;
width: 90vw;
margin-bottom: 2rem;
}
.chart-header {
font-weight: 300;
}
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div class="cast">
<ol class="persons">
<CastListItem
v-for="credit in cast"
:key="credit.id"
:credit-item="credit"
/>
</ol>
</div>
</template>
<script setup lang="ts">
import { defineProps } from "vue";
import CastListItem from "@/components/CastListItem.vue";
import type {
IMovie,
IShow,
IPerson,
ICast,
ICrew
} from "../interfaces/IList";
interface Props {
cast: Array<IMovie | IShow | IPerson | ICast | ICrew>;
}
defineProps<Props>();
</script>
<style lang="scss">
.cast {
position: relative;
top: 0;
left: 0;
ol {
overflow-x: scroll;
padding: 0;
list-style-type: none;
margin: 0;
display: flex;
scrollbar-width: none; /* for Firefox */
&::-webkit-scrollbar {
display: none; /* for Chrome, Safari, and Opera */
}
}
}
</style>

View File

@@ -0,0 +1,187 @@
<template>
<li class="cast-card">
<a
class="cast-card__link"
role="button"
tabindex="0"
:aria-label="ariaLabel"
@click="openCastItem"
@keydown.enter="openCastItem"
>
<div class="cast-card__image-wrapper">
<img
class="cast-card__image"
:src="pictureUrl"
:alt="imageAltText"
loading="lazy"
/>
</div>
<div class="cast-card__content">
<p class="cast-card__name">{{ creditItem.name || creditItem.title }}</p>
<p v-if="metaText" class="cast-card__meta">{{ metaText }}</p>
</div>
</a>
</li>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useStore } from "vuex";
import type { ICast, ICrew, IMovie, IShow } from "../interfaces/IList";
interface Props {
creditItem: ICast | ICrew | IMovie | IShow;
}
const props = defineProps<Props>();
const store = useStore();
const pictureUrl = computed(() => {
const baseUrl = "https://image.tmdb.org/t/p/w185";
if ("profile_path" in props.creditItem && props.creditItem.profile_path) {
return baseUrl + props.creditItem.profile_path;
}
if ("poster" in props.creditItem && props.creditItem.poster) {
return baseUrl + props.creditItem.poster;
}
return "/assets/no-image_small.svg";
});
const metaText = computed(() => {
if ("character" in props.creditItem && props.creditItem.character) {
return props.creditItem.character;
}
if ("job" in props.creditItem && props.creditItem.job) {
return props.creditItem.job;
}
if ("year" in props.creditItem && props.creditItem.year) {
return props.creditItem.year;
}
return "";
});
const imageAltText = computed(() => {
const name = props.creditItem.name || (props.creditItem as any).title || "";
if ("character" in props.creditItem) {
return `${name} as ${props.creditItem.character}`;
}
if ("job" in props.creditItem) {
return `${name}, ${props.creditItem.job}`;
}
return name ? `Poster for ${name}` : "No image available";
});
const ariaLabel = computed(() => {
const name = props.creditItem.name || (props.creditItem as any).title || "";
if ("character" in props.creditItem && props.creditItem.character) {
return `View ${name}, played ${props.creditItem.character}`;
}
if ("job" in props.creditItem && props.creditItem.job) {
return `View ${name}, ${props.creditItem.job}`;
}
return `View ${name}`;
});
function openCastItem() {
store.dispatch("popup/open", { ...props.creditItem });
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
.cast-card {
list-style: none;
margin: 0 10px 10px 0;
width: 150px;
flex-shrink: 0;
&:first-of-type {
margin-left: 0;
}
}
.cast-card__link {
display: flex;
flex-direction: column;
height: 100%;
text-decoration: none;
color: inherit;
cursor: pointer;
border-radius: 10px;
overflow: hidden;
background-color: var(
--highlight-secondary,
var(--background-color-secondary)
);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover,
&:focus {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
outline: none;
}
&:focus-visible {
outline: 2px solid var(--highlight-color);
outline-offset: 2px;
}
}
.cast-card__image-wrapper {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
overflow: hidden;
background: linear-gradient(
135deg,
var(--background-color) 0%,
var(--background-color-secondary) 100%
);
}
.cast-card__image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cast-card__content {
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 60px;
}
.cast-card__name {
margin: 0;
font-size: 0.95rem;
font-weight: 500;
line-height: 1.3;
color: var(--highlight-bg, var(--text-color));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.cast-card__meta {
margin: 0;
font-size: 0.85rem;
font-weight: 400;
line-height: 1.3;
color: var(--highlight-bg, var(--text-color-70));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,277 @@
<template>
<section class="discover-minimal">
<div class="discover-minimal-header">
<div class="header-content">
<h2 class="discover-title">Explore Collections</h2>
<p class="discover-description">
Curated selections organized by genre, mood, and decade
</p>
</div>
<router-link to="/discover" class="view-all-link">
<span class="desktop-only">View All Categories </span>
<span class="mobile-only">View All </span>
</router-link>
</div>
<DiscoverShowcase @select="navigateToDiscover" />
<div class="featured-collections-wrapper">
<div class="featured-collections-header">
<div class="header-decorator"></div>
<h3 class="featured-title">Featured Picks</h3>
<div class="header-decorator"></div>
</div>
<div class="featured-collections">
<ResultsSection
v-for="list in featuredLists"
:key="list.id"
:api-function="list.apiFunction"
:title="list.title"
:short-list="true"
section-type="discover"
/>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import ResultsSection from "@/components/ResultsSection.vue";
import DiscoverShowcase from "@/components/DiscoverShowcase.vue";
import { getTmdbMovieDiscoverByName } from "../api";
const router = useRouter();
const featuredLists = [
{
id: "feel_good",
title: "Feel Good",
apiFunction: () => getTmdbMovieDiscoverByName("feel_good")
},
{
id: "2000s_classics",
title: "2000s Classics",
apiFunction: () => getTmdbMovieDiscoverByName("2000s_classics")
},
{
id: "horror_hits",
title: "Horror Hits",
apiFunction: () => getTmdbMovieDiscoverByName("horror_hits")
}
];
function navigateToDiscover(categoryId?: string) {
router.push(`/discover${categoryId ? `?category=${categoryId}` : ""}`);
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.discover-minimal {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.01) 0%,
rgba(255, 255, 255, 0.03) 50%,
rgba(255, 255, 255, 0.01) 100%
);
padding: 3rem 0;
position: relative;
margin: 2rem 0;
width: 100%;
&::before {
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 90%;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.2) 50%,
transparent 100%
);
}
&::after {
content: "";
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 90%;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.15) 50%,
transparent 100%
);
}
@include mobile {
padding: 1rem 0 0.5rem;
margin: 0;
background: transparent;
&::before,
&::after {
display: none;
}
}
}
.discover-minimal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1.5rem 2rem;
gap: 1rem;
@include mobile {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 1rem 0.6rem;
gap: 0.75rem;
}
.header-content {
flex: 1;
@include mobile {
min-width: 0;
}
}
.discover-title {
margin: 0 0 0.5rem;
font-size: 2rem;
font-weight: 600;
color: var(--text-color);
letter-spacing: -0.5px;
@include mobile {
font-size: 1.75rem;
margin: 0 0 0.15rem;
font-weight: 600;
}
}
.discover-description {
margin: 0;
font-size: 0.95rem;
color: $text-color-70;
font-weight: 300;
@include mobile {
display: none;
}
}
.view-all-link {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 25px;
color: $text-color-70;
font-size: 0.9rem;
font-weight: 400;
text-decoration: none;
transition: all 0.3s ease;
white-space: nowrap;
@include mobile {
padding: 0.45rem 0.85rem;
font-size: 0.75rem;
border-radius: 20px;
flex-shrink: 0;
}
&:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
color: var(--text-color);
transform: translateX(2px);
}
}
}
.featured-collections-wrapper {
padding-top: 2rem;
position: relative;
@include mobile {
margin-top: 0;
padding-top: 0.5rem;
}
}
.featured-collections-header {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0 1.5rem 1.5rem;
max-width: 1400px;
margin: 0 auto;
@include mobile {
padding: 0 1rem 0.4rem;
gap: 0.4rem;
}
.header-decorator {
flex: 1;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0.3) 100%
);
&:last-child {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.3) 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
}
}
.featured-title {
margin: 0;
font-size: 1.4rem;
font-weight: 500;
color: var(--text-color);
letter-spacing: 0.5px;
white-space: nowrap;
text-transform: uppercase;
font-size: 0.9rem;
color: $text-color-70;
@include mobile {
font-size: 0.8rem;
}
}
}
.featured-collections {
background: rgba(0, 0, 0, 0.15);
border-radius: 20px;
max-width: calc(100% - 4rem);
margin: 0 auto;
@include mobile {
border-radius: 12px;
padding: 0.25rem 0;
max-width: calc(100% - 2rem);
}
}
</style>

View File

@@ -0,0 +1,360 @@
<template>
<div class="category-showcase">
<div class="categories-grid">
<button
v-for="category in categories"
:key="category.id"
class="category-card"
:class="[
`category-${category.id}`,
{ active: activeCategory === category.id }
]"
@click="$emit('select', category.id)"
>
<component :is="category.icon" class="category-icon" />
<div class="category-info">
<h3 class="category-name">{{ category.label }}</h3>
<p class="category-count">
<span class="desktop-only">{{ category.count }} collections</span>
<span class="mobile-only">{{ category.count }}</span>
</p>
</div>
<div class="category-arrow"></div>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import IconPopular from "@/icons/IconPopular.vue";
import IconSpotlights from "@/icons/IconSpotlights.vue";
import IconTheater from "@/icons/IconTheater.vue";
import IconCalendar from "@/icons/IconCalendar.vue";
import IconStar from "@/icons/IconStar.vue";
interface Props {
activeCategory?: string;
}
withDefaults(defineProps<Props>(), {
activeCategory: ""
});
defineEmits<{
select: [categoryId: string];
}>();
const router = useRouter();
const categories = [
{ id: "popular", label: "Popular", icon: IconPopular, count: 5 },
{ id: "genres", label: "Genres", icon: IconSpotlights, count: 13 },
{ id: "moods", label: "Moods & Themes", icon: IconTheater, count: 7 },
{ id: "decades", label: "By Decade", icon: IconCalendar, count: 4 },
{ id: "special", label: "Special Collections", icon: IconStar, count: 11 }
];
function navigateToDiscover(categoryId?: string) {
router.push(`/discover${categoryId ? `?category=${categoryId}` : ""}`);
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.category-showcase {
padding: 1.5rem;
padding-top: 0;
@include mobile {
padding: 0 1rem 0.6rem;
}
}
.categories-grid {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
max-width: 1200px;
margin: 0 auto;
justify-content: center;
@include mobile {
gap: 0.45rem;
}
}
.category-card {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.9rem;
padding: 1rem 1.5rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 50px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
@include mobile {
padding: 0.45rem 0.7rem;
gap: 0.4rem;
border-radius: 20px;
}
&.category-popular {
background: rgba(255, 80, 80, 0.15);
border-color: rgba(255, 80, 80, 0.3);
.category-icon {
fill: rgba(255, 120, 120, 0.9);
}
}
&.category-genres {
background: rgba(80, 140, 255, 0.15);
border-color: rgba(80, 140, 255, 0.3);
.category-icon {
fill: rgba(120, 170, 255, 0.9);
}
}
&.category-moods {
background: rgba(160, 80, 255, 0.15);
border-color: rgba(160, 80, 255, 0.3);
.category-icon {
fill: rgba(180, 120, 255, 0.9);
}
}
&.category-decades {
background: rgba(80, 200, 200, 0.15);
border-color: rgba(80, 200, 200, 0.3);
.category-icon {
fill: rgba(100, 220, 220, 0.9);
}
}
&.category-special {
background: rgba(255, 180, 80, 0.15);
border-color: rgba(255, 180, 80, 0.3);
.category-icon {
fill: rgba(255, 200, 120, 0.9);
}
}
&.active {
transform: translateY(-3px) scale(1.03);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
&::before {
opacity: 1;
}
.category-icon {
transform: rotate(5deg) scale(1.15);
}
.category-arrow {
opacity: 1;
transform: translateX(4px);
}
&.category-popular {
background: rgba(255, 80, 80, 0.3);
border-color: rgba(255, 80, 80, 0.6);
.category-icon {
fill: rgba(255, 160, 160, 1);
}
}
&.category-genres {
background: rgba(80, 140, 255, 0.3);
border-color: rgba(80, 140, 255, 0.6);
.category-icon {
fill: rgba(160, 210, 255, 1);
}
}
&.category-moods {
background: rgba(160, 80, 255, 0.3);
border-color: rgba(160, 80, 255, 0.6);
.category-icon {
fill: rgba(220, 160, 255, 1);
}
}
&.category-decades {
background: rgba(80, 200, 200, 0.3);
border-color: rgba(80, 200, 200, 0.6);
.category-icon {
fill: rgba(140, 255, 255, 1);
}
}
&.category-special {
background: rgba(255, 180, 80, 0.3);
border-color: rgba(255, 180, 80, 0.6);
.category-icon {
fill: rgba(255, 230, 160, 1);
}
}
}
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.15) 0%,
transparent 100%
);
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover {
transform: translateY(-3px) scale(1.03);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
&.category-popular {
background: rgba(255, 80, 80, 0.25);
border-color: rgba(255, 80, 80, 0.5);
.category-icon {
fill: rgba(255, 140, 140, 1);
}
}
&.category-genres {
background: rgba(80, 140, 255, 0.25);
border-color: rgba(80, 140, 255, 0.5);
.category-icon {
fill: rgba(140, 190, 255, 1);
}
}
&.category-moods {
background: rgba(160, 80, 255, 0.25);
border-color: rgba(160, 80, 255, 0.5);
.category-icon {
fill: rgba(200, 140, 255, 1);
}
}
&.category-decades {
background: rgba(80, 200, 200, 0.25);
border-color: rgba(80, 200, 200, 0.5);
.category-icon {
fill: rgba(120, 240, 240, 1);
}
}
&.category-special {
background: rgba(255, 180, 80, 0.25);
border-color: rgba(255, 180, 80, 0.5);
.category-icon {
fill: rgba(255, 220, 140, 1);
}
}
&::before {
opacity: 1;
}
.category-icon {
transform: rotate(5deg) scale(1.15);
}
.category-arrow {
opacity: 1;
transform: translateX(4px);
}
}
.category-icon {
width: 24px;
height: 24px;
fill: var(--text-color);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
@include mobile {
width: 16px;
height: 16px;
}
}
.category-info {
display: flex;
align-items: center;
gap: 0.6rem;
line-height: 1;
@include mobile {
gap: 0.4rem;
}
}
.category-name {
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: white;
white-space: nowrap;
@include mobile {
font-size: 0.8rem;
}
}
.category-count {
margin: 0;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
background: rgba(255, 255, 255, 0.1);
padding: 0.2rem 0.6rem;
border-radius: 12px;
white-space: nowrap;
@include mobile {
font-size: 0.65rem;
padding: 0.15rem 0.4rem;
}
}
.category-arrow {
font-size: 1.1rem;
color: white;
opacity: 0;
transition: all 0.3s ease;
margin-left: 0.25rem;
@include mobile {
display: none;
}
}
}
</style>

234
src/components/Graph.vue Normal file
View File

@@ -0,0 +1,234 @@
<template>
<div class="graph-wrapper">
<canvas ref="graphCanvas"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, onBeforeUnmount } from "vue";
import {
Chart,
LineElement,
BarElement,
PointElement,
LineController,
BarController,
LinearScale,
CategoryScale,
Legend,
Title,
Tooltip,
Filler,
ChartType
} from "chart.js";
import type { BarOptions, ChartOptions } from "chart.js";
import type { Ref } from "vue";
import { convertSecondsToHumanReadable } from "../utils";
import { GraphTypes, GraphValueTypes } from "../interfaces/IGraph";
import type { IGraphDataset, IGraphData } from "../interfaces/IGraph";
Chart.register(
LineElement,
BarElement,
PointElement,
LineController,
BarController,
LinearScale,
CategoryScale,
Legend,
Title,
Tooltip,
Filler
);
interface Props {
name?: string;
data: IGraphData;
type: ChartType;
stacked: boolean;
datasetDescriptionSuffix: string;
tooltipDescriptionSuffix: string;
graphValueType?: GraphValueTypes;
}
const props = defineProps<Props>();
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
let graphInstance: Chart | null = null;
const graphTemplates = [
{
borderColor: "#6366F1",
backgroundColor: "rgba(99,102,241,0.12)"
},
{
borderColor: "#F59E0B",
backgroundColor: "rgba(245,158,11,0.12)"
},
{
borderColor: "#10B981",
backgroundColor: "rgba(16,185,129,0.12)"
}
];
onMounted(() => generateGraph());
watch(() => props.data, generateGraph, { deep: true });
onBeforeUnmount(() => {
if (graphInstance) graphInstance.destroy();
});
function removeEmptyDataset(dataset: IGraphDataset) {
return dataset;
return !dataset.data.every(point => point === 0);
}
function hydrateDataset(dataset: IGraphDataset, index: number) {
const base = graphTemplates[index % graphTemplates.length];
if (props.type === "bar") {
return {
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
data: dataset.data,
backgroundColor: base.borderColor,
inflateAmount: 0,
borderRadius: {
topLeft: 8,
topRight: 8,
bottomLeft: 8,
bottomRight: 8
},
borderSkipped: false,
borderWidth: 2,
borderColor: "transparent",
// Slight spacing between categories
barPercentage: 0.8,
categoryPercentage: 0.9
} as BarOptions;
}
// Line chart — subtle, minimal points
return {
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
data: dataset.data,
borderColor: base.borderColor,
backgroundColor: base.backgroundColor,
borderWidth: 2,
tension: 0.35,
fill: true,
pointRadius: 2,
pointHoverRadius: 5,
pointHitRadius: 12,
pointBackgroundColor: base.borderColor,
pointBorderColor: base.borderColor,
pointBorderWidth: 0
};
}
function generateGraph() {
if (!graphCanvas.value) return;
const datasets = props.data.series
.filter(removeEmptyDataset)
.map(hydrateDataset);
const chartData = {
labels: props.data.labels,
datasets
};
const options: ChartOptions = {
maintainAspectRatio: false,
responsive: true,
layout: {
padding: { top: 8 }
},
plugins: {
legend: {
display: true
},
tooltip: {
backgroundColor: "#111827",
bodyColor: "#e5e7eb",
padding: 12,
cornerRadius: 8,
displayColors: true,
callbacks: {
label: (tooltipItem: any) => {
const context = tooltipItem.dataset.label.split(" ")[0];
let type = GraphTypes.Plays;
let value = tooltipItem.raw;
if (props.graphValueType === String(GraphTypes.Duration)) {
value = convertSecondsToHumanReadable(value);
type = GraphTypes.Duration;
}
const text = `${context} ${type}`;
return `${text}: ${value}`;
}
}
}
},
scales: {
x: {
stacked: props.stacked,
grid: {
display: false,
drawBorder: false
},
ticks: {
color: "#9CA3AF",
font: { size: 11 }
}
},
y: {
stacked: props.stacked,
beginAtZero: true,
grid: {
color: "rgba(0,0,0,0.04)",
drawBorder: false
},
ticks: {
color: "#9CA3AF",
font: { size: 11 },
padding: 8,
callback: (value: number) => {
if (props.graphValueType === String(GraphTypes.Duration)) {
return convertSecondsToHumanReadable(value);
}
return value;
}
}
}
}
};
if (graphInstance) {
graphInstance.data = chartData;
graphInstance.update();
return;
}
graphInstance = new Chart(graphCanvas.value, {
type: props.type,
data: chartData,
options
});
}
</script>
<style scoped lang="scss">
.graph-wrapper {
position: relative;
width: 100%;
height: 100%;
min-height: 240px;
}
</style>

View File

@@ -1,87 +0,0 @@
<template>
<section>
<LandingBanner />
<div v-for="list in lists">
<list-header :title="list.title" :link="'/list/' + list.route" />
<results-list :results="list.data" :shortList="true" />
<loader v-if="!list.data.length" />
</div>
</section>
</template>
<script>
import LandingBanner from "@/components/LandingBanner";
import ListHeader from "@/components/ListHeader";
import ResultsList from "@/components/ResultsList";
import Loader from "@/components/ui/Loader";
import { getTmdbMovieListByName, getRequests } from "@/api";
export default {
name: "home",
components: { LandingBanner, ResultsList, ListHeader, Loader },
data() {
return {
imageFile: "/pulp-fiction.jpg",
requests: [],
nowplaying: [],
upcoming: [],
popular: []
};
},
computed: {
lists() {
return [
{
title: "Requests",
route: "request",
data: this.requests
},
{
title: "Now playing",
route: "now_playing",
data: this.nowplaying
},
{
title: "Upcoming",
route: "upcoming",
data: this.upcoming
},
{
title: "Popular",
route: "popular",
data: this.popular
}
];
}
},
methods: {
fetchRequests() {
getRequests().then(results => (this.requests = results.results));
},
fetchNowPlaying() {
getTmdbMovieListByName("now_playing").then(
results => (this.nowplaying = results.results)
);
},
fetchUpcoming() {
getTmdbMovieListByName("upcoming").then(
results => (this.upcoming = results.results)
);
},
fetchPopular() {
getTmdbMovieListByName("popular").then(
results => (this.popular = results.results)
);
}
},
created() {
this.fetchRequests();
this.fetchNowPlaying();
this.fetchUpcoming();
this.fetchPopular();
}
};
</script>

View File

@@ -1,94 +1,218 @@
<template>
<header v-bind:style="{ 'background-image': 'url(' + imageFile + ')' }">
<header ref="headerElement" :class="{ expanded, noselect: true }">
<img ref="imageElement" :src="bannerImage" alt="Page banner image" />
<div class="container">
<h1 class="title">Request movies or tv shows</h1>
<strong class="subtitle"
>Create a profile to track and view requests</strong
>
</div>
<div
class="expand-icon"
@click="expand"
@keydown.enter="expand"
@mouseover="upgradeImage"
@focus="focus"
>
<IconExpand v-if="!expanded" />
<IconShrink v-else />
</div>
</header>
</template>
<script>
export default {
props: {
image: {
type: String,
required: false
}
},
data() {
return {
imageFile: "/pulp-fiction.jpg"
};
},
beforeMount() {
if (this.image && this.image.length > 0) {
this.imageFile = this.image;
<script setup lang="ts">
import { ref } from "vue";
import IconExpand from "@/icons/IconExpand.vue";
import IconShrink from "@/icons/IconShrink.vue";
import type { Ref } from "vue";
const ASSET_URL = "https://request.movie/assets/";
const images: Array<string> = [
"pulp-fiction.jpg",
"arrival.jpg",
"disaster-artist.jpg",
"dune.jpg",
"mandalorian.jpg"
];
const bannerImage: Ref<string> = ref();
const expanded: Ref<boolean> = ref(false);
const headerElement: Ref<HTMLElement> = ref(null);
const imageElement: Ref<HTMLImageElement> = ref(null);
const defaultHeaderHeight: Ref<string> = ref();
// const disableProxy = true;
function expand() {
expanded.value = !expanded.value;
let height = defaultHeaderHeight?.value;
if (expanded.value) {
const aspectRation =
imageElement.value.naturalHeight / imageElement.value.naturalWidth;
height = `${imageElement.value.clientWidth * aspectRation}px`;
defaultHeaderHeight.value = headerElement.value.style.height;
}
headerElement.value.style.setProperty("--header-height", height);
}
};
function focus(event: FocusEvent) {
event.preventDefault();
}
function randomImage(): string {
const image = images[Math.floor(Math.random() * images.length)];
return ASSET_URL + image;
}
bannerImage.value = randomImage();
// function sliceToHeaderSize(url: string): string {
// let width = headerElement.value?.getBoundingClientRect()?.width || 1349;
// let height = headerElement.value?.getBoundingClientRect()?.height || 261;
// if (disableProxy) return url;
// return buildProxyURL(width, height, url);
// }
// function upgradeImage() {
// if (disableProxy || imageUpgraded.value == true) return;
// const headerSize = 90;
// const height = window.innerHeight - headerSize;
// const width = window.innerWidth - headerSize;
// const proxyHost = `http://imgproxy.schleppe:8080/insecure/`;
// const proxySizeOptions = `q:65/plain/`;
// bannerImage.value = `${proxyHost}${proxySizeOptions}${
// ASSET_URL + image.value
// }`;
// }
// function buildProxyURL(width: number, height: number, asset: string): string {
// const proxyHost = `http://imgproxy.schleppe:8080/insecure/`;
// const proxySizeOptions = `resize:fill:${width}:${height}:ce/q:65/plain/`;
// return `${proxyHost}${proxySizeOptions}${asset}`;
// }
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "scss/variables";
@import "scss/media-queries";
header {
width: 100%;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
position: relative;
@include tablet-min {
height: 284px;
}
&:before {
content: "";
position: absolute;
top: 0;
left: 0;
header {
width: 100%;
height: 100%;
background-color: $background-70;
transition: background-color 0.5s ease;
}
.container {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: color 0.5s ease;
}
transition: height 0.5s ease;
overflow: hidden;
--header-height: 25vh;
.title {
font-weight: 500;
font-size: 22px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: $text-color;
margin: 0;
height: var(--header-height);
@include tablet-min {
font-size: 2.5rem;
> * {
z-index: 1;
}
img {
position: absolute;
z-index: 0;
object-fit: cover;
width: 100%;
}
&.expanded {
// height: calc(100vh - var(--header-size));
// width: calc(100vw - var(--header-size));
// @include mobile {
// width: 100vw;
// height: 100vh;
// }
&:before {
background-color: transparent;
}
.title,
.subtitle {
opacity: 0;
}
}
.expand-icon {
visibility: hidden;
opacity: 0;
transition: all 0.5s ease-in-out;
height: 1.8rem;
width: 1.8rem;
fill: var(--text-color-50);
position: absolute;
top: 0.5rem;
right: 1rem;
&:hover {
cursor: pointer;
fill: var(--text-color-90);
}
}
&:hover {
.expand-icon {
visibility: visible;
opacity: 1;
}
}
&:before {
content: "";
z-index: 1;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--background-70);
transition: inherit;
}
.container {
text-align: center;
position: relative;
transition: color 0.5s ease;
}
.title {
font-weight: 500;
font-size: 22px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: $text-color;
margin: 0;
opacity: 1;
@include tablet-min {
font-size: 2.5rem;
}
}
.subtitle {
display: block;
font-size: 14px;
font-weight: 300;
color: $text-color-70;
margin: 5px 0;
opacity: 1;
@include tablet-min {
font-size: 1.3rem;
}
}
}
.subtitle {
display: block;
font-size: 14px;
font-weight: 300;
color: $text-color-70;
margin: 5px 0;
@include tablet-min {
font-size: 1.3rem;
}
}
}
</style>

View File

@@ -1,106 +0,0 @@
<template>
<header :class="{ 'sticky': sticky }">
<h2>{{ title }}</h2>
<div v-if="info instanceof Array" class="flex flex-direction-column">
<span v-for="item in info" class="info">{{ item }}</span>
</div>
<span v-else class="info">{{ info }}</span>
<router-link v-if="link" :to="link" class='view-more' :aria-label="`View all ${title}`">
View All
</router-link>
</header>
</template>
<script>
export default {
props: {
title: {
type: String,
required: true
},
sticky: {
type: Boolean,
required: false,
default: true
},
info: {
type: [String, Array],
required: false
},
link: {
type: String,
required: false
}
}
}
</script>
<style lang="scss" scoped>
@import './src/scss/variables';
@import './src/scss/media-queries';
@import './src/scss/main';
header {
width: 100%;
min-height: 80px;
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 0.75rem;
padding-right: 0.75rem;
&.sticky {
background-color: $background-color;
position: sticky;
position: -webkit-sticky;
top: $header-size;
z-index: 4;
}
h2 {
font-size: 1.4rem;
font-weight: 300;
text-transform: capitalize;
line-height: 1.4rem;
margin: 0;
color: $text-color;
}
.view-more {
font-size: 0.9rem;
font-weight: 300;
letter-spacing: .5px;
color: $text-color-70;
text-decoration: none;
transition: color .5s ease;
cursor: pointer;
&:after{
content: " →";
}
&:hover{
color: $text-color;
}
}
.info {
font-size: 13px;
font-weight: 300;
letter-spacing: .5px;
color: $text-color;
text-decoration: none;
text-align: right;
}
@include tablet-min {
padding-left: 1.25rem;;
}
@include desktop-lg-min {
padding-left: 1.75rem;
}
}
</style>

View File

@@ -1,113 +0,0 @@
<template>
<div>
<list-header :title="listTitle" :info="info" :sticky="true" />
<results-list :results="results" v-if="results" />
<loader v-if="!results.length" />
<div v-if="page < totalPages" class="fullwidth-button">
<seasoned-button @click="loadMore">load more</seasoned-button>
</div>
</div>
</template>
<script>
import ListHeader from '@/components/ListHeader'
import ResultsList from '@/components/ResultsList'
import SeasonedButton from '@/components/ui/SeasonedButton'
import Loader from '@/components/ui/Loader'
import { getTmdbMovieListByName, getRequests } from '@/api'
import store from '@/store'
export default {
components: { ListHeader, ResultsList, SeasonedButton, Loader },
data() {
return {
legalTmdbLists: [ 'now_playing', 'upcoming', 'popular' ],
results: [],
page: 1,
totalPages: 0,
totalResults: 0,
loading: true
}
},
computed: {
listTitle() {
if (this.results.length === 0)
return ''
const routeListName = this.$route.params.name
console.log('routelistname', routeListName)
return routeListName.includes('_') ? routeListName.split('_').join(' ') : routeListName
},
info() {
if (this.results.length === 0)
return [null, null]
return [this.pageCount, this.resultCount]
},
resultCount() {
const loadedResults = this.results.length
const totalResults = this.totalResults < 10000 ? this.totalResults : '∞'
return `${loadedResults} of ${totalResults} results`
},
pageCount() {
return `Page ${this.page} of ${this.totalPages}`
}
},
methods: {
loadMore() {
console.log(this.$route)
this.loading = true;
this.page++
window.history.replaceState({}, 'search', `/#/${this.$route.fullPath}?page=${this.page}`)
this.init()
},
init() {
const routeListName = this.$route.params.name
if (routeListName === 'request') {
getRequests(this.page)
.then(results => {
this.results = this.results.concat(...results.results)
this.page = results.page
this.totalPages = results.total_pages
this.totalResults = results.total_results
})
} else if (this.legalTmdbLists.includes(routeListName)) {
getTmdbMovieListByName(routeListName, this.page)
.then(results => {
this.results = this.results.concat(...results.results)
this.page = results.page
this.totalPages = results.total_pages
this.totalResults = results.total_results
})
} else {
// TODO handle if list is not found
console.log('404 this is not a tmdb list')
}
this.loading = false
}
},
created() {
if (this.results.length === 0)
this.init()
store.dispatch('documentTitle/updateTitle', `${this.$router.history.current.name} ${this.$route.params.name}`)
}
}
</script>
<style lang="scss" scoped>
.fullwidth-button {
width: 100%;
margin: 1rem 0;
padding-bottom: 2rem;
display: flex;
justify-content: center;
}
</style>

View File

@@ -1,556 +0,0 @@
<template>
<section class="movie">
<!-- HEADER w/ POSTER -->
<header
ref="header"
:class="compact ? 'compact' : ''"
@click="compact = !compact"
>
<figure class="movie__poster">
<img
class="movie-item__img is-loaded"
ref="poster-image"
src="~assets/placeholder.png"
/>
</figure>
<h1 class="movie__title" v-if="movie">{{ movie.title }}</h1>
<loading-placeholder v-else :count="1" />
</header>
<!-- Siderbar and movie info -->
<div class="movie__main">
<div class="movie__wrap movie__wrap--main">
<!-- SIDEBAR ACTIONS -->
<div class="movie__actions" v-if="movie">
<sidebar-list-element
:iconRef="'#iconNot_exsits'"
:active="matched"
:iconRefActive="'#iconExists'"
:textActive="'Already in plex 🎉'"
>
Not yet in plex
</sidebar-list-element>
<sidebar-list-element
@click="sendRequest"
:iconRef="'#iconSent'"
:active="requested"
:textActive="'Requested to be downloaded'"
>
Request to be downloaded?
</sidebar-list-element>
<sidebar-list-element
v-if="isPlexAuthenticated && matched"
@click="openInPlex"
:iconString="'⏯ '"
>
Watch in plex now!
</sidebar-list-element>
<sidebar-list-element
v-if="admin"
@click="showTorrents = !showTorrents"
:iconRef="'#icon_torrents'"
:active="showTorrents"
:supplementaryText="numberOfTorrentResults"
>
Search for torrents
</sidebar-list-element>
<sidebar-list-element @click="openTmdb" :iconRef="'#icon_info'">
See more info
</sidebar-list-element>
</div>
<!-- Loading placeholder -->
<div class="movie__actions text-input__loading" v-else>
<div
class="movie__actions-link"
v-for="_ in admin ? Array(4) : Array(3)"
>
<div
class="movie__actions-text text-input__loading--line"
style="margin: 9px; margin-left: -3px"
></div>
</div>
</div>
<!-- MOVIE INFO -->
<div class="movie__info">
<!-- Loading placeholder -->
<div
class="movie__description noselect"
@click="truncatedDescription = !truncatedDescription"
v-if="!loading"
>
<span :class="truncatedDescription ? 'truncated' : null">{{
movie.overview
}}</span>
<button class="truncate-toggle"><i></i></button>
</div>
<div v-else class="movie__description">
<loading-placeholder :count="5" />
</div>
<div class="movie__details" v-if="movie">
<div v-if="movie.year">
<h2 class="title">Release Date</h2>
<div class="text">{{ movie.year }}</div>
</div>
<div v-if="movie.rating">
<h2 class="title">Rating</h2>
<div class="text">{{ movie.rating }}</div>
</div>
<div v-if="movie.type == 'show'">
<h2 class="title">Seasons</h2>
<div class="text">{{ movie.seasons }}</div>
</div>
<div v-if="movie.genres">
<h2 class="title">Genres</h2>
<div class="text">{{ movie.genres.join(", ") }}</div>
</div>
<div v-if="movie.type == 'show'">
<h2 class="title">Production status</h2>
<div class="text">{{ movie.production_status }}</div>
</div>
<div v-if="movie.type == 'show'">
<h2 class="title">Runtime</h2>
<div class="text">{{ movie.runtime[0] }} minutes</div>
</div>
</div>
</div>
<!-- TODO: change this classname, this is general -->
<div class="movie__admin" v-if="movie && movie.credits">
<h2 class="movie__details-title">Cast</h2>
<div style="display: flex; flex-wrap: wrap">
<person
v-for="cast in movie.credits.cast"
:info="cast"
style="flex-basis: 0"
></person>
</div>
</div>
</div>
<!-- TORRENT LIST -->
<TorrentList
v-if="movie"
:show="showTorrents"
:query="title"
:tmdb_id="id"
:admin="admin"
></TorrentList>
</div>
</section>
</template>
<script>
import storage from "@/storage";
import img from "@/directives/v-image";
import TorrentList from "./TorrentList";
import Person from "./Person";
import SidebarListElement from "./ui/sidebarListElem";
import store from "@/store";
import LoadingPlaceholder from "./ui/LoadingPlaceholder";
import {
getMovie,
getPerson,
getShow,
request,
getRequestStatus,
watchLink
} from "@/api";
export default {
// props: ['id', 'type'],
props: {
id: {
required: true,
type: Number
},
type: {
required: false,
type: String
}
},
components: { TorrentList, Person, LoadingPlaceholder, SidebarListElement },
directives: { img: img }, // TODO decide to remove or use
data() {
return {
ASSET_URL: "https://image.tmdb.org/t/p/",
ASSET_SIZES: ["w500", "w780", "original"],
movie: undefined,
title: undefined,
poster: undefined,
backdrop: undefined,
matched: false,
userLoggedIn: storage.sessionId ? true : false,
requested: false,
admin: localStorage.getItem("admin") == "true" ? true : false,
showTorrents: false,
compact: false,
loading: true,
truncatedDescription: true
};
},
watch: {
id: function (val) {
if (this.type === "movie") {
this.fetchMovie(val);
} else {
this.fetchShow(val);
}
},
backdrop: function (backdrop) {
if (backdrop != null) {
const style = {
backgroundImage:
"url(" + this.ASSET_URL + this.ASSET_SIZES[1] + backdrop + ")"
};
Object.assign(this.$refs.header.style, style);
}
}
},
computed: {
numberOfTorrentResults: () => {
let numTorrents = store.getters["torrentModule/resultCount"];
return numTorrents !== null ? numTorrents + " results" : null;
},
isPlexAuthenticated: () => {
return store.getters["userModule/isPlexAuthenticated"];
}
},
methods: {
parseResponse(movie) {
this.loading = false;
this.movie = { ...movie };
this.title = movie.title;
this.poster = movie.poster;
this.backdrop = movie.backdrop;
this.matched = movie.exists_in_plex || false;
this.checkIfRequested(movie).then(status => (this.requested = status));
store.dispatch("documentTitle/updateTitle", movie.title);
this.setPosterSrc();
},
async checkIfRequested(movie) {
return await getRequestStatus(movie.id, movie.type);
},
setPosterSrc() {
const poster = this.$refs["poster-image"];
if (this.poster == null) {
poster.src = "/no-image.png";
return;
}
poster.src = `${this.ASSET_URL}${this.ASSET_SIZES[0]}${this.poster}`;
},
sendRequest() {
request(this.id, this.type, storage.token).then(resp => {
if (resp.success) {
this.requested = true;
}
});
},
openInPlex() {
watchLink(this.title, this.movie.year, storage.token).then(
watchLink => (window.location = watchLink)
);
},
openTmdb() {
const tmdbType = this.type === "show" ? "tv" : this.type;
window.location.href =
"https://www.themoviedb.org/" + tmdbType + "/" + this.id;
}
},
created() {
this.prevDocumentTitle = store.getters["documentTitle/title"];
if (this.type === "movie") {
getMovie(this.id, true)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: "404" });
});
} else if (this.type == "person") {
getPerson(this.id, true)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: "404" });
});
} else {
getShow(this.id, true)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: "404" });
});
}
},
beforeDestroy() {
store.dispatch("documentTitle/updateTitle", this.prevDocumentTitle);
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/loading-placeholder";
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "./src/scss/main";
header {
$duration: 0.2s;
height: 250px;
transform: scaleY(1);
transition: height $duration ease;
transform-origin: top;
position: relative;
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
background-color: $background-color;
display: flex;
align-items: center;
@include tablet-min {
height: 350px;
}
&:before {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
background: $background-dark-85;
}
@include mobile {
&.compact {
height: 100px;
}
}
}
.movie__poster {
display: none;
@include desktop {
background: $background-color;
height: 0;
display: block;
position: absolute;
width: calc(45% - 40px);
top: 40px;
left: 40px;
> img {
width: 100%;
}
}
}
.truncate-toggle {
border: none;
background: none;
width: 100%;
display: flex;
align-items: center;
text-align: center;
color: $text-color;
> i {
font-style: unset;
font-size: 0.7rem;
transition: 0.3s ease all;
transform: rotateY(180deg);
}
&::before,
&::after {
content: "";
flex: 1;
border-bottom: 1px solid $text-color-50;
}
&::before {
margin-right: 1rem;
}
&::after {
margin-left: 1rem;
}
}
.movie {
&__wrap {
display: flex;
&--header {
align-items: center;
height: 100%;
}
&--main {
display: flex;
flex-wrap: wrap;
flex-direction: column;
@include tablet-min {
flex-direction: row;
}
background-color: $background-color;
color: $text-color;
}
}
&__img {
display: block;
width: 100%;
opacity: 0;
transform: scale(0.97) translateZ(0);
transition: opacity 0.5s ease, transform 0.5s ease;
&.is-loaded {
opacity: 1;
transform: scale(1);
}
}
&__title {
position: relative;
padding: 20px;
color: $green;
text-align: center;
width: 100%;
@include tablet-min {
width: 55%;
text-align: left;
margin-left: 45%;
padding: 30px 30px 30px 40px;
}
h1 {
font-weight: 500;
line-height: 1.4;
font-size: 24px;
@include tablet-min {
font-size: 30px;
}
}
}
&__main {
min-height: calc(100vh - 250px);
@include tablet-min {
min-height: 0;
}
height: 100%;
}
&__actions {
text-align: center;
width: 100%;
order: 2;
padding: 20px;
border-top: 1px solid $text-color-5;
@include tablet-min {
order: 1;
width: 45%;
padding: 185px 0 40px 40px;
border-top: 0;
}
}
&__info {
width: 100%;
padding: 20px;
order: 1;
@include tablet-min {
order: 2;
padding: 40px;
width: 55%;
margin-left: 45%;
}
}
&__info {
margin-left: 0;
}
&__description {
font-weight: 300;
font-size: 13px;
line-height: 1.8;
margin-bottom: 20px;
& .truncated {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
& + .truncate-toggle > i {
transform: rotateY(0deg) rotateZ(180deg);
}
}
@include tablet-min {
margin-bottom: 30px;
font-size: 14px;
}
}
&__details {
display: flex;
flex-wrap: wrap;
> div {
margin-bottom: 20px;
margin-right: 20px;
@include tablet-min {
margin-bottom: 30px;
margin-right: 30px;
}
& .title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
font-size: 14px;
color: $green;
@include tablet-min {
font-size: 16px;
}
}
& .text {
font-weight: 300;
font-size: 14px;
margin-top: 5px;
}
}
}
&__admin {
width: 100%;
padding: 20px;
order: 2;
@include tablet-min {
order: 3;
padding: 40px;
padding-top: 0px;
width: 100%;
}
&-title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
text-align: center;
font-size: 14px;
color: $green;
padding-bottom: 20px;
@include tablet-min {
font-size: 16px;
}
}
}
}
</style>

View File

@@ -1,12 +0,0 @@
<template>
<div class="container info">
<movie :id="$route.params.id" :type="'page'"></movie>
</div>
</template>
<script>
import Movie from './Movie';
export default {
components: { Movie }
}
</script>

View File

@@ -1,104 +0,0 @@
<template>
<div class="movie-popup" @click="$popup.close()">
<div class="movie-popup__box" @click.stop>
<movie :id="id" :type="type"></movie>
<button class="movie-popup__close" @click="$popup.close()"></button>
</div>
<i class="loader"></i>
</div>
</template>
<script>
import Movie from './Movie';
export default {
props: {
id: {
type: Number,
required: true
},
type: {
type: String,
required: true
}
},
components: { Movie },
methods: {
checkEventForEscapeKey(event) {
if (event.keyCode == 27) {
this.$popup.close()
}
}
},
created(){
window.addEventListener('keyup', this.checkEventForEscapeKey)
document.getElementsByTagName("body")[0].classList += " no-scroll";
},
beforeDestroy() {
window.removeEventListener('keyup', this.checkEventForEscapeKey)
document.getElementsByTagName("body")[0].classList.remove("no-scroll");
}
}
</script>
<style lang="scss">
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.movie-popup{
position: fixed;
top: 0;
left: 0;
z-index: 20;
width: 100%;
height: 100vh;
background: rgba($dark, 0.93);
-webkit-overflow-scrolling: touch;
overflow: auto;
&__box{
width: 100%;
max-width: 768px;
position: relative;
z-index: 5;
background: $background-color-secondary;
padding-bottom: 50px;
@include tablet-min{
padding-bottom: 0;
margin: 40px auto;
}
}
&__close{
display: block;
position: absolute;
top: 0;
right: 0;
border: 0;
background: transparent;
width: 40px;
height: 40px;
transition: background 0.5s ease;
cursor: pointer;
&:before,
&:after{
content: "";
display: block;
position: absolute;
top: 19px;
left: 10px;
width: 20px;
height: 2px;
background: $white;
}
&:before{
transform: rotate(45deg);
}
&:after{
transform: rotate(-45deg);
}
&:hover{
background: $green;
}
}
}
</style>

View File

@@ -1,249 +0,0 @@
<template>
<li class="movie-item" :class="{ shortList: shortList }">
<figure class="movie-item__poster">
<img
class="movie-item__img"
ref="poster-image"
@click="openMoviePopup(movie.id, movie.type)"
:alt="posterAltText"
:data-src="poster"
src="~assets/placeholder.png"
/>
<div v-if="movie.download" class="progress">
<progress :value="movie.download.progress" max="100"></progress>
<span>{{ movie.download.state }}: {{ movie.download.progress }}%</span>
</div>
</figure>
<div class="movie-item__info">
<p v-if="movie.title || movie.name">{{ movie.title || movie.name }}</p>
<p v-if="movie.year">{{ movie.year }}</p>
<p v-if="movie.type == 'person'">
Known for: {{ movie.known_for_department }}
</p>
</div>
</li>
</template>
<script>
import img from "../directives/v-image";
export default {
props: {
movie: {
type: Object,
required: true
},
shortList: {
type: Boolean,
required: false
}
},
directives: {
img: img
},
data() {
return {
poster: undefined,
observed: false,
posterSizes: [
{
id: "w500",
minWidth: 500
},
{
id: "w342",
minWidth: 342
},
{
id: "w185",
minWidth: 185
},
{
id: "w154",
minWidth: 0
}
]
};
},
computed: {
posterAltText: function () {
const type = this.movie.type || "";
const title = this.movie.title || this.movie.name;
return this.movie.poster
? `Poster for ${type} ${title}`
: `Missing image for ${type} ${title}`;
}
},
beforeMount() {
if (this.movie.poster != null) {
this.poster = "https://image.tmdb.org/t/p/w500" + this.movie.poster;
} else {
this.poster = "/no-image.png";
}
},
mounted() {
const poster = this.$refs["poster-image"];
if (poster == null) return;
const imageObserver = new IntersectionObserver((entries, imgObserver) => {
entries.forEach(entry => {
if (entry.isIntersecting && this.observed == false) {
const lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImage.className = lazyImage.className + " is-loaded";
this.observed = true;
}
});
});
imageObserver.observe(poster);
},
methods: {
openMoviePopup(id, type) {
this.$popup.open(id, type);
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "./src/scss/main";
.movie-item {
padding: 10px;
width: 50%;
background-color: $background-color;
transition: background-color 0.5s ease;
@include tablet-min {
padding: 15px;
width: 33%;
}
@include tablet-landscape-min {
padding: 15px;
width: 25%;
}
@include desktop-min {
padding: 15px;
width: 20%;
}
@include desktop-lg-min {
padding: 15px;
width: 12.5%;
}
&:hover &__info > p {
color: $text-color;
}
&__poster {
text-decoration: none;
color: $text-color-70;
font-weight: 300;
> img {
width: 100%;
opacity: 0;
transform: scale(0.97) translateZ(0);
transition: opacity 1s ease, transform 0.5s ease;
&.is-loaded {
opacity: 1;
transform: scale(1);
}
&:hover {
transform: scale(1.03);
box-shadow: 0 0 10px rgba($dark, 0.1);
}
}
}
&__info {
padding-top: 15px;
font-weight: 300;
> p {
color: $text-color-70;
margin: 0;
font-size: 11px;
letter-spacing: 0.5px;
transition: color 0.5s ease;
cursor: pointer;
@include mobile-ls-min {
font-size: 12px;
}
@include tablet-min {
font-size: 14px;
}
}
}
}
.no-image {
background-color: var(--text-color);
color: var(--background-color);
width: 100%;
height: 383px;
display: flex;
align-items: center;
justify-content: center;
span {
font-size: 1.5rem;
width: 70%;
text-align: center;
text-transform: uppercase;
}
&:hover {
transform: scale(1);
}
}
</style>
<style lang="scss" scoped>
@import "./src/scss/variables";
.progress {
position: absolute;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
margin-bottom: 0.8rem;
> progress {
width: 95%;
}
> span {
position: absolute;
font-size: 1rem;
line-height: 1.4rem;
color: $white;
}
progress {
border-radius: 4px;
height: 1.4rem;
}
progress::-webkit-progress-bar {
background-color: rgba($black, 0.55);
border-radius: 4px;
}
progress::-webkit-progress-value {
background-color: $green-70;
border-radius: 4px;
}
progress::-moz-progress-bar {
/* style rules */
background-color: green;
}
}
</style>

View File

@@ -1,314 +0,0 @@
<template>
<div>
<nav class="nav">
<router-link class="nav__logo" :to="{name: 'home'}" exact title="Vue.js — TMDb App">
<svg class="nav__logo-image">
<use xlink:href="#svgLogo"></use>
</svg>
</router-link>
<div class="nav__hamburger" @click="toggleNav">
<div v-for="_ in 3" class="bar"></div>
</div>
<ul class="nav__list">
<li class="nav__item" v-for="item in listTypes">
<router-link class="nav__link" :to="'/list/' + item.route">
<div class="nav__link-wrap">
<svg class="nav__link-icon">
<use :xlink:href="'#icon_' + item.route"></use>
</svg>
<span class="nav__link-title">{{ item.title }}</span>
</div>
</router-link>
</li>
<li class="nav__item nav__item--profile">
<router-link class="nav__link nav__link--profile" :to="{name: 'signin'}" v-if="!userLoggedIn">
<div class="nav__link-wrap">
<svg class="nav__link-icon">
<use xlink:href="#iconLogin"></use>
</svg>
<span class="nav__link-title">Sign in</span>
</div>
</router-link>
<router-link class="nav__link nav__link--profile" :to="{name: 'profile'}" v-if="userLoggedIn">
<div class="nav__link-wrap">
<svg class="nav__link-icon">
<use xlink:href="#iconLogin"></use>
</svg>
<span class="nav__link-title">Profile</span>
</div>
</router-link>
</li>
</ul>
</nav>
<div class="spacer"></div>
</div>
</template>
<script>
import storage from '@/storage'
export default {
data(){
return {
listTypes: storage.homepageLists,
userLoggedIn: localStorage.getItem('token') ? true : false
}
},
methods: {
setUserStatus(){
this.userLoggedIn = localStorage.getItem('token') ? true : false;
},
toggleNav(){
document.querySelector('.nav__hamburger').classList.toggle('nav__hamburger--active');
document.querySelector('.nav__list').classList.toggle('nav__list--active');
}
},
created(){
// TODO move this to state manager
eventHub.$on('setUserStatus', this.setUserStatus);
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.icon {
width: 30px;
}
.spacer {
@include mobile-only {
width: 100%;
height: $header-size;
}
}
.nav {
transition: background .5s ease;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 50px;
z-index: 10;
display: block;
color: $text-color;
background-color: $background-color-secondary;
@include tablet-min{
width: 95px;
height: 100vh;
}
&__logo {
width: 55px;
height: $header-size;
display: flex;
align-items: center;
justify-content: center;
background: $background-nav-logo;
@include tablet-min{
width: 95px;
}
&-image{
width: 35px;
height: 31px;
fill: $green;
transition: transform 0.5s ease;
@include tablet-min{
width: 45px;
height: 40px;
}
}
&:hover &-image {
transform: scale(1.04);
}
}
&__hamburger {
display: block;
position: fixed;
width: 55px;
height: 50px;
top: 0;
right: 0;
cursor: pointer;
z-index: 10;
border-left: 1px solid $background-color;
@include tablet-min{
display: none;
}
.bar {
position: absolute;
width: 23px;
height: 1px;
background-color: $text-color-70;
transition: all 300ms ease;
&:nth-child(1) {
left: 16px;
top: 17px;
}
&:nth-child(2) {
left: 16px;
top: 25px;
&:after {
content: "";
position: absolute;
left: 0px;
top: 0px;
width: 23px;
height: 1px;
transition: all 300ms ease;
}
}
&:nth-child(3) {
right: 15px;
top: 33px;
}
}
&--active {
.bar{
&:nth-child(1),
&:nth-child(3){
width: 0;
}
&:nth-child(2) {
transform: rotate(-45deg);
}
&:nth-child(2):after {
transform: rotate(-90deg);
// background: rgba($c-dark, 0.5);
background-color: $text-color-70;
}
}
}
}
&__list {
list-style: none;
padding: 0;
margin: 0;
text-align: center;
width: 100%;
position: fixed;
left: 0;
top: 50px;
border-top: 1px solid $background-color;
@include mobile-only {
display: flex;
flex-wrap: wrap;
font-size: 0;
opacity: 0;
visibility: hidden;
background-color: $background-95;
text-align: left;
&--active{
opacity: 1;
visibility: visible;
}
}
@include tablet-min {
display: flex;
position: relative;
display: block;
width: 100%;
border-top: 0;
top: 0;
}
}
&__item {
transition: background .5s ease, color .5s ease, border .5s ease;
background-color: $background-color-secondary;
color: $text-color-70;
@include mobile-only {
flex: 0 0 50%;
text-align: center;
border-bottom: 1px solid $background-color;
&:nth-child(odd){
border-right: 1px solid $background-color;
&:last-child {
// flex: 0 0 100%;
}
}
}
@include tablet-min {
width: 100%;
border-bottom: 1px solid $text-color-5;
&--profile {
position: fixed;
right: 0;
top: 0;
width: $header-size;
height: $header-size;
border-bottom: 0;
border-left: 1px solid $background-color;
}
}
&:hover, .is-active {
color: $text-color;
background-color: $background-color;
}
}
&__link {
background-color: inherit; // a elements have a transparent background
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
font-size: 7px;
font-weight: 300;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.5px;
position: relative;
cursor: pointer;
&-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
@include mobile-only {
font-size: 10px;
padding: 20px 0;
}
@include tablet-min {
width: 95px;
height: 95px;
font-size: 9px;
&--profile {
width: 75px;
height: 75px;
background-color: $background-color-secondary;
}
}
&-icon {
width: 20px;
height: 20px;
fill: $text-color-70;
@include tablet-min {
width: 20px;
height: 20px;
margin-bottom: 5px;
}
}
&-title {
margin-top: 5px;
display: block;
width: 100%;
}
&:hover &-icon, &.is-active &-icon {
fill: $text-color;
}
}
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<header>
<h2>{{ prettify }}</h2>
<h3>{{ subtitle }}</h3>
<router-link
v-if="shortList"
:to="urlify"
class="view-more"
:aria-label="`View all ${title}`"
>
View All
</router-link>
<div v-else-if="info">
<div v-if="info instanceof Array" class="flex flex-direction-column">
<span v-for="item in info" :key="item" class="info">{{ item }}</span>
</div>
<span v-else class="info">{{ info }}</span>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from "vue";
interface Props {
title: string;
subtitle?: string;
info?: string | Array<string>;
link?: string;
shortList?: boolean;
sectionType?: "list" | "discover";
}
const props = withDefaults(defineProps<Props>(), {
sectionType: "list"
});
const urlify = computed(() => {
const normalizedTitle = props.title
.toLowerCase()
.replace(/'s\b/g, "") // Remove possessive 's
.replace(/[^\w\d\s-]/g, "") // Remove special characters (keep word chars, dashes, digits, spaces)
.replace(/\s+/g, "_") // Replace spaces with underscores
.replace(/-/g, "_") // Replace dash with underscore
.replace(/_+/g, "_"); // Replace multiple underscores with single underscore
return `/${props.sectionType}/${normalizedTitle}`;
});
const prettify = computed(() => {
return props.title.includes("_")
? props.title.split("_").join(" ")
: props.title;
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background-color: $background-color;
position: sticky;
position: -webkit-sticky;
top: $header-size;
z-index: 1;
h2 {
font-size: 1.4rem;
font-weight: 300;
text-transform: capitalize;
line-height: 1.4rem;
margin: 0;
color: $text-color;
}
.view-more {
font-size: 0.9rem;
font-weight: 300;
letter-spacing: 0.5px;
color: $text-color-70;
text-decoration: none;
transition: color 0.5s ease;
cursor: pointer;
&:after {
content: " →";
}
&:hover {
color: $text-color;
}
}
.info {
font-size: 13px;
font-weight: 300;
letter-spacing: 0.5px;
color: $text-color;
text-decoration: none;
text-align: right;
}
@include tablet-min {
padding-left: 1.25rem;
}
@include desktop-lg-min {
padding-left: 1.75rem;
}
}
</style>

View File

@@ -1,63 +0,0 @@
<template>
<div class="persons">
<div class="persons--image" :style="{
'background-image': 'url(' + getPicture(info) + ')' }"></div>
<span>{{ info.name }}</span>
</div>
</template>
<script>
export default {
name: 'Person',
components: {
},
props: {
info: Object
},
data() {
return {
}
},
created() {},
beforeMount() {},
computed: {
},
methods: {
getPicture: (person) => {
if (person)
return 'https://image.tmdb.org/t/p/w185' + person.profile_path;
}
}
}
</script>
<style lang="scss">
.persons {
display: flex;
// border: 1px solid black;
flex-direction: column;
margin: 0 0.5rem;
span {
font-size: 0.6rem;
}
&--image {
border-radius: 50%;
height: 70px;
width: 70px;
// height: auto;
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
}
&--name {
}
}
</style>

237
src/components/Popup.vue Normal file
View File

@@ -0,0 +1,237 @@
<template>
<div
v-if="isOpen"
ref="popupContainer"
class="movie-popup"
role="dialog"
aria-modal="true"
tabindex="-1"
@click="close"
@keydown.enter="close"
@keydown="handleKeydown"
>
<div class="movie-popup__box" @click.stop>
<person v-if="type === 'person'" :id="id" type="person" />
<movie v-else :id="id" :type="type"></movie>
<button class="movie-popup__close" @click="close" tabindex="0"></button>
</div>
<i class="loader"></i>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
import { useStore } from "vuex";
import Movie from "@/components/popup/Movie.vue";
import Person from "@/components/popup/Person.vue";
import type { Ref } from "vue";
import { MediaTypes } from "../interfaces/IList";
interface URLQueryParameters {
id: number;
type: MediaTypes;
}
const store = useStore();
const isOpen: Ref<boolean> = ref();
const id: Ref<string> = ref();
const type: Ref<MediaTypes> = ref();
const popupContainer = ref<HTMLElement | null>(null);
let previouslyFocusedElement: HTMLElement | null = null;
const unsubscribe = store.subscribe((mutation, state) => {
if (!mutation.type.includes("popup")) return;
isOpen.value = state.popup.open;
id.value = state.popup.id;
type.value = state.popup.type;
if (isOpen.value) {
document.getElementsByTagName("body")[0].classList.add("no-scroll");
} else {
document.getElementsByTagName("body")[0].classList.remove("no-scroll");
}
});
function getFromURLQuery(): URLQueryParameters {
let _id: number;
let _type: MediaTypes;
const params = new URLSearchParams(window.location.search);
params.forEach((_, key) => {
if (
key !== MediaTypes.Movie &&
key !== MediaTypes.Show &&
key !== MediaTypes.Person
) {
return;
}
_id = Number(params.get(key));
_type = key;
});
return { id: _id, type: _type };
}
function open(_id: number, _type: string) {
if (!_id || !_type) return;
store.dispatch("popup/open", { id: _id, type: _type });
}
function close() {
store.dispatch("popup/close");
}
function checkEventForEscapeKey(event: KeyboardEvent) {
if (event.keyCode !== 27) return;
close();
}
function getFocusableElements(): HTMLElement[] {
if (!popupContainer.value) return [];
const focusableSelectors = [
"button:not([disabled])",
"a[href]",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])'
].join(", ");
return Array.from(
popupContainer.value.querySelectorAll(focusableSelectors)
) as HTMLElement[];
}
function trapFocus(event: KeyboardEvent) {
if (event.key !== "Tab") return;
const focusableElements = getFocusableElements();
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}
function handleKeydown(event: KeyboardEvent) {
trapFocus(event);
}
function setInitialFocus() {
nextTick(() => {
// Focus the popup container itself instead of a specific element
// This allows tab to start fresh without any element being focused
if (popupContainer.value) {
popupContainer.value.focus();
}
});
}
watch(isOpen, newValue => {
if (newValue) {
// Store the previously focused element
previouslyFocusedElement = document.activeElement as HTMLElement;
// Set focus to popup
setInitialFocus();
} else {
// Restore focus to previously focused element
if (previouslyFocusedElement) {
previouslyFocusedElement.focus();
}
}
});
window.addEventListener("keyup", checkEventForEscapeKey);
onMounted(() => {
const query = getFromURLQuery();
open(query?.id, query?.type);
});
onBeforeUnmount(() => {
unsubscribe();
window.removeEventListener("keyup", checkEventForEscapeKey);
});
</script>
<style lang="scss">
@import "scss/variables";
@import "scss/media-queries";
.movie-popup {
position: fixed;
top: 0;
left: 0;
z-index: 20;
width: 100%;
height: 100%;
background: rgba($dark, 0.93);
-webkit-overflow-scrolling: touch;
overflow: auto;
&:focus {
outline: none;
}
&__box {
max-width: 768px;
position: relative;
z-index: 5;
margin: 8vh auto;
@include mobile {
margin: 0 0 50px 0;
}
}
&__close {
display: block;
position: absolute;
top: 0;
right: 0;
border: 0;
background: transparent;
width: 40px;
height: 40px;
transition: background 0.5s ease;
cursor: pointer;
z-index: 5;
&:before,
&:after {
content: "";
display: block;
position: absolute;
top: 19px;
left: 10px;
width: 20px;
height: 2px;
background-color: white;
}
&:before {
transform: rotate(45deg);
}
&:after {
transform: rotate(-45deg);
}
&:hover {
background-color: var(--highlight-color);
}
}
}
</style>

View File

@@ -1,150 +0,0 @@
<template>
<section class="profile">
<div class="profile__content" v-if="userLoggedIn">
<header class="profile__header">
<h2 class="profile__title">{{ emoji }} Welcome {{ username }}</h2>
<div class="button--group">
<seasoned-button @click="toggleSettings">{{ showSettings ? 'hide settings' : 'show settings' }}</seasoned-button>
<seasoned-button @click="logOut">Log out</seasoned-button>
</div>
</header>
<settings v-if="showSettings"></settings>
<list-header title="User requests" :info="resultCount" />
<results-list v-if="results" :results="results" />
</div>
<section class="not-found" v-if="!userLoggedIn">
<div class="not-found__content">
<h2 class="not-found__title">Authentication Request Failed</h2>
<router-link :to="{name: 'signin'}" exact title="Sign in here">
<button class="not-found__button button">Sign In</button>
</router-link>
</div>
</section>
</section>
</template>
<script>
import storage from '@/storage'
import store from '@/store'
import ListHeader from '@/components/ListHeader'
import ResultsList from '@/components/ResultsList'
import Settings from '@/components/Settings'
import SeasonedButton from '@/components/ui/SeasonedButton'
import { getEmoji, getUserRequests } from '@/api'
export default {
components: { ListHeader, ResultsList, Settings, SeasonedButton },
data(){
return{
userLoggedIn: '',
emoji: '',
results: undefined,
totalResults: undefined,
showSettings: false
}
},
computed: {
resultCount() {
if (this.results === undefined)
return
const loadedResults = this.results.length
const totalResults = this.totalResults < 10000 ? this.totalResults : '∞'
return `${loadedResults} of ${totalResults} results`
},
username: () => store.getters['userModule/username']
},
methods: {
toggleSettings() {
this.showSettings = this.showSettings ? false : true;
if (this.showSettings) {
this.$router.replace({ query: { settings: true} })
} else {
this.$router.replace({ name: 'profile' })
}
},
logOut(){
this.$router.push('logout')
}
},
created(){
if(!localStorage.getItem('token')){
this.userLoggedIn = false;
} else {
this.userLoggedIn = true;
this.showSettings = window.location.toString().includes('settings=true')
getUserRequests()
.then(results => {
this.results = results.results
this.totalResults = results.total_results
})
getEmoji()
.then(resp => {
const { emoji } = resp
this.emoji = emoji
store.dispatch('documentTitle/updateEmoji', emoji)
})
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.button--group {
display: flex;
}
// DUPLICATE CODE
.profile{
&__header{
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
border-bottom: 1px solid $text-color-5;
@include mobile-only {
flex-direction: column;
align-items: flex-start;
.button--group {
padding-top: 2rem;
}
}
@include tablet-min{
padding: 29px 30px;
}
@include tablet-landscape-min{
padding: 29px 50px;
}
@include desktop-min{
padding: 29px 60px;
}
}
&__title{
margin: 0;
font-size: 16px;
line-height: 16px;
color: $text-color;
font-weight: 300;
@include tablet-min{
font-size: 18px;
line-height: 18px;
}
}
}
</style>

View File

@@ -1,110 +0,0 @@
<template>
<section>
<h1>Register new user</h1>
<seasoned-input placeholder="username" icon="Email" type="username" :value.sync="username" @enter="submit"/>
<seasoned-input placeholder="password" icon="Keyhole" type="password" :value.sync="password" @enter="submit"/>
<seasoned-input placeholder="repeat password" icon="Keyhole" type="password" :value.sync="passwordRepeat" @enter="submit"/>
<seasoned-button @click="submit">Register</seasoned-button>
<router-link class="link" to="/signin">Have a user? Sign in here</router-link>
<seasoned-messages :messages.sync="messages"></seasoned-messages>
</section>
</template>
<script>
import { register } from '@/api'
import SeasonedButton from '@/components/ui/SeasonedButton'
import SeasonedInput from '@/components/ui/SeasonedInput'
import SeasonedMessages from '@/components/ui/SeasonedMessages'
export default {
components: { SeasonedButton, SeasonedInput, SeasonedMessages },
data() {
return {
messages: [],
username: null,
password: null,
passwordRepeat: null
}
},
methods: {
submit() {
this.messages = [];
let { username, password, passwordRepeat } = this;
if (username == null || username.length == 0) {
this.messages.push({ type: 'error', title: 'Missing username' })
return
} else if (password == null || password.length == 0) {
this.messages.push({ type: 'error', title: 'Missing password' })
return
} else if (passwordRepeat == null || passwordRepeat.length == 0) {
this.messages.push({ type: 'error', title: 'Missing repeat password' })
return
} else if (passwordRepeat != password) {
this.messages.push({ type: 'error', title: 'Passwords do not match' })
return
}
this.registerUser(username, password)
},
registerUser(username, password) {
register(username, password, true)
.then(data => {
if (data.success){
localStorage.setItem('token', data.token);
const jwtData = parseJwt(data.token)
localStorage.setItem('username', jwtData['username']);
localStorage.setItem('admin', jwtData['admin'] || false);
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'profile' })
}
})
.catch(error => {
if (error.status === 401) {
this.messages.push({ type: 'error', title: 'Access denied', message: 'Incorrect username or password' })
}
else {
this.messages.push({ type: 'error', title: 'Unexpected error', message: error.message })
}
});
},
logOut(){
localStorage.clear();
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'home' });
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
section {
padding: 1.3rem;
@include tablet-min {
padding: 4rem;
}
h1 {
margin: 0;
line-height: 16px;
color: $text-color;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
}
.link {
display: block;
width: max-content;
margin-top: 1rem;
}
}
</style>

View File

@@ -1,68 +1,76 @@
<template>
<ul class="results" :class="{'shortList': shortList}">
<movies-list-item v-for='movie in results' :movie="movie" />
</ul>
<template>
<div>
<ul
v-if="results && results.length"
class="results"
:class="{ shortList: shortList }"
>
<results-list-item
v-for="(result, index) in results"
:key="generateResultKey(index, `${result.type}-${result.id}`)"
:list-item="result"
/>
</ul>
<span v-else-if="!loading" class="no-results">No results found</span>
</div>
</template>
<script setup lang="ts">
import ResultsListItem from "@/components/ResultsListItem.vue";
import type { ListResults } from "../interfaces/IList";
<script>
import MoviesListItem from '@/components/MoviesListItem'
export default {
components: { MoviesListItem },
props: {
results: {
type: Array,
required: true
},
shortList: {
type: Boolean,
required: false,
default: false
}
interface Props {
results: Array<ListResults>;
shortList?: boolean;
loading?: boolean;
}
defineProps<Props>();
function generateResultKey(index: string | number | symbol, value: string) {
return `${String(index)}-${value}`;
}
}
</script>
<style lang="scss" scoped>
@import './src/scss/media-queries';
@import "scss/media-queries";
@import "scss/main";
.results {
display: flex;
flex-wrap: wrap;
margin: 0;
padding: 0;
list-style: none;
.no-results {
width: 100%;
display: block;
text-align: center;
margin: 1.5rem;
font-size: 1.2rem;
}
&.shortList > li {
display: none;
.results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
grid-auto-rows: auto;
margin: 0;
padding: 0;
list-style: none;
&:nth-child(-n+4) {
display: block;
@include mobile {
grid-template-columns: repeat(2, 1fr);
}
&.shortList {
overflow: auto;
grid-auto-flow: column;
max-width: 100vw;
@include noscrollbar;
> li {
min-width: 225px;
}
@include tablet-min {
max-width: calc(100vw - var(--header-size));
}
}
}
}
@include tablet-min {
.results.shortList > li:nth-child(-n+6) {
display: block;
}
}
@include tablet-landscape-min {
.results.shortList > li:nth-child(-n+8) {
display: block;
}
}
@include desktop-min {
.results.shortList > li:nth-child(-n+10) {
display: block;
}
}
@include desktop-lg-min {
.results.shortList > li:nth-child(-n+16) {
display: block;
}
}
</style>
</style>

View File

@@ -0,0 +1,187 @@
<template>
<li ref="list-item" class="movie-item">
<figure
ref="posterElement"
class="movie-item__poster"
@click="openMoviePopup"
@keydown.enter="openMoviePopup"
>
<img
class="movie-item__img"
:alt="posterAltText"
:data-src="poster"
src="/assets/placeholder.png"
/>
<div v-if="listItem.download" class="progress">
<progress :value="listItem.download.progress" max="100"></progress>
<span
>{{ listItem.download.state }}:
{{ listItem.download.progress }}%</span
>
</div>
</figure>
<div
class="movie-item__info"
@click="openMoviePopup"
@keydown.enter="openMoviePopup"
>
<p v-if="listItem.title || listItem.name" class="movie-item__title">
{{ listItem.title || listItem.name }}
</p>
<p v-if="listItem.year">{{ listItem.year }}</p>
<p v-if="listItem.type == 'person'">
Known for: {{ listItem.known_for_department }}
</p>
</div>
</li>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import type { Ref } from "vue";
import type { IMovie, IShow, IPerson } from "../interfaces/IList";
interface Props {
listItem: IMovie | IShow | IPerson;
}
const props = defineProps<Props>();
const store = useStore();
const IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500";
const IMAGE_FALLBACK = "/assets/no-image.svg";
const poster: Ref<string> = ref();
const posterElement: Ref<HTMLElement> = ref(null);
const observed: Ref<boolean> = ref(false);
if (props.listItem?.poster) {
poster.value = IMAGE_BASE_URL + props.listItem.poster;
} else {
poster.value = IMAGE_FALLBACK;
}
const posterAltText = computed(() => {
const type = props.listItem.type || "";
let title = "";
if ("name" in props.listItem) title = props.listItem.name;
else if ("title" in props.listItem) title = props.listItem.title;
return props.listItem.poster
? `Poster for ${type} ${title}`
: `Missing image for ${type} ${title}`;
});
function observePosterAndSetImageSource() {
const imageElement = posterElement.value.getElementsByTagName("img")[0];
if (imageElement == null) return;
const imageObserver = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting && observed.value === false) {
const lazyImage = entry.target as HTMLImageElement;
lazyImage.src = lazyImage.dataset.src;
posterElement.value.classList.add("is-loaded");
observed.value = true;
}
});
});
imageObserver.observe(imageElement);
}
onMounted(observePosterAndSetImageSource);
function openMoviePopup() {
store.dispatch("popup/open", { ...props.listItem });
}
// const imageSize = computed(() => {
// if (!posterElement.value) return;
// const { height, width } = posterElement.value.getBoundingClientRect();
// return {
// height: Math.ceil(height),
// width: Math.ceil(width)
// };
// });
// import img from "../directives/v-image";
// directives: {
// img: img
// },
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
.movie-item {
padding: 15px;
width: 100%;
background-color: var(--background-color);
&:hover &__info > p {
color: $text-color;
}
&__poster {
text-decoration: none;
color: $text-color-70;
font-weight: 300;
position: relative;
transform: scale(0.97) translateZ(0);
&::before {
content: "";
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
background-color: var(--background-color);
transition: 1s background-color ease;
}
&:hover {
transform: scale(1.03);
box-shadow: 0 0 10px rgba($dark, 0.1);
}
&.is-loaded::before {
background-color: transparent;
}
img {
width: 100%;
border-radius: 10px;
}
}
&__info {
padding-top: 10px;
font-weight: 300;
> p {
color: $text-color-70;
margin: 0;
font-size: 14px;
letter-spacing: 0.5px;
transition: color 0.5s ease;
cursor: pointer;
@include mobile-ls-min {
font-size: 12px;
}
@include tablet-min {
font-size: 14px;
}
}
}
&__title {
font-weight: 400;
}
}
</style>

View File

@@ -0,0 +1,208 @@
<template>
<div ref="resultSection" class="resultSection">
<page-header
v-bind="{ title, info, shortList, sectionType: props.sectionType }"
/>
<div
v-if="!loadedPages.includes(1) && loading == false"
class="button-container"
>
<seasoned-button class="load-button" :full-width="true" @click="loadLess"
>load previous</seasoned-button
>
</div>
<results-list v-bind="{ results, shortList, loading }" />
<loader v-if="loading" />
<div ref="loadMoreButton" class="button-container">
<seasoned-button
v-if="!loading && !shortList && page != totalPages && results.length"
class="load-button"
:full-width="true"
@click="loadMore"
>load more</seasoned-button
>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import PageHeader from "@/components/PageHeader.vue";
import ResultsList from "@/components/ResultsList.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import Loader from "@/components/ui/Loader.vue";
import type { Ref } from "vue";
import type { IList, ListResults } from "../interfaces/IList";
import type ISection from "../interfaces/ISection";
interface Props extends ISection {
title: string;
apiFunction: (page: number) => Promise<IList>;
shortList?: boolean;
sectionType?: "list" | "discover";
}
const props = withDefaults(defineProps<Props>(), {
sectionType: "list"
});
const results: Ref<ListResults> = ref([]);
const page: Ref<number> = ref(1);
const loadedPages: Ref<number[]> = ref([]);
const totalResults: Ref<number> = ref(0);
const totalPages: Ref<number> = ref(0);
const loading: Ref<boolean> = ref(true);
const autoLoad: Ref<boolean> = ref(false);
const observer: Ref<IntersectionObserver> = ref(null);
const resultSection = ref(null);
const loadMoreButton = ref(null);
function pageCountString(_page: number, _totalPages: number) {
return `Page ${_page} of ${_totalPages}`;
}
function resultCountString(_results: ListResults, _totalResults: number) {
const loadedResults = _results.length;
const __totalResults = _totalResults < 10000 ? _totalResults : "∞";
return `${loadedResults} of ${__totalResults} results`;
}
function setLoading(state: boolean) {
loading.value = state;
}
const info = computed(() => {
if (results.value.length === 0) return [null, null];
const pageCount = pageCountString(page.value, totalPages.value);
const resultCount = resultCountString(results.value, totalResults.value);
return [pageCount, resultCount];
});
function getPageFromUrl() {
const _page = new URLSearchParams(window.location.search).get("page");
if (!_page) return null;
return Number(_page);
}
function updateQueryParams() {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) {
params.set("page", page.value?.toString());
} else if (page.value > 1) {
params.append("page", page.value?.toString());
}
window.history.replaceState(
{},
"search",
`${window.location.protocol}//${window.location.hostname}${
window.location.port ? `:${window.location.port}` : ""
}${window.location.pathname}${
params.toString().length ? `?${params}` : ""
}`
);
}
function getListResults(front = false) {
props
.apiFunction(page.value)
.then(listResponse => {
if (!front)
results.value = results.value.concat(...listResponse.results);
else results.value = listResponse.results.concat(...results.value);
page.value = listResponse.page;
loadedPages.value.push(page.value);
loadedPages.value = loadedPages.value.sort((a, b) => a - b);
totalPages.value = listResponse.total_pages;
totalResults.value = listResponse.total_results;
})
.then(updateQueryParams)
.finally(() => setLoading(false));
}
function loadMore() {
if (!autoLoad.value) {
autoLoad.value = true;
}
loading.value = true;
const maxPage = [...loadedPages.value].slice(-1)[0];
if (Number.isNaN(maxPage)) return;
page.value = maxPage + 1;
getListResults();
}
function loadLess() {
loading.value = true;
const minPage = loadedPages.value[0];
if (minPage === 1) return;
page.value = minPage - 1;
getListResults(true);
}
function handleButtonIntersection(entries) {
entries.map(entry =>
entry.isIntersecting && autoLoad.value ? loadMore() : null
);
}
function setupAutoloadObserver() {
observer.value = new IntersectionObserver(handleButtonIntersection, {
root: resultSection.value.$el,
rootMargin: "0px",
threshold: 0
});
observer.value.observe(loadMoreButton.value);
}
page.value = getPageFromUrl() || page.value;
if (results.value?.length === 0) getListResults();
onMounted(() => {
if (!props?.shortList) setupAutoloadObserver();
});
// beforeDestroy() {
// this.observer = undefined;
// }
// };
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.resultSection {
background-color: var(--background-color);
}
.button-container {
display: flex;
justify-content: center;
display: flex;
width: 100%;
}
.load-button {
margin: 2rem 0;
@include mobile {
margin: 1rem 0;
}
&:last-of-type {
margin-bottom: 4rem;
@include mobile {
margin-bottom: 2rem;
}
}
}
</style>

View File

@@ -1,122 +0,0 @@
<template>
<div>
<list-header :title="title" :info="resultCount" :sticky="true" />
<results-list :results="results" />
<div v-if="page < totalPages" class="fullwidth-button">
<seasoned-button @click="loadMore">load more</seasoned-button>
</div>
<div class="notFound" v-if="results.length == 0 && loading == false">
<h1 class="notFound-title">No results for search: <b>{{ query }}</b></h1>
</div>
<loader v-if="loading" />
</div>
</template>
<style lang="scss" scoped>
.notFound {
display: flex;
justify-content: center;
align-items: center;
&-title {
font-weight: 400;
}
}
</style>
<script>
import { searchTmdb } from '@/api'
import ListHeader from '@/components/ListHeader'
import ResultsList from '@/components/ResultsList'
import SeasonedButton from '@/components/ui/SeasonedButton'
import Loader from '@/components/ui/Loader'
export default {
components: { ListHeader, ResultsList, SeasonedButton, Loader },
props: {
propQuery: {
type: String,
required: false
},
propPage: {
type: Number,
required: false
}
},
data() {
return {
loading: true,
query: String,
title: String,
page: Number,
adult: undefined,
mediaType: null,
totalPages: 0,
results: [],
totalResults: []
}
},
computed: {
resultCount() {
const loadedResults = this.results.length
const totalResults = this.totalResults < 10000 ? this.totalResults : '∞'
return `${loadedResults} of ${totalResults} results`
}
},
methods: {
search(query=this.query, page=this.page, adult=this.adult, mediaType=this.mediaType) {
searchTmdb(query, page, adult, mediaType)
.then(this.parseResponse)
},
parseResponse(data) {
if (this.results.length > 0) {
this.results.push(...data.results)
} else {
this.results = data.results
}
this.totalPages = data.total_pages
this.totalResults = data.total_results || data.results.length
this.loading = false
},
loadMore() {
this.page++
window.history.replaceState({}, 'search', `/#/search?query=${this.query}&page=${this.page}`)
this.search()
}
},
created() {
const { query, page, adult, media_type } = this.$route.query
if (!query) {
// abort
console.error('abort, no query')
}
this.query = decodeURIComponent(query)
this.page = page || 1
this.adult = adult || this.adult
this.mediaType = media_type || this.mediaType
this.title = `Search results: ${this.query}`
this.search()
}
}
</script>
<style lang="scss" scoped>
.fullwidth-button {
width: 100%;
margin: 1rem 0;
padding-bottom: 2rem;
display: flex;
justify-content: center;
}
</style>

View File

@@ -1,376 +0,0 @@
<template>
<div>
<div class="search">
<input
ref="input"
type="text"
placeholder="Search for movie or show"
aria-label="Search input for finding a movie or show"
autocorrect="off"
autocapitalize="off"
tabindex="1"
v-model="query"
@input="handleInput"
@click="focus = true"
@keydown.escape="handleEscape"
@keyup.enter="handleSubmit"
@keydown.up="navigateUp"
@keydown.down="navigateDown" />
<svg class="search-icon" fill="currentColor" @click="handleSubmit"><use xlink:href="#iconSearch"></use></svg>
</div>
<transition name="fade">
<div class="dropdown" v-if="!disabled && focus && query.length > 0">
<div class="filter">
<h2>Filter your search:</h2>
<div class="filter-items">
<toggle-button :options="searchTypes" :selected.sync="selectedSearchType" />
<label>Adult
<input type="checkbox" value="adult" v-model="adult">
</label>
</div>
</div>
<hr />
<div class="dropdown-results" v-if="elasticSearchResults.length">
<ul v-for="(item, index) in elasticSearchResults"
@click="openResult(item, index + 1)"
:class="{ active: index + 1 === selectedResult}">
{{ item.name }}
</ul>
</div>
<div v-else class="dropdown">
<div class="dropdown-results">
<h2 class="not-found">No results for query: <b>{{ query }}</b></h2>
</div>
</div>
<seasoned-button class="end-section" fullWidth="true"
@click="focus = false" :active="elasticSearchResults.length + 1 === selectedResult">
close
</seasoned-button>
</div>
</transition>
</div>
</template>
<script>
import SeasonedButton from '@/components/ui/SeasonedButton'
import ToggleButton from '@/components/ui/ToggleButton';
import { elasticSearchMoviesAndShows } from '@/api'
import config from '@/config.json'
export default {
name: 'SearchInput',
components: {
SeasonedButton,
ToggleButton
},
props: ['value'],
data() {
return {
adult: true,
searchTypes: ['all', 'movie', 'show', 'person'],
selectedSearchType: 'all',
query: this.value,
focus: false,
disabled: false,
scrollListener: undefined,
scrollDistance: 0,
elasticSearchResults: [],
selectedResult: 0,
}
},
watch: {
focus: function(val) {
if (val === true) {
window.addEventListener('scroll', this.disableFocus)
} else {
window.removeEventListener('scroll', this.disableFocus)
this.scrollDistance = 0
}
},
adult: function(value) {
this.handleInput()
}
},
beforeMount() {
const elasticUrl = config.ELASTIC_URL
if (elasticUrl === undefined || elasticUrl === false || elasticUrl === '') {
this.disabled = true
}
},
beforeDestroy() {
console.log('scroll eventlistener not removed, destroying!')
window.removeEventListener('scroll', this.disableFocus)
},
methods: {
navigateDown() {
this.focus = true
this.selectedResult++
},
navigateUp() {
this.focus = true
this.selectedResult--
const input = this.$refs.input;
const textLength = input.value.length
setTimeout(() => {
input.focus()
input.setSelectionRange(textLength, textLength + 1)
}, 1)
},
openResult(item, index) {
this.selectedResult = index;
this.$popup.open(item.id, item.type)
},
handleInput(e){
this.selectedResult = 0
this.$emit('input', this.query);
if (! this.focus) {
this.focus = true;
}
elasticSearchMoviesAndShows(this.query)
.then(resp => {
const data = resp.hits.hits
let results = data.map(item => {
const index = item._index.slice(0, -1)
if (index === 'movie' || item._source.original_title) {
return {
name: item._source.original_title,
id: item._source.id,
adult: item._source.adult,
type: 'movie'
}
} else if (index === 'show' || item._source.original_name) {
return {
name: item._source.original_name,
id: item._source.id,
adult: item._source.adult,
type: 'show'
}
}
})
results = this.removeDuplicates(results)
this.elasticSearchResults = results
})
},
removeDuplicates(searchResults) {
let filteredResults = []
searchResults.map(result => {
const numberOfDuplicates = filteredResults.filter(filterItem => filterItem.id == result.id)
if (numberOfDuplicates.length >= 1) {
return null
}
filteredResults.push(result)
})
if (this.adult == false) {
filteredResults = filteredResults.filter(result => result.adult == false)
}
return filteredResults
},
handleSubmit() {
let searchResults = this.elasticSearchResults
if (this.selectedResult > searchResults.length) {
this.focus = false
this.selectedResult = 0
} else if (this.selectedResult > 0) {
const resultItem = searchResults[this.selectedResult - 1]
this.$popup.open(resultItem.id, resultItem.type)
} else {
const encodedQuery = encodeURI(this.query.replace('/ /g, "+"'))
const media_type = this.selectedSearchType !== 'all' ? this.selectedSearchType : null
this.$router.push({ name: 'search', query: { query: encodedQuery, adult: this.adult, media_type }});
this.focus = false
this.selectedResult = 0
}
},
handleEscape() {
if (this.$popup.isOpen) {
console.log('THIS WAS FUCKOING OPEN!')
} else {
this.focus = false
}
},
disableFocus(_) {
this.focus = false
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import './src/scss/main';
.fade-enter-active {
transition: opacity .2s;
}
.fade-leave-active {
transition: opacity .2s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.filter {
// background-color: rgba(004, 122, 125, 0.2);
width: 100%;
display: flex;
flex-direction: column;
margin: 1rem 2rem;
h2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 400;
}
&-items {
display: flex;
flex-direction: row;
align-items: center;
> :not(:first-child) {
margin-left: 1rem;
}
}
}
hr {
display: block;
height: 1px;
border: 0;
border-bottom: 1px solid $text-color-50;
margin-top: 10px;
margin-bottom: 10px;
width: 90%;
}
.dropdown {
width: 100%;
position: relative;
display: flex;
flex-wrap: wrap;
z-index: 5;
min-height: $header-size;
right: 0px;
background-color: $background-color-secondary;
@include mobile-only {
position: fixed;
top: 50px;
padding-top: 20px;
width: calc(100%);
}
.not-found {
font-weight: 400;
}
&-results {
padding-left: 60px;
width: 100%;
@include mobile-only {
padding-left: 45px;
}
> ul {
font-size: 1.3rem;
padding: 0;
margin: 0.2rem 0;
width: calc(100% - 25px);
max-width: fit-content;
list-style: none;
color: rgba(0, 0, 0, 0.5);
text-transform: capitalize;
cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
color: $text-color-50;
&.active, &:hover, &:active {
color: $text-color;
border-bottom: 2px solid $text-color;
}
}
}
}
.search {
height: $header-size;
display: flex;
position: fixed;
flex-wrap: wrap;
z-index: 5;
border: 0;
background-color: $background-color-secondary;
// TODO check if this is for mobile
width: calc(100% - 110px);
top: 0;
right: 55px;
@include tablet-min{
position: relative;
width: 100%;
right: 0px;
}
input {
display: block;
width: 100%;
padding: 13px 0 13px 45px;
outline: none;
margin: 0;
border: 0;
background-color: $background-color-secondary;
font-weight: 300;
font-size: 19px;
color: $text-color;
transition: background-color .5s ease, color .5s ease;
@include tablet-min {
padding: 13px 30px 13px 60px;
}
}
&-icon{
width: 20px;
height: 20px;
fill: $text-color-50;
transition: fill 0.5s ease;
pointer-events: none;
position: absolute;
left: 15px;
top: 15px;
@include tablet-min{
top: 27px;
left: 25px;
}
}
}
</style>

View File

@@ -1,198 +0,0 @@
<template>
<section class="profile">
<div class="profile__content" v-if="userLoggedIn">
<section class='settings'>
<h3 class='settings__header'>Plex account</h3>
<div v-if="!hasPlexUser">
<span class="settings__info">Sign in to your plex account to get information about recently added movies and to see your watch history</span>
<form class="form">
<seasoned-input placeholder="plex username" icon="Email" :value.sync="plexUsername"/>
<seasoned-input placeholder="plex password" icon="Keyhole" type="password"
:value.sync="plexPassword" @submit="authenticatePlex" />
<seasoned-button @click="authenticatePlex">link plex account</seasoned-button>
</form>
</div>
<div v-else>
<span class="settings__info">Awesome, your account is already authenticated with plex! Enjoy viewing your seasoned search history, plex watch history and real-time torrent download progress.</span>
<seasoned-button @click="unauthenticatePlex">un-link plex account</seasoned-button>
</div>
<seasoned-messages :messages.sync="messages" />
<hr class='setting__divider'>
<h3 class='settings__header'>Change password</h3>
<form class="form">
<seasoned-input placeholder="new password" icon="Keyhole" type="password"
:value.sync="newPassword" />
<seasoned-input placeholder="repeat new password" icon="Keyhole" type="password"
:value.sync="newPasswordRepeat" />
<seasoned-button @click="changePassword">change password</seasoned-button>
</form>
<hr class='setting__divider'>
</section>
</div>
<section class="not-found" v-else>
<div class="not-found__content">
<h2 class="not-found__title">Authentication Request Failed</h2>
<router-link :to="{name: 'signin'}" exact title="Sign in here">
<button class="not-found__button button">Sign In</button>
</router-link>
</div>
</section>
</section>
</template>
<script>
import store from '@/store'
import storage from '@/storage'
import SeasonedInput from '@/components/ui/SeasonedInput'
import SeasonedButton from '@/components/ui/SeasonedButton'
import SeasonedMessages from '@/components/ui/SeasonedMessages'
import { getSettings, updateSettings, linkPlexAccount, unlinkPlexAccount } from '@/api'
export default {
components: { SeasonedInput, SeasonedButton, SeasonedMessages },
data(){
return{
userLoggedIn: '',
messages: [],
plexUsername: null,
plexPassword: null,
newPassword: null,
newPasswordRepeat: null,
emoji: null
}
},
computed: {
hasPlexUser: function() {
return this.settings && this.settings['plex_userid']
},
settings: {
get: () => {
return store.getters['userModule/settings']
},
set: function(newSettings) {
store.dispatch('userModule/setSettings', newSettings)
}
}
},
methods: {
setValue(l, t) {
this[l] = t
},
changePassword() {
return
},
async authenticatePlex() {
let username = this.plexUsername
let password = this.plexPassword
const response = await linkPlexAccount(username, password)
this.messages.push({
type: response.success ? 'success' : 'error',
title: response.success ? 'Authenticated with plex' : 'Something went wrong',
message: response.message
})
if (response.success)
getSettings().then(settings => this.settings = settings)
},
async unauthenticatePlex() {
const response = await unlinkPlexAccount()
this.messages.push({
type: response.success ? 'success' : 'error',
title: response.success ? 'Unlinked plex account ' : 'Something went wrong',
message: response.message
})
if (response.success)
getSettings().then(settings => this.settings = settings)
}
},
created(){
const token = localStorage.getItem('token') || false;
if (token){
this.userLoggedIn = true
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
a {
text-decoration: none;
}
// DUPLICATE CODE
.form {
> div:last-child {
margin-top: 1rem;
}
&__group{
justify-content: unset;
&__input-icon {
margin-top: 8px;
height: 22px;
width: 22px;
}
&-input {
padding: 10px 5px 10px 45px;
height: 40px;
font-size: 17px;
width: 75%;
@include desktop-min {
width: 400px;
}
}
}
}
.settings {
padding: 3rem;
@include mobile-only {
padding: 1rem;
}
&__header {
margin: 0;
line-height: 16px;
color: $text-color;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
}
&__info {
display: block;
margin-bottom: 25px;
}
hr {
display: block;
height: 1px;
border: 0;
border-bottom: 1px solid $text-color-50;
margin-top: 30px;
margin-bottom: 70px;
margin-left: 20px;
width: 96%;
text-align: left;
}
span {
font-weight: 200;
size: 16px;
}
}
</style>

View File

@@ -1,114 +0,0 @@
<template>
<section>
<h1>Sign in</h1>
<seasoned-input placeholder="username"
icon="Email"
type="email"
@enter="submit"
:value.sync="username" />
<seasoned-input placeholder="password" icon="Keyhole" type="password" :value.sync="password" @enter="submit"/>
<seasoned-button @click="submit">sign in</seasoned-button>
<router-link class="link" to="/register">Don't have a user? Register here</router-link>
<seasoned-messages :messages.sync="messages"></seasoned-messages>
</section>
</template>
<script>
import { login } from '@/api'
import storage from '../storage'
import SeasonedInput from '@/components/ui/SeasonedInput'
import SeasonedButton from '@/components/ui/SeasonedButton'
import SeasonedMessages from '@/components/ui/SeasonedMessages'
import { parseJwt } from '@/utils'
export default {
components: { SeasonedInput, SeasonedButton, SeasonedMessages },
data(){
return{
messages: [],
username: null,
password: null
}
},
methods: {
setValue(l, t) {
this[l] = t
},
submit() {
this.messages = [];
let username = this.username;
let password = this.password;
if (username == null || username.length == 0) {
this.messages.push({ type: 'error', title: 'Missing username' })
return
}
if (password == null || password.length == 0) {
this.messages.push({ type: 'error', title: 'Missing password' })
return
}
this.signin(username, password)
},
signin(username, password) {
login(username, password, true)
.then(data => {
if (data.success){
const jwtData = parseJwt(data.token)
localStorage.setItem('token', data.token);
localStorage.setItem('username', jwtData['username']);
localStorage.setItem('admin', jwtData['admin'] || false);
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'profile' })
}
})
.catch(error => {
if (error.status === 401) {
this.messages.push({ type: 'error', title: 'Access denied', message: 'Incorrect username or password' })
}
else {
this.messages.push({ type: 'error', title: 'Unexpected error', message: error.message })
}
});
}
},
created(){
document.title = 'Sign in' + storage.pageTitlePostfix;
storage.backTitle = document.title;
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
section {
padding: 1.3rem;
@include tablet-min {
padding: 4rem;
}
h1 {
margin: 0;
line-height: 16px;
color: $text-color;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
}
.link {
display: block;
width: max-content;
margin-top: 1rem;
}
}
</style>

View File

@@ -1,519 +0,0 @@
<template>
<div v-if="show" class="container">
<h2 class="torrentHeader-text">Searching for: {{ editedSearchQuery || query }}</h2>
<!-- <div class="torrentHeader">
<span class="torrentHeader-text">Searching for:&nbsp;</span>
<span id="search" :contenteditable="editSearchQuery ? true : false" class="torrentHeader-text editable">{{ editedSearchQuery || query }}</span>
<svg v-if="!editSearchQuery" class="torrentHeader-editIcon" @click="toggleEditSearchQuery">
<use xlink:href="#icon_radar"></use>
</svg>
<svg v-else class="torrentHeader-editIcon" @click="toggleEditSearchQuery">
<use xlink:href="#icon_check"></use>
</svg>
</div> -->
<div v-if="listLoaded">
<div v-if="torrents.length > 0">
<!-- <ul class="filter">
<li class="filter-item" v-for="(item, index) in release_types" @click="applyFilter(item, index)" :class="{'active': item === selectedRelaseType}">{{ item }}</li>
</ul> -->
<toggle-button :options="release_types" :selected.sync="selectedRelaseType" class="toggle"></toggle-button>
<table>
<tr class="table__header noselect">
<th @click="sortTable('name')" :class="selectedSortableClass('name')">
<span>Name</span>
<span v-if="prevCol === 'name' && direction"></span>
<span v-if="prevCol === 'name' && !direction"></span>
</th>
<th @click="sortTable('seed')" :class="selectedSortableClass('seed')">
<span>Seed</span>
<span v-if="prevCol === 'seed' && direction"></span>
<span v-if="prevCol === 'seed' && !direction"></span>
</th>
<th @click="sortTable('size')" :class="selectedSortableClass('size')">
<span>Size</span>
<span v-if="prevCol === 'size' && direction"></span>
<span v-if="prevCol === 'size' && !direction"></span>
<th>
<span>Magnet</span>
</th>
</tr>
<tr v-for="torrent in torrents" class="table__content">
<td @click="expand($event, torrent.name)">{{ torrent.name }}</td>
<td @click="expand($event, torrent.name)">{{ torrent.seed }}</td>
<td @click="expand($event, torrent.name)">{{ torrent.size }}</td>
<td @click="sendTorrent(torrent.magnet, torrent.name, $event)" class="download">
<svg class="download__icon"><use xlink:href="#iconUnmatched"></use></svg>
</td>
</tr>
</table>
<div style="
display: flex;
justify-content: center;
padding: 1rem;
">
<seasonedButton @click="resetTorrentsAndToggleEditSearchQuery">Edit search query</seasonedButton>
</div>
</div>
<div v-else style="display: flex;
padding-bottom: 2rem;
justify-content: center;
flex-direction: column;
width: 100%;
align-items: center;">
<h2>No results found</h2>
<br />
<div class="editQuery" v-if="editSearchQuery">
<seasonedInput placeholder="Torrent query" icon="_torrents" :value.sync="editedSearchQuery" @enter="fetchTorrents(editedSearchQuery)" />
<div style="height: 45px; width: 5px;"></div>
<seasonedButton @click="fetchTorrents(editedSearchQuery)">Search</seasonedButton>
</div>
<seasonedButton @click="toggleEditSearchQuery" :active="editSearchQuery ? true : false">Edit search query</seasonedButton>
</div>
</div>
<div v-else class="torrentloader"><i></i></div>
</div>
</template>
<script>
import storage from '@/storage'
import store from '@/store'
import { sortableSize } from '@/utils'
import { searchTorrents, addMagnet } from '@/api'
import SeasonedButton from '@/components/ui/SeasonedButton'
import SeasonedInput from '@/components/ui/SeasonedInput'
import ToggleButton from '@/components/ui/ToggleButton'
export default {
components: { SeasonedButton, SeasonedInput, ToggleButton },
props: {
query: {
type: String,
require: true
},
tmdb_id: {
type: Number,
require: true
},
tmdb_type: String,
admin: Boolean,
show: Boolean
},
data() {
return {
listLoaded: false,
torrents: [],
torrentResponse: undefined,
currentPage: 0,
prevCol: '',
direction: false,
release_types: ['all'],
selectedRelaseType: 'all',
editSearchQuery: false,
editedSearchQuery: ''
}
},
beforeMount() {
if (localStorage.getItem('admin')) {
this.fetchTorrents()
}
store.dispatch('torrentModule/reset')
},
watch: {
selectedRelaseType: function(newValue) {
this.applyFilter(newValue)
}
},
methods: {
selectedSortableClass(headerName) {
return headerName === this.prevCol ? 'active' : ''
},
resetTorrentsAndToggleEditSearchQuery() {
this.torrents = []
this.toggleEditSearchQuery()
},
toggleEditSearchQuery() {
this.editSearchQuery = !this.editSearchQuery;
},
expand(event, name) {
const existingExpandedElement = document.getElementsByClassName('expanded')[0]
const clickedElement = event.target.parentNode;
const scopedStyleDataVariable = Object.keys(clickedElement.dataset)[0]
if (existingExpandedElement) {
const expandedSibling = event.target.parentNode.nextSibling.className === 'expanded'
existingExpandedElement.remove()
const table = document.getElementsByTagName('table')[0]
table.style.display = 'block'
if (expandedSibling) {
return
}
}
const nameRow = document.createElement('tr')
const nameCol = document.createElement('td')
nameRow.className = 'expanded'
nameRow.dataset[scopedStyleDataVariable] = "";
nameCol.innerText = name
nameCol.dataset[scopedStyleDataVariable] = "";
nameRow.appendChild(nameCol)
clickedElement.insertAdjacentElement('afterend', nameRow)
},
sendTorrent(magnet, name, event){
this.$notifications.info({
title: 'Adding torrent 🦜',
description: this.query,
timeout: 3000
})
event.target.parentNode.classList.add('active')
addMagnet(magnet, name, this.tmdb_id)
.catch((resp) => { console.log('error:', resp.data) })
.then((resp) => {
console.log('addTorrent resp: ', resp)
this.$notifications.success({
title: 'Torrent added 🎉',
description: this.query,
timeout: 3000
})
})
},
sortTable(col, sameDirection=false) {
if (this.prevCol === col && sameDirection === false) {
this.direction = !this.direction
}
switch (col) {
case 'name':
this.sortName()
break
case 'seed':
this.sortSeed()
break
case 'size':
this.sortSize()
break
}
this.prevCol = col
},
sortName() {
const torrentsCopy = [...this.torrents]
if (this.direction) {
this.torrents = torrentsCopy.sort((a, b) => (a.name < b.name) ? 1 : -1)
} else {
this.torrents = torrentsCopy.sort((a, b) => (a.name > b.name) ? 1 : -1)
}
},
sortSeed() {
const torrentsCopy = [...this.torrents]
if (this.direction) {
this.torrents = torrentsCopy.sort((a, b) => parseInt(a.seed) - parseInt(b.seed));
} else {
this.torrents = torrentsCopy.sort((a, b) => parseInt(b.seed) - parseInt(a.seed));
}
},
sortSize() {
const torrentsCopy = [...this.torrents]
if (this.direction) {
this.torrents = torrentsCopy.sort((a, b) => parseInt(sortableSize(a.size)) - parseInt(sortableSize(b.size)));
} else {
this.torrents = torrentsCopy.sort((a, b) => parseInt(sortableSize(b.size)) - parseInt(sortableSize(a.size)));
}
},
findRelaseTypes() {
this.torrents.forEach(item => this.release_types.push(...item.release_type))
this.release_types = [...new Set(this.release_types)]
},
applyFilter(item, index) {
this.selectedRelaseType = item;
const torrents = [...this.torrentResponse]
if (item === 'all') {
this.torrents = torrents
this.sortTable(this.prevCol, true)
return
}
this.torrents = torrents.filter(torrent => torrent.release_type.includes(item))
this.sortTable(this.prevCol, true)
},
updateResultCountInStore() {
store.dispatch('torrentModule/setResults', this.torrents)
store.dispatch('torrentModule/setResultCount', this.torrentResponse.length)
},
fetchTorrents(query=undefined){
this.listLoaded = false;
this.editSearchQuery = false;
searchTorrents(query || this.query, 'all', this.currentPage, storage.token)
.then(data => {
this.torrentResponse = [...data.results];
this.torrents = data.results;
this.listLoaded = true;
})
.then(this.updateResultCountInStore)
.then(this.findRelaseTypes)
.catch(e => {
const error = e.toString()
this.errorMessage = error.indexOf('401') != -1 ? 'Permission denied' : 'Nothing found';
this.listLoaded = true;
});
},
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
.expanded {
display: flex;
padding: 0.25rem 1rem;
max-width: 100%;
border-left: 1px solid $text-color;
border-right: 1px solid $text-color;
border-bottom: 1px solid $text-color;
td {
word-break: break-all;
padding: 0.5rem 0.15rem;
width: 100%;
}
}
</style>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "./src/scss/elements";
.toggle {
max-width: unset !important;
margin: 1rem 0;
}
.container {
background-color: $background-color;
padding: 0 1rem;
}
.torrentHeader {
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 20px;
&-text {
font-weight: 400;
text-transform: uppercase;
font-size: 14px;
color: $green;
text-align: center;
margin: 0;
@include tablet-min {
font-size: 16px
}
&.editable {
cursor: pointer;
}
}
&-editIcon {
margin-left: 10px;
margin-top: -3px;
width: 22px;
height: 22px;
&:hover {
fill: $green;
cursor: pointer;
}
}
}
table {
border-collapse: collapse;
width: 100%;
table-layout: fixed;
}
.table__content, .table__header {
display: flex;
padding: 0;
border-left: 1px solid $text-color;
border-right: 1px solid $text-color;
border-bottom: 1px solid $text-color;
th, td {
display: flex;
flex-direction: column;
flex-basis: 100%;
padding: 0.4rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
min-width: 75px;
}
th:first-child, td:first-child {
flex: 1;
}
th:not(:first-child), td:not(:first-child) {
flex: 0.2;
}
th:nth-child(2), td:nth-child(2) {
flex: 0.1;
}
@include mobile-only {
th:first-child, td:first-child {
display: none;
&.show {
display: block;
align: flex-end;
}
}
th:not(:first-child), td:not(:first-child) {
flex: 1;
}
}
}
.table__content {
td:not(:last-child) {
border-right: 1px solid $text-color;
}
}
.table__content:last-child {
margin-bottom: 1rem;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
}
.table__header {
color: $text-color;
text-transform: uppercase;
cursor: pointer;
background-color: $background-color-secondary;
border-top: 1px solid $text-color;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
th {
display: flex;
flex-direction: row;
font-weight: 400;
letter-spacing: 0.7px;
// font-size: 1.08rem;
font-size: 15px;
&::before {
content: '';
min-width: 0.2rem;
}
span:first-child {
margin-right: 0.6rem;
}
span:nth-child(2) {
margin-right: 0.1rem;
}
}
th:not(:last-child) {
border-right: 1px solid $text-color;
}
}
.editQuery {
display: flex;
width: 70%;
justify-content: center;
@include mobile-only {
width: 90%;
}
}
.download {
&__icon {
fill: $text-color-70;
height: 1.2rem;
&:hover {
fill: $text-color;
cursor: pointer;
}
}
&.active &__icon {
fill: $green;
}
}
.torrentloader {
width: 100%;
padding: 2rem 0;
i {
animation: load 1s linear infinite;
border: 2px solid $text-color;
border-radius: 50%;
display: block;
height: 30px;
left: 50%;
margin: 0 auto;
width: 30px;
&:after {
border: 5px solid $green;
border-radius: 50%;
content: '';
left: 10px;
position: absolute;
top: 16px;
}
}
}
@keyframes load {
100% { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<div v-if="watchStats" class="stats-overview">
<div class="stat-card">
<div class="stat-value">{{ watchStats.totalPlays }}</div>
<div class="stat-label">Total Plays</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.totalHours }}h</div>
<div class="stat-label">Watch Time</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.moviePlays }}</div>
<div class="stat-label">Movies watched</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.episodePlays }}</div>
<div class="stat-label">Episodes watched</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { WatchStats } from "../../composables/useTautulliStats";
interface Props {
watchStats: WatchStats | null;
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
}
.stat-card {
background: var(--background-ui);
padding: 1.5rem;
border-radius: 12px;
text-align: center;
transition: transform 0.2s;
&:hover {
transform: translateY(-4px);
}
@include mobile-only {
padding: 1rem;
}
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--highlight-color);
margin-bottom: 0.5rem;
@include mobile-only {
font-size: 2rem;
}
}
.stat-label {
font-size: 0.9rem;
color: var(--text-color-60);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 300;
@include mobile-only {
font-size: 0.8rem;
}
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div v-if="topContent.length > 0" class="watch-history">
<h3 class="section-title">Last Watched</h3>
<div class="top-content-list">
<div
v-for="(item, index) in topContent"
:key="index"
class="top-content-item"
>
<div class="content-rank">{{ index + 1 }}</div>
<div class="content-details">
<div class="content-title">{{ item.title }}</div>
<div class="content-meta">
{{ item.type }} {{ item.plays }} plays {{ item.duration }}min
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface TopContentItem {
title: string;
type: string;
plays: number;
duration: number;
}
interface Props {
topContent: TopContentItem[];
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.watch-history {
margin-top: 2rem;
}
.section-title {
margin: 0 0 1rem 0;
font-size: 1.2rem;
font-weight: 500;
color: $text-color;
}
.top-content-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
@include mobile-only {
grid-template-columns: 1fr;
}
}
.top-content-item {
display: flex;
align-items: center;
gap: 1rem;
background: var(--background-ui);
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--text-color-50);
transition: all 0.2s;
&:hover {
border-color: var(--text-color);
transform: translateY(-2px);
}
}
.content-rank {
font-size: 1.5rem;
font-weight: 700;
color: var(--highlight-color);
min-width: 2.5rem;
text-align: center;
}
.content-details {
flex: 1;
}
.content-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.25rem;
}
.content-meta {
font-size: 0.85rem;
color: var(--text-color-60);
}
</style>

View File

@@ -0,0 +1,276 @@
<template>
<transition name="shut">
<ul class="dropdown">
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events -->
<li
v-for="(result, _index) in searchResults"
:key="`${_index}-${result.title}-${result.type}`"
:class="`result di-${_index} ${_index === index ? 'active' : ''}`"
@click="openPopup(result)"
>
<IconMovie v-if="result.type == 'movie'" class="type-icon" />
<IconShow v-if="result.type == 'show'" class="type-icon" />
<span class="title">{{ result.title }}</span>
</li>
<li
v-if="searchResults.length"
:class="`info di-${searchResults.length}`"
>
<span> Select from list or press enter to search </span>
</li>
</ul>
</transition>
</template>
<script setup lang="ts">
import type { Ref } from "vue";
import { ref, watch, defineProps } from "vue";
import { useStore } from "vuex";
import IconMovie from "../../icons/IconMovie.vue";
import IconShow from "../../icons/IconShow.vue";
import { elasticSearchMoviesAndShows } from "../../api";
import { MediaTypes } from "../../interfaces/IList";
import type {
IAutocompleteResult,
IAutocompleteSearchResults
} from "../../interfaces/IAutocompleteSearch";
interface Props {
query?: string;
index?: number;
results?: Array<IAutocompleteResult>;
}
interface Emit {
(e: "update:results", value: Array<IAutocompleteResult>);
}
const numberOfResults = 10;
const props = defineProps<Props>();
const emit = defineEmits<Emit>();
const store = useStore();
const searchResults: Ref<Array<IAutocompleteResult>> = ref([]);
const keyboardNavigationIndex: Ref<number> = ref(0);
let disableOnFailure = false;
watch(
() => props.query,
newQuery => {
if (newQuery?.length > 0 && !disableOnFailure)
fetchAutocompleteResults(); /* eslint-disable-line no-use-before-define */
}
);
function openPopup(result: IAutocompleteResult) {
if (!result.id || !result.type) return;
store.dispatch("popup/open", { ...result });
}
function removeDuplicates(_searchResults: Array<IAutocompleteResult>) {
const filteredResults = [];
_searchResults.forEach(result => {
if (result === undefined) return;
const numberOfDuplicates = filteredResults.filter(
filterItem => filterItem.id === result.id
);
if (numberOfDuplicates.length >= 1) {
return;
}
filteredResults.push(result);
});
return filteredResults;
}
function elasticTypeToMediaType(type: string): MediaTypes {
if (type === "movie") return MediaTypes.Movie;
if (type === "tv_series") return MediaTypes.Show;
return null;
}
function parseElasticResponse(elasticResponse: IAutocompleteSearchResults) {
const { hits } = elasticResponse.hits;
const data = hits.length > 0 ? hits : (searchResults.value ?? []);
const results: Array<IAutocompleteResult> = [];
data.forEach(item => {
if (!item._index) return;
results.push({
title: item._source?.original_name || item._source.original_title,
id: item._source.id,
adult: item._source.adult,
type: elasticTypeToMediaType(item._source.type)
});
});
return removeDuplicates(results).map((el, index) => {
return { ...el, index };
});
}
async function fetchAutocompleteResults() {
keyboardNavigationIndex.value = 0;
searchResults.value = [];
return elasticSearchMoviesAndShows(props.query, numberOfResults)
.catch(error => {
// TODO display error
disableOnFailure = true;
throw error;
})
.then(elasticResponse => parseElasticResponse(elasticResponse))
.then(_searchResults => {
emit("update:results", _searchResults);
searchResults.value = _searchResults;
})
.catch(error => {
// TODO display error
disableOnFailure = true;
throw error;
});
}
// on load functions
fetchAutocompleteResults();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
$sizes: 22;
@for $i from 0 through $sizes {
.dropdown .di-#{$i} {
visibility: visible;
transform-origin: top center;
animation: scaleZ 200ms calc(50ms * #{$i}) ease-in forwards;
}
}
@keyframes scaleZ {
0% {
opacity: 0;
transform: scale(0);
}
80% {
transform: scale(1.07);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.dropdown {
top: var(--header-size);
position: relative;
height: 100%;
width: 100%;
max-width: 720px;
display: flex;
flex-direction: column;
flex-wrap: wrap;
z-index: 5;
margin-top: -1px;
border-top: none;
padding: 0;
@include mobile {
position: fixed;
left: 0;
max-width: 100vw;
}
@include tablet-min {
top: unset;
--gutter: 1.5rem;
max-width: calc(100% - (2 * var(--gutter)));
margin: -1px var(--gutter) 0 var(--gutter);
}
@include desktop {
max-width: 720px;
}
}
li.result {
background-color: var(--background-95);
color: var(--text-color-50);
padding: 0.5rem 2rem;
list-style: none;
opacity: 0;
height: 56px;
width: 100%;
visibility: hidden;
display: flex;
align-items: center;
padding: 0.5rem 2rem;
font-size: 1.4rem;
text-transform: capitalize;
list-style: none;
cursor: pointer;
white-space: nowrap;
transition:
color 0.1s ease,
fill 0.4s ease;
span {
overflow-x: hidden;
text-overflow: ellipsis;
transition: inherit;
}
&.active,
&:hover,
&:active {
color: var(--text-color);
border-bottom: 2px solid var(--color-green);
.type-icon {
fill: var(--text-color);
}
}
.type-icon {
width: 28px;
height: 28px;
margin-right: 1rem;
transition: inherit;
fill: var(--text-color-50);
}
}
li.info {
visibility: hidden;
opacity: 0;
display: flex;
justify-content: center;
padding: 0 1rem;
color: var(--text-color-50);
background-color: var(--background-95);
color: var(--text-color-50);
font-size: 0.6rem;
height: 16px;
width: 100%;
}
.shut-leave-to {
height: 0px;
transition: height 0.4s ease;
flex-wrap: no-wrap;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<nav>
<!-- eslint-disable-next-line vuejs-accessibility/anchor-has-content -->
<a v-if="isHome" class="nav__logo" href="/">
<TmdbLogo class="logo" />
</a>
<router-link v-else class="nav__logo" to="/" exact>
<TmdbLogo class="logo" />
</router-link>
<SearchInput />
<Hamburger class="mobile-only" />
<NavigationIcon class="desktop-only" :route="profileRoute" />
<!-- <div class="navigation-icons-grid mobile-only" :class="{ open: isOpen }"> -->
<div v-if="isOpen" class="navigation-icons-grid mobile-only">
<NavigationIcons>
<NavigationIcon :route="profileRoute" />
</NavigationIcons>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useStore } from "vuex";
import { useRoute } from "vue-router";
import SearchInput from "@/components/header/SearchInput.vue";
import Hamburger from "@/components/ui/Hamburger.vue";
import NavigationIcons from "@/components/header/NavigationIcons.vue";
import NavigationIcon from "@/components/header/NavigationIcon.vue";
import TmdbLogo from "@/icons/tmdb-logo.vue";
import IconProfile from "@/icons/IconProfile.vue";
import IconProfileLock from "@/icons/IconProfileLock.vue";
import type INavigationIcon from "../../interfaces/INavigationIcon";
const route = useRoute();
const store = useStore();
const signinNavigationIcon: INavigationIcon = {
title: "Signin",
route: "/login",
icon: IconProfileLock
};
const profileNavigationIcon: INavigationIcon = {
title: "Profile",
route: "/profile",
icon: IconProfile
};
const isHome = computed(() => route.path === "/");
const isOpen = computed(() => store.getters["hamburger/isOpen"]);
const loggedIn = computed(() => store.getters["user/loggedIn"]);
const profileRoute = computed(() =>
!loggedIn.value ? signinNavigationIcon : profileNavigationIcon
);
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.spacer {
@include mobile-only {
width: 100%;
height: $header-size;
}
}
nav {
display: grid;
grid-template-columns: var(--header-size) 1fr var(--header-size);
> * {
z-index: 10;
}
}
.nav__logo {
overflow: hidden;
}
.logo {
padding: 1rem;
fill: var(--color-green);
width: var(--header-size);
height: var(--header-size);
display: flex;
align-items: center;
justify-content: center;
background: $background-nav-logo;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.08);
}
@include mobile {
padding: 0.5rem;
}
}
.navigation-icons-grid {
display: flex;
flex-wrap: wrap;
position: fixed;
top: var(--header-size);
left: 0;
width: 100%;
background-color: $background-95;
visibility: hidden;
opacity: 0;
transition: opacity 0.4s ease;
opacity: 1;
visibility: visible;
&.open {
}
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<router-link
v-if="route?.requiresAuth == undefined || (route?.requiresAuth && loggedIn)"
:key="route?.title"
:to="{ path: route?.route }"
>
<li
class="navigation-link"
:class="{ active: matchesActiveRoute(), 'nofill-stroke': useStroke }"
>
<component :is="route.icon" class="navigation-icon"></component>
<span>{{ route.title }}</span>
</li>
</router-link>
</template>
<script setup lang="ts">
import { useStore } from "vuex";
import { computed } from "vue";
import type INavigationIcon from "../../interfaces/INavigationIcon";
interface Props {
route: INavigationIcon;
active?: string;
useStroke?: boolean;
}
const props = defineProps<Props>();
const store = useStore();
const loggedIn = computed(() => store.getters["user/loggedIn"]);
function matchesActiveRoute() {
const currentRoute = props.route.title.toLowerCase();
return props?.active?.includes(currentRoute);
}
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.navigation-link {
display: grid;
place-items: center;
min-height: var(--header-size);
list-style: none;
padding: 1rem 0.15rem;
text-align: center;
background-color: var(--background-color-secondary);
transition:
transform 0.3s ease,
color 0.3s ease,
stoke 0.3s ease,
fill 0.3s ease,
background-color 0.5s ease;
transition: all 0.3s ease;
&:hover {
transform: scale(1.05);
}
&:hover,
&.active {
background-color: var(--background-color);
span,
.navigation-icon {
color: var(--text-color);
fill: var(--text-color);
}
}
span {
text-transform: uppercase;
font-size: 11px;
margin-top: 0.25rem;
color: var(--text-color-70);
}
&.nofill-stroke {
.navigation-icon {
stroke: var(--text-color-70);
fill: none !important;
}
&:hover .navigation-icon,
&.active .navigation-icon {
stroke: var(--text-color);
}
}
}
a {
text-decoration: none;
}
.navigation-icon {
width: 28px;
fill: var(--text-color-70);
transition: inherit;
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<ul class="navigation-icons">
<NavigationIcon
v-for="_route in routes"
:key="_route.route"
:route="_route"
:active="activeRoute"
:use-stroke="_route?.useStroke"
/>
<slot></slot>
</ul>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRoute } from "vue-router";
import NavigationIcon from "@/components/header/NavigationIcon.vue";
import IconMailboxFull from "@/icons/IconMailboxFull.vue";
import IconNowPlaying from "@/icons/IconNowPlaying.vue";
import IconPopular from "@/icons/IconPopular.vue";
import IconUpcoming from "@/icons/IconUpcoming.vue";
import IconSettings from "@/icons/IconSettings.vue";
import IconActivity from "@/icons/IconActivity.vue";
import IconHelm from "@/icons/IconHelm.vue";
import IconDiscover from "@/icons/IconDiscover.vue";
import type INavigationIcon from "../../interfaces/INavigationIcon";
const route = useRoute();
const activeRoute = ref(window?.location?.pathname);
const routes: INavigationIcon[] = [
{
title: "Requests",
route: "/list/requests",
icon: IconMailboxFull
},
{
title: "Now Playing",
route: "/list/now_playing",
icon: IconNowPlaying
},
{
title: "Discover",
route: "/discover",
icon: IconDiscover
},
{
title: "Popular",
route: "/list/popular",
icon: IconPopular
},
{
title: "Upcoming",
route: "/list/upcoming",
icon: IconUpcoming
},
{
title: "Activity",
route: "/activity",
requiresAuth: true,
useStroke: true,
icon: IconActivity
},
{
title: "Torrents",
route: "/torrents",
requiresAuth: true,
icon: IconHelm
},
{
title: "Settings",
route: "/settings",
requiresAuth: true,
useStroke: true,
icon: IconSettings
}
];
function setActiveRoute(_route: string) {
activeRoute.value = _route;
}
watch(route, () => setActiveRoute(window?.location?.pathname || ""));
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.navigation-icons {
display: grid;
grid-column: 1fr;
padding-left: 0;
margin: 0;
background-color: var(--background-color-secondary);
z-index: 15;
width: 100%;
@include desktop {
grid-template-rows: var(--header-size);
}
@include mobile {
grid-template-columns: 1fr 1fr;
}
}
</style>

View File

@@ -0,0 +1,297 @@
<template>
<div>
<div class="search" :class="{ active: inputIsActive }">
<IconSearch class="search-icon" tabindex="-1" />
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -->
<input
ref="inputElement"
v-model="query"
type="text"
placeholder="Search for movie or show"
aria-label="Search input for finding a movie or show"
autocorrect="off"
autocapitalize="off"
tabindex="0"
@input="handleInput"
@click="focus"
@keydown.escape="handleEscape"
@keyup.enter="handleSubmit"
@keydown.up="navigateUp"
@keydown.down="navigateDown"
@focus="focus"
@blur="blur"
/>
<IconClose
v-if="query && query.length"
tabindex="0"
aria-label="button"
class="close-icon"
@click="clearInput"
@keydown.enter.stop="clearInput"
/>
</div>
<AutocompleteDropdown
v-if="showAutocompleteResults"
v-model:results="dropdownResults"
:query="query"
:index="dropdownIndex"
/>
</div>
</template>
<!-- Handles constructing markup and state for dropdown.
Markup:
Consist of: search icon, input & close button.
State:
State is passing input variable `query` to dropdown and carrying state
of selected dropdown element as variable `index`. This is because
index is manipulated based on arrow key events from same input as
the `query`.
-->
<script setup lang="ts">
import type { Ref } from "vue";
import { ref, computed } from "vue";
import { useStore } from "vuex";
import { useRouter, useRoute } from "vue-router";
import AutocompleteDropdown from "./AutocompleteDropdown.vue";
import IconSearch from "../../icons/IconSearch.vue";
import IconClose from "../../icons/IconClose.vue";
import { IAutocompleteResult } from "../../interfaces/IAutocompleteSearch";
const store = useStore();
const router = useRouter();
const route = useRoute();
const query: Ref<string> = ref(null);
const disabled: Ref<boolean> = ref(false);
const dropdownIndex: Ref<number> = ref(-1);
const dropdownResults: Ref<IAutocompleteResult[]> = ref([]);
const inputIsActive: Ref<boolean> = ref(false);
const inputElement: Ref<HTMLInputElement> = ref(null);
const isOpen = computed(() => store.getters["popup/isOpen"]);
const showAutocompleteResults = computed(() => {
return (
!disabled.value &&
inputIsActive.value &&
query.value &&
query.value.length > 0
);
});
const params = new URLSearchParams(window.location.search);
if (params && params.has("query")) {
query.value = decodeURIComponent(params.get("query"));
}
const ELASTIC_URL = import.meta.env.VITE_ELASTIC_URL;
const ELASTIC_API_KEY = import.meta.env.VITE_ELASTIC_API_KEY;
if (!ELASTIC_URL || !ELASTIC_API_KEY) {
disabled.value = true;
}
function navigateDown() {
if (dropdownIndex.value < dropdownResults.value.length - 1) {
dropdownIndex.value += 1;
}
}
function navigateUp() {
if (dropdownIndex.value > -1) dropdownIndex.value -= 1;
const textLength = inputElement.value.value.length;
setTimeout(() => {
inputElement.value.focus();
inputElement.value.setSelectionRange(textLength, textLength + 1);
}, 1);
}
function search() {
const encodedQuery = encodeURI(query.value.replace("/ /g", "+"));
router.push({
name: "search",
query: {
...route.query,
query: encodedQuery
}
});
}
function handleInput() {
dropdownIndex.value = -1;
}
function focus() {
inputIsActive.value = true;
}
function reset() {
inputElement.value.blur();
dropdownIndex.value = -1;
inputIsActive.value = false;
}
function blur() {
return setTimeout(reset, 150);
}
function clearInput() {
query.value = "";
inputElement.value.focus();
}
function handleSubmit() {
if (!query.value || query.value.length === 0) return;
// if index is set, navigation has happened. Open popup else search
if (dropdownIndex.value >= 0) {
const resultItem = dropdownResults.value[dropdownIndex.value];
store.dispatch("popup/open", {
id: resultItem?.id,
type: resultItem?.type
});
return;
}
search();
reset();
}
function handleEscape() {
if (!isOpen.value) reset();
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
.close-icon {
position: absolute;
top: calc(50% - 12px);
right: 0;
cursor: pointer;
fill: var(--text-color);
height: 24px;
width: 24px;
@include tablet-min {
right: 6px;
}
}
.filter {
width: 100%;
display: flex;
flex-direction: column;
margin: 1rem 2rem;
h2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 400;
}
&-items {
display: flex;
flex-direction: row;
align-items: center;
> :not(:first-child) {
margin-left: 1rem;
}
}
}
hr {
display: block;
height: 1px;
border: 0;
border-bottom: 1px solid $text-color-50;
margin-top: 10px;
margin-bottom: 10px;
width: 90%;
}
.search.active {
input {
border-color: var(--color-green);
}
.search-icon {
fill: var(--color-green);
}
}
.search {
height: $header-size;
display: flex;
position: fixed;
flex-wrap: wrap;
z-index: 5;
border: 0;
background-color: $background-color-secondary;
// TODO check if this is for mobile
width: calc(100% - 110px);
top: 0;
right: 55px;
@include tablet-min {
position: relative;
width: 100%;
right: 0px;
}
input {
display: block;
width: 100%;
padding: 13px 28px 13px 45px;
outline: none;
margin: 0;
border: 0;
background-color: $background-color-secondary;
font-weight: 300;
font-size: 18px;
color: $text-color;
border-bottom: 1px solid transparent;
&:focus {
// border-bottom: 1px solid var(--color-green);
border-color: var(--color-green);
}
@include tablet-min {
font-size: 24px;
padding: 13px 40px 13px 60px;
}
}
&-icon {
width: 20px;
height: 20px;
fill: var(--text-color-50);
pointer-events: none;
position: absolute;
left: 15px;
top: calc(50% - 10px);
@include tablet-min {
width: 24px;
height: 24px;
top: calc(50% - 12px);
left: 22px;
}
}
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<div class="plex-connect">
<div class="info-box">
<IconInfo class="info-icon" />
<p>
Sign in to your Plex account to get information about recently added
movies and to see your watch history
</p>
</div>
<div class="signin-container">
<button @click="handleAuth" :disabled="loading" class="plex-signin-btn">
{{ loading ? "Connecting..." : "Sign in with Plex" }}
<IconPlex v-if="!loading" class="plex-icon" />
</button>
<p class="popup-note">A popup window will open for authentication</p>
</div>
</div>
</template>
<script setup lang="ts">
import { usePlexAuth } from "@/composables/usePlexAuth";
import IconInfo from "@/icons/IconInfo.vue";
import IconPlex from "@/icons/IconPlex.vue";
const emit = defineEmits<{
authSuccess: [token: string];
authError: [message: string];
}>();
const { loading, openAuthPopup } = usePlexAuth();
function handleAuth() {
openAuthPopup(
token => emit("authSuccess", token),
error => emit("authError", error)
);
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.info-box {
display: flex;
gap: 0.65rem;
padding: 0.65rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
margin-bottom: 0.85rem;
border-left: 3px solid var(--highlight-color);
@include mobile-only {
padding: 0.6rem;
gap: 0.55rem;
margin-bottom: 0.7rem;
}
p {
margin: 0;
font-size: 0.9rem;
line-height: 1.4;
@include mobile-only {
font-size: 0.85rem;
}
}
}
.info-icon {
width: 20px;
height: 20px;
fill: var(--highlight-color);
flex-shrink: 0;
@include mobile-only {
width: 18px;
height: 18px;
}
}
.signin-container {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.plex-signin-btn {
padding: 1rem 1.75rem;
background-color: #c87818;
color: $white;
border: none;
border-radius: 0.75rem;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
align-self: flex-start;
box-shadow: 0 4px 12px rgba(200, 120, 24, 0.25);
letter-spacing: -0.01em;
@include mobile-only {
width: 100%;
padding: 0.9rem 1.4rem;
font-size: 1rem;
}
&:hover:not(:disabled) {
background-color: #b36a15;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(200, 120, 24, 0.4);
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 4px 12px rgba(200, 120, 24, 0.3);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.plex-icon {
flex-shrink: 0;
--size: 24px;
width: var(--size);
height: var(--size);
fill: currentColor;
}
}
.popup-note {
margin: 0;
font-size: 0.85rem;
opacity: 0.65;
}
</style>

View File

@@ -0,0 +1,230 @@
<template>
<a
v-if="item.plexUrl"
:href="item.plexUrl"
target="_blank"
rel="noopener noreferrer"
class="plex-library-item"
>
<figure :class="`item-poster ${item.type}`">
<img
v-if="item.poster"
:src="item.poster"
:alt="item.title"
class="poster-image"
@error="handleImageError"
/>
<div v-else class="poster-fallback">
<component :is="fallbackIconComponent" />
</div>
</figure>
<div class="item-details">
<p class="item-title">{{ item.title }}</p>
<div class="item-meta">
<span v-if="item.year" class="item-year">{{ item.year }}</span>
<span v-if="item.rating" class="item-rating">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
{{ item.rating }}
</span>
</div>
<div v-if="showExtras" class="item-extras">
<span v-if="item.artist">{{ item.artist }}</span>
<span v-if="item.episodes">{{ item.episodes }} episodes</span>
<span v-if="item.tracks">{{ item.tracks }} tracks</span>
</div>
</div>
</a>
<div v-else class="plex-library-item plex-library-item--no-link">
<figure class="item-poster">
<img
v-if="item.poster"
:src="item.poster"
:alt="item.title"
class="poster-image"
@error="handleImageError"
/>
<div v-else class="poster-fallback">
<component :is="fallbackIconComponent" />
</div>
</figure>
<div class="item-details">
<p class="item-title">{{ item.title }}</p>
<div class="item-meta">
<span v-if="item.year" class="item-year">{{ item.year }}</span>
<span v-if="item.rating" class="item-rating">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
{{ item.rating }}
</span>
</div>
<div v-if="showExtras" class="item-extras">
<span v-if="item.artist">{{ item.artist }}</span>
<span v-if="item.episodes">{{ item.episodes }} episodes</span>
<span v-if="item.tracks">{{ item.tracks }} tracks</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";
interface LibraryItem {
title: string;
poster?: string;
fallbackIcon?: string;
year?: number;
rating?: number;
artist?: string;
episodes?: number;
tracks?: number;
plexUrl?: string | null;
}
interface Props {
item: LibraryItem;
showExtras?: boolean;
}
const props = defineProps<Props>();
const fallbackIconComponent = computed(() => {
if (props.item.fallbackIcon === "🎬") return IconMovie;
if (props.item.fallbackIcon === "📺") return IconShow;
if (props.item.fallbackIcon === "🎵") return IconMusic;
return IconMovie; // Default fallback
});
function handleImageError(event: Event) {
const target = event.target as HTMLImageElement;
target.style.display = "none";
}
</script>
<style style="scss" scoped>
.plex-library-item {
display: flex;
flex-direction: column;
gap: 8px;
cursor: pointer;
transition: transform 0.2s;
text-decoration: none;
color: inherit;
}
.plex-library-item:hover {
transform: translateY(-4px);
}
.plex-library-item--no-link {
cursor: default;
}
.plex-library-item--no-link:hover {
transform: none;
}
.item-poster {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
border-radius: 8px;
overflow: hidden;
background: #333;
margin: 0;
&.music {
aspect-ratio: 1/1;
}
}
.poster-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.poster-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #333 0%, #222 100%);
padding: 20%;
svg {
width: 100%;
height: 100%;
fill: #666;
}
}
.item-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.item-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #fff;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.item-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #888;
}
.item-year {
color: #aaa;
}
.item-rating {
display: flex;
align-items: center;
gap: 4px;
color: #fbbf24;
}
.item-extras {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 11px;
color: #888;
}
@media (max-width: 768px) {
.item-title {
font-size: 13px;
}
.item-meta {
font-size: 11px;
}
}
</style>

View File

@@ -0,0 +1,382 @@
<template>
<div class="modal-overlay library-modal-overlay" @click="emit('close')">
<div class="library-modal-content" @click.stop>
<div class="library-modal-header">
<div class="library-header-title">
<div class="library-icon-large">
<component :is="libraryIconComponent" />
</div>
<div>
<h3>{{ getLibraryTitle(libraryType) }}</h3>
<p class="library-subtitle">{{ details.total }} items</p>
</div>
</div>
<button class="close-btn" @click="emit('close')">
<IconClose />
</button>
</div>
<div class="library-modal-body">
<!-- Stats Overview -->
<div class="library-stats-overview">
<div class="overview-stat">
<span class="overview-label">Total Items</span>
<span class="overview-value">{{
formatNumber(details.total)
}}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'tv shows'">
<span class="overview-label">Seasons</span>
<span class="overview-value">{{
formatNumber(details?.childCount)
}}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'tv shows'">
<span class="overview-label">Episodes</span>
<span class="overview-value">{{
formatNumber(details?.leafCount)
}}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'music'">
<span class="overview-label">Tracks</span>
<span class="overview-value">{{ details?.totalTracks }}</span>
</div>
<div class="overview-stat">
<span class="overview-label">Duration</span>
<span class="overview-value">{{
convertSecondsToHumanReadable(details?.duration / 1000)
}}</span>
</div>
</div>
<!-- Recently Added -->
<div class="library-section">
<h4 class="section-title">Recently Added</h4>
<div class="recent-items-grid">
<PlexLibraryItem
v-for="(item, index) in recentlyAdded"
:key="index"
:item="item"
:show-extras="
libraryType === 'music' || libraryType === 'tv shows'
"
/>
</div>
</div>
<!-- Top Genres -->
<div class="library-section">
<h4 class="section-title">Top Genres</h4>
<div class="genre-list">
<div
v-for="(genre, index) in details.genres"
:key="index"
class="genre-item"
>
<span class="genre-name">{{ genre.name }}</span>
<div class="genre-bar-container">
<div
class="genre-bar"
:style="{
width: (genre.count / details.total) * 100 + '%'
}"
></div>
</div>
<span class="genre-count">{{ genre.count }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from "vue";
import IconClose from "@/icons/IconClose.vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";
import PlexLibraryItem from "@/components/plex/PlexLibraryItem.vue";
import { getLibraryTitle } from "@/utils/plexHelpers";
import { plexRecentlyAddedInLibrary } from "@/api";
import { processLibraryItem } from "@/utils/plexHelpers";
import { formatNumber, convertSecondsToHumanReadable } from "@/utils";
import { usePlexAuth } from "@/composables/usePlexAuth";
const { getPlexAuthCookie } = usePlexAuth();
const authToken = getPlexAuthCookie();
interface LibraryDetails {
id: number;
title: string;
total: number;
childCount?: number;
leafCount?: number;
duration: number;
genres: Array<{
name: string;
count: number;
}>;
}
interface Props {
libraryType: string;
details: LibraryDetails;
serverUrl: string;
serverMachineId: string;
}
const props = defineProps<Props>();
let recentlyAdded = ref([]);
const emit = defineEmits<{
(e: "close"): void;
}>();
const libraryIconComponent = computed(() => {
if (props.libraryType === "movies") return IconMovie;
if (props.libraryType === "tv shows") return IconShow;
if (props.libraryType === "music") return IconMusic;
return IconMovie;
});
function fetchRecentlyAdded() {
plexRecentlyAddedInLibrary(props.details.id).then(added => {
recentlyAdded.value = added?.MediaContainer?.Metadata.map(el =>
processLibraryItem(
el,
props.libraryType,
authToken,
props.serverUrl,
props.serverMachineId
)
);
});
}
function checkEventForEscapeKey(event: KeyboardEvent) {
if (event.key !== "Escape") return;
emit("close");
}
window.addEventListener("keyup", checkEventForEscapeKey);
onMounted(() => {
fetchRecentlyAdded();
});
onBeforeUnmount(() => {
window.removeEventListener("keyup", checkEventForEscapeKey);
});
</script>
<style lang="scss" scoped>
@import "scss/media-queries.scss";
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
@include mobile {
padding: 0;
}
}
.library-modal-content {
background: #1a1a1a;
border-radius: 12px;
width: 100%;
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
@include mobile {
max-height: 100vh;
border-radius: unset;
}
}
.library-modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24px;
border-bottom: 1px solid #333;
}
.library-header-title {
display: flex;
align-items: center;
gap: 16px;
}
.library-icon-large {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
fill: var(--highlight-color);
}
}
.library-modal-header h3 {
margin: 0;
font-size: 24px;
color: #fff;
}
.library-subtitle {
margin: 4px 0 0;
font-size: 14px;
color: #888;
}
.close-btn {
--size: 2.4rem;
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0.5rem;
height: var(--size);
width: var(--size);
border-radius: 6px;
fill: white;
transition: all 0.2s;
@include mobile {
margin: auto 0;
}
}
.close-btn:hover {
background: #333;
color: #fff;
}
.library-modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.library-stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.overview-stat {
background: #252525;
padding: 16px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.overview-label {
font-size: 12px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.overview-value {
font-size: 24px;
font-weight: 600;
color: #fff;
}
.library-section {
margin-bottom: 32px;
}
.section-title {
margin: 0 0 16px;
font-size: 18px;
color: #fff;
font-weight: 600;
}
.recent-items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 20px;
}
.genre-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.genre-item {
display: grid;
grid-template-columns: 120px 1fr 60px;
align-items: center;
gap: 12px;
}
.genre-name {
font-size: 14px;
color: #fff;
}
.genre-bar-container {
height: 8px;
background: #333;
border-radius: 4px;
overflow: hidden;
}
.genre-bar {
height: 100%;
background: linear-gradient(90deg, #e5a00d 0%, #ffbf3f 100%);
border-radius: 4px;
transition: width 0.3s ease;
}
.genre-count {
font-size: 14px;
color: #888;
text-align: right;
}
@media (max-width: 768px) {
.library-stats-overview {
grid-template-columns: repeat(2, 1fr);
}
.recent-items-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 16px;
}
.genre-item {
grid-template-columns: 100px 1fr 50px;
}
}
</style>

View File

@@ -0,0 +1,219 @@
<template>
<div class="library-stats">
<div
v-for="stat in displayStats"
:key="stat.key"
class="stat-card"
:class="{
disabled: stat.value === undefined || stat.value === 0 || loading,
unclickable: !!!stat.clickable
}"
@click="
stat.clickable &&
stat.value?.total > 0 &&
!loading &&
handleClick(stat.key)
"
>
<div class="stat-icon">
<component :is="stat.icon" />
</div>
<div class="stat-content">
<div class="stat-value" v-if="!loading">
{{ formatNumber(stat.value?.total) }}
</div>
<div class="stat-value loading-dots" v-else>...</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { formatNumber } from "@/utils";
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";
import IconClock from "@/icons/IconClock.vue";
interface LibraryStat {
id: number;
title: string;
total: number;
childCount?: number;
leafCount?: number;
}
interface Props {
movies: LibraryStat;
shows: LibraryStat;
music: LibraryStat;
watchtime: number;
loading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
openLibrary: [type: string];
}>();
const displayStats = computed(() => [
{
key: "movies",
icon: IconMovie,
value: props.movies,
label: "Movies",
clickable: true
},
{
key: "tv shows",
icon: IconShow,
value: props.shows,
label: "TV Shows",
clickable: true
},
{
key: "music",
icon: IconMusic,
value: props.music,
label: "Albums",
clickable: true
},
{
key: "watchtime",
icon: IconClock,
value: props.watchtime,
label: "Hours Watched",
clickable: false
}
]);
function handleClick(type: string) {
emit("openLibrary", type);
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.library-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
margin-bottom: 0.85rem;
@include tablet-only {
grid-template-columns: repeat(3, 1fr);
}
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 0.65rem;
}
}
.stat-card {
background-color: var(--background-ui);
border-radius: 0.5rem;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
transition: all 0.2s ease;
border: 1px solid transparent;
@include mobile-only {
padding: 0.85rem 0.75rem;
}
&:hover:not(.disabled, .unclickable) {
background-color: var(--background-40);
border-color: var(--highlight-color);
cursor: pointer;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.disabled {
opacity: 0.6;
&:hover {
transform: none;
border-color: transparent;
}
}
}
.stat-icon {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
@include mobile-only {
width: 2rem;
height: 2rem;
}
svg {
width: 100%;
height: 100%;
fill: var(--highlight-color);
transition: fill 0.2s ease;
}
}
.stat-card:hover:not(.disabled) .stat-icon svg {
fill: var(--color-green-90);
}
.stat-content {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-color);
line-height: 1;
margin-bottom: 0.25rem;
@include mobile-only {
font-size: 1.3rem;
}
}
.stat-label {
font-size: 0.75rem;
color: var(--text-color-60);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
@include mobile-only {
font-size: 0.7rem;
}
}
.loading-dots {
animation: loadingDots 1.5s infinite;
}
@keyframes loadingDots {
0%,
20% {
opacity: 0.3;
}
50% {
opacity: 1;
}
100% {
opacity: 0.3;
}
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<div v-if="username" class="plex-profile-card">
<div class="profile-header">
<img
v-if="userData?.thumb"
:src="userData.thumb"
alt="Profile"
class="profile-avatar"
/>
<div v-else class="profile-avatar-placeholder">
{{ username.charAt(0).toUpperCase() }}
</div>
<div class="profile-info">
<div class="username-row">
<h3 class="profile-username">{{ username }}</h3>
<svg
class="connected-checkmark"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
<div v-if="userData?.email" class="profile-email">
{{ userData.email }}
</div>
<div class="profile-badges">
<div
v-if="userData?.subscription?.active"
class="profile-badge plex-pass"
>
<svg
width="14"
height="14"
viewBox="0 0 256 256"
fill="currentColor"
>
<path
d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0zm0 230.4C66.9 230.4 25.6 189.1 25.6 128S66.9 25.6 128 25.6 230.4 66.9 230.4 128 189.1 230.4 128 230.4z"
/>
</svg>
Plex Pass
</div>
<div v-if="userData?.joined_at" class="profile-badge member-since">
{{ formatMemberSince(userData.joined_at) }}
</div>
<div
v-if="userData?.two_factor_enabled"
class="profile-badge two-factor"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
2FA
</div>
<div
v-if="userData?.experimental_features"
class="profile-badge experimental"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
></path>
</svg>
Labs
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
username: string;
userData: any;
}
defineProps<Props>();
function formatMemberSince(dateString: string) {
try {
const date = new Date(dateString);
const now = new Date();
const years = now.getFullYear() - date.getFullYear();
if (years === 0) return "New Member";
if (years === 1) return "1 Year";
return `${years} Years`;
} catch {
return "Member";
}
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.plex-profile-card {
background-color: var(--background-ui);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.85rem;
border: 1px solid var(--background-40);
@include mobile-only {
padding: 0.85rem;
}
}
.profile-header {
display: flex;
gap: 0.85rem;
align-items: center;
}
.profile-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--highlight-color);
flex-shrink: 0;
@include mobile-only {
width: 50px;
height: 50px;
}
}
.profile-avatar-placeholder {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--highlight-color),
var(--background-40)
);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-color);
flex-shrink: 0;
@include mobile-only {
width: 50px;
height: 50px;
font-size: 1.3rem;
}
}
.profile-info {
flex: 1;
min-width: 0;
}
.username-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.profile-username {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
@include mobile-only {
font-size: 1.1rem;
}
}
.connected-checkmark {
color: var(--color-success-highlight);
flex-shrink: 0;
animation: checkmarkPop 0.3s ease-out;
@include mobile-only {
width: 18px;
height: 18px;
}
}
@keyframes checkmarkPop {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.profile-email {
font-size: 0.85rem;
color: var(--text-color-60);
margin-bottom: 0.4rem;
word-break: break-all;
@include mobile-only {
font-size: 0.8rem;
}
}
.profile-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.4rem;
}
.profile-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.65rem;
border-radius: 1rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
@include mobile-only {
font-size: 0.65rem;
padding: 0.2rem 0.55rem;
}
&.plex-pass {
background-color: #cc7b19;
color: $white;
text-transform: uppercase;
svg {
width: 12px;
height: 12px;
@include mobile-only {
width: 11px;
height: 11px;
}
}
}
&.member-since {
background-color: var(--background-40);
color: var(--text-color-70);
}
&.two-factor {
background-color: var(--color-success);
color: $white;
svg {
width: 11px;
height: 11px;
@include mobile-only {
width: 10px;
height: 10px;
}
}
}
&.experimental {
background-color: #8b5cf6;
color: $white;
svg {
width: 11px;
height: 11px;
@include mobile-only {
width: 10px;
height: 10px;
}
}
}
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<div class="plex-server-info">
<div class="plex-details">
<div class="detail-row">
<span class="detail-label">
<IconServer class="label-icon" style="fill: var(--text-color)" />
Plex server name
</span>
<span class="detail-value">{{ serverName || "Unknown" }}</span>
</div>
<div class="detail-row">
<span class="detail-label">
<IconSync class="label-icon" style="stroke: var(--text-color)" />
Last Sync
</span>
<span class="detail-value">{{ lastSync || "Never" }}</span>
</div>
</div>
<div class="plex-actions">
<seasoned-button @click="$emit('sync')" :disabled="syncing">
<IconSync v-if="!syncing" class="button-icon" />
{{ syncing ? "Syncing..." : "Sync Library" }}
</seasoned-button>
<seasoned-button @click="$emit('unlink')">
Unlink Account
</seasoned-button>
</div>
</div>
</template>
<script setup lang="ts">
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import IconServer from "@/icons/IconServer.vue";
import IconSync from "@/icons/IconSync.vue";
interface Props {
serverName: string;
lastSync: string;
syncing?: boolean;
}
defineProps<Props>();
defineEmits<{
sync: [];
unlink: [];
}>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.plex-details {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.85rem;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.55rem 0.65rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
@include mobile-only {
padding: 0.5rem 0.6rem;
}
}
.detail-label {
font-size: 0.85rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
@include mobile-only {
font-size: 0.8rem;
}
svg {
flex-shrink: 0;
}
.label-icon {
width: 16px;
height: 16px;
}
}
.detail-value {
font-size: 0.95rem;
@include mobile-only {
font-size: 0.9rem;
}
}
.plex-actions {
display: flex;
gap: 0.65rem;
@include mobile-only {
flex-direction: column;
gap: 0.6rem;
}
button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
svg {
flex-shrink: 0;
}
.button-icon {
width: 16px;
height: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<div class="modal-overlay" @click="emit('cancel')">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>Unlink Plex Account</h3>
<button class="close-btn" @click="emit('cancel')">
<IconClose />
</button>
</div>
<div class="modal-body">
<p>
Are you sure you want to unlink your Plex account? You will lose
access to:
</p>
<ul>
<li>Watch history tracking</li>
<li>Recently added content notifications</li>
<li>Real-time download progress</li>
</ul>
</div>
<div class="modal-footer">
<button class="cancel-btn" @click="emit('cancel')">Cancel</button>
<button class="confirm-btn" @click="emit('confirm')">
Unlink Account
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconClose from "@/icons/IconClose.vue";
const emit = defineEmits<{
(e: "confirm"): void;
(e: "cancel"): void;
}>();
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal-content {
background: #1a1a1a;
border-radius: 12px;
width: 100%;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
border-bottom: 1px solid #333;
}
.modal-header h3 {
margin: 0;
font-size: 20px;
color: #fff;
}
.close-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 8px;
border-radius: 6px;
transition: all 0.2s;
}
.close-btn:hover {
background: #333;
color: #fff;
}
.modal-body {
padding: 24px;
}
.modal-body p {
margin: 0 0 16px;
color: #ccc;
font-size: 14px;
line-height: 1.6;
}
.modal-body ul {
margin: 0;
padding-left: 20px;
color: #aaa;
font-size: 14px;
line-height: 1.8;
}
.modal-body li {
margin-bottom: 8px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #333;
}
.cancel-btn,
.confirm-btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.cancel-btn {
background: #333;
color: #fff;
}
.cancel-btn:hover {
background: #444;
}
.confirm-btn {
background: #dc2626;
color: #fff;
}
.confirm-btn:hover {
background: #b91c1c;
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<li
class="sidebar-list-element"
:class="{ active, disabled }"
:tabindex="disabled ? -1 : 0"
role="button"
:aria-disabled="disabled"
@click="emit('click')"
@keydown.enter.prevent="emit('click')"
@keydown.space.prevent="emit('click')"
>
<slot></slot>
</li>
</template>
<script setup lang="ts">
interface Props {
active?: boolean;
disabled?: boolean;
}
interface Emit {
(e: "click");
}
const emit = defineEmits<Emit>();
defineProps<Props>();
</script>
<style lang="scss">
@import "scss/media-queries";
li.sidebar-list-element {
display: flex;
align-items: center;
text-decoration: none;
text-transform: uppercase;
color: var(--text-color-50);
font-size: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--text-color-5);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
&:first-of-type {
padding-top: 0;
}
div > svg,
svg {
width: 26px;
height: 26px;
margin-right: 1rem;
transition: all 0.3s ease;
fill: var(--text-color-70);
}
&:hover,
&:focus,
&.active {
color: var(--text-color);
outline: none;
div > svg,
svg {
fill: var(--text-color);
transform: scale(1.1, 1.1);
}
}
&:focus-visible {
outline: 2px solid var(--highlight-color);
outline-offset: 2px;
border-radius: 4px;
}
&.active > div > svg,
&.active > svg {
fill: var(--highlight-color);
}
&.disabled {
cursor: default;
}
.pending {
color: #f8bd2d;
}
.meta {
margin-left: auto;
text-align: right;
}
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div
ref="descriptionElement"
class="movie-description noselect"
@click="overflow ? (truncated = !truncated) : null"
@keydown.enter="overflow ? (truncated = !truncated) : null"
>
<span :class="{ truncated }">{{ description }}</span>
<button v-if="description && overflow" class="truncate-toggle">
<IconArrowDown :class="{ rotate: !truncated }" />
</button>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import type { Ref } from "vue";
import IconArrowDown from "../../icons/IconArrowDown.vue";
interface Props {
description: string;
}
const props = defineProps<Props>();
const truncated: Ref<boolean> = ref(true);
const overflow: Ref<boolean> = ref(false);
const descriptionElement: Ref<HTMLElement> = ref(null);
function removeElements(elems: NodeListOf<Element>) {
elems.forEach(el => el.remove());
}
// The description element overflows text after 4 rows with css
// line-clamp this takes the same text and adds to a temporary
// element without css overflow. If the temp element is
// higher then description element, we display expand button
function checkDescriptionOverflowing() {
const element = descriptionElement?.value;
if (!element) return;
const { height, width } = element.getBoundingClientRect();
const { fontSize, lineHeight } = getComputedStyle(element);
const descriptionComparisonElement = document.createElement("div");
descriptionComparisonElement.setAttribute(
"style",
`max-width: ${Math.ceil(
width + 10
)}px; display: block; font-size: ${fontSize}; line-height: ${lineHeight};`
);
// Don't know why need to add 10px to width, but works out perfectly
descriptionComparisonElement.classList.add("dummy-non-overflow");
descriptionComparisonElement.innerText = props.description;
document.body.appendChild(descriptionComparisonElement);
const elemWithoutOverflowHeight =
descriptionComparisonElement.getBoundingClientRect().height;
overflow.value = elemWithoutOverflowHeight > height;
removeElements(document.querySelectorAll(".dummy-non-overflow"));
}
onMounted(checkDescriptionOverflowing);
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.movie-description {
font-weight: 300;
font-size: 13px;
line-height: 1.8;
margin-bottom: 20px;
transition: all 1s ease;
@include tablet-min {
margin-bottom: 30px;
font-size: 14px;
}
}
span.truncated {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
.truncate-toggle {
border: none;
background: none;
width: 100%;
height: 30px;
display: flex;
align-items: center;
text-align: center;
color: var(--text-color);
margin-top: 1rem;
cursor: pointer;
svg {
transition: 0.4s ease all;
height: 22px;
width: 22px;
fill: var(--text-color);
&.rotate {
transform: rotateX(180deg);
}
}
&::before,
&::after {
content: "";
flex: 1;
border-bottom: 1px solid var(--text-color-50);
}
&::before {
margin-right: 1rem;
}
&::after {
margin-left: 1rem;
}
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="movie-detail">
<h2 class="title">{{ title }}</h2>
<span v-if="detail" class="info">{{ detail }}</span>
<slot></slot>
</div>
</template>
<script setup lang="ts">
interface Props {
title: string;
detail?: string | number;
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.movie-detail {
margin-bottom: 20px;
&:last-of-type {
margin-bottom: 0px;
}
@include tablet-min {
margin-bottom: 30px;
}
h2.title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
font-size: 1.2rem;
color: var(--highlight-color);
@include mobile {
font-size: 1.1rem;
}
}
span.info {
font-weight: 300;
font-size: 1rem;
letter-spacing: 0.8px;
margin-top: 5px;
}
}
</style>

View File

@@ -0,0 +1,593 @@
<template>
<section class="movie">
<!-- HEADER w/ POSTER -->
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events -->
<header
ref="backdropElement"
:class="compact ? 'compact' : ''"
@click="compact = !compact"
>
<figure class="movie__poster">
<img
ref="poster-image"
class="movie-item__img is-loaded"
alt="Movie poster"
:src="poster"
/>
</figure>
<div v-if="media" class="movie__title">
<h1>{{ media.title }}</h1>
<i>{{ media.tagline }}</i>
</div>
<loading-placeholder v-else :count="2" />
</header>
<!-- Siderbar and movie info -->
<div class="movie__main">
<div class="movie__wrap movie__wrap--main">
<!-- SIDEBAR ACTIONS -->
<div v-if="media" class="movie__actions">
<action-button :active="media?.exists_in_plex" :disabled="true">
<IconThumbsUp v-if="media?.exists_in_plex" />
<IconThumbsDown v-else />
{{
!media?.exists_in_plex
? "Not yet available"
: "Already available 🎉"
}}
</action-button>
<action-button :active="requested" @click="sendRequest">
<transition name="fade" mode="out-in">
<div v-if="!requested" key="request"><IconRequest /></div>
<div v-else key="requested"><IconRequested /></div>
</transition>
{{ !requested ? `Request ${type}?` : "Already requested" }}
</action-button>
<action-button
v-if="plexUserId && media?.exists_in_plex"
@click="openInPlex"
>
<IconPlay />
Open and watch in plex now!
</action-button>
<action-button
v-if="cast?.length"
:active="showCast"
@click="() => (showCast = !showCast)"
>
<IconProfile class="icon" />
{{ showCast ? "Hide cast" : "Show cast" }}
</action-button>
<action-button
v-if="admin === true"
:active="showTorrents"
@click="showTorrents = !showTorrents"
>
<IconBinoculars />
Search for torrents
<span v-if="numberOfTorrentResults" class="meta">{{
numberOfTorrentResults
}}</span>
</action-button>
<action-button @click="openTmdb">
<IconInfo />
See more info
</action-button>
</div>
<!-- Loading placeholder -->
<div v-else class="movie__actions text-input__loading">
<div
v-for="index in admin ? Array(4) : Array(3)"
:key="index"
class="movie__actions-link"
>
<div
class="movie__actions-text text-input__loading--line"
style="margin: 9px; margin-left: -3px"
></div>
</div>
</div>
<!-- MOVIE INFO -->
<div class="movie__info">
<!-- Loading placeholder -->
<div v-if="loading">
<loading-placeholder :count="5" />
</div>
<Description
v-if="!loading && media && media.overview"
:description="media.overview"
/>
<div v-if="media" class="movie__details">
<Detail
v-if="media.year"
title="Release date"
:detail="media.year"
/>
<Detail
v-if="media.type === MediaTypes.Movie && media.rating"
title="Rating"
:detail="media.rating"
/>
<Detail
v-if="media.type == MediaTypes.Show"
title="Seasons"
:detail="media.seasons"
/>
<Detail
v-if="media.genres && media.genres.length"
title="Genres"
:detail="media.genres.join(', ')"
/>
<Detail
v-if="
media.production_status &&
media.production_status !== 'Released'
"
title="Production status"
:detail="media.production_status"
/>
<Detail
v-if="media.runtime"
title="Runtime"
:detail="humanMinutes(media.runtime)"
/>
</div>
</div>
<!-- TODO: change this classname, this is general -->
<div v-if="showCast && cast?.length" class="movie__admin">
<Detail title="cast">
<CastList :cast="cast" />
</Detail>
</div>
</div>
<!-- TORRENT LIST -->
<TorrentList
v-if="media && admin && showTorrents"
class="torrents"
:query="media?.title"
:tmdb-id="id"
></TorrentList>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import type { Ref } from "vue";
// import img from "@/directives/v-image";
import IconProfile from "../../icons/IconProfile.vue";
import IconThumbsUp from "../../icons/IconThumbsUp.vue";
import IconThumbsDown from "../../icons/IconThumbsDown.vue";
import IconInfo from "../../icons/IconInfo.vue";
import IconRequest from "../../icons/IconRequest.vue";
import IconRequested from "../../icons/IconRequested.vue";
import IconBinoculars from "../../icons/IconBinoculars.vue";
import IconPlay from "../../icons/IconPlay.vue";
import TorrentList from "../torrent/TruncatedTorrentResults.vue";
import CastList from "../CastList.vue";
import Detail from "./Detail.vue";
import ActionButton from "./ActionButton.vue";
import Description from "./Description.vue";
import LoadingPlaceholder from "../ui/LoadingPlaceholder.vue";
import type { IColors } from "../../interfaces/IColors.ts";
import type {
IMovie,
IShow,
IMediaCredits,
ICast
} from "../../interfaces/IList";
import type {
IRequestStatusResponse,
IRequestSubmitResponse
} from "../../interfaces/IRequestResponse";
import { MediaTypes } from "../../interfaces/IList";
import { humanMinutes } from "../../utils";
import {
getMovie,
getShow,
getMovieCredits,
getShowCredits,
request,
getRequestStatus
// watchLink
} from "../../api";
interface Props {
id: number;
type: MediaTypes.Movie | MediaTypes.Show;
}
const props = defineProps<Props>();
const ASSET_URL = "https://image.tmdb.org/t/p/";
const COLORS_API = import.meta.env.VITE_SEASONED_COLORS_API || "";
const ASSET_SIZES = ["w500", "w780", "original"];
const media: Ref<IMovie | IShow> = ref();
const requested: Ref<boolean> = ref();
const showTorrents: Ref<boolean> = ref();
const showCast: Ref<boolean> = ref();
const cast: Ref<ICast[]> = ref([]);
const compact: Ref<boolean> = ref();
const loading: Ref<boolean> = ref();
const backdropElement: Ref<HTMLElement> = ref();
const store = useStore();
const admin = computed(() => store.getters["user/admin"]);
const plexUserId = computed(() => store.getters["user/plexUserId"]);
const poster = computed(() => {
if (!media.value) return "/assets/placeholder.png";
if (!media.value?.poster) return "/assets/no-image.svg";
// compute & update highlight colors from poster image
colorsFromPoster(media.value.poster); // eslint-disable-line
return `${ASSET_URL}${ASSET_SIZES[0]}${media.value.poster}`;
});
const numberOfTorrentResults = computed(() => {
const count = store.getters["torrentModule/resultCount"];
return count ? `${count} results` : null;
});
function setCast(_cast: ICast[]) {
cast.value = _cast;
}
function setRequested(
requestResponse: IRequestStatusResponse | IRequestSubmitResponse
) {
if (requestResponse?.success) {
requested.value = requestResponse?.success;
return;
}
requested.value = false;
}
function setBackdrop(): void {
if (
!media.value?.backdrop ||
!backdropElement.value?.style ||
backdropElement.value?.style?.backgroundImage !== ""
)
return;
const backdropURL = `${ASSET_URL}${ASSET_SIZES[1]}${media.value.backdrop}`;
backdropElement.value.style.backgroundImage = `url(${backdropURL})`;
}
function getCredits(
type: MediaTypes.Movie | MediaTypes.Show
): Promise<IMediaCredits> {
if (type === MediaTypes.Movie) {
return getMovieCredits(props.id);
}
if (type === MediaTypes.Show) {
return getShowCredits(props.id);
}
return Promise.reject();
}
function setAndReturnMedia(_media: IMovie | IShow) {
media.value = _media;
return _media;
}
function fetchMedia() {
if (!props.id || !props.type) {
console.error("Unable to fetch media, requires id & type"); // eslint-disable-line no-console
return;
}
let apiFunction: typeof getMovie;
let parameters: {
checkExistance: boolean;
credits: boolean;
releaseDates?: boolean;
};
if (props.type === MediaTypes.Movie) {
apiFunction = getMovie;
parameters = { checkExistance: true, credits: false };
} else if (props.type === MediaTypes.Show) {
apiFunction = getShow;
parameters = { checkExistance: true, credits: false };
}
apiFunction(props.id, { ...parameters })
.then(setAndReturnMedia)
.then(() => getCredits(props.type))
.then(credits => setCast(credits?.cast || []))
.then(() => getRequestStatus(props.id, props.type))
.then(requestResponse => setRequested(requestResponse))
.then(setBackdrop);
}
function sendRequest() {
request(props.id, props.type).then(requestResponse =>
setRequested(requestResponse)
);
}
function openInPlex(): boolean {
// watchLink()
return false;
}
function openTmdb() {
const tmdbType = props.type === MediaTypes.Show ? "tv" : props.type;
const tmdbURL = `https://www.themoviedb.org/${tmdbType}/${props.id}`;
window.location.href = tmdbURL;
}
function colorMain(colors: IColors) {
const parent = document.getElementsByClassName(
"movie-popup"
)[0] as HTMLElement;
parent.style.setProperty("--highlight-color", colors.s ?? colors.p);
parent.style.setProperty("--highlight-bg", colors.bg);
parent.style.setProperty("--highlight-secondary", colors.p);
parent.style.setProperty("--text-color", "#ffffff");
parent.style.setProperty("--text-color-90", "rgba(255, 255, 255, 0.9)");
parent.style.setProperty("--text-color-70", "rgba(255, 255, 255, 0.7)");
parent.style.setProperty("--text-color-50", "rgba(255, 255, 255, 0.5)");
parent.style.setProperty("--text-color-10", "rgba(255, 255, 255, 0.1)");
parent.style.setProperty("--text-color-5", "rgba(255, 255, 255, 0.05)");
}
async function colorsFromPoster(posterPath: string) {
const url = new URL("/colors", COLORS_API);
url.searchParams.append("id", posterPath.replace("/", ""));
url.searchParams.append("size", "w342");
fetch(url.href)
.then(resp => {
if (resp.ok) return resp.json();
throw new Error(`invalid status: '${resp.status}' from server.`);
})
.then(colorMain)
.catch(error => console.log("unable to get colors, error:", error)); // eslint-disable-line no-console
}
// On created functions
fetchMedia();
store.dispatch("torrentModule/setResultCount", null);
// End on create functions
onMounted(setBackdrop);
</script>
<style lang="scss" scoped>
@import "scss/loading-placeholder";
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
header {
$duration: 0.2s;
transform: scaleY(1);
transition: height $duration ease;
transform-origin: top;
position: relative;
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
background-color: $background-color;
display: grid;
grid-template-columns: 1fr 1fr;
height: 350px;
@include mobile {
grid-template-columns: 1fr;
height: 250px;
place-items: center;
}
* {
z-index: 2;
}
&::before {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
background: $background-dark-85;
}
@include mobile {
&.compact {
height: 100px;
}
}
}
.movie__poster {
display: none;
border-radius: 1.6rem;
@include desktop {
background: var(--background-color);
height: auto;
display: block;
width: calc(100% - 80px);
margin: 40px;
> img {
width: 100%;
border-radius: calc(1.6rem - 1px);
}
}
}
.movie {
&__wrap {
&--header {
align-items: center;
height: 100%;
}
&--main {
display: flex;
flex-wrap: wrap;
flex-direction: column;
@include tablet-min {
flex-direction: row;
}
background-color: var(--highlight-bg, var(--background-color));
color: var(--text-color);
}
}
&__img {
display: block;
width: 100%;
opacity: 0;
transform: scale(0.97) translateZ(0);
transition:
opacity 0.5s ease,
transform 0.5s ease;
&.is-loaded {
opacity: 1;
transform: scale(1);
}
}
&__title {
position: relative;
padding: 20px;
text-align: center;
width: 100%;
height: fit-content;
@include tablet-min {
text-align: left;
padding: 140px 30px 0 40px;
}
h1 {
color: var(--highlight-color);
font-weight: 500;
line-height: 1.2;
font-size: 2.2rem;
font-weight: 600;
letter-spacing: 1px;
margin-bottom: 0;
@include tablet-min {
font-size: 30px;
font-size: 2.2rem;
}
}
i {
display: block;
color: rgba(255, 255, 255, 0.8);
color: var(--highlight-secondary);
margin-top: 1rem;
}
}
&__actions {
text-align: center;
width: 100%;
order: 2;
padding: 20px;
border-top: 1px solid var(--text-color-50);
@include tablet-min {
order: 1;
width: 45%;
padding: 185px 0 40px 40px;
border-top: 0;
}
}
&__info {
width: 100%;
padding: 20px;
order: 1;
@include tablet-min {
order: 2;
padding: 40px;
width: 55%;
margin-left: 45%;
}
}
&__info {
margin-left: 0;
}
&__details {
display: flex;
flex-wrap: wrap;
> * {
margin-right: 30px;
@include mobile {
margin-right: 20px;
}
}
}
&__admin {
width: 100%;
padding: 20px;
order: 2;
@include tablet-min {
order: 3;
padding: 40px;
padding-top: 0px;
width: 100%;
}
&-title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
text-align: center;
font-size: 14px;
color: $green;
padding-bottom: 20px;
@include tablet-min {
font-size: 16px;
}
}
}
.torrents {
background-color: var(--highlight-bg, var(--background-color));
padding: 0 1rem;
@include mobile {
padding: 0 0.5rem;
}
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<section class="person">
<header ref="header">
<div class="info">
<h1 v-if="person">
{{ person.name }}
</h1>
<div v-else>
<loading-placeholder :count="1" />
<loading-placeholder :count="1" line-class="short" :top="3.5" />
</div>
<span v-if="person && person['known_for_department']" class="known-for">
{{
person.known_for_department === "Acting"
? "Actor"
: person.known_for_department
}}
</span>
</div>
<figure class="person__poster">
<img
ref="poster-image"
class="person-item__img is-loaded"
alt="Image of person"
:src="poster"
/>
</figure>
</header>
<div v-if="loading">
<loading-placeholder :count="6" />
<loading-placeholder line-class="short" :top="3" />
<loading-placeholder :count="6" line-class="fullwidth" />
<loading-placeholder line-class="short" :top="4.5" />
<loading-placeholder />
</div>
<div v-if="person">
<Detail v-if="age" title="Age" :detail="age" />
<Detail
v-if="person"
title="Born"
:detail="person.place_of_birth ? person.place_of_birth : '(Not found)'"
/>
<Detail v-if="person.biography" title="Biography">
<Description :description="person.biography" />
</Detail>
<Detail
v-if="creditedMovies.length"
title="movies"
:detail="`Credited in ${creditedMovies.length} movies`"
>
<CastList :cast="creditedMovies" />
</Detail>
<Detail
v-if="creditedShows.length"
title="shows"
:detail="`Credited in ${creditedShows.length} shows`"
>
<CastList :cast="creditedShows" />
</Detail>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import CastList from "@/components/CastList.vue";
import Detail from "@/components/popup/Detail.vue";
import Description from "@/components/popup/Description.vue";
import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder.vue";
import type { Ref, ComputedRef } from "vue";
import { getPerson, getPersonCredits } from "../../api";
import type {
IPerson,
IPersonCredits,
IMovie,
IShow
} from "../../interfaces/IList";
import { MediaTypes } from "../../interfaces/IList";
interface Props {
id: number;
}
const props = defineProps<Props>();
const ASSET_URL = "https://image.tmdb.org/t/p/";
const ASSET_SIZES = ["w500", "w780", "original"];
const person: Ref<IPerson> = ref();
const credits: Ref<IPersonCredits> = ref();
const loading: Ref<boolean> = ref(false);
const creditedMovies: Ref<Array<IMovie | IShow>> = ref([]);
const creditedShows: Ref<Array<IMovie | IShow>> = ref([]);
const poster: ComputedRef<string> = computed(() => {
if (!person.value) return "/assets/placeholder.png";
if (!person.value?.poster) return "/assets/no-image.svg";
return `${ASSET_URL}${ASSET_SIZES[0]}${person.value.poster}`;
});
const age: ComputedRef<string> = computed(() => {
if (!person.value?.birthday) return "";
const today = new Date().getFullYear();
const birthYear = new Date(person.value.birthday).getFullYear();
return `${today - birthYear} years old`;
});
function setCredits(_credits: IPersonCredits) {
credits.value = _credits;
}
function setPerson(_person: IPerson) {
person.value = _person;
}
function sortPopularity(a: IMovie | IShow, b: IMovie | IShow): number {
return a.popularity < b.popularity ? 1 : -1;
}
function alreadyExists(
item: IMovie | IShow,
pos: number,
self: Array<IMovie | IShow>
) {
const names = self.map(_item => _item.title);
return names.indexOf(item.title) === pos;
}
function personCreditedFrom(cast: Array<IMovie | IShow>): void {
creditedMovies.value = cast
.filter(credit => credit.type === MediaTypes.Movie)
.filter(alreadyExists)
.sort(sortPopularity);
creditedShows.value = cast
.filter(credit => credit.type === MediaTypes.Show)
.filter(alreadyExists)
.sort(sortPopularity);
}
function fetchPerson() {
if (!props.id) {
console.error("Unable to fetch person, missing id!"); // eslint-disable-line no-console
return;
}
getPerson(props.id)
.then(setPerson)
.then(() => getPersonCredits(person.value?.id))
.then(setCredits)
.then(() => personCreditedFrom(credits.value?.cast));
}
// On create functions
fetchPerson();
</script>
<style lang="scss" scoped>
@import "scss/loading-placeholder";
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
section.person {
overflow: hidden;
position: relative;
padding: 40px;
background-color: var(--background-color);
@include mobile {
padding: 50px 20px 10px;
}
&:before {
content: "";
display: block;
position: absolute;
top: -130px;
left: -100px;
z-index: 1;
width: 1000px;
height: 500px;
transform: rotate(21deg);
background-color: #062541;
@include mobile {
// top: -52vw;
top: -215px;
}
}
}
header {
$duration: 0.2s;
transition: height $duration ease;
position: relative;
background-color: transparent;
display: grid;
grid-template-columns: 1fr 1fr;
height: 350px;
z-index: 2;
@include mobile {
height: 180px;
}
.info {
display: flex;
flex-direction: column;
padding: 30px;
padding-left: 0;
text-align: left;
@include mobile {
padding: 0;
}
}
h1 {
color: $green;
width: 100%;
font-weight: 500;
line-height: 1.4;
font-size: 30px;
margin-top: 0;
@include mobile {
font-size: 24px;
margin: 10px 0;
// padding: 30px 30px 30px 40px;
}
}
.known-for {
color: rgba(255, 255, 255, 0.8);
font-size: 1.2rem;
}
}
.person__poster {
display: block;
margin: auto;
width: fit-content;
border-radius: 10px;
background-color: grey;
animation: pulse 1s infinite ease-in-out;
@keyframes pulse {
0% {
background-color: rgba(165, 165, 165, 0.1);
}
50% {
background-color: rgba(165, 165, 165, 0.3);
}
100% {
background-color: rgba(165, 165, 165, 0.1);
}
}
> img {
border-radius: 10px;
width: 100%;
@include mobile {
max-width: 225px;
}
}
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div class="change-password">
<div class="password-card">
<form class="password-form" @submit.prevent>
<seasoned-input
v-model="oldPassword"
placeholder="Current password"
icon="Keyhole"
type="password"
/>
<div class="password-generator">
<button class="generator-toggle" @click="toggleGenerator">
<IconKey class="toggle-icon" />
<span>{{
showGenerator ? "Hide" : "Generate Strong Password"
}}</span>
</button>
<div v-if="showGenerator">
<password-generator @password-generated="handleGeneratedPassword" />
</div>
</div>
<seasoned-input
v-model="newPassword"
placeholder="New password"
icon="Keyhole"
type="password"
/>
<seasoned-input
v-model="newPasswordRepeat"
placeholder="Confirm new password"
icon="Keyhole"
type="password"
/>
<seasoned-button @click="changePassword" :disabled="loading">
{{ loading ? "Updating..." : "Change Password" }}
</seasoned-button>
</form>
<seasoned-messages v-model:messages="messages" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import SeasonedInput from "@/components/ui/SeasonedInput.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import PasswordGenerator from "@/components/settings/PasswordGenerator.vue";
import IconKey from "@/icons/IconKey.vue";
import type { Ref } from "vue";
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
const showGenerator = ref(false);
const oldPassword: Ref<string> = ref("");
const newPassword: Ref<string> = ref("");
const newPasswordRepeat: Ref<string> = ref("");
const messages: Ref<IErrorMessage[]> = ref([]);
const loading = ref(false);
function handleGeneratedPassword(password: string) {
newPassword.value = password;
newPasswordRepeat.value = password;
}
async function changePassword(event: CustomEvent) {
try {
messages.value.push({
message: "Password change is currently disabled",
title: "Feature Disabled",
type: ErrorMessageTypes.Warning
} as IErrorMessage);
// Clear form
oldPassword.value = "";
newPassword.value = "";
newPasswordRepeat.value = "";
loading.value = false;
} catch (error) {
console.log("not valid! error:", error); // eslint-disable-line no-console
loading.value = false;
}
}
function toggleGenerator() {
showGenerator.value = !showGenerator.value;
/*
if (showGenerator.value && !generatedPassword.value) {
generateWordsPassword();
}
*/
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.password-card {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.password-form {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.generator-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
background-color: var(--background-ui);
border: 1px solid var(--background-40);
border-radius: 0.5rem;
color: $text-color;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
&:hover {
background-color: var(--background-40);
border-color: var(--highlight-color);
}
}
.toggle-icon {
width: 18px;
height: 18px;
fill: var(--highlight-color);
}
.btn-icon {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<div>
<h3 class="settings__header">Plex account</h3>
<div v-if="!plexUserId">
<span class="info"
>Sign in to your plex account to get information about recently added
movies and to see your watch history</span
>
<form class="form">
<seasoned-input
v-model="username"
placeholder="plex username"
type="email"
/>
<seasoned-input
v-model="password"
placeholder="plex password"
type="password"
@enter="authenticatePlex"
>
</seasoned-input>
<seasoned-button @click="authenticatePlex"
>link plex account</seasoned-button
>
</form>
</div>
<div v-else>
<span class="info"
>Awesome, your account is already authenticated with plex! Enjoy viewing
your seasoned search history, plex watch history and real-time torrent
download progress.</span
>
<seasoned-button @click="unauthenticatePlex"
>un-link plex account</seasoned-button
>
</div>
<seasoned-messages v-model:messages="messages" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useStore } from "vuex";
import seasonedInput from "@/components/ui/SeasonedInput.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import type { Ref, ComputedRef } from "vue";
import { linkPlexAccount, unlinkPlexAccount } from "../../api";
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
interface Emit {
(e: "reload");
}
const username: Ref<string> = ref("");
const password: Ref<string> = ref("");
const messages: Ref<IErrorMessage[]> = ref([]);
const store = useStore();
const emit = defineEmits<Emit>();
const plexUserId: ComputedRef<boolean> = computed(
() => store.getters["user/plexUserId"]
);
async function authenticatePlex() {
const { success, message } = await linkPlexAccount(
username.value,
password.value
);
if (success) {
username.value = "";
password.value = "";
emit("reload");
}
messages.value.push({
type: success ? ErrorMessageTypes.Success : ErrorMessageTypes.Error,
title: success ? "Authenticated with plex" : "Something went wrong",
message
} as IErrorMessage);
}
async function unauthenticatePlex() {
const response = await unlinkPlexAccount();
if (response?.success) {
emit("reload");
}
messages.value.push({
type: response.success
? ErrorMessageTypes.Success
: ErrorMessageTypes.Error,
title: response.success
? "Unlinked plex account "
: "Something went wrong",
message: response.message
} as IErrorMessage);
}
</script>
<style lang="scss" scoped>
.info {
display: block;
margin-bottom: 25px;
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<div class="danger-zone">
<h3 class="danger-zone__title">{{ title }}</h3>
<p class="danger-zone__description">
{{ description }}
</p>
<button class="danger-zone__button" @click="$emit('action')">
{{ buttonText }}
</button>
</div>
</template>
<script setup lang="ts">
interface Props {
title: string;
description: string;
buttonText: string;
}
interface Emit {
(e: "action"): void;
}
defineProps<Props>();
defineEmits<Emit>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.danger-zone {
padding: 1.25rem;
background: rgba(220, 48, 35, 0.1);
border: 1px solid var(--color-error-highlight);
border-radius: 0.5rem;
@include mobile-only {
padding: 1rem;
}
&__title {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-error-highlight);
}
&__description {
margin: 0 0 1rem 0;
font-size: 0.875rem;
color: var(--text-color-70);
line-height: 1.5;
}
&__button {
padding: 0.625rem 1.25rem;
background: var(--color-error);
color: white;
border: 1px solid var(--color-error-highlight);
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--color-error-highlight);
}
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div class="data-export">
<div class="export-options">
<!-- Request History Card -->
<RequestHistory :data="requestStats" />
<!-- Export Data Card -->
<ExportSection :data="requestStats" />
<!-- Local Storage Items -->
<StorageManager />
<!-- Delete Account -->
<DangerZoneAction
title="Delete Account"
description="Permanently delete your account and all associated data. This action cannot be undone."
button-text="Delete My Account"
@action="confirmDelete"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import StorageManager from "./StorageManager.vue";
import ExportSection from "./ExportSection.vue";
import RequestHistory from "./RequestHistory.vue";
import DangerZoneAction from "./DangerZoneAction.vue";
const requestStats = ref({
total: 45,
approved: 38,
pending: 7
});
function confirmDelete() {
const confirmed = confirm(
"Are you sure you want to *permanently delete* your account and all associated data? This action cannot be undone."
);
if (!confirmed) return;
}
</script>
<style lang="scss" scoped>
.export-options {
display: flex;
flex-direction: column;
gap: 0.65rem;
gap: 2rem;
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div class="settings-section-card">
<div class="settings-section-header">
<h2>Export Your Data</h2>
<p>
Download a copy of your account data including requests, watch history,
and preferences.
</p>
</div>
<!-- Export to JSON & CSV section -->
<div class="export-actions">
<button
class="export-btn"
@click="() => exportData('json')"
:disabled="exporting"
>
<IconActivity v-if="exporting" class="spin" />
<span v-else>Export as JSON</span>
</button>
<button
class="export-btn"
@click="() => exportData('csv')"
:disabled="exporting"
>
<IconActivity v-if="exporting" class="spin" />
<span v-else>Export as CSV</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps } from "vue";
import IconActivity from "@/icons/IconActivity.vue";
interface Props {
data: any;
}
const props = defineProps<Props>();
const exporting = ref(false);
async function exportData(format: "json" | "csv") {
exporting.value = true;
// Mock export
await new Promise(resolve => setTimeout(resolve, 1500));
const data = {
username: "user123",
requests: props?.data,
exportDate: new Date().toISOString()
};
const blob = new Blob(
[format === "json" ? JSON.stringify(data, null, 2) : convertToCSV(data)],
{ type: format === "json" ? "application/json" : "text/csv" }
);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `seasoned-data-export.${format}`;
link.click();
URL.revokeObjectURL(url);
exporting.value = false;
}
function convertToCSV(data: any): string {
return `Username,Total Requests,Approved,Pending,Export Date\n${data.username},${data.requests.total},${data.requests.approved},${data.requests.pending},${data.exportDate}`;
}
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
@import "scss/shared-settings";
.export-actions {
display: flex;
gap: 0.55rem;
@include mobile-only {
flex-direction: column;
gap: 0.5rem;
}
}
.export-btn {
flex: 1;
padding: 0.55rem 0.85rem;
background-color: var(--highlight-color);
color: white;
border: none;
border-radius: 0.25rem;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
&:hover:not(:disabled) {
background-color: var(--color-green-90);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
svg {
width: 16px;
height: 16px;
fill: white;
&.spin {
animation: spin 1s linear infinite;
}
}
}
</style>

View File

@@ -0,0 +1,597 @@
<template>
<div class="password-generator">
<div class="generator-panel">
<div class="generator-tabs">
<button
:class="['tab', { 'tab--active': mode === 'words' }]"
@click="mode = 'words'"
>
Passphrase
</button>
<button
:class="['tab', { 'tab--active': mode === 'chars' }]"
@click="mode = 'chars'"
>
Random
</button>
</div>
<div v-if="mode === 'words'" class="generator-content">
<div class="generator-header">
<h4>Passphrase Generator</h4>
<p>Create a memorable password using random words</p>
</div>
<div class="password-display" @click="copyPassword">
<span class="password-text password-text--mono">{{
generatedPassword
}}</span>
<button
class="copy-btn"
:title="copied ? 'Copied!' : 'Click to copy'"
>
{{ copied ? "✓" : "📋" }}
</button>
</div>
<div class="generator-options">
<div class="option-row">
<div class="slider-header">
<label>Words</label>
<span class="slider-value">{{ wordCount }}</span>
</div>
<input
v-model.number="wordCount"
type="range"
min="3"
max="7"
class="slider"
@input="generateWordsPassword"
/>
<div class="slider-labels">
<span>3</span>
<span>7</span>
</div>
</div>
</div>
</div>
<div v-else class="generator-content">
<div class="generator-header">
<h4>Random Password Generator</h4>
<p>Generate a secure random password</p>
</div>
<div class="password-display" @click="copyPassword">
<span class="password-text password-text--mono">{{
generatedPassword
}}</span>
<button
class="copy-btn"
:title="copied ? 'Copied!' : 'Click to copy'"
>
{{ copied ? "✓" : "📋" }}
</button>
</div>
<div class="generator-options">
<div class="option-row">
<div class="slider-header">
<label>Length</label>
<span class="slider-value">{{ charLength }}</span>
</div>
<input
v-model.number="charLength"
type="range"
min="12"
max="46"
class="slider"
@input="generateCharsPassword"
/>
<div class="slider-labels">
<span>12</span>
<span>46</span>
</div>
</div>
<div class="option-row checkbox-row">
<label>
<input
v-model="includeUppercase"
type="checkbox"
@change="generateCharsPassword"
/>
Uppercase (A-Z)
</label>
<label>
<input
v-model="includeLowercase"
type="checkbox"
@change="generateCharsPassword"
/>
Lowercase (a-z)
</label>
<label>
<input
v-model="includeNumbers"
type="checkbox"
@change="generateCharsPassword"
/>
Numbers (0-9)
</label>
<label>
<input
v-model="includeSymbols"
type="checkbox"
@change="generateCharsPassword"
/>
Symbols (!@#$)
</label>
</div>
</div>
</div>
<div class="generator-actions">
<button class="action-btn action-btn--secondary" @click="regenerate">
<IconActivity class="btn-icon" />
Regenerate
</button>
<button class="action-btn action-btn--primary" @click="usePassword">
Use This Password
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import IconActivity from "@/icons/IconActivity.vue";
import { useRandomWords } from "@/composables/useRandomWords";
interface Emit {
(e: "passwordGenerated", password: string): void;
}
const emit = defineEmits<Emit>();
const mode = ref<"words" | "chars">("words");
const generatedPassword = ref("");
const copied = ref(false);
// Words mode options
const wordCount = ref(4);
const separator = ref("-");
// Chars mode options
const charLength = ref(16);
const includeUppercase = ref(true);
const includeLowercase = ref(true);
const includeNumbers = ref(true);
const includeSymbols = ref(true);
const { getRandomWords } = useRandomWords();
async function generateWordsPassword() {
const words = await getRandomWords(wordCount.value);
const password = words.join(separator.value);
generatedPassword.value = password;
}
function generateCharsPassword() {
let charset = "";
if (includeUppercase.value) charset += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
if (includeLowercase.value) charset += "abcdefghijklmnopqrstuvwxyz";
if (includeNumbers.value) charset += "0123456789";
if (includeSymbols.value) charset += "!@#$%^&*()_+-=[]{}|;:,.<>?";
if (charset === "") charset = "abcdefghijklmnopqrstuvwxyz";
let password = "";
for (let i = 0; i < charLength.value; i++) {
password += charset.charAt(Math.floor(Math.random() * charset.length));
}
generatedPassword.value = password;
}
async function regenerate() {
if (mode.value === "words") {
await generateWordsPassword();
} else {
generateCharsPassword();
}
}
async function copyPassword() {
try {
await navigator.clipboard.writeText(generatedPassword.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}
function usePassword() {
emit("passwordGenerated", generatedPassword.value);
// TODO: emit
// showGenerator.value = false;
}
watch(mode, async () => {
if (mode.value === "words") {
await generateWordsPassword();
} else {
generateCharsPassword();
}
});
onMounted(generateWordsPassword);
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.password-generator {
margin-bottom: 1rem;
}
.generator-panel {
margin-top: 0.75rem;
padding: 1rem;
background-color: var(--background-ui);
border-radius: 0.5rem;
border: 1px solid var(--background-40);
@include mobile-only {
padding: 0.75rem;
}
}
.generator-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tab {
flex: 1;
padding: 0.65rem 1rem;
background-color: transparent;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
color: $text-color-70;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
&:hover {
background-color: var(--background-40);
}
&--active {
background-color: var(--highlight-color);
border-color: var(--highlight-color);
color: $white;
}
}
.generator-content {
margin-bottom: 1rem;
}
.generator-header {
margin-bottom: 0.75rem;
h4 {
margin: 0 0 0.15rem 0;
font-size: 0.95rem;
font-weight: 500;
color: $text-color;
line-height: 1.3;
}
p {
margin: 0;
font-size: 0.8rem;
color: $text-color-70;
line-height: 1.3;
}
}
.password-display {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background-color: var(--background-color);
border: 2px solid var(--highlight-color);
border-radius: 0.5rem;
margin-bottom: 0.75rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
background-color: var(--background-40);
}
@include mobile-only {
padding: 0.6rem;
}
}
.password-text {
flex: 1;
font-size: 1.8rem;
font-weight: 500;
color: var(--highlight-color);
user-select: all;
word-break: break-all;
word-break: break-word;
-webkit-hyphens: auto;
hyphens: auto;
@include mobile-only {
font-size: 0.95rem;
}
&--mono {
font-family: "Courier New", monospace;
@include mobile-only {
font-size: 0.85rem;
}
}
}
.copy-btn {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem;
transition: transform 0.2s;
&:hover {
transform: scale(1.1);
}
}
.generator-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.option-row {
display: flex;
flex-direction: column;
gap: 0.75rem;
label {
font-size: 0.95rem;
color: $text-color;
font-weight: 600;
line-height: 1.2;
}
&.checkbox-row {
flex-direction: row;
flex-wrap: wrap;
gap: 0.75rem;
@include mobile-only {
flex-direction: column;
gap: 0.6rem;
}
label {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 400;
cursor: pointer;
font-size: 0.85rem;
input[type="checkbox"] {
cursor: pointer;
width: 16px;
height: 16px;
}
}
}
}
.slider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.slider-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--highlight-color);
min-width: 2.5rem;
text-align: right;
}
.slider-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: $text-color-50;
margin-top: 0.25rem;
padding: 0 0.25rem;
}
.slider {
width: 100%;
height: 10px;
border-radius: 5px;
background: var(--background-40);
outline: none;
appearance: none;
cursor: pointer;
transition: background 0.2s;
margin: 0.5rem 0;
@include mobile-only {
height: 12px;
}
&:hover {
background: var(--background-40);
}
&::-webkit-slider-thumb {
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--highlight-color);
cursor: grab;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
margin-top: -7px;
@include mobile-only {
width: 28px;
height: 28px;
margin-top: -8px;
}
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
}
&:active {
cursor: grabbing;
transform: scale(1.05);
}
}
&::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--highlight-color);
cursor: grab;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
@include mobile-only {
width: 28px;
height: 28px;
}
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
}
&:active {
cursor: grabbing;
transform: scale(1.05);
}
}
&::-webkit-slider-runnable-track {
height: 10px;
border-radius: 5px;
@include mobile-only {
height: 12px;
}
}
&::-moz-range-track {
height: 10px;
border-radius: 5px;
background: var(--background-40);
@include mobile-only {
height: 12px;
}
}
}
.separator-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
font-family: "Courier New", monospace;
text-align: center;
&:focus {
outline: none;
border-color: var(--highlight-color);
}
&::placeholder {
color: $text-color-50;
font-family: inherit;
}
}
.generator-actions {
display: flex;
gap: 0.5rem;
@include mobile-only {
flex-direction: column;
gap: 0.5rem;
}
}
.action-btn {
flex: 1;
padding: 0.6rem 1rem;
border: none;
border-radius: 0.25rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
&--secondary {
background-color: var(--background-color);
color: $text-color;
border: 1px solid var(--background-40);
&:hover {
background-color: var(--background-40);
}
}
&--primary {
background-color: var(--highlight-color);
color: $white;
&:hover {
background-color: var(--color-green-90);
}
}
}
.btn-icon {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>

View File

@@ -0,0 +1,349 @@
<template>
<div class="plex-settings">
<!-- Unconnected state -->
<PlexAuthButton
v-if="!showPlexInformation"
@auth-success="handleAuthSuccess"
@auth-error="handleAuthError"
/>
<!-- Connected state -->
<div v-else class="plex-connected">
<PlexProfileCard
v-if="plexUsername"
:username="plexUsername"
:userData="plexUserData"
/>
<PlexLibraryStats
:movies="libraryStats?.movies"
:shows="libraryStats?.['tv shows']"
:music="libraryStats?.music"
:watchtime="libraryStats?.watchtime || 0"
:loading="syncingLibrary"
@open-library="showLibraryDetails"
/>
<PlexServerInfo
:serverName="plexServer"
:lastSync="lastSync"
:syncing="syncingServer"
@sync="syncLibrary"
@unlink="() => (showUnlinkModal = true)"
/>
</div>
<!-- Messages -->
<SeasonedMessages v-model:messages="messages" />
<!-- Unlink Confirmation Modal -->
<PlexUnlinkModal
v-if="showUnlinkModal"
@confirm="unauthenticatePlex"
@cancel="() => (showUnlinkModal = false)"
/>
<!-- Library Details Modal -->
<PlexLibraryModal
v-if="showLibraryModal && selectedLibrary"
:libraryType="selectedLibrary"
:details="libraryStats[selectedLibrary]"
:serverUrl="plexServerUrl"
:serverMachineId="plexMachineId"
@close="closeLibraryModal"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import PlexAuthButton from "@/components/plex/PlexAuthButton.vue";
import PlexProfileCard from "@/components/plex/PlexProfileCard.vue";
import PlexLibraryStats from "@/components/plex/PlexLibraryStats.vue";
import PlexServerInfo from "@/components/plex/PlexServerInfo.vue";
import PlexUnlinkModal from "@/components/plex/PlexUnlinkModal.vue";
import PlexLibraryModal from "@/components/plex/PlexLibraryModal.vue";
import { usePlexAuth } from "@/composables/usePlexAuth";
import {
fetchPlexServers,
fetchPlexUserData,
fetchLibraryDetails
} from "@/composables/usePlexApi";
import type { Ref } from "vue";
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
const messages: Ref<IErrorMessage[]> = ref([]);
const syncingServer = ref(false);
const syncingLibrary = ref(false);
const showUnlinkModal = ref(false);
const plexUsername = ref<string>("");
const plexUserData = ref<any>(null);
const showPlexInformation = ref<boolean>(false);
const hasLocalStorageData = ref<boolean>(false);
const showLibraryModal = ref<boolean>(false);
const selectedLibrary = ref<string>("");
const plexServer = ref("");
const plexServerUrl = ref("");
const plexMachineId = ref("");
const lastSync = ref(sessionStorage.getItem("plex_library_last_sync"));
const libraryStats = ref({
movies: 0,
shows: 0,
music: 0,
watchtime: 0
});
const emit = defineEmits<{
(e: "reload"): void;
}>();
// Composables
const { getPlexAuthCookie, setPlexAuthCookie, cleanup } = usePlexAuth();
// ----- Connection check -----
function checkPlexConnection() {
const authToken = getPlexAuthCookie();
showPlexInformation.value = !!authToken;
return showPlexInformation.value;
}
// ----- Library loading -----
async function loadPlexServer() {
// return cached value from sessionStorage if exists
const cacheKey = "plex_server_data";
const cachedData = sessionStorage.getItem(cacheKey);
if (cachedData) {
const server = JSON.parse(cachedData);
plexServer.value = server?.name;
plexServerUrl.value = server?.url;
plexMachineId.value = server?.machineIdentifier;
return;
}
// get token from cookie
const authToken = getPlexAuthCookie();
if (!authToken) return;
// make api call for data
syncingServer.value = true;
const server = await fetchPlexServers(authToken);
if (server) {
// set server name & id
plexServer.value = server?.name;
plexServerUrl.value = server?.url;
plexMachineId.value = server?.machineIdentifier;
// cache in sessionStorage
sessionStorage.setItem(cacheKey, JSON.stringify(server));
// set last-sync date
const now = new Date().toLocaleString();
lastSync.value = now;
sessionStorage.setItem("plex_library_last_sync", now);
} else {
console.log("unable to load plex server informmation");
}
syncingServer.value = false;
}
// ----- User data loading -----
async function loadPlexUserData() {
// return cached value from sessionStorage if exists
const cacheKey = "plex_user_data";
const cachedData = sessionStorage.getItem(cacheKey);
hasLocalStorageData.value = !!cachedData;
if (cachedData) {
plexUserData.value = JSON.parse(cachedData);
plexUsername.value = plexUserData.value.username;
return;
}
// get token from cookie
const authToken = getPlexAuthCookie();
if (!authToken) return;
// make api call for data
const userData = await fetchPlexUserData(authToken);
if (userData) {
// set plex user data
plexUserData.value = userData;
plexUsername.value = userData?.username;
// cache in sessionStorage
sessionStorage.setItem(cacheKey, JSON.stringify(userData));
} else {
console.log("unable to load user data from plex");
}
}
// ----- Load plex libary details -----
async function loadPlexLibraries() {
// return cached value from sessionStorage if exists
const cacheKey = "plex_library_data";
const cachedData = sessionStorage.getItem(cacheKey);
hasLocalStorageData.value = !!cachedData;
if (cachedData) {
libraryStats.value = JSON.parse(cachedData);
return;
}
// get token from cookie
const authToken = getPlexAuthCookie();
if (!authToken) return;
// make api call for data
syncingLibrary.value = true;
const library = await fetchLibraryDetails();
if (library) {
libraryStats.value = library;
// cache in sessionStorage
sessionStorage.setItem(cacheKey, JSON.stringify(library));
} else {
console.log("unable to load plex library details");
}
syncingLibrary.value = false;
}
// ----- OAuth flow (handlers for PlexAuthButton events) -----
async function handleAuthSuccess(authToken: string) {
setPlexAuthCookie(authToken);
checkPlexConnection();
const success = await loadAll();
if (success) {
messages.value.push({
type: ErrorMessageTypes.Success,
title: "Authenticated with Plex",
message: "Successfully connected your Plex account"
} as IErrorMessage);
} else {
console.error("[PlexSettings] Error in handleAuthSuccess:");
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Authentication failed",
message: "An error occurred while connecting to Plex"
} as IErrorMessage);
}
}
function handleAuthError(errorMessage: string) {
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Authentication failed",
message: errorMessage
} as IErrorMessage);
}
// ----- Unlink flow -----
async function unauthenticatePlex() {
showUnlinkModal.value = false;
sessionStorage.removeItem("plex_user_data");
sessionStorage.removeItem("plex_server_data");
sessionStorage.removeItem("plex_library_data");
sessionStorage.removeItem("plex_library_last_sync");
document.cookie =
"plex_auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Strict";
plexUserData.value = null;
plexUsername.value = "";
showPlexInformation.value = false;
emit("reload");
messages.value.push({
type: ErrorMessageTypes.Success,
title: "Unlinked Plex account",
message: "All browser storage has been clear of plex account"
} as IErrorMessage);
}
// ----- Library modal -----
function showLibraryDetails(type: string) {
selectedLibrary.value = type;
document.getElementsByTagName("body")[0].classList.add("no-scroll");
showLibraryModal.value = true;
}
function closeLibraryModal() {
showLibraryModal.value = false;
document.getElementsByTagName("body")[0].classList.remove("no-scroll");
selectedLibrary.value = "";
}
// ----- Sync -----
async function syncLibrary() {
const authToken = getPlexAuthCookie();
if (!authToken) {
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Sync failed",
message: "No authentication token found"
} as IErrorMessage);
return;
}
sessionStorage.removeItem("plex_user_data");
sessionStorage.removeItem("plex_server_data");
sessionStorage.removeItem("plex_library_data");
const success = await loadAll();
if (success) {
messages.value.push({
type: ErrorMessageTypes.Success,
title: "Library synced",
message: "Your Plex library has been successfully synced"
} as IErrorMessage);
} else {
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Sync failed",
message: "An error occurred while syncing your library"
} as IErrorMessage);
}
}
// ---- Helper load all ----
async function loadAll() {
let success = false;
try {
await Promise.all([
loadPlexServer(),
loadPlexUserData(),
loadPlexLibraries()
]);
success = true;
} catch (error) {
console.log("loadall error, some info might be missing");
}
checkPlexConnection();
return success;
}
// ---- Lifecycle functions ----
onMounted(loadAll);
onUnmounted(() => {
cleanup();
});
</script>
<style scoped>
.plex-settings {
max-width: 800px;
}
.plex-connected {
display: flex;
flex-direction: column;
gap: 24px;
}
</style>

View File

@@ -0,0 +1,233 @@
<template>
<div class="profile-hero">
<div class="profile-hero__main">
<div class="profile-hero__avatar">
<div class="avatar-large">{{ userInitials }}</div>
</div>
<div class="profile-hero__info">
<h1 class="profile-hero__name">{{ username }}</h1>
<span :class="['profile-hero__badge', `badge--${userRole}`]">
<a v-if="userRole === 'admin'" href="/admin">{{ userRole }}</a>
<span v-else>{{ userRole }}</span>
</span>
<p class="profile-hero__member">Member since {{ memberSince }}</p>
</div>
</div>
<div class="profile-hero__stats">
<div class="stat-large">
<span class="stat-large__value">{{ stats.totalRequests }}</span>
<span class="stat-large__label">Requests</span>
</div>
<div class="stat-divider"></div>
<div class="stat-large">
<span class="stat-large__value">{{ stats.magnetsAdded }}</span>
<span class="stat-large__label">Magnets Added</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useStore } from "vuex";
const store = useStore();
const username = computed(() => store.getters["user/username"] || "User");
const userRole = computed(() =>
store.getters["user/admin"] ? "admin" : "user"
);
const userInitials = computed(() => {
return username.value.slice(0, 2).toUpperCase();
});
const memberSince = computed(() => {
const date = new Date();
date.setMonth(date.getMonth() - 6);
return date.toLocaleDateString("en-US", {
month: "short",
year: "numeric"
});
});
const stats = {
totalRequests: 45,
magnetsAdded: 127
};
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.profile-hero {
background-color: var(--background-color-secondary);
border-radius: 0.75rem;
padding: 1.5rem;
border: 1px solid var(--background-40);
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
@include mobile-only {
flex-direction: column;
padding: 1.5rem 1.25rem;
border-radius: 0.5rem;
text-align: center;
gap: 1rem;
}
&__main {
display: flex;
align-items: center;
gap: 1.5rem;
@include mobile-only {
flex-direction: column;
gap: 0.75rem;
}
}
&__avatar {
flex-shrink: 0;
}
&__info {
display: flex;
flex-direction: column;
gap: 0.35rem;
@include mobile-only {
align-items: center;
}
}
&__name {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
line-height: 1.1;
@include mobile-only {
font-size: 1.5rem;
}
}
&__badge {
display: inline-block;
padding: 0.25rem 0.7rem;
border-radius: 2rem;
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
width: fit-content;
@include mobile-only {
padding: 0.2rem 0.6rem;
font-size: 0.7rem;
}
&.badge--admin {
background-color: var(--color-warning);
color: black;
}
&.badge--user {
background-color: var(--background-40);
}
}
&__member {
margin: 0;
font-size: 0.85rem;
color: var(--text-color-70);
@include mobile-only {
font-size: 0.8rem;
}
}
&__stats {
display: flex;
align-items: center;
gap: 1.75rem;
padding-left: 1.75rem;
border-left: 1px solid var(--background-40);
@include mobile-only {
width: 100%;
padding: 1rem 0 0 0;
border-left: none;
border-top: 1px solid var(--background-40);
justify-content: center;
gap: 1.25rem;
}
}
}
.avatar-large {
width: 70px;
height: 70px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--highlight-color),
var(--color-green-70)
);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
font-weight: 700;
color: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
@include mobile-only {
width: 80px;
height: 80px;
font-size: 2rem;
}
}
.stat-large {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
&__value {
font-size: 1.75rem;
font-weight: 700;
color: var(--highlight-color);
line-height: 1;
@include mobile-only {
font-size: 1.75rem;
}
}
&__label {
font-size: 0.75rem;
color: var(--text-color-70);
text-transform: uppercase;
font-weight: 500;
letter-spacing: 0.5px;
@include mobile-only {
font-size: 0.75rem;
}
}
}
.stat-divider {
width: 1px;
height: 45px;
background-color: var(--background-40);
@include mobile-only {
height: 45px;
}
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<div class="export-card">
<div class="settings-section-header">
<h2>Request History</h2>
<p>View and download your complete request history.</p>
</div>
<div class="stats-grid">
<div class="stat-mini">
<span class="stat-mini__value">{{ data.total }}</span>
<span class="stat-mini__label">Total</span>
</div>
<div class="stat-mini">
<span class="stat-mini__value">{{ data.approved }}</span>
<span class="stat-mini__label">Approved</span>
</div>
<div class="stat-mini">
<span class="stat-mini__value">{{ data.pending }}</span>
<span class="stat-mini__label">Pending</span>
</div>
</div>
<button class="view-btn" @click="viewHistory">View Full History</button>
</div>
</template>
<script setup lang="ts">
import { defineProps } from "vue";
import { useRouter } from "vue-router";
interface Props {
data: any;
}
defineProps<Props>();
const router = useRouter();
function viewHistory() {
router.push({ name: "profile" });
}
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
@import "scss/shared-settings";
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.stat-mini {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 0.4rem;
background-color: var(--background-color);
border-radius: 0.25rem;
@include mobile-only {
padding: 0.45rem 0.35rem;
}
&__value {
font-size: 1.2rem;
font-weight: 600;
color: var(--highlight-color);
@include mobile-only {
font-size: 1.1rem;
}
}
&__label {
font-size: 0.7rem;
text-transform: uppercase;
margin-top: 0.15rem;
@include mobile-only {
font-size: 0.65rem;
}
}
}
.view-btn {
width: 100%;
padding: 0.55rem 0.85rem;
background-color: var(--background-color);
border: 1px solid var(--background-40);
border-radius: 0.25rem;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
color: var(--text-color);
&:hover {
background-color: var(--background-40);
border-color: var(--highlight-color);
}
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div class="security-settings">
<div class="security-settings__intro">
<h2 class="security-settings__title">Security</h2>
<p class="security-settings__description">
Keep your account safe by using a strong, unique password. We recommend
using a passphrase or generated password that's hard to guess.
</p>
</div>
<change-password />
</div>
</template>
<script setup lang="ts">
import ChangePassword from "@/components/profile/ChangePassword.vue";
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.security-settings {
&__intro {
margin-bottom: 1rem;
@include mobile-only {
margin-bottom: 0.85rem;
}
}
&__title {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 700;
line-height: 1.3;
}
&__description {
margin: 0;
font-size: 0.95rem;
line-height: 1.6;
color: var(--text-color-70);
}
}
</style>

View File

@@ -0,0 +1,215 @@
<template>
<div class="storage-manager">
<StorageSectionBrowser
:sections="storageSections"
@clear-item="clearItem"
/>
<DangerZoneAction
title="Clear All Browser Data"
description="Remove all locally stored data at once. This includes preferences, history, and cached information."
button-text="Clear All Data"
@action="clearAllData"
/>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from "vue";
import IconCookie from "@/icons/IconCookie.vue";
import IconDatabase from "@/icons/IconDatabase.vue";
import IconTimer from "@/icons/IconTimer.vue";
import StorageSectionBrowser from "./StorageSectionBrowser.vue";
import DangerZoneAction from "./DangerZoneAction.vue";
import { formatBytes } from "../../utils";
interface StorageItem {
key: string;
description: string;
size: string;
type: "local" | "session" | "cookie";
}
const notifications: {
success: (options: {
title: string;
description?: string;
timeout?: number;
}) => void;
error: (options: {
title: string;
description?: string;
timeout?: number;
}) => void;
} = inject("notifications");
const dict = {
commandPalette_stats: "Usage statistics for command palette navigation",
"theme-preference": "Your selected color theme",
plex_user_data: "Cached Plex account information",
plex_library_data: "Cached Plex library details per section",
plex_server_data: "Cached Plex server information",
plex_library_last_sync: "UTC time string for last synced Plex data",
plex_auth_token: "Authorized token from Plex.tv",
authorization: "This sites user login token"
};
const storageItems = computed<StorageItem[]>(() => {
const items: StorageItem[] = [];
// local storage
Object.keys(localStorage).map(key => {
items.push({
key,
description: dict[key] ?? "",
size: formatBytes(localStorage[key]?.length || 0),
type: "local"
});
});
// session storage
Object.keys(sessionStorage).map(key => {
items.push({
key,
description: dict[key] ?? "",
size: formatBytes(sessionStorage[key]?.length || 0),
type: "session"
});
});
// cookies
if (document.cookie) {
document.cookie.split(";").forEach(cookie => {
const [key, _] = cookie.trim().split("=");
if (key) {
items.push({
key,
description: dict[key] ?? "",
size: formatBytes(cookie.length || 0),
type: "cookie"
});
}
});
}
return items;
});
const getTotalSize = (items: StorageItem[]) => {
const totalBytes = items.reduce((acc, item) => {
const match = item.size.match(/^([\d.]+)\s*(\w+)$/);
if (!match) return acc;
const value = parseFloat(match[1]);
const unit = match[2];
return (
acc +
(unit === "KB"
? value * 1024
: unit === "MB"
? value * 1024 * 1024
: value)
);
}, 0);
return formatBytes(totalBytes);
};
const storageSections = computed(() => [
{
type: "local" as const,
title: "LocalStorage",
iconComponent: IconDatabase,
description:
"LocalStorage keeps data permanently on your device, even after closing your browser. It's used to remember your preferences and settings between visits.",
items: storageItems.value.filter(item => item.type === "local"),
get totalSize() {
return getTotalSize(this.items);
}
},
{
type: "session" as const,
title: "SessionStorage",
iconComponent: IconTimer,
description:
"SessionStorage keeps data temporarily while you browse. It's automatically cleared when you close your browser tab or window.",
items: storageItems.value.filter(item => item.type === "session"),
get totalSize() {
return getTotalSize(this.items);
}
},
{
type: "cookie" as const,
title: "Cookies",
iconComponent: IconCookie,
description:
"Cookies are small text files stored by your browser. They can be temporary (session cookies) or persistent, and are often used for authentication and tracking your activity.",
items: storageItems.value.filter(item => item.type === "cookie"),
get totalSize() {
return getTotalSize(this.items);
}
}
]);
function clearItem(key: string, type: "local" | "session" | "cookie") {
try {
if (type === "local") {
localStorage.removeItem(key);
} else if (type === "session") {
sessionStorage.removeItem(key);
} else if (type === "cookie") {
document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}
notifications.success({
title: "Data Cleared",
description: `${key} has been cleared`,
timeout: 3000
});
// Force re-render
storageItems.value;
} catch (error) {
notifications.error({
title: "Error",
description: `Failed to clear ${key}`,
timeout: 5000
});
}
}
function clearAllData() {
const confirmed = confirm(
"Are you sure you want to clear all locally stored data? This action cannot be undone."
);
if (!confirmed) return;
try {
localStorage.clear();
sessionStorage.clear();
document.cookie.split(";").forEach(cookie => {
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
});
notifications.success({
title: "All Data Cleared",
description: "All locally stored data has been removed",
timeout: 3000
});
} catch (error) {
notifications.error({
title: "Error",
description: "Failed to clear all data",
timeout: 5000
});
}
}
</script>
<style lang="scss" scoped>
.storage-manager {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,365 @@
<template>
<div class="browser-storage">
<div class="settings-section-header">
<h2>Browser Storage</h2>
<p>
Your browser stores data locally to make this site faster and remember
your settings. View what's saved on this device and remove items
anytime.
</p>
</div>
<div class="storage-sections">
<div
v-for="section in sections"
:key="section.type"
:class="`storage-section storage-section--${section.type}`"
>
<button
class="storage-section__header"
@click="
expandedSections[section.type] = !expandedSections[section.type]
"
>
<div class="storage-section__header-content">
<component :is="section.iconComponent" class="section-icon" />
<h3 class="storage-section__title">{{ section.title }}</h3>
<span class="storage-section__count">{{
section.items.length
}}</span>
<span class="storage-section__size">{{ section.totalSize }}</span>
</div>
<svg
class="storage-section__chevron"
:class="{
'storage-section__chevron--expanded':
expandedSections[section.type]
}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div
v-if="expandedSections[section.type]"
class="storage-section__content"
>
<p class="storage-section__description">{{ section.description }}</p>
<div class="storage-items">
<div
v-for="item in section.items"
:key="item.key"
:class="`storage-item storage-item--${section.type}`"
>
<component :is="section.iconComponent" class="type-icon" />
<div class="storage-item__info">
<h4 class="storage-item__title">{{ item.key }}</h4>
<p class="storage-item__description">
<span v-if="item.description">{{ item.description }} · </span>
<span class="storage-item__size">{{ item.size }}</span>
</p>
</div>
<button
class="storage-item__delete"
@click="$emit('clear-item', item.key, section.type)"
:title="`Clear ${item.key}`"
>
<IconClose />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import IconClose from "@/icons/IconClose.vue";
interface StorageItem {
key: string;
description: string;
size: string;
type: "local" | "session" | "cookie";
}
interface StorageSection {
type: "local" | "session" | "cookie";
title: string;
description: string;
iconComponent: any;
items: StorageItem[];
totalSize: string;
}
defineProps<{
sections: StorageSection[];
}>();
defineEmits<{
"clear-item": [key: string, type: "local" | "session" | "cookie"];
}>();
const expandedSections = ref<Record<string, boolean>>({
local: false,
session: false,
cookie: false
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
@import "scss/shared-settings";
.browser-storage {
&__intro {
margin-bottom: 2rem;
}
}
.storage-sections {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.storage-section {
border-radius: 0.5rem;
background: var(--background-ui);
overflow: hidden;
border: 2px solid transparent;
transition: border-color 0.2s ease;
&--local {
border-color: rgba(139, 92, 246, 0.2);
.section-icon,
.type-icon {
stroke: #8b5cf6;
}
}
&--session {
border-color: rgba(245, 158, 11, 0.2);
.section-icon,
.type-icon {
stroke: #f59e0b;
}
}
&--cookie {
border-color: rgba(236, 72, 153, 0.2);
.section-icon,
.type-icon {
fill: #ec4899;
}
}
&__header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
background: none;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: var(--background-40);
}
}
&__header-content {
display: flex;
align-items: center;
gap: 0.75rem;
.section-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 0.5rem;
background: var(--background-40);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-70);
}
&__size {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-color-50);
font-family: monospace;
}
&__chevron {
width: 20px;
height: 20px;
stroke: var(--text-color-70);
transition: transform 0.2s ease;
&--expanded {
transform: rotate(180deg);
}
}
&__content {
border-top: 1px solid var(--background-40);
}
&__description {
margin: 0;
padding: 1rem 1.25rem;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-color-70);
background: var(--background-color);
font-style: italic;
}
}
.storage-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem 1rem 1rem;
}
.storage-item {
display: grid;
grid-template-columns: auto 1fr auto;
background: var(--background-color);
border-radius: 0.25rem;
overflow: hidden;
transition: all 0.2s ease;
border-left: 3px solid;
&--local {
border-color: #8b5cf6;
background: linear-gradient(
90deg,
rgba(139, 92, 246, 0.1),
var(--background-color)
);
}
&--session {
border-color: #f59e0b;
background: linear-gradient(
90deg,
rgba(245, 158, 11, 0.1),
var(--background-color)
);
}
&--cookie {
border-color: #ec4899;
background: linear-gradient(
90deg,
rgba(236, 72, 153, 0.1),
var(--background-color)
);
}
&:hover .storage-item__delete {
background: var(--color-error-highlight);
}
&:hover .type-icon {
opacity: 1;
}
.type-icon {
width: 1.5rem;
height: 1.5rem;
opacity: 0.6;
transition: opacity 0.2s;
margin: auto 1.5rem;
@include mobile-only {
width: 1.25rem;
height: 1.25rem;
margin: auto 1rem;
}
}
&__info {
min-width: 0;
padding: 0.85rem 0.85rem 0.85rem 0;
display: flex;
flex-direction: column;
justify-content: center;
@include mobile-only {
padding: 0.75rem 0.75rem 0.75rem 0;
}
}
&__title {
margin: 0 0 0.3rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
@include mobile-only {
font-size: 1rem;
}
}
&__description {
margin: 0;
font-size: 0.8rem;
color: var(--text-color-70);
line-height: 1.4;
@include mobile-only {
font-size: 0.75rem;
}
}
&__size {
color: var(--text-color-50);
font-family: monospace;
}
&__delete {
width: 70px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-error);
border: none;
cursor: pointer;
transition: all 0.2s;
@include mobile-only {
width: 60px;
}
svg {
width: 20px;
height: 20px;
fill: white;
transition: transform 0.2s;
@include mobile-only {
width: 18px;
height: 18px;
}
}
&:hover {
background: var(--color-error-highlight);
svg {
transform: scale(1.1);
}
}
&:active svg {
transform: scale(0.9);
}
}
}
</style>

View File

@@ -0,0 +1,462 @@
<template>
<div class="server-storage">
<div class="server-storage__intro">
<h2 class="server-storage__title">Server Storage</h2>
<p class="server-storage__description">
Data stored on our servers to sync across your devices and provide
personalized features.
</p>
</div>
<div class="server-sections">
<div
v-for="section in serverSections"
:key="section.type"
:class="`server-section server-section--${section.type}`"
>
<button
class="server-section__header"
@click="
expandedSections[section.type] = !expandedSections[section.type]
"
>
<div class="server-section__header-content">
<component :is="section.iconComponent" class="section-icon" />
<h3 class="server-section__title">{{ section.title }}</h3>
<span class="server-section__count">{{
section.items.length
}}</span>
<span class="server-section__size">{{ section.totalSize }}</span>
</div>
<svg
class="server-section__chevron"
:class="{
'server-section__chevron--expanded':
expandedSections[section.type]
}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div
v-if="expandedSections[section.type]"
class="server-section__content"
>
<p class="server-section__description">{{ section.description }}</p>
<div class="server-items">
<div
v-for="item in section.items"
:key="item.key"
:class="`server-item server-item--${section.type}`"
>
<component :is="section.iconComponent" class="type-icon" />
<div class="server-item__info">
<h4 class="server-item__title">{{ item.key }}</h4>
<p class="server-item__description">
<span v-if="item.description">{{ item.description }} · </span>
<span class="server-item__size">{{ item.size }}</span>
<span v-if="item.lastSynced" class="server-item__synced">
· Last synced: {{ item.lastSynced }}</span
>
</p>
</div>
<button
class="server-item__delete"
@click="$emit('clear-item', item.key, section.type)"
:title="`Delete ${item.key}`"
>
<IconClose />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import IconClose from "@/icons/IconClose.vue";
import IconProfile from "@/icons/IconProfile.vue";
import IconSettings from "@/icons/IconSettings.vue";
import IconActivity from "@/icons/IconActivity.vue";
interface ServerItem {
key: string;
description: string;
size: string;
lastSynced?: string;
}
defineEmits<{
"clear-item": [key: string, type: string];
}>();
const expandedSections = ref<Record<string, boolean>>({
profile: false,
preferences: false,
activity: false
});
// Mock server data
const serverSections = computed(() => [
{
type: "profile",
title: "Profile Data",
iconComponent: IconProfile,
description:
"Your account information, settings, and preferences stored on our servers.",
items: [
{
key: "user_profile",
description: "User account details",
size: "2.4 KB",
lastSynced: "2 hours ago"
},
{
key: "avatar_image",
description: "Profile picture",
size: "145 KB",
lastSynced: "1 day ago"
},
{
key: "email_preferences",
description: "Notification settings",
size: "512 Bytes",
lastSynced: "3 days ago"
}
],
totalSize: "147.9 KB"
},
{
type: "preferences",
title: "Synced Preferences",
iconComponent: IconSettings,
description:
"Settings that sync across all your devices when you sign in.",
items: [
{
key: "theme_settings",
description: "Color theme and appearance",
size: "1.1 KB",
lastSynced: "5 hours ago"
},
{
key: "playback_settings",
description: "Video and audio preferences",
size: "856 Bytes",
lastSynced: "1 day ago"
},
{
key: "library_filters",
description: "Saved filters and sorting",
size: "2.3 KB",
lastSynced: "2 days ago"
}
],
totalSize: "4.3 KB"
},
{
type: "activity",
title: "Activity History",
iconComponent: IconActivity,
description:
"Your viewing history and watch progress stored on our servers.",
items: [
{
key: "watch_history",
description: "Recently watched items",
size: "12.5 KB",
lastSynced: "1 hour ago"
},
{
key: "watch_progress",
description: "Playback positions",
size: "8.2 KB",
lastSynced: "30 minutes ago"
},
{
key: "favorites",
description: "Starred and favorited content",
size: "3.7 KB",
lastSynced: "6 hours ago"
}
],
totalSize: "24.4 KB"
}
]);
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.server-storage {
&__intro {
margin-bottom: 2rem;
}
&__title {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-color);
}
&__description {
margin: 0;
color: var(--text-color-70);
font-size: 0.95rem;
line-height: 1.6;
}
}
.server-sections {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.server-section {
border-radius: 0.5rem;
background: var(--background-ui);
overflow: hidden;
border: 2px solid transparent;
transition: border-color 0.2s ease;
&--profile {
border-color: rgba(59, 130, 246, 0.2);
.section-icon,
.type-icon {
fill: #3b82f6;
}
}
&--preferences {
border-color: rgba(16, 185, 129, 0.2);
.section-icon,
.type-icon {
fill: #10b981;
}
}
&--activity {
border-color: rgba(245, 158, 11, 0.2);
.section-icon,
.type-icon {
fill: #f59e0b;
}
}
&__header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
background: none;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: var(--background-40);
}
}
&__header-content {
display: flex;
align-items: center;
gap: 0.75rem;
.section-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 0.5rem;
background: var(--background-40);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-70);
}
&__size {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-color-50);
font-family: monospace;
}
&__chevron {
width: 20px;
height: 20px;
stroke: var(--text-color-70);
transition: transform 0.2s ease;
&--expanded {
transform: rotate(180deg);
}
}
&__content {
border-top: 1px solid var(--background-40);
}
&__description {
margin: 0;
padding: 1rem 1.25rem;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-color-70);
background: var(--background-color);
font-style: italic;
}
}
.server-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem 1rem 1rem;
}
.server-item {
display: grid;
grid-template-columns: auto 1fr auto;
background: var(--background-color);
border-radius: 0.25rem;
overflow: hidden;
transition: all 0.2s ease;
border-left: 3px solid;
&--profile {
border-color: #3b82f6;
background: linear-gradient(
90deg,
rgba(59, 130, 246, 0.1),
var(--background-color)
);
}
&--preferences {
border-color: #10b981;
background: linear-gradient(
90deg,
rgba(16, 185, 129, 0.1),
var(--background-color)
);
}
&--activity {
border-color: #f59e0b;
background: linear-gradient(
90deg,
rgba(245, 158, 11, 0.1),
var(--background-color)
);
}
&:hover .server-item__delete {
background: var(--color-error-highlight);
}
&:hover .type-icon {
opacity: 1;
}
.type-icon {
width: 1.5rem;
height: 1.5rem;
opacity: 0.6;
transition: opacity 0.2s;
margin: auto 1.5rem;
@include mobile-only {
width: 1.25rem;
height: 1.25rem;
margin: auto 1rem;
}
}
&__info {
min-width: 0;
padding: 0.85rem 0.85rem 0.85rem 0;
display: flex;
flex-direction: column;
justify-content: center;
@include mobile-only {
padding: 0.75rem 0.75rem 0.75rem 0;
}
}
&__title {
margin: 0 0 0.3rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
@include mobile-only {
font-size: 1rem;
}
}
&__description {
margin: 0;
font-size: 0.8rem;
color: var(--text-color-70);
line-height: 1.4;
@include mobile-only {
font-size: 0.75rem;
}
}
&__size {
color: var(--text-color-50);
font-family: monospace;
}
&__synced {
color: var(--text-color-50);
font-style: italic;
}
&__delete {
width: 70px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-error);
border: none;
cursor: pointer;
transition: all 0.2s;
@include mobile-only {
width: 60px;
}
svg {
width: 20px;
height: 20px;
fill: white;
transition: transform 0.2s;
@include mobile-only {
width: 18px;
height: 18px;
}
}
&:hover {
background: var(--color-error-highlight);
svg {
transform: scale(1.1);
}
}
&:active svg {
transform: scale(0.9);
}
}
}
</style>

View File

@@ -0,0 +1,355 @@
<template>
<div class="theme-preferences">
<div class="current-theme">
<div class="theme-display">
<div class="theme-icon" :data-theme="selectedTheme">
<div class="icon-inner"></div>
</div>
<div class="theme-info">
<span class="theme-label">Current Theme</span>
<h3 class="theme-name">{{ currentThemeName }}</h3>
</div>
</div>
</div>
<div class="theme-grid">
<button
v-for="theme in themes"
:key="theme.value"
:class="['theme-card', { active: selectedTheme === theme.value }]"
@click="selectTheme(theme.value)"
>
<div class="theme-card__preview" :data-theme="theme.value">
<div class="preview-circle"></div>
</div>
<span class="theme-card__name">{{ theme.label }}</span>
<div v-if="selectedTheme === theme.value" class="theme-card__badge">
Active
</div>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { useTheme } from "@/composables/useTheme";
const themes = [
{ value: "auto", label: "Auto" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "ocean", label: "Ocean" },
{ value: "nordic", label: "Nordic" },
{ value: "halloween", label: "Halloween" }
] as const;
const { currentTheme, savedTheme, setTheme } = useTheme();
const selectedTheme = currentTheme;
const currentThemeName = computed(
() => themes.find(t => t.value === selectedTheme.value)?.label ?? "Auto"
);
function selectTheme(theme: string) {
setTheme(theme as any);
}
onMounted(() => {
selectedTheme.value = savedTheme.value;
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.current-theme {
margin-bottom: 2rem;
padding: 1rem 2rem;
background-color: var(--background-ui);
border-radius: 1rem;
border: 2px solid var(--background-40);
@include mobile-only {
margin-bottom: 1.5rem;
padding: 1.5rem;
border-radius: 0.75rem;
}
.theme-display {
display: flex;
align-items: center;
gap: 1.5rem;
@include mobile-only {
gap: 1.25rem;
}
}
.theme-icon {
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
@include mobile-only {
width: 70px;
height: 70px;
}
.icon-inner {
width: 100%;
height: 100%;
border-radius: 50%;
border: 3px solid;
}
&[data-theme="light"] .icon-inner {
background: linear-gradient(135deg, #f8f8f8, #e8e8e8);
border-color: #01d277;
}
&[data-theme="dark"] .icon-inner {
background: linear-gradient(135deg, #1a1a1a, #0a0a0a);
border-color: #01d277;
}
&[data-theme="ocean"] .icon-inner {
background: linear-gradient(135deg, #0f2027, #2c5364);
border-color: #00d4ff;
}
&[data-theme="nordic"] .icon-inner {
background: linear-gradient(135deg, #f5f0e8, #d8cdb9);
border-color: #3d6e4e;
}
&[data-theme="halloween"] .icon-inner {
background: linear-gradient(135deg, #1a0e2e, #2d1b3d);
border-color: #ff6600;
}
&[data-theme="auto"] .icon-inner {
background: conic-gradient(#f8f8f8 0deg 180deg, #1a1a1a 180deg);
border-color: #01d277;
}
}
.theme-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
span {
font-size: 0.85rem;
color: $text-color-70;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
@include mobile-only {
font-size: 0.75rem;
}
}
h3 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
line-height: 1;
@include mobile-only {
font-size: 1.4rem;
}
}
}
.theme-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.theme-card {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 1rem;
background-color: var(--background-ui);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
text-align: center;
@include mobile-only {
padding: 0.85rem;
border-radius: 0.5rem;
&:hover {
transform: none;
}
&:active {
transform: scale(0.97);
}
}
&:hover {
border-color: var(--highlight-color);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
&.active {
border-color: var(--highlight-color);
background-color: var(--background-40);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&__preview {
width: 100%;
height: 120px;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 0.75rem;
position: relative;
border: 1px solid var(--background-40);
@include mobile-only {
height: 100px;
margin-bottom: 0.6rem;
}
.preview-circle {
position: absolute;
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
border-radius: 50%;
}
&::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
height: 20px;
border-radius: 4px;
border: 1px solid;
}
}
&__preview[data-theme="light"] {
background: #f8f8f8;
.preview-circle {
background: #01d277;
}
&::before {
background: #fff;
border-color: rgba(8, 28, 36, 0.1);
}
}
&__preview[data-theme="dark"] {
background: #111;
.preview-circle {
background: #01d277;
}
&::before {
background: #060708;
border-color: rgba(255, 255, 255, 0.1);
}
}
&__preview[data-theme="ocean"] {
background: #0f2027;
.preview-circle {
background: #00d4ff;
}
&::before {
background: #203a43;
border-color: rgba(0, 212, 255, 0.2);
}
}
&__preview[data-theme="nordic"] {
background: #f5f0e8;
.preview-circle {
background: #3d6e4e;
}
&::before {
background: #fffef9;
border-color: rgba(61, 110, 78, 0.2);
}
}
&__preview[data-theme="halloween"] {
background: #1a0e2e;
.preview-circle {
background: #ff6600;
}
&::before {
background: #2d1b3d;
border-color: rgba(255, 102, 0, 0.2);
}
}
&__preview[data-theme="auto"] {
border-color: black;
background: linear-gradient(
135deg,
#f8f8f8 0%,
#f8f8f8 50%,
#111 50%,
#111 100%
);
.preview-circle {
left: auto;
right: 8px;
background: #01d277;
}
&::before {
right: auto;
width: calc(50% - 10px);
background: #fff;
border-color: rgba(8, 28, 36, 0.1);
}
}
&__name {
font-size: 0.9rem;
font-weight: 600;
line-height: 1;
color: var(--text-color);
@include mobile-only {
font-size: 0.85rem;
}
}
&__badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem 0.5rem;
background-color: var(--highlight-color);
color: white;
border-radius: 1rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
@include mobile-only {
top: 0.4rem;
right: 0.4rem;
padding: 0.2rem 0.4rem;
font-size: 0.6rem;
}
}
}
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div class="user-profile">
<div class="profile-card">
<div class="avatar-circle">{{ userInitials }}</div>
<div class="profile-details">
<div class="name-row">
<span class="username">{{ username }}</span>
<span :class="['role-badge', `role-badge--${userRole}`]">{{
userRole
}}</span>
<span
v-if="plexUsername"
class="role-badge role-badge--plex"
:title="`Connected as ${plexUsername}`"
>
<svg
width="12"
height="12"
viewBox="0 0 256 256"
fill="currentColor"
>
<path
d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0zm57.7 128.7l-48 48c-.4.4-.9.7-1.4.9-.5.2-1.1.4-1.6.4s-1.1-.1-1.6-.4c-.5-.2-1-.5-1.4-.9l-48-48c-1.6-1.6-1.6-4.1 0-5.7 1.6-1.6 4.1-1.6 5.7 0l41.1 41.1V80c0-2.2 1.8-4 4-4s4 1.8 4 4v84.1l41.1-41.1c1.6-1.6 4.1-1.6 5.7 0 .8.8 1.2 1.8 1.2 2.8s-.4 2.1-1.2 2.9z"
/>
</svg>
Plex
</span>
</div>
<span class="member-info">Member since {{ memberSince }}</span>
<span v-if="plexUsername" class="plex-info"
>Connected as {{ plexUsername }}</span
>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
const store = useStore();
const plexUsername = ref<string>("");
const username = computed(() => store.getters["user/username"] || "User");
const userRole = computed(() =>
store.getters["user/admin"] ? "admin" : "user"
);
const userInitials = computed(() => username.value.slice(0, 2).toUpperCase());
const memberSinceDate = computed(() => {
const date = new Date();
date.setMonth(date.getMonth() - 6);
return date;
});
const memberSince = computed(() =>
memberSinceDate.value.toLocaleDateString("en-US", {
month: "short",
year: "numeric"
})
);
const monthsActive = computed(() => {
const now = new Date();
return (
(now.getFullYear() - memberSinceDate.value.getFullYear()) * 12 +
now.getMonth() -
memberSinceDate.value.getMonth()
);
});
// Load Plex username from localStorage
function loadPlexUsername() {
const cachedData = localStorage.getItem("plex_user_data");
if (cachedData) {
try {
const plexData = JSON.parse(cachedData);
plexUsername.value = plexData.username || "";
} catch (error) {
console.error("Error parsing cached Plex data:", error);
}
}
}
onMounted(() => {
loadPlexUsername();
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.user-profile {
@include mobile-only {
width: 100%;
}
}
.profile-card {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.85rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
@include mobile-only {
padding: 0.75rem;
gap: 0.75rem;
}
}
.avatar-circle {
width: 55px;
height: 55px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--highlight-color),
var(--color-green-70)
);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
font-weight: 600;
color: $white;
flex-shrink: 0;
@include mobile-only {
width: 48px;
height: 48px;
font-size: 1.1rem;
}
}
.profile-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.name-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.username {
font-size: 1.05rem;
font-weight: 600;
color: $text-color;
line-height: 1.2;
@include mobile-only {
font-size: 0.95rem;
}
}
.member-info {
font-size: 0.8rem;
color: $text-color-70;
line-height: 1.2;
@include mobile-only {
font-size: 0.75rem;
}
}
.plex-info {
font-size: 0.75rem;
color: #cc7b19;
line-height: 1.2;
display: flex;
align-items: center;
gap: 0.3rem;
@include mobile-only {
font-size: 0.7rem;
}
}
.role-badge {
padding: 0.2rem 0.55rem;
border-radius: 0.25rem;
font-size: 0.65rem;
text-transform: uppercase;
font-weight: 600;
line-height: 1;
display: inline-flex;
align-items: center;
gap: 0.25rem;
&--admin {
background-color: var(--color-warning);
color: $black;
}
&--user {
background-color: var(--background-40);
color: $text-color;
}
&--plex {
background-color: #cc7b19;
color: $white;
cursor: help;
svg {
flex-shrink: 0;
}
}
}
</style>

View File

@@ -0,0 +1,6 @@
<template>
<code
>Monitor active torrents requested. Requires authentication with owners plex
library!</code
>
</template>

Some files were not shown because too many files have changed in this diff Show More