Compare commits

...

373 Commits

Author SHA1 Message Date
dependabot[bot]
5c00fec305 Bump yaml from 2.8.2 to 2.8.3
Bumps [yaml](https://github.com/eemeli/yaml) from 2.8.2 to 2.8.3.
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v2.8.2...v2.8.3)

---
updated-dependencies:
- dependency-name: yaml
  dependency-version: 2.8.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 02:21:31 +00:00
c3eefb3ec6 Enhance torrent components, command palette, and global stylings
- Torrent Table gets more consistent background styling
- StorageSectionBrowser w/ different chevron icons for open/close
- Vim keyboard navigation for command palette
- Update main.scss with animations which don't stack & interfere
- Command pallet vim vertical navigation
2026-03-23 23:22:39 +01:00
444e633f50 properly log user out by removing all browser storage 2026-03-11 00:39:05 +01:00
013117507e Feat: Discover page (#108)
* Add discover page components with category showcase

* Add section page component for browsing discover categories

* Add icons for discover page categories (spotlights, theater, compass, calendar, star)

* Add discover icon (compass navigation) for main navigation

* Update navigation to use IconDiscover for discover route

* Add discover and section page routes with interfaces

* Update home page and components to integrate with discover navigation

* Remove deprecated ListPage component
2026-03-11 00:14:49 +01:00
604cada126 API & Navigation Updates
Add discover API endpoint, update command palette, and fix Plex auth

- Add getTmdbMovieDiscoverByName() function to api.ts for discover categories
- Add discover route to command palette with IconBinoculars and description
- Replace IconBinoculars with IconSearch for torrent search input in SeasonedInput
- Fix Plex auth cookie to include domain attribute for cross-subdomain support
2026-03-11 00:08:29 +01:00
1e9077a819 Movie Popup Enhancements
Add remove request button and improve torrent search UX

- Remove emoji from 'Already available' text
- Add 'Remove request' button visible only to admins on requested items with IconTombstone
- Replace torrent search icon from IconBinoculars to IconHelm
- Add helm spin animation that rotates clockwise when opening torrent search
- Add helm spin animation that rotates counter-clockwise when closing torrent search
- Add helmKey reactive ref to trigger animation on every state change
2026-03-11 00:08:29 +01:00
1dbd22d42e Icon System Infrastructure
Add icon conversion tooling and new icon library

- Add icon-converter.mjs script to transform SVG files into Vue components
- Converts kebab-case filenames to PascalCase (e.g., clipboard-text.svg → IconClipboardText.vue)
- Wraps SVG content in proper Vue template structure
- Sets width/height to 100% for consistent sizing
- Add 38 new icon components for future use (IconHelm, IconMailboxFull, IconCheck, IconWarning, IconClipboardText, IconExpandVertical, IconShrinkVertical, and more)
2026-03-11 00:08:29 +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
f180b7f39b Updated header text and font size 2022-01-09 15:58:04 +01:00
a2fbfcb13c Removed /dist prefix from built js file 2022-01-03 20:29:57 +01:00
d640f7f882 Removed /dist prefix from all image paths 2022-01-03 20:29:03 +01:00
d43c12b103 Prettierrc file 2022-01-03 17:50:55 +01:00
38c3792675 Add 'is-loaded' class after image intersects viewport 2022-01-03 17:50:12 +01:00
ac2785abd5 Increased opacity delay 2022-01-03 17:49:35 +01:00
1ff6a0e831 Linting 2022-01-03 17:49:22 +01:00
7a3b709404 Update router to use history not hash mode. 2021-05-18 10:21:00 +02:00
KevinMidboe
d63cb4ac52 Merge branch 'master' of github.com:kevinmidboe/seasoned 2020-04-09 23:01:25 +02:00
b6ee1cf906 Profile replaces route with query settings=true when enabled. 2020-04-09 23:00:50 +02:00
60201b1b67 Login and register pages now checks inputs for errors. throwError parameter on login and register functions allows us to receive the request object not just the decoded json. 2020-04-09 21:39:29 +02:00
a8b8603649 /login is alias of signin component. 2020-04-09 20:59:49 +02:00
e193528fe9 Routes with meta requiresAuth redirects to login page if token not set in localstorage 2020-04-09 20:58:58 +02:00
73afb34964 Logout route that clears localstorage for anything set clientside. 2020-04-09 20:53:24 +02:00
65bbc453e6 seasoned messages looks better when messages contains only title. 2020-04-09 20:27:11 +02:00
KevinMidboe
188477ab64 404 page now has button to navigate to previous page. 2020-04-09 19:58:50 +02:00
KevinMidboe
a31bfb6b39 Updated seasonedbutton to not have a wrapping div. 2020-04-09 19:55:57 +02:00
681ed69ef0 Removed padding on right side of search input and removed unused comment. 2020-02-25 13:44:48 +01:00
b771428b4d Changed placeholder for earch input 2020-02-25 13:44:31 +01:00
fc0103ee5d Change the document title prefix from request to seasoned 2020-02-25 12:12:07 +01:00
55067b81b8 Merge branch 'master' of github.com:KevinMidboe/seasoned 2020-02-25 12:09:45 +01:00
dfe2b5df09 Removed default emoji prefix of document title. 2020-02-25 12:09:13 +01:00
dc0c435163 If settings dont exist, return false for isAuthenticated. 2020-02-21 23:03:31 +01:00
9d1ac56b9a Also check localstorage for settings if not found in state. 2020-02-21 22:58:49 +01:00
fc2c3664d9 Resolved merge conflict. 2020-02-21 22:52:36 +01:00
0bd45ed777 New sidebarelement for users that are logged inn. Now they can be redirected directly to the movie in plex. 2020-02-21 22:51:39 +01:00
3912766982 Reverted active logic for seasonedButton. 2020-02-20 14:09:08 +01:00
3becce2a6c Moved isPlexAuthenticated from movie component to userModule. 2020-02-20 14:08:46 +01:00
20b8692c91 Forgot to toggle isActive when clicked. 2020-02-20 13:56:56 +01:00
14ac780aa5 Should not overwrite prop data. Copy and set to internal data attribute. 2020-02-20 13:55:04 +01:00
d836870612 Toggle active boolean to set class on buttons. 2020-02-20 13:41:39 +01:00
bc6f706e4a New mediaquery to check if hover is available then only style hover when it is. This solves sticky hover styling on mobile. 2020-02-20 13:33:08 +01:00
6ac6a9b039 Readded noselect class to description. 2020-02-20 10:41:46 +01:00
85be80d712 Removed unused code for poster image. 2020-02-20 10:41:21 +01:00
105be1e411 noselect was not the issue, bug in css-loader. 2020-02-20 00:47:55 +01:00
010830243e noselect class was preventing taps on mobile. 2020-02-20 00:25:59 +01:00
923dc46dc7 Removed setTimeout 2020-02-20 00:24:34 +01:00
f2ef5366f5 Merge pull request #48 from KevinMidboe/refactor/image-loading
Refactor/image loading
2020-02-20 00:22:14 +01:00
20380a4587 Merge branch 'master' into refactor/image-loading 2020-02-20 00:21:43 +01:00
069ef2c458 Cleaned up some css, better loading of backdrop, simplified DOM, more meta data for tvshows and added truncating of description. 2020-02-20 00:19:08 +01:00
2f430b2d8f Cleaned up some of the styling for movieslistitem. 2020-02-19 23:54:20 +01:00
f7a579a438 IntersecrionObserver checks ref intersection when mounted. 2020-02-19 23:53:51 +01:00
b9ddd998bc When type person show known for department. 2020-02-19 23:52:25 +01:00
ae59d02df2 Poster image dom simplified. 2020-02-19 23:52:03 +01:00
ec205bab0c Update .drone.yml 2020-02-07 01:19:08 +01:00
ed49d825b8 Merge pull request #44 from KevinMidboe/feature/searchFiltering
Feature/search filtering
2020-01-31 22:51:21 +01:00
a9db8be46a Removed duplicated top_rated icon. 2020-01-31 22:32:12 +01:00
1caa3c7fae Removed unsued comments and added alt tag to images 2020-01-31 22:27:45 +01:00
2ea4bffd49 Update .drone.yml 2020-01-31 22:21:49 +01:00
5ae52f59fc Merge pull request #47 from KevinMidboe/feature/lazy-loading-images
Lazy loading for list items.
2020-01-31 22:18:42 +01:00
a7e6d25d3f Lazy loading for list items.
This is somewhat inefficient because each list item has its own instance
of a intersectionObserver.
Improvements include:
- Poster has placeholder image as source from mount
- When component mounts we attach the observer
- When observerd in viewport find
  - Find the correct image height based on the placeholders height
  - Change src to dynamic poster url
2020-01-31 22:14:13 +01:00
83751a4e3e Update .drone.yml 2020-01-20 19:15:42 +01:00
0e9daab187 Removed unused raven link under body 2020-01-20 19:10:45 +01:00
4390491873 Update .drone.yml 2020-01-20 19:07:48 +01:00
d620a4cc2e Update .drone.yml 2020-01-20 19:07:00 +01:00
32669e5bef Update .drone.yml 2020-01-20 19:00:36 +01:00
6edad3991f Create .drone.yml 2020-01-20 18:59:58 +01:00
50acf0bedc Merge pull request #46 from KevinMidboe/fix/admin-from-jwt
Fix/admin from jwt
2020-01-10 23:32:43 +01:00
d4369ec7a4 JwtToken decoded to set data from jwt contents.
JwtDecode used to read data from the jwt token and set admin and
username. Resolves issue #45.
2020-01-10 23:29:34 +01:00
c16543099e JwtDecode function for reading content of jwt token. 2020-01-10 23:28:45 +01:00
f2a65d755c Show info also appends check_existance to url. 2019-12-27 23:39:35 +01:00
1fd48edd42 Set default adult value to true. 2019-12-27 23:31:20 +01:00
68e45303c6 Filtering for search in autocomplete dropdown.
- Accessibility
 - Tabindex updated for search <input> to have priority over nav items.
 - Aria label
- Search icon clickable for searching.
- Filter for adult and searchType.
- When clicking a autocomplete search result, the clicked item is set as
selectedResult.
- Remove duplicates from elastic search result.
- Added filter parameters to our $router.push function.
2019-12-27 22:18:45 +01:00
532993e9dd Merge pull request #41 from KevinMidboe/refactor
General refactoring and small feature release
2019-12-27 22:04:36 +01:00
d19d72ce0c Merge pull request #40 from KevinMidboe/feature/user-graphs
Authenticate plex account in settings gives access to activity graph for your plex user
2019-12-27 22:01:49 +01:00
d1820a08cf Same css for .light & .dark as for color-scheme. 2019-12-27 21:51:31 +01:00
bc73665b12 Elem text wrapped <li> and active icon ref fixed.
Supplementary and content text are now wrapped in a <li> item. This with
better styling selectors formats the icon correctly alongside the text.
Fixed active icon ref function that had an incorrect if statement so the
activeIconRef would never be returned.
2019-12-26 12:46:57 +01:00
9edb19569a Change to have height to min-height: 75px 2019-12-26 12:21:43 +01:00
7802a89d15 New toggle release filter and fixed expand torrent
Use the toggleButton for filtering release types in torrent response.
There is a click to expand the full name of the torrent. This is mostly
for mobile where the name is hidden. Fixed an issue where the expanded
list element would not get the correct styling and break the table in
half. Now we also set an data attribute for the expanded element. This
allows our scoped styling to reach the expanded element.
Also increased padding on expanded content.
2019-12-26 12:04:17 +01:00
915260f41b Admin var checks localstorage for admin == "true". 2019-12-26 12:01:26 +01:00
0d57e9a03b Tmdb search w/ adult and media type filters.
Adult is set to disable filtering adult material for search results.
Media type checks if movie, show or person and appends type to url path
for searches by type only. E.g. mediaType = 'show' ->
api/v1/search/show?query=Friends.
2019-12-26 01:59:01 +01:00
582207d453 Error msg on empty response & added search params
- On empty search responses Search page show a error message that there
where no respones.
- For page loads directly to the search page new url query parameters
are checked: adult and media_type. These are then used to fetch updated
tmdb search parameters. Adult = true disables filtering for adult
material and media_type decides if the search is multi, movie, show or
person. (Frontend for filtering media_type is not added yet.)
2019-12-26 01:37:29 +01:00
b1b08bfa04 Movielist item shows the title and now name for cases where the result item is a person. 2019-12-26 01:35:56 +01:00
14e883672d Higher z-index & checks for browser compatibility.
- Increased the z-index of the darkmode toggle emoji icon.
- supported function for checking the browser for prefered color scheme.
This is mainly to set the current mode to dark if the color scheme is
currently dark.
2019-12-26 01:32:20 +01:00
7a405140db Loading var for loader start and more header info.
New loading var for holding the state of the request. This makes it
easier to show the loading and an error if the result is empty but the
request is finished.
More header info! The header now displays list elements of info in a
column on the right side of header. Used here for result and page
current and total count.
2019-12-26 01:26:46 +01:00
35497f5bd2 General mobile & desktop queries. -only classes for hiding the the
opposite of desktop or mobile.
2019-12-26 01:18:29 +01:00
91b19785d6 Darker colors for background-color for preferred color schema dark 2019-12-26 01:17:18 +01:00
a301d21cc2 Merge branch 'feature/user-graphs' into refactor 2019-12-26 01:14:34 +01:00
a2a4b9a553 getPerson endpoint and is called properly when movie.vue opens with type 'person'. 2019-12-26 01:08:19 +01:00
45f45559fd Day number input has defined background and text color. 2019-12-26 00:35:46 +01:00
458256132a Updated color for .light --background-ui 2019-12-26 00:33:05 +01:00
0f2c166e1c Added $background-ui to .dark and .light classes for manually toggling color preference 2019-12-26 00:31:30 +01:00
1c7a688cb8 Moved fetch call for getting charts to api.js 2019-12-26 00:28:33 +01:00
6269f178e9 Store added to api 2019-12-26 00:20:40 +01:00
3e7527ee19 defined variables for green-70 (rgba(1, 210, 119, .73)) and background-ui (#edeef0) 2019-12-26 00:18:14 +01:00
2236316863 api functions for linking, unlinking-plex account, get settings and update settings. 2019-12-26 00:15:45 +01:00
cc2fded193 Removed unnessesary filename declaratiom 2019-12-26 00:10:03 +01:00
f32e0a8ab0 Store user module for users settings and username. 2019-12-26 00:09:02 +01:00
ec6e6d2ba0 Class declarations for simple flex selectors. 2019-12-26 00:08:26 +01:00
ca85635b03 Settings with new feature to autnhenticate plex account with your season account. Also moved settings to computed value of new user store module. 2019-12-26 00:06:56 +01:00
32257dc64e Info can now also be Array and will display the list elements in a column. Also made hader sticky and decreased some margin and increased the font. 2019-12-24 13:23:22 +01:00
6bba319735 Formatting 2019-12-24 13:14:10 +01:00
dcce972fdc New togglebutton component for selection data types for the graphs in activitypage. 2019-12-24 13:14:00 +01:00
32e25fb983 Accounts with linked plex accounts can view their watch history per day by duration on number of plays. Have two graphs and adding more requires a new canvas element and new list element in this.charts. 2019-12-24 13:08:57 +01:00
e7882869e6 Created checkStatusAndReturnJson middleware for checking for responses status is ok(200-299) or not and if it does returns a json parsed object. 2019-11-25 23:28:23 +01:00
d0a251f69a Moved register and login requests to api.js. 2019-11-25 23:25:08 +01:00
9bc7f29162 Decreased the padding around movie list items on large screens to let the grow a bit larger. 2019-11-25 23:12:36 +01:00
3ff963f007 Implemented download activity overlay on movielistitems if progress movie object key exists. 2019-11-25 23:11:59 +01:00
bcfce66ec0 Matched is set to false if exists_in_plex is undefined (not a part of
the response body).
nesteDataToString is simplified in the way it parses its input data.
getMovie uses optional second parameter check_existance to check if the
file exists in plex.
2019-11-25 23:03:22 +01:00
33e3ee3489 Authenticating with plex now happens to seasonedShows backend and not through plex.tv. 2019-11-25 22:57:05 +01:00
e3502a7690 Searching tmdb should also include authorization token for search history. 2019-11-25 22:56:01 +01:00
8d09ba4d07 get movie now has optional url parameters to also include existance and release dates in response 2019-11-25 22:55:34 +01:00
ba670d06aa Added charjs and fetch user activity to graph from new user/activity endpoint. This fetches tautulli stats based on the plex user_id linked with the seasoned account. 2019-11-05 01:08:38 +01:00
a11ad2f651 Merge pull request #34 from KevinMidboe/fix/post-magnet-data
Added application json content type header
2019-10-31 19:18:36 +01:00
755bd116d5 Added application json content type headaer 2019-10-31 19:16:18 +01:00
9e33784781 Merge pull request #33 from KevinMidboe/fix/post-magnet-data
Data was sent as [object object], now we stringify the content first.
2019-10-31 18:42:04 +01:00
470bcdd72e Data was sent as [object object], now we stringify the content first. 2019-10-31 18:41:40 +01:00
d56a7d4dfe Merge pull request #32 from KevinMidboe/fix/mobile-seasoned-message-formatting
Better formatting for seasoned messages on mobile
2019-10-30 23:46:46 +01:00
b46e586c92 Resize the content for seasoned messages and the settings wrapper to look better on mobile 2019-10-30 23:45:48 +01:00
563eb3f1ef Merge pull request #31 from KevinMidboe/fix/restrictive-background-scroll
Disable scroll on content behind popover movie view
2019-10-30 22:12:44 +01:00
98644513ad When movie popup opens we add a no-scroll class to the body element. This prevents scrolling the content behind the popover content. 2019-10-30 22:11:09 +01:00
3033db02b8 Merge pull request #29 from KevinMidboe/fix/search-input-navigation-resets-cursor
Reset search-input cursor on upwards navigation
2019-10-30 21:57:05 +01:00
70a6ed189b When navigating up in the autocomplete search result list the cursor usually reset back to the start of the input. Now we get the element and use focus and setSelectionRange to move the cursor back to the end at the very next frame. 2019-10-30 21:55:39 +01:00
d7e4d2095c Merge pull request #2 from KevinMidboe/release/v2
Release/v1
2019-10-23 19:54:43 +02:00
1c0799a30a Also check the items source for name or title to find out if the response item came from movie or show index 2019-10-23 00:49:38 +02:00
2b3955060f Updated yarn lock 2019-10-23 00:46:10 +02:00
4ac4d642e7 Removed autogenerated docs 2019-10-23 00:43:17 +02:00
3d12cd2735 Added check svg icon for torrentList 2019-10-23 00:39:30 +02:00
4a44924f56 Updated webpack to resolve common file extensions 2019-10-23 00:38:36 +02:00
3910b5d7b2 Forgot to add getter defined for documentTitle store module 2019-10-23 00:33:32 +02:00
4a32fe5255 SeasonedInput can now be initialized with a value 2019-10-23 00:33:00 +02:00
8b9b2be891 Sortable class function for finding out which header should get a class showing that the column is selected 2019-10-23 00:32:42 +02:00
96321831d1 0 is computed as False, allow 0 just not undefined or null 2019-10-23 00:31:49 +02:00
39cd5ce04a Re-wrote most all api calls to use fetch over axios. There is still a problem with form authentication with plex. The response we get does not seem to be a json object. Updated what is expected to return from altered api methods in each component that uses them 2019-10-23 00:30:37 +02:00
4a46bbd2be Movie now uses the new documentTitle module to set when loading movie and when popover dismissed we set it to the previous documenTitle. Created a getter for documentTitle. This makes it easier to only get the title variable and not try save and parse the document title with all the extra prefixes 2019-10-22 23:34:54 +02:00
f45dcc560c Removed A LOT of the functionality in MoviesList and replaced it with the ResultsList component. Now loading of search results, lists (either directly by query or link) and users requests from profile are all separated out to their own page component; Search.vue, ListPage.vue and Profile.vue respectivly. With the change Home has been completly redone to use this new funcionality 2019-10-22 23:24:08 +02:00
1a014bea15 Added some fresh new todos 2019-10-22 23:19:24 +02:00
6d6f1ffd06 Updated seasonedinput to also handle two-way binded value prop. This changes is reflected most all places that seaoned-input is used
. Fuck, also added the new ResultsList which replaces MoviesList
2019-10-22 23:18:24 +02:00
4528b240e1 Popover now also removes its eventlistener on close 2019-10-22 23:08:03 +02:00
c454d9c9e0 Misc cleanup, more definitions of color with scss variables and added a lot of color transitions for when switching theme color. 2019-10-22 23:07:21 +02:00
f8c284cd71 Removed messages stylesheet 2019-10-22 22:58:37 +02:00
46daff2ddb Removed unused warning|error|success (s)css variables and added green-90; green color with 90% opacity 2019-10-22 22:58:04 +02:00
9bb98ce569 Removed unused script element and updated positioning of text to center, even on mobile. 2019-10-22 22:53:51 +02:00
001c243f95 New store module for setting the document title. Each route changes the document title to its name 2019-10-22 22:52:24 +02:00
0fdaf5bd4e Cleaned up 404 page. Removed elements and property set the height of background 2019-10-22 18:47:25 +02:00
931918c60b Movie title also gets a loading placeholder before we get a respons. (Loadingplaceholder are grey pulsing bars that indicate where content is going to load) 2019-10-21 19:50:13 +02:00
a9d3246b97 New route to settings directly 2019-10-21 00:25:29 +02:00
cde119592d Redid the template for profile, regist and siginin to better use the
components we have made and to use update function definition.
Changed the message system with SeasonedMessages. This means simpler
interaction and less duplicate code now that the messagesystem is a
separate component that both interface with.
2019-10-21 00:25:01 +02:00
031127fb1f Merge branch 'release/v2' of github.com:KevinMidboe/seasoned into release/v2 2019-10-21 00:15:59 +02:00
fa50dd3455 Finished dark mode! This means re-doing all sass variables in the
variables.scss file and defining css variables in :root and alterting
them based on prefered color scheme. This gives us a mechanism to set
custom color schemes for the entire site from one place and changing
between them just by setting a class to the body element. This is done
by overwriting the css variables and then our scss variables use these
changes and apply them downward. This seems like a really nice setup for
the switching between- and adding color schemes.
Also did a lot of cleanup of unused, duplicate or errors styling
throughout the application.
2019-10-21 00:13:21 +02:00
49c418c3f1 Toggle for manually setting dark or light mode 2019-10-20 23:19:19 +02:00
8e7aa77ee3 Removed normalize because we dont need any help 2019-10-20 23:16:25 +02:00
4b0fcca5d2 Number of torrent results is now dynamically fetched from store and sent as supplementaryText to sidebar component 2019-10-15 20:48:10 +02:00
585fa5afcf Added vuex module for setting if darkmode is supported in users browser 2019-10-05 18:02:16 +02:00
38cec8c31a Added vue-svg-inline-loader package and updated webpack config 2019-10-04 18:26:53 +02:00
431cb7c034 sortableSize helper function re-done to be a async function 2019-10-04 17:44:17 +02:00
91a92a30ad Renamed movie/sidebarAction.vue to ui/sidebarListElem.vue. Completly rewrote the component. Uses slots for text, way better semantic html elements used and logic is moved from the dom to computed functions. 2019-10-04 17:43:23 +02:00
9d819e9a14 Removed unused console statements 2019-10-04 16:11:29 +02:00
ca910089c5 Removed duplicate styling rules 2019-10-04 00:50:27 +02:00
6270206812 Reset webkit styling for our inputs 2019-10-04 00:40:45 +02:00
1d1a78608e Moved away inline css and added mobile rule to stretch the input wrapper closer to the edges of the screen 2019-10-04 00:36:36 +02:00
2e8795a317 Fixed bug where the toggle state was out of sync 2019-10-04 00:33:12 +02:00
f39560e041 Updated a stupid function name a longer name 2019-10-04 00:32:40 +02:00
6f74a5bff4 Input components now emit a "enter" event and our torrent input searches if "enter" event is received 2019-10-04 00:28:27 +02:00
c339045a0e When searching for torrents we can now edit the search query and search again 2019-10-04 00:22:07 +02:00
9e38b67857 Use our store value for number of torrents and implemented a getter in the sidebar action button component and action call from the torrentList component. 2019-10-04 00:21:18 +02:00
a6f72c8f6b Implemented store to allow torrentSearch to tell our sidebar action buttons how many results we got 2019-10-04 00:20:03 +02:00
c8f9cb7e22 Button gets a defualt height of 45px and more rules for setting input to 100% of parent 2019-10-04 00:17:31 +02:00
7bb624b942 Inputs now take up 100% of the div and the other div should device the size 2019-10-04 00:14:42 +02:00
b11d2f752b WIP. Collapsing backgroup header for move view on touchw 2019-08-14 00:22:27 +02:00
1a82b751ea Merge pull request #13 from KevinMidboe/snyk-fix-fb49d4d8d3aed8aec2f03b293aa793a6
[Snyk] Fix for 1 vulnerable dependencies
2019-07-27 12:38:38 +02:00
snyk-test
45bc0389ac fix: package.json to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-AXIOS-174505
2019-07-27 10:37:31 +00:00
67d3af0ed0 Emoji api now uses URL object to construct url path 2019-07-06 19:37:48 +02:00
377 changed files with 23850 additions and 16147 deletions

112
.drone.yml Normal file
View File

@@ -0,0 +1,112 @@
---
kind: pipeline
type: docker
name: seasoned build
platform:
os: linux
arch: amd64
volumes:
- name: cache
host:
path: /tmp/cache
steps:
- 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:
event:
include:
- 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>

10
.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"bracketSpacing": true,
"arrowParens": "avoid",
"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)

View File

@@ -1,350 +0,0 @@
/*!
* AnchorJS - v4.0.0 - 2017-06-02
* https://github.com/bryanbraun/anchorjs
* Copyright (c) 2017 Bryan Braun; Licensed MIT
*/
/* eslint-env amd, node */
// https://github.com/umdjs/umd/blob/master/templates/returnExports.js
(function(root, factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define([], factory);
} else if (typeof module === 'object' && module.exports) {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory();
} else {
// Browser globals (root is window)
root.AnchorJS = factory();
root.anchors = new root.AnchorJS();
}
})(this, function() {
'use strict';
function AnchorJS(options) {
this.options = options || {};
this.elements = [];
/**
* Assigns options to the internal options object, and provides defaults.
* @param {Object} opts - Options object
*/
function _applyRemainingDefaultOptions(opts) {
opts.icon = opts.hasOwnProperty('icon') ? opts.icon : '\ue9cb'; // Accepts characters (and also URLs?), like '#', '¶', '❡', or '§'.
opts.visible = opts.hasOwnProperty('visible') ? opts.visible : 'hover'; // Also accepts 'always' & 'touch'
opts.placement = opts.hasOwnProperty('placement')
? opts.placement
: 'right'; // Also accepts 'left'
opts.class = opts.hasOwnProperty('class') ? opts.class : ''; // Accepts any class name.
// Using Math.floor here will ensure the value is Number-cast and an integer.
opts.truncate = opts.hasOwnProperty('truncate')
? Math.floor(opts.truncate)
: 64; // Accepts any value that can be typecast to a number.
}
_applyRemainingDefaultOptions(this.options);
/**
* Checks to see if this device supports touch. Uses criteria pulled from Modernizr:
* https://github.com/Modernizr/Modernizr/blob/da22eb27631fc4957f67607fe6042e85c0a84656/feature-detects/touchevents.js#L40
* @returns {Boolean} - true if the current device supports touch.
*/
this.isTouchDevice = function() {
return !!(
'ontouchstart' in window ||
(window.DocumentTouch && document instanceof DocumentTouch)
);
};
/**
* Add anchor links to page elements.
* @param {String|Array|Nodelist} selector - A CSS selector for targeting the elements you wish to add anchor links
* to. Also accepts an array or nodeList containing the relavant elements.
* @returns {this} - The AnchorJS object
*/
this.add = function(selector) {
var elements,
elsWithIds,
idList,
elementID,
i,
index,
count,
tidyText,
newTidyText,
readableID,
anchor,
visibleOptionToUse,
indexesToDrop = [];
// We reapply options here because somebody may have overwritten the default options object when setting options.
// For example, this overwrites all options but visible:
//
// anchors.options = { visible: 'always'; }
_applyRemainingDefaultOptions(this.options);
visibleOptionToUse = this.options.visible;
if (visibleOptionToUse === 'touch') {
visibleOptionToUse = this.isTouchDevice() ? 'always' : 'hover';
}
// Provide a sensible default selector, if none is given.
if (!selector) {
selector = 'h2, h3, h4, h5, h6';
}
elements = _getElements(selector);
if (elements.length === 0) {
return this;
}
_addBaselineStyles();
// We produce a list of existing IDs so we don't generate a duplicate.
elsWithIds = document.querySelectorAll('[id]');
idList = [].map.call(elsWithIds, function assign(el) {
return el.id;
});
for (i = 0; i < elements.length; i++) {
if (this.hasAnchorJSLink(elements[i])) {
indexesToDrop.push(i);
continue;
}
if (elements[i].hasAttribute('id')) {
elementID = elements[i].getAttribute('id');
} else if (elements[i].hasAttribute('data-anchor-id')) {
elementID = elements[i].getAttribute('data-anchor-id');
} else {
tidyText = this.urlify(elements[i].textContent);
// Compare our generated ID to existing IDs (and increment it if needed)
// before we add it to the page.
newTidyText = tidyText;
count = 0;
do {
if (index !== undefined) {
newTidyText = tidyText + '-' + count;
}
index = idList.indexOf(newTidyText);
count += 1;
} while (index !== -1);
index = undefined;
idList.push(newTidyText);
elements[i].setAttribute('id', newTidyText);
elementID = newTidyText;
}
readableID = elementID.replace(/-/g, ' ');
// The following code builds the following DOM structure in a more effiecient (albeit opaque) way.
// '<a class="anchorjs-link ' + this.options.class + '" href="#' + elementID + '" aria-label="Anchor link for: ' + readableID + '" data-anchorjs-icon="' + this.options.icon + '"></a>';
anchor = document.createElement('a');
anchor.className = 'anchorjs-link ' + this.options.class;
anchor.href = '#' + elementID;
anchor.setAttribute('aria-label', 'Anchor link for: ' + readableID);
anchor.setAttribute('data-anchorjs-icon', this.options.icon);
if (visibleOptionToUse === 'always') {
anchor.style.opacity = '1';
}
if (this.options.icon === '\ue9cb') {
anchor.style.font = '1em/1 anchorjs-icons';
// We set lineHeight = 1 here because the `anchorjs-icons` font family could otherwise affect the
// height of the heading. This isn't the case for icons with `placement: left`, so we restore
// line-height: inherit in that case, ensuring they remain positioned correctly. For more info,
// see https://github.com/bryanbraun/anchorjs/issues/39.
if (this.options.placement === 'left') {
anchor.style.lineHeight = 'inherit';
}
}
if (this.options.placement === 'left') {
anchor.style.position = 'absolute';
anchor.style.marginLeft = '-1em';
anchor.style.paddingRight = '0.5em';
elements[i].insertBefore(anchor, elements[i].firstChild);
} else {
// if the option provided is `right` (or anything else).
anchor.style.paddingLeft = '0.375em';
elements[i].appendChild(anchor);
}
}
for (i = 0; i < indexesToDrop.length; i++) {
elements.splice(indexesToDrop[i] - i, 1);
}
this.elements = this.elements.concat(elements);
return this;
};
/**
* Removes all anchorjs-links from elements targed by the selector.
* @param {String|Array|Nodelist} selector - A CSS selector string targeting elements with anchor links,
* OR a nodeList / array containing the DOM elements.
* @returns {this} - The AnchorJS object
*/
this.remove = function(selector) {
var index,
domAnchor,
elements = _getElements(selector);
for (var i = 0; i < elements.length; i++) {
domAnchor = elements[i].querySelector('.anchorjs-link');
if (domAnchor) {
// Drop the element from our main list, if it's in there.
index = this.elements.indexOf(elements[i]);
if (index !== -1) {
this.elements.splice(index, 1);
}
// Remove the anchor from the DOM.
elements[i].removeChild(domAnchor);
}
}
return this;
};
/**
* Removes all anchorjs links. Mostly used for tests.
*/
this.removeAll = function() {
this.remove(this.elements);
};
/**
* Urlify - Refine text so it makes a good ID.
*
* To do this, we remove apostrophes, replace nonsafe characters with hyphens,
* remove extra hyphens, truncate, trim hyphens, and make lowercase.
*
* @param {String} text - Any text. Usually pulled from the webpage element we are linking to.
* @returns {String} - hyphen-delimited text for use in IDs and URLs.
*/
this.urlify = function(text) {
// Regex for finding the nonsafe URL characters (many need escaping): & +$,:;=?@"#{}|^~[`%!'<>]./()*\
var nonsafeChars = /[& +$,:;=?@"#{}|^~[`%!'<>\]\.\/\(\)\*\\]/g,
urlText;
// The reason we include this _applyRemainingDefaultOptions is so urlify can be called independently,
// even after setting options. This can be useful for tests or other applications.
if (!this.options.truncate) {
_applyRemainingDefaultOptions(this.options);
}
// Note: we trim hyphens after truncating because truncating can cause dangling hyphens.
// Example string: // " ⚡⚡ Don't forget: URL fragments should be i18n-friendly, hyphenated, short, and clean."
urlText = text
.trim() // "⚡⚡ Don't forget: URL fragments should be i18n-friendly, hyphenated, short, and clean."
.replace(/\'/gi, '') // "⚡⚡ Dont forget: URL fragments should be i18n-friendly, hyphenated, short, and clean."
.replace(nonsafeChars, '-') // "⚡⚡-Dont-forget--URL-fragments-should-be-i18n-friendly--hyphenated--short--and-clean-"
.replace(/-{2,}/g, '-') // "⚡⚡-Dont-forget-URL-fragments-should-be-i18n-friendly-hyphenated-short-and-clean-"
.substring(0, this.options.truncate) // "⚡⚡-Dont-forget-URL-fragments-should-be-i18n-friendly-hyphenated-"
.replace(/^-+|-+$/gm, '') // "⚡⚡-Dont-forget-URL-fragments-should-be-i18n-friendly-hyphenated"
.toLowerCase(); // "⚡⚡-dont-forget-url-fragments-should-be-i18n-friendly-hyphenated"
return urlText;
};
/**
* Determines if this element already has an AnchorJS link on it.
* Uses this technique: http://stackoverflow.com/a/5898748/1154642
* @param {HTMLElemnt} el - a DOM node
* @returns {Boolean} true/false
*/
this.hasAnchorJSLink = function(el) {
var hasLeftAnchor =
el.firstChild &&
(' ' + el.firstChild.className + ' ').indexOf(' anchorjs-link ') > -1,
hasRightAnchor =
el.lastChild &&
(' ' + el.lastChild.className + ' ').indexOf(' anchorjs-link ') > -1;
return hasLeftAnchor || hasRightAnchor || false;
};
/**
* Turns a selector, nodeList, or array of elements into an array of elements (so we can use array methods).
* It also throws errors on any other inputs. Used to handle inputs to .add and .remove.
* @param {String|Array|Nodelist} input - A CSS selector string targeting elements with anchor links,
* OR a nodeList / array containing the DOM elements.
* @returns {Array} - An array containing the elements we want.
*/
function _getElements(input) {
var elements;
if (typeof input === 'string' || input instanceof String) {
// See https://davidwalsh.name/nodelist-array for the technique transforming nodeList -> Array.
elements = [].slice.call(document.querySelectorAll(input));
// I checked the 'input instanceof NodeList' test in IE9 and modern browsers and it worked for me.
} else if (Array.isArray(input) || input instanceof NodeList) {
elements = [].slice.call(input);
} else {
throw new Error('The selector provided to AnchorJS was invalid.');
}
return elements;
}
/**
* _addBaselineStyles
* Adds baseline styles to the page, used by all AnchorJS links irregardless of configuration.
*/
function _addBaselineStyles() {
// We don't want to add global baseline styles if they've been added before.
if (document.head.querySelector('style.anchorjs') !== null) {
return;
}
var style = document.createElement('style'),
linkRule =
' .anchorjs-link {' +
' opacity: 0;' +
' text-decoration: none;' +
' -webkit-font-smoothing: antialiased;' +
' -moz-osx-font-smoothing: grayscale;' +
' }',
hoverRule =
' *:hover > .anchorjs-link,' +
' .anchorjs-link:focus {' +
' opacity: 1;' +
' }',
anchorjsLinkFontFace =
' @font-face {' +
' font-family: "anchorjs-icons";' + // Icon from icomoon; 10px wide & 10px tall; 2 empty below & 4 above
' src: url(data:n/a;base64,AAEAAAALAIAAAwAwT1MvMg8yG2cAAAE4AAAAYGNtYXDp3gC3AAABpAAAAExnYXNwAAAAEAAAA9wAAAAIZ2x5ZlQCcfwAAAH4AAABCGhlYWQHFvHyAAAAvAAAADZoaGVhBnACFwAAAPQAAAAkaG10eASAADEAAAGYAAAADGxvY2EACACEAAAB8AAAAAhtYXhwAAYAVwAAARgAAAAgbmFtZQGOH9cAAAMAAAAAunBvc3QAAwAAAAADvAAAACAAAQAAAAEAAHzE2p9fDzz1AAkEAAAAAADRecUWAAAAANQA6R8AAAAAAoACwAAAAAgAAgAAAAAAAAABAAADwP/AAAACgAAA/9MCrQABAAAAAAAAAAAAAAAAAAAAAwABAAAAAwBVAAIAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAMCQAGQAAUAAAKZAswAAACPApkCzAAAAesAMwEJAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAAAAAAAAAAAAAQAAg//0DwP/AAEADwABAAAAAAQAAAAAAAAAAAAAAIAAAAAAAAAIAAAACgAAxAAAAAwAAAAMAAAAcAAEAAwAAABwAAwABAAAAHAAEADAAAAAIAAgAAgAAACDpy//9//8AAAAg6cv//f///+EWNwADAAEAAAAAAAAAAAAAAAAACACEAAEAAAAAAAAAAAAAAAAxAAACAAQARAKAAsAAKwBUAAABIiYnJjQ3NzY2MzIWFxYUBwcGIicmNDc3NjQnJiYjIgYHBwYUFxYUBwYGIwciJicmNDc3NjIXFhQHBwYUFxYWMzI2Nzc2NCcmNDc2MhcWFAcHBgYjARQGDAUtLXoWOR8fORYtLTgKGwoKCjgaGg0gEhIgDXoaGgkJBQwHdR85Fi0tOAobCgoKOBoaDSASEiANehoaCQkKGwotLXoWOR8BMwUFLYEuehYXFxYugC44CQkKGwo4GkoaDQ0NDXoaShoKGwoFBe8XFi6ALjgJCQobCjgaShoNDQ0NehpKGgobCgoKLYEuehYXAAAADACWAAEAAAAAAAEACAAAAAEAAAAAAAIAAwAIAAEAAAAAAAMACAAAAAEAAAAAAAQACAAAAAEAAAAAAAUAAQALAAEAAAAAAAYACAAAAAMAAQQJAAEAEAAMAAMAAQQJAAIABgAcAAMAAQQJAAMAEAAMAAMAAQQJAAQAEAAMAAMAAQQJAAUAAgAiAAMAAQQJAAYAEAAMYW5jaG9yanM0MDBAAGEAbgBjAGgAbwByAGoAcwA0ADAAMABAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAH//wAP) format("truetype");' +
' }',
pseudoElContent =
' [data-anchorjs-icon]::after {' +
' content: attr(data-anchorjs-icon);' +
' }',
firstStyleEl;
style.className = 'anchorjs';
style.appendChild(document.createTextNode('')); // Necessary for Webkit.
// We place it in the head with the other style tags, if possible, so as to
// not look out of place. We insert before the others so these styles can be
// overridden if necessary.
firstStyleEl = document.head.querySelector('[rel="stylesheet"], style');
if (firstStyleEl === undefined) {
document.head.appendChild(style);
} else {
document.head.insertBefore(style, firstStyleEl);
}
style.sheet.insertRule(linkRule, style.sheet.cssRules.length);
style.sheet.insertRule(hoverRule, style.sheet.cssRules.length);
style.sheet.insertRule(pseudoElContent, style.sheet.cssRules.length);
style.sheet.insertRule(anchorjsLinkFontFace, style.sheet.cssRules.length);
}
}
return AnchorJS;
});

View File

@@ -1,12 +0,0 @@
.input {
font-family: inherit;
display: block;
width: 100%;
height: 2rem;
padding: .5rem;
margin-bottom: 1rem;
border: 1px solid #ccc;
font-size: .875rem;
border-radius: 3px;
box-sizing: border-box;
}

View File

@@ -1,544 +0,0 @@
/*! Basscss | http://basscss.com | MIT License */
.h1{ font-size: 2rem }
.h2{ font-size: 1.5rem }
.h3{ font-size: 1.25rem }
.h4{ font-size: 1rem }
.h5{ font-size: .875rem }
.h6{ font-size: .75rem }
.font-family-inherit{ font-family:inherit }
.font-size-inherit{ font-size:inherit }
.text-decoration-none{ text-decoration:none }
.bold{ font-weight: bold; font-weight: bold }
.regular{ font-weight:normal }
.italic{ font-style:italic }
.caps{ text-transform:uppercase; letter-spacing: .2em; }
.left-align{ text-align:left }
.center{ text-align:center }
.right-align{ text-align:right }
.justify{ text-align:justify }
.nowrap{ white-space:nowrap }
.break-word{ word-wrap:break-word }
.line-height-1{ line-height: 1 }
.line-height-2{ line-height: 1.125 }
.line-height-3{ line-height: 1.25 }
.line-height-4{ line-height: 1.5 }
.list-style-none{ list-style:none }
.underline{ text-decoration:underline }
.truncate{
max-width:100%;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.list-reset{
list-style:none;
padding-left:0;
}
.inline{ display:inline }
.block{ display:block }
.inline-block{ display:inline-block }
.table{ display:table }
.table-cell{ display:table-cell }
.overflow-hidden{ overflow:hidden }
.overflow-scroll{ overflow:scroll }
.overflow-auto{ overflow:auto }
.clearfix:before,
.clearfix:after{
content:" ";
display:table
}
.clearfix:after{ clear:both }
.left{ float:left }
.right{ float:right }
.fit{ max-width:100% }
.max-width-1{ max-width: 24rem }
.max-width-2{ max-width: 32rem }
.max-width-3{ max-width: 48rem }
.max-width-4{ max-width: 64rem }
.border-box{ box-sizing:border-box }
.align-baseline{ vertical-align:baseline }
.align-top{ vertical-align:top }
.align-middle{ vertical-align:middle }
.align-bottom{ vertical-align:bottom }
.m0{ margin:0 }
.mt0{ margin-top:0 }
.mr0{ margin-right:0 }
.mb0{ margin-bottom:0 }
.ml0{ margin-left:0 }
.mx0{ margin-left:0; margin-right:0 }
.my0{ margin-top:0; margin-bottom:0 }
.m1{ margin: .5rem }
.mt1{ margin-top: .5rem }
.mr1{ margin-right: .5rem }
.mb1{ margin-bottom: .5rem }
.ml1{ margin-left: .5rem }
.mx1{ margin-left: .5rem; margin-right: .5rem }
.my1{ margin-top: .5rem; margin-bottom: .5rem }
.m2{ margin: 1rem }
.mt2{ margin-top: 1rem }
.mr2{ margin-right: 1rem }
.mb2{ margin-bottom: 1rem }
.ml2{ margin-left: 1rem }
.mx2{ margin-left: 1rem; margin-right: 1rem }
.my2{ margin-top: 1rem; margin-bottom: 1rem }
.m3{ margin: 2rem }
.mt3{ margin-top: 2rem }
.mr3{ margin-right: 2rem }
.mb3{ margin-bottom: 2rem }
.ml3{ margin-left: 2rem }
.mx3{ margin-left: 2rem; margin-right: 2rem }
.my3{ margin-top: 2rem; margin-bottom: 2rem }
.m4{ margin: 4rem }
.mt4{ margin-top: 4rem }
.mr4{ margin-right: 4rem }
.mb4{ margin-bottom: 4rem }
.ml4{ margin-left: 4rem }
.mx4{ margin-left: 4rem; margin-right: 4rem }
.my4{ margin-top: 4rem; margin-bottom: 4rem }
.mxn1{ margin-left: -.5rem; margin-right: -.5rem; }
.mxn2{ margin-left: -1rem; margin-right: -1rem; }
.mxn3{ margin-left: -2rem; margin-right: -2rem; }
.mxn4{ margin-left: -4rem; margin-right: -4rem; }
.ml-auto{ margin-left:auto }
.mr-auto{ margin-right:auto }
.mx-auto{ margin-left:auto; margin-right:auto; }
.p0{ padding:0 }
.pt0{ padding-top:0 }
.pr0{ padding-right:0 }
.pb0{ padding-bottom:0 }
.pl0{ padding-left:0 }
.px0{ padding-left:0; padding-right:0 }
.py0{ padding-top:0; padding-bottom:0 }
.p1{ padding: .5rem }
.pt1{ padding-top: .5rem }
.pr1{ padding-right: .5rem }
.pb1{ padding-bottom: .5rem }
.pl1{ padding-left: .5rem }
.py1{ padding-top: .5rem; padding-bottom: .5rem }
.px1{ padding-left: .5rem; padding-right: .5rem }
.p2{ padding: 1rem }
.pt2{ padding-top: 1rem }
.pr2{ padding-right: 1rem }
.pb2{ padding-bottom: 1rem }
.pl2{ padding-left: 1rem }
.py2{ padding-top: 1rem; padding-bottom: 1rem }
.px2{ padding-left: 1rem; padding-right: 1rem }
.p3{ padding: 2rem }
.pt3{ padding-top: 2rem }
.pr3{ padding-right: 2rem }
.pb3{ padding-bottom: 2rem }
.pl3{ padding-left: 2rem }
.py3{ padding-top: 2rem; padding-bottom: 2rem }
.px3{ padding-left: 2rem; padding-right: 2rem }
.p4{ padding: 4rem }
.pt4{ padding-top: 4rem }
.pr4{ padding-right: 4rem }
.pb4{ padding-bottom: 4rem }
.pl4{ padding-left: 4rem }
.py4{ padding-top: 4rem; padding-bottom: 4rem }
.px4{ padding-left: 4rem; padding-right: 4rem }
.col{
float:left;
box-sizing:border-box;
}
.col-right{
float:right;
box-sizing:border-box;
}
.col-1{
width:8.33333%;
}
.col-2{
width:16.66667%;
}
.col-3{
width:25%;
}
.col-4{
width:33.33333%;
}
.col-5{
width:41.66667%;
}
.col-6{
width:50%;
}
.col-7{
width:58.33333%;
}
.col-8{
width:66.66667%;
}
.col-9{
width:75%;
}
.col-10{
width:83.33333%;
}
.col-11{
width:91.66667%;
}
.col-12{
width:100%;
}
@media (min-width: 40em){
.sm-col{
float:left;
box-sizing:border-box;
}
.sm-col-right{
float:right;
box-sizing:border-box;
}
.sm-col-1{
width:8.33333%;
}
.sm-col-2{
width:16.66667%;
}
.sm-col-3{
width:25%;
}
.sm-col-4{
width:33.33333%;
}
.sm-col-5{
width:41.66667%;
}
.sm-col-6{
width:50%;
}
.sm-col-7{
width:58.33333%;
}
.sm-col-8{
width:66.66667%;
}
.sm-col-9{
width:75%;
}
.sm-col-10{
width:83.33333%;
}
.sm-col-11{
width:91.66667%;
}
.sm-col-12{
width:100%;
}
}
@media (min-width: 52em){
.md-col{
float:left;
box-sizing:border-box;
}
.md-col-right{
float:right;
box-sizing:border-box;
}
.md-col-1{
width:8.33333%;
}
.md-col-2{
width:16.66667%;
}
.md-col-3{
width:25%;
}
.md-col-4{
width:33.33333%;
}
.md-col-5{
width:41.66667%;
}
.md-col-6{
width:50%;
}
.md-col-7{
width:58.33333%;
}
.md-col-8{
width:66.66667%;
}
.md-col-9{
width:75%;
}
.md-col-10{
width:83.33333%;
}
.md-col-11{
width:91.66667%;
}
.md-col-12{
width:100%;
}
}
@media (min-width: 64em){
.lg-col{
float:left;
box-sizing:border-box;
}
.lg-col-right{
float:right;
box-sizing:border-box;
}
.lg-col-1{
width:8.33333%;
}
.lg-col-2{
width:16.66667%;
}
.lg-col-3{
width:25%;
}
.lg-col-4{
width:33.33333%;
}
.lg-col-5{
width:41.66667%;
}
.lg-col-6{
width:50%;
}
.lg-col-7{
width:58.33333%;
}
.lg-col-8{
width:66.66667%;
}
.lg-col-9{
width:75%;
}
.lg-col-10{
width:83.33333%;
}
.lg-col-11{
width:91.66667%;
}
.lg-col-12{
width:100%;
}
}
.flex{ display:-webkit-box; display:-webkit-flex; display:-ms-flexbox; display:flex }
@media (min-width: 40em){
.sm-flex{ display:-webkit-box; display:-webkit-flex; display:-ms-flexbox; display:flex }
}
@media (min-width: 52em){
.md-flex{ display:-webkit-box; display:-webkit-flex; display:-ms-flexbox; display:flex }
}
@media (min-width: 64em){
.lg-flex{ display:-webkit-box; display:-webkit-flex; display:-ms-flexbox; display:flex }
}
.flex-column{ -webkit-box-orient:vertical; -webkit-box-direction:normal; -webkit-flex-direction:column; -ms-flex-direction:column; flex-direction:column }
.flex-wrap{ -webkit-flex-wrap:wrap; -ms-flex-wrap:wrap; flex-wrap:wrap }
.items-start{ -webkit-box-align:start; -webkit-align-items:flex-start; -ms-flex-align:start; -ms-grid-row-align:flex-start; align-items:flex-start }
.items-end{ -webkit-box-align:end; -webkit-align-items:flex-end; -ms-flex-align:end; -ms-grid-row-align:flex-end; align-items:flex-end }
.items-center{ -webkit-box-align:center; -webkit-align-items:center; -ms-flex-align:center; -ms-grid-row-align:center; align-items:center }
.items-baseline{ -webkit-box-align:baseline; -webkit-align-items:baseline; -ms-flex-align:baseline; -ms-grid-row-align:baseline; align-items:baseline }
.items-stretch{ -webkit-box-align:stretch; -webkit-align-items:stretch; -ms-flex-align:stretch; -ms-grid-row-align:stretch; align-items:stretch }
.self-start{ -webkit-align-self:flex-start; -ms-flex-item-align:start; align-self:flex-start }
.self-end{ -webkit-align-self:flex-end; -ms-flex-item-align:end; align-self:flex-end }
.self-center{ -webkit-align-self:center; -ms-flex-item-align:center; align-self:center }
.self-baseline{ -webkit-align-self:baseline; -ms-flex-item-align:baseline; align-self:baseline }
.self-stretch{ -webkit-align-self:stretch; -ms-flex-item-align:stretch; align-self:stretch }
.justify-start{ -webkit-box-pack:start; -webkit-justify-content:flex-start; -ms-flex-pack:start; justify-content:flex-start }
.justify-end{ -webkit-box-pack:end; -webkit-justify-content:flex-end; -ms-flex-pack:end; justify-content:flex-end }
.justify-center{ -webkit-box-pack:center; -webkit-justify-content:center; -ms-flex-pack:center; justify-content:center }
.justify-between{ -webkit-box-pack:justify; -webkit-justify-content:space-between; -ms-flex-pack:justify; justify-content:space-between }
.justify-around{ -webkit-justify-content:space-around; -ms-flex-pack:distribute; justify-content:space-around }
.content-start{ -webkit-align-content:flex-start; -ms-flex-line-pack:start; align-content:flex-start }
.content-end{ -webkit-align-content:flex-end; -ms-flex-line-pack:end; align-content:flex-end }
.content-center{ -webkit-align-content:center; -ms-flex-line-pack:center; align-content:center }
.content-between{ -webkit-align-content:space-between; -ms-flex-line-pack:justify; align-content:space-between }
.content-around{ -webkit-align-content:space-around; -ms-flex-line-pack:distribute; align-content:space-around }
.content-stretch{ -webkit-align-content:stretch; -ms-flex-line-pack:stretch; align-content:stretch }
.flex-auto{
-webkit-box-flex:1;
-webkit-flex:1 1 auto;
-ms-flex:1 1 auto;
flex:1 1 auto;
min-width:0;
min-height:0;
}
.flex-none{ -webkit-box-flex:0; -webkit-flex:none; -ms-flex:none; flex:none }
.fs0{ flex-shrink: 0 }
.order-0{ -webkit-box-ordinal-group:1; -webkit-order:0; -ms-flex-order:0; order:0 }
.order-1{ -webkit-box-ordinal-group:2; -webkit-order:1; -ms-flex-order:1; order:1 }
.order-2{ -webkit-box-ordinal-group:3; -webkit-order:2; -ms-flex-order:2; order:2 }
.order-3{ -webkit-box-ordinal-group:4; -webkit-order:3; -ms-flex-order:3; order:3 }
.order-last{ -webkit-box-ordinal-group:100000; -webkit-order:99999; -ms-flex-order:99999; order:99999 }
.relative{ position:relative }
.absolute{ position:absolute }
.fixed{ position:fixed }
.top-0{ top:0 }
.right-0{ right:0 }
.bottom-0{ bottom:0 }
.left-0{ left:0 }
.z1{ z-index: 1 }
.z2{ z-index: 2 }
.z3{ z-index: 3 }
.z4{ z-index: 4 }
.border{
border-style:solid;
border-width: 1px;
}
.border-top{
border-top-style:solid;
border-top-width: 1px;
}
.border-right{
border-right-style:solid;
border-right-width: 1px;
}
.border-bottom{
border-bottom-style:solid;
border-bottom-width: 1px;
}
.border-left{
border-left-style:solid;
border-left-width: 1px;
}
.border-none{ border:0 }
.rounded{ border-radius: 3px }
.circle{ border-radius:50% }
.rounded-top{ border-radius: 3px 3px 0 0 }
.rounded-right{ border-radius: 0 3px 3px 0 }
.rounded-bottom{ border-radius: 0 0 3px 3px }
.rounded-left{ border-radius: 3px 0 0 3px }
.not-rounded{ border-radius:0 }
.hide{
position:absolute !important;
height:1px;
width:1px;
overflow:hidden;
clip:rect(1px, 1px, 1px, 1px);
}
@media (max-width: 40em){
.xs-hide{ display:none !important }
}
@media (min-width: 40em) and (max-width: 52em){
.sm-hide{ display:none !important }
}
@media (min-width: 52em) and (max-width: 64em){
.md-hide{ display:none !important }
}
@media (min-width: 64em){
.lg-hide{ display:none !important }
}
.display-none{ display:none !important }

View File

@@ -1,93 +0,0 @@
Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,23 +0,0 @@
@font-face{
font-family: 'Source Code Pro';
font-weight: 400;
font-style: normal;
font-stretch: normal;
src: url('EOT/SourceCodePro-Regular.eot') format('embedded-opentype'),
url('WOFF2/TTF/SourceCodePro-Regular.ttf.woff2') format('woff2'),
url('WOFF/OTF/SourceCodePro-Regular.otf.woff') format('woff'),
url('OTF/SourceCodePro-Regular.otf') format('opentype'),
url('TTF/SourceCodePro-Regular.ttf') format('truetype');
}
@font-face{
font-family: 'Source Code Pro';
font-weight: 700;
font-style: normal;
font-stretch: normal;
src: url('EOT/SourceCodePro-Bold.eot') format('embedded-opentype'),
url('WOFF2/TTF/SourceCodePro-Bold.ttf.woff2') format('woff2'),
url('WOFF/OTF/SourceCodePro-Bold.otf.woff') format('woff'),
url('OTF/SourceCodePro-Bold.otf') format('opentype'),
url('TTF/SourceCodePro-Bold.ttf') format('truetype');
}

View File

@@ -1,123 +0,0 @@
/*
github.com style (c) Vasily Polovnyov <vast@whiteants.net>
*/
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
color: #333;
background: #f8f8f8;
-webkit-text-size-adjust: none;
}
.hljs-comment,
.diff .hljs-header,
.hljs-javadoc {
color: #998;
font-style: italic;
}
.hljs-keyword,
.css .rule .hljs-keyword,
.hljs-winutils,
.nginx .hljs-title,
.hljs-subst,
.hljs-request,
.hljs-status {
color: #1184CE;
}
.hljs-number,
.hljs-hexcolor,
.ruby .hljs-constant {
color: #ed225d;
}
.hljs-string,
.hljs-tag .hljs-value,
.hljs-phpdoc,
.hljs-dartdoc,
.tex .hljs-formula {
color: #ed225d;
}
.hljs-title,
.hljs-id,
.scss .hljs-preprocessor {
color: #900;
font-weight: bold;
}
.hljs-list .hljs-keyword,
.hljs-subst {
font-weight: normal;
}
.hljs-class .hljs-title,
.hljs-type,
.vhdl .hljs-literal,
.tex .hljs-command {
color: #458;
font-weight: bold;
}
.hljs-tag,
.hljs-tag .hljs-title,
.hljs-rules .hljs-property,
.django .hljs-tag .hljs-keyword {
color: #000080;
font-weight: normal;
}
.hljs-attribute,
.hljs-variable,
.lisp .hljs-body {
color: #008080;
}
.hljs-regexp {
color: #009926;
}
.hljs-symbol,
.ruby .hljs-symbol .hljs-string,
.lisp .hljs-keyword,
.clojure .hljs-keyword,
.scheme .hljs-keyword,
.tex .hljs-special,
.hljs-prompt {
color: #990073;
}
.hljs-built_in {
color: #0086b3;
}
.hljs-preprocessor,
.hljs-pragma,
.hljs-pi,
.hljs-doctype,
.hljs-shebang,
.hljs-cdata {
color: #999;
font-weight: bold;
}
.hljs-deletion {
background: #fdd;
}
.hljs-addition {
background: #dfd;
}
.diff .hljs-change {
background: #0086b3;
}
.hljs-chunk {
color: #aaa;
}

View File

@@ -1,168 +0,0 @@
/* global anchors */
// add anchor links to headers
anchors.options.placement = 'left';
anchors.add('h3');
// Filter UI
var tocElements = document.getElementById('toc').getElementsByTagName('li');
document.getElementById('filter-input').addEventListener('keyup', function(e) {
var i, element, children;
// enter key
if (e.keyCode === 13) {
// go to the first displayed item in the toc
for (i = 0; i < tocElements.length; i++) {
element = tocElements[i];
if (!element.classList.contains('display-none')) {
location.replace(element.firstChild.href);
return e.preventDefault();
}
}
}
var match = function() {
return true;
};
var value = this.value.toLowerCase();
if (!value.match(/^\s*$/)) {
match = function(element) {
var html = element.firstChild.innerHTML;
return html && html.toLowerCase().indexOf(value) !== -1;
};
}
for (i = 0; i < tocElements.length; i++) {
element = tocElements[i];
children = Array.from(element.getElementsByTagName('li'));
if (match(element) || children.some(match)) {
element.classList.remove('display-none');
} else {
element.classList.add('display-none');
}
}
});
var items = document.getElementsByClassName('toggle-sibling');
for (var j = 0; j < items.length; j++) {
items[j].addEventListener('click', toggleSibling);
}
function toggleSibling() {
var stepSibling = this.parentNode.getElementsByClassName('toggle-target')[0];
var icon = this.getElementsByClassName('icon')[0];
var klass = 'display-none';
if (stepSibling.classList.contains(klass)) {
stepSibling.classList.remove(klass);
icon.innerHTML = '▾';
} else {
stepSibling.classList.add(klass);
icon.innerHTML = '▸';
}
}
function showHashTarget(targetId) {
if (targetId) {
var hashTarget = document.getElementById(targetId);
// new target is hidden
if (
hashTarget &&
hashTarget.offsetHeight === 0 &&
hashTarget.parentNode.parentNode.classList.contains('display-none')
) {
hashTarget.parentNode.parentNode.classList.remove('display-none');
}
}
}
function scrollIntoView(targetId) {
// Only scroll to element if we don't have a stored scroll position.
if (targetId && !history.state) {
var hashTarget = document.getElementById(targetId);
if (hashTarget) {
hashTarget.scrollIntoView();
}
}
}
function gotoCurrentTarget() {
showHashTarget(location.hash.substring(1));
scrollIntoView(location.hash.substring(1));
}
window.addEventListener('hashchange', gotoCurrentTarget);
gotoCurrentTarget();
var toclinks = document.getElementsByClassName('pre-open');
for (var k = 0; k < toclinks.length; k++) {
toclinks[k].addEventListener('mousedown', preOpen, false);
}
function preOpen() {
showHashTarget(this.hash.substring(1));
}
var split_left = document.querySelector('#split-left');
var split_right = document.querySelector('#split-right');
var split_parent = split_left.parentNode;
var cw_with_sb = split_left.clientWidth;
split_left.style.overflow = 'hidden';
var cw_without_sb = split_left.clientWidth;
split_left.style.overflow = '';
Split(['#split-left', '#split-right'], {
elementStyle: function(dimension, size, gutterSize) {
return {
'flex-basis': 'calc(' + size + '% - ' + gutterSize + 'px)'
};
},
gutterStyle: function(dimension, gutterSize) {
return {
'flex-basis': gutterSize + 'px'
};
},
gutterSize: 20,
sizes: [33, 67]
});
// Chrome doesn't remember scroll position properly so do it ourselves.
// Also works on Firefox and Edge.
function updateState() {
history.replaceState(
{
left_top: split_left.scrollTop,
right_top: split_right.scrollTop
},
document.title
);
}
function loadState(ev) {
if (ev) {
// Edge doesn't replace change history.state on popstate.
history.replaceState(ev.state, document.title);
}
if (history.state) {
split_left.scrollTop = history.state.left_top;
split_right.scrollTop = history.state.right_top;
}
}
window.addEventListener('load', function() {
// Restore after Firefox scrolls to hash.
setTimeout(function() {
loadState();
// Update with initial scroll position.
updateState();
// Update scroll positions only after we've loaded because Firefox
// emits an initial scroll event with 0.
split_left.addEventListener('scroll', updateState);
split_right.addEventListener('scroll', updateState);
}, 1);
});
window.addEventListener('popstate', loadState);

View File

@@ -1,15 +0,0 @@
.gutter {
background-color: #f5f5f5;
background-repeat: no-repeat;
background-position: 50%;
}
.gutter.gutter-vertical {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII=');
cursor: ns-resize;
}
.gutter.gutter-horizontal {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==');
cursor: ew-resize;
}

View File

@@ -1,586 +0,0 @@
/*! Split.js - v1.3.5 */
// https://github.com/nathancahill/Split.js
// Copyright (c) 2017 Nathan Cahill; Licensed MIT
(function(global, factory) {
typeof exports === 'object' && typeof module !== 'undefined'
? (module.exports = factory())
: typeof define === 'function' && define.amd
? define(factory)
: (global.Split = factory());
})(this, function() {
'use strict';
// The programming goals of Split.js are to deliver readable, understandable and
// maintainable code, while at the same time manually optimizing for tiny minified file size,
// browser compatibility without additional requirements, graceful fallback (IE8 is supported)
// and very few assumptions about the user's page layout.
var global = window;
var document = global.document;
// Save a couple long function names that are used frequently.
// This optimization saves around 400 bytes.
var addEventListener = 'addEventListener';
var removeEventListener = 'removeEventListener';
var getBoundingClientRect = 'getBoundingClientRect';
var NOOP = function() {
return false;
};
// Figure out if we're in IE8 or not. IE8 will still render correctly,
// but will be static instead of draggable.
var isIE8 = global.attachEvent && !global[addEventListener];
// This library only needs two helper functions:
//
// The first determines which prefixes of CSS calc we need.
// We only need to do this once on startup, when this anonymous function is called.
//
// Tests -webkit, -moz and -o prefixes. Modified from StackOverflow:
// http://stackoverflow.com/questions/16625140/js-feature-detection-to-detect-the-usage-of-webkit-calc-over-calc/16625167#16625167
var calc =
['', '-webkit-', '-moz-', '-o-']
.filter(function(prefix) {
var el = document.createElement('div');
el.style.cssText = 'width:' + prefix + 'calc(9px)';
return !!el.style.length;
})
.shift() + 'calc';
// The second helper function allows elements and string selectors to be used
// interchangeably. In either case an element is returned. This allows us to
// do `Split([elem1, elem2])` as well as `Split(['#id1', '#id2'])`.
var elementOrSelector = function(el) {
if (typeof el === 'string' || el instanceof String) {
return document.querySelector(el);
}
return el;
};
// The main function to initialize a split. Split.js thinks about each pair
// of elements as an independant pair. Dragging the gutter between two elements
// only changes the dimensions of elements in that pair. This is key to understanding
// how the following functions operate, since each function is bound to a pair.
//
// A pair object is shaped like this:
//
// {
// a: DOM element,
// b: DOM element,
// aMin: Number,
// bMin: Number,
// dragging: Boolean,
// parent: DOM element,
// isFirst: Boolean,
// isLast: Boolean,
// direction: 'horizontal' | 'vertical'
// }
//
// The basic sequence:
//
// 1. Set defaults to something sane. `options` doesn't have to be passed at all.
// 2. Initialize a bunch of strings based on the direction we're splitting.
// A lot of the behavior in the rest of the library is paramatized down to
// rely on CSS strings and classes.
// 3. Define the dragging helper functions, and a few helpers to go with them.
// 4. Loop through the elements while pairing them off. Every pair gets an
// `pair` object, a gutter, and special isFirst/isLast properties.
// 5. Actually size the pair elements, insert gutters and attach event listeners.
var Split = function(ids, options) {
if (options === void 0) options = {};
var dimension;
var clientDimension;
var clientAxis;
var position;
var paddingA;
var paddingB;
var elements;
// All DOM elements in the split should have a common parent. We can grab
// the first elements parent and hope users read the docs because the
// behavior will be whacky otherwise.
var parent = elementOrSelector(ids[0]).parentNode;
var parentFlexDirection = global.getComputedStyle(parent).flexDirection;
// Set default options.sizes to equal percentages of the parent element.
var sizes =
options.sizes ||
ids.map(function() {
return 100 / ids.length;
});
// Standardize minSize to an array if it isn't already. This allows minSize
// to be passed as a number.
var minSize = options.minSize !== undefined ? options.minSize : 100;
var minSizes = Array.isArray(minSize)
? minSize
: ids.map(function() {
return minSize;
});
var gutterSize = options.gutterSize !== undefined ? options.gutterSize : 10;
var snapOffset = options.snapOffset !== undefined ? options.snapOffset : 30;
var direction = options.direction || 'horizontal';
var cursor =
options.cursor ||
(direction === 'horizontal' ? 'ew-resize' : 'ns-resize');
var gutter =
options.gutter ||
function(i, gutterDirection) {
var gut = document.createElement('div');
gut.className = 'gutter gutter-' + gutterDirection;
return gut;
};
var elementStyle =
options.elementStyle ||
function(dim, size, gutSize) {
var style = {};
if (typeof size !== 'string' && !(size instanceof String)) {
if (!isIE8) {
style[dim] = calc + '(' + size + '% - ' + gutSize + 'px)';
} else {
style[dim] = size + '%';
}
} else {
style[dim] = size;
}
return style;
};
var gutterStyle =
options.gutterStyle ||
function(dim, gutSize) {
return (obj = {}), (obj[dim] = gutSize + 'px'), obj;
var obj;
};
// 2. Initialize a bunch of strings based on the direction we're splitting.
// A lot of the behavior in the rest of the library is paramatized down to
// rely on CSS strings and classes.
if (direction === 'horizontal') {
dimension = 'width';
clientDimension = 'clientWidth';
clientAxis = 'clientX';
position = 'left';
paddingA = 'paddingLeft';
paddingB = 'paddingRight';
} else if (direction === 'vertical') {
dimension = 'height';
clientDimension = 'clientHeight';
clientAxis = 'clientY';
position = 'top';
paddingA = 'paddingTop';
paddingB = 'paddingBottom';
}
// 3. Define the dragging helper functions, and a few helpers to go with them.
// Each helper is bound to a pair object that contains it's metadata. This
// also makes it easy to store references to listeners that that will be
// added and removed.
//
// Even though there are no other functions contained in them, aliasing
// this to self saves 50 bytes or so since it's used so frequently.
//
// The pair object saves metadata like dragging state, position and
// event listener references.
function setElementSize(el, size, gutSize) {
// Split.js allows setting sizes via numbers (ideally), or if you must,
// by string, like '300px'. This is less than ideal, because it breaks
// the fluid layout that `calc(% - px)` provides. You're on your own if you do that,
// make sure you calculate the gutter size by hand.
var style = elementStyle(dimension, size, gutSize);
// eslint-disable-next-line no-param-reassign
Object.keys(style).forEach(function(prop) {
return (el.style[prop] = style[prop]);
});
}
function setGutterSize(gutterElement, gutSize) {
var style = gutterStyle(dimension, gutSize);
// eslint-disable-next-line no-param-reassign
Object.keys(style).forEach(function(prop) {
return (gutterElement.style[prop] = style[prop]);
});
}
// Actually adjust the size of elements `a` and `b` to `offset` while dragging.
// calc is used to allow calc(percentage + gutterpx) on the whole split instance,
// which allows the viewport to be resized without additional logic.
// Element a's size is the same as offset. b's size is total size - a size.
// Both sizes are calculated from the initial parent percentage,
// then the gutter size is subtracted.
function adjust(offset) {
var a = elements[this.a];
var b = elements[this.b];
var percentage = a.size + b.size;
a.size = (offset / this.size) * percentage;
b.size = percentage - (offset / this.size) * percentage;
setElementSize(a.element, a.size, this.aGutterSize);
setElementSize(b.element, b.size, this.bGutterSize);
}
// drag, where all the magic happens. The logic is really quite simple:
//
// 1. Ignore if the pair is not dragging.
// 2. Get the offset of the event.
// 3. Snap offset to min if within snappable range (within min + snapOffset).
// 4. Actually adjust each element in the pair to offset.
//
// ---------------------------------------------------------------------
// | | <- a.minSize || b.minSize -> | |
// | | | <- this.snapOffset || this.snapOffset -> | | |
// | | | || | | |
// | | | || | | |
// ---------------------------------------------------------------------
// | <- this.start this.size -> |
function drag(e) {
var offset;
if (!this.dragging) {
return;
}
// Get the offset of the event from the first side of the
// pair `this.start`. Supports touch events, but not multitouch, so only the first
// finger `touches[0]` is counted.
if ('touches' in e) {
offset = e.touches[0][clientAxis] - this.start;
} else {
offset = e[clientAxis] - this.start;
}
// If within snapOffset of min or max, set offset to min or max.
// snapOffset buffers a.minSize and b.minSize, so logic is opposite for both.
// Include the appropriate gutter sizes to prevent overflows.
if (offset <= elements[this.a].minSize + snapOffset + this.aGutterSize) {
offset = elements[this.a].minSize + this.aGutterSize;
} else if (
offset >=
this.size - (elements[this.b].minSize + snapOffset + this.bGutterSize)
) {
offset = this.size - (elements[this.b].minSize + this.bGutterSize);
}
// Actually adjust the size.
adjust.call(this, offset);
// Call the drag callback continously. Don't do anything too intensive
// in this callback.
if (options.onDrag) {
options.onDrag();
}
}
// Cache some important sizes when drag starts, so we don't have to do that
// continously:
//
// `size`: The total size of the pair. First + second + first gutter + second gutter.
// `start`: The leading side of the first element.
//
// ------------------------------------------------
// | aGutterSize -> ||| |
// | ||| |
// | ||| |
// | ||| <- bGutterSize |
// ------------------------------------------------
// | <- start size -> |
function calculateSizes() {
// Figure out the parent size minus padding.
var a = elements[this.a].element;
var b = elements[this.b].element;
this.size =
a[getBoundingClientRect]()[dimension] +
b[getBoundingClientRect]()[dimension] +
this.aGutterSize +
this.bGutterSize;
this.start = a[getBoundingClientRect]()[position];
}
// stopDragging is very similar to startDragging in reverse.
function stopDragging() {
var self = this;
var a = elements[self.a].element;
var b = elements[self.b].element;
if (self.dragging && options.onDragEnd) {
options.onDragEnd();
}
self.dragging = false;
// Remove the stored event listeners. This is why we store them.
global[removeEventListener]('mouseup', self.stop);
global[removeEventListener]('touchend', self.stop);
global[removeEventListener]('touchcancel', self.stop);
self.parent[removeEventListener]('mousemove', self.move);
self.parent[removeEventListener]('touchmove', self.move);
// Delete them once they are removed. I think this makes a difference
// in memory usage with a lot of splits on one page. But I don't know for sure.
delete self.stop;
delete self.move;
a[removeEventListener]('selectstart', NOOP);
a[removeEventListener]('dragstart', NOOP);
b[removeEventListener]('selectstart', NOOP);
b[removeEventListener]('dragstart', NOOP);
a.style.userSelect = '';
a.style.webkitUserSelect = '';
a.style.MozUserSelect = '';
a.style.pointerEvents = '';
b.style.userSelect = '';
b.style.webkitUserSelect = '';
b.style.MozUserSelect = '';
b.style.pointerEvents = '';
self.gutter.style.cursor = '';
self.parent.style.cursor = '';
}
// startDragging calls `calculateSizes` to store the inital size in the pair object.
// It also adds event listeners for mouse/touch events,
// and prevents selection while dragging so avoid the selecting text.
function startDragging(e) {
// Alias frequently used variables to save space. 200 bytes.
var self = this;
var a = elements[self.a].element;
var b = elements[self.b].element;
// Call the onDragStart callback.
if (!self.dragging && options.onDragStart) {
options.onDragStart();
}
// Don't actually drag the element. We emulate that in the drag function.
e.preventDefault();
// Set the dragging property of the pair object.
self.dragging = true;
// Create two event listeners bound to the same pair object and store
// them in the pair object.
self.move = drag.bind(self);
self.stop = stopDragging.bind(self);
// All the binding. `window` gets the stop events in case we drag out of the elements.
global[addEventListener]('mouseup', self.stop);
global[addEventListener]('touchend', self.stop);
global[addEventListener]('touchcancel', self.stop);
self.parent[addEventListener]('mousemove', self.move);
self.parent[addEventListener]('touchmove', self.move);
// Disable selection. Disable!
a[addEventListener]('selectstart', NOOP);
a[addEventListener]('dragstart', NOOP);
b[addEventListener]('selectstart', NOOP);
b[addEventListener]('dragstart', NOOP);
a.style.userSelect = 'none';
a.style.webkitUserSelect = 'none';
a.style.MozUserSelect = 'none';
a.style.pointerEvents = 'none';
b.style.userSelect = 'none';
b.style.webkitUserSelect = 'none';
b.style.MozUserSelect = 'none';
b.style.pointerEvents = 'none';
// Set the cursor, both on the gutter and the parent element.
// Doing only a, b and gutter causes flickering.
self.gutter.style.cursor = cursor;
self.parent.style.cursor = cursor;
// Cache the initial sizes of the pair.
calculateSizes.call(self);
}
// 5. Create pair and element objects. Each pair has an index reference to
// elements `a` and `b` of the pair (first and second elements).
// Loop through the elements while pairing them off. Every pair gets a
// `pair` object, a gutter, and isFirst/isLast properties.
//
// Basic logic:
//
// - Starting with the second element `i > 0`, create `pair` objects with
// `a = i - 1` and `b = i`
// - Set gutter sizes based on the _pair_ being first/last. The first and last
// pair have gutterSize / 2, since they only have one half gutter, and not two.
// - Create gutter elements and add event listeners.
// - Set the size of the elements, minus the gutter sizes.
//
// -----------------------------------------------------------------------
// | i=0 | i=1 | i=2 | i=3 |
// | | isFirst | | isLast |
// | pair 0 pair 1 pair 2 |
// | | | | |
// -----------------------------------------------------------------------
var pairs = [];
elements = ids.map(function(id, i) {
// Create the element object.
var element = {
element: elementOrSelector(id),
size: sizes[i],
minSize: minSizes[i]
};
var pair;
if (i > 0) {
// Create the pair object with it's metadata.
pair = {
a: i - 1,
b: i,
dragging: false,
isFirst: i === 1,
isLast: i === ids.length - 1,
direction: direction,
parent: parent
};
// For first and last pairs, first and last gutter width is half.
pair.aGutterSize = gutterSize;
pair.bGutterSize = gutterSize;
if (pair.isFirst) {
pair.aGutterSize = gutterSize / 2;
}
if (pair.isLast) {
pair.bGutterSize = gutterSize / 2;
}
// if the parent has a reverse flex-direction, switch the pair elements.
if (
parentFlexDirection === 'row-reverse' ||
parentFlexDirection === 'column-reverse'
) {
var temp = pair.a;
pair.a = pair.b;
pair.b = temp;
}
}
// Determine the size of the current element. IE8 is supported by
// staticly assigning sizes without draggable gutters. Assigns a string
// to `size`.
//
// IE9 and above
if (!isIE8) {
// Create gutter elements for each pair.
if (i > 0) {
var gutterElement = gutter(i, direction);
setGutterSize(gutterElement, gutterSize);
gutterElement[addEventListener](
'mousedown',
startDragging.bind(pair)
);
gutterElement[addEventListener](
'touchstart',
startDragging.bind(pair)
);
parent.insertBefore(gutterElement, element.element);
pair.gutter = gutterElement;
}
}
// Set the element size to our determined size.
// Half-size gutters for first and last elements.
if (i === 0 || i === ids.length - 1) {
setElementSize(element.element, element.size, gutterSize / 2);
} else {
setElementSize(element.element, element.size, gutterSize);
}
var computedSize = element.element[getBoundingClientRect]()[dimension];
if (computedSize < element.minSize) {
element.minSize = computedSize;
}
// After the first iteration, and we have a pair object, append it to the
// list of pairs.
if (i > 0) {
pairs.push(pair);
}
return element;
});
function setSizes(newSizes) {
newSizes.forEach(function(newSize, i) {
if (i > 0) {
var pair = pairs[i - 1];
var a = elements[pair.a];
var b = elements[pair.b];
a.size = newSizes[i - 1];
b.size = newSize;
setElementSize(a.element, a.size, pair.aGutterSize);
setElementSize(b.element, b.size, pair.bGutterSize);
}
});
}
function destroy() {
pairs.forEach(function(pair) {
pair.parent.removeChild(pair.gutter);
elements[pair.a].element.style[dimension] = '';
elements[pair.b].element.style[dimension] = '';
});
}
if (isIE8) {
return {
setSizes: setSizes,
destroy: destroy
};
}
return {
setSizes: setSizes,
getSizes: function getSizes() {
return elements.map(function(element) {
return element.size;
});
},
collapse: function collapse(i) {
if (i === pairs.length) {
var pair = pairs[i - 1];
calculateSizes.call(pair);
if (!isIE8) {
adjust.call(pair, pair.size - pair.bGutterSize);
}
} else {
var pair$1 = pairs[i];
calculateSizes.call(pair$1);
if (!isIE8) {
adjust.call(pair$1, pair$1.aGutterSize);
}
}
},
destroy: destroy
};
};
return Split;
});

View File

@@ -1,140 +0,0 @@
.documentation {
font-family: Helvetica, sans-serif;
color: #666;
line-height: 1.5;
background: #f5f5f5;
}
.black {
color: #666;
}
.bg-white {
background-color: #fff;
}
h4 {
margin: 20px 0 10px 0;
}
.documentation h3 {
color: #000;
}
.border-bottom {
border-color: #ddd;
}
a {
color: #1184CE;
text-decoration: none;
}
.documentation a[href]:hover {
text-decoration: underline;
}
a:hover {
cursor: pointer;
}
.py1-ul li {
padding: 5px 0;
}
.max-height-100 {
max-height: 100%;
}
.height-viewport-100 {
height: 100vh;
}
section:target h3 {
font-weight:700;
}
.documentation td,
.documentation th {
padding: .25rem .25rem;
}
h1:hover .anchorjs-link,
h2:hover .anchorjs-link,
h3:hover .anchorjs-link,
h4:hover .anchorjs-link {
opacity: 1;
}
.fix-3 {
width: 25%;
max-width: 244px;
}
.fix-3 {
width: 25%;
max-width: 244px;
}
@media (min-width: 52em) {
.fix-margin-3 {
margin-left: 25%;
}
}
.pre, pre, code, .code {
font-family: Source Code Pro,Menlo,Consolas,Liberation Mono,monospace;
font-size: 14px;
}
.fill-light {
background: #F9F9F9;
}
.width2 {
width: 1rem;
}
.input {
font-family: inherit;
display: block;
width: 100%;
height: 2rem;
padding: .5rem;
margin-bottom: 1rem;
border: 1px solid #ccc;
font-size: .875rem;
border-radius: 3px;
box-sizing: border-box;
}
table {
border-collapse: collapse;
}
.prose table th,
.prose table td {
text-align: left;
padding:8px;
border:1px solid #ddd;
}
.prose table th:nth-child(1) { border-right: none; }
.prose table th:nth-child(2) { border-left: none; }
.prose table {
border:1px solid #ddd;
}
.prose-big {
font-size: 18px;
line-height: 30px;
}
.quiet {
opacity: 0.7;
}
.minishadow {
box-shadow: 2px 2px 10px #f3f3f3;
}

View File

@@ -1,770 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset='utf-8' />
<title>seasoned-request 1.0.0 | Documentation</title>
<meta name='description' content='seasoned request app'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<link href='assets/bass.css' rel='stylesheet' />
<link href='assets/style.css' rel='stylesheet' />
<link href='assets/github.css' rel='stylesheet' />
<link href='assets/split.css' rel='stylesheet' />
</head>
<body class='documentation m0'>
<div class='flex'>
<div id='split-left' class='overflow-auto fs0 height-viewport-100'>
<div class='py1 px2'>
<h3 class='mb0 no-anchor'>seasoned-request</h3>
<div class='mb1'><code>1.0.0</code></div>
<input
placeholder='Filter'
id='filter-input'
class='col12 block input'
type='text' />
<div id='toc'>
<ul class='list-reset h5 py1-ul'>
<li><a
href='#getmovie'
class="">
getMovie
</a>
</li>
<li><a
href='#getshow'
class="">
getShow
</a>
</li>
<li><a
href='#gettmdblistbypath'
class="">
getTmdbListByPath
</a>
</li>
<li><a
href='#searchtmdb'
class="">
searchTmdb
</a>
</li>
<li><a
href='#searchtorrents'
class="">
searchTorrents
</a>
</li>
<li><a
href='#addmagnet'
class="">
addMagnet
</a>
</li>
<li><a
href='#request'
class="">
request
</a>
</li>
<li><a
href='#elasticsearchmoviesandshows'
class="">
elasticSearchMoviesAndShows
</a>
</li>
</ul>
</div>
<div class='mt1 h6 quiet'>
<a href='https://documentation.js.org/reading-documentation.html'>Need help reading this?</a>
</div>
</div>
</div>
<div id='split-right' class='relative overflow-auto height-viewport-100'>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='getmovie'>
getMovie
</h3>
</div>
<p>Fetches tmdb movie by id. Can optionally include cast credits in result object.</p>
<div class='pre p1 fill-light mt0'>getMovie</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>id</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number">number</a>)</code>
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>credits</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean">boolean</a>
= <code>false</code>)</code>
Include credits
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Tmdb response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='getshow'>
getShow
</h3>
</div>
<p>Fetches tmdb show by id. Can optionally include cast credits in result object.</p>
<div class='pre p1 fill-light mt0'>getShow</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>id</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number">number</a>)</code>
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>credits</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean">boolean</a>
= <code>false</code>)</code>
Include credits
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Tmdb response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='gettmdblistbypath'>
getTmdbListByPath
</h3>
</div>
<p>Fetches tmdb list by path.</p>
<div class='pre p1 fill-light mt0'>getTmdbListByPath</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>listPath</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>)</code>
Path of list
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>page</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number">number</a>
= <code>1</code>)</code>
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Tmdb list response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='searchtmdb'>
searchTmdb
</h3>
</div>
<p>Fetches tmdb movies and shows by query.</p>
<div class='pre p1 fill-light mt0'>searchTmdb</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>query</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>)</code>
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>page</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number">number</a>
= <code>1</code>)</code>
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Tmdb response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='searchtorrents'>
searchTorrents
</h3>
</div>
<p>Search for torrents by query</p>
<div class='pre p1 fill-light mt0'>searchTorrents</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>query</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>)</code>
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>authorization_token</span> <code class='quiet'>(any)</code>
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>credits</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean">boolean</a>)</code>
Include credits
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Torrent response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='addmagnet'>
addMagnet
</h3>
</div>
<p>Add magnet to download queue.</p>
<div class='pre p1 fill-light mt0'>addMagnet</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>magnet</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>)</code>
Magnet link
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>name</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean">boolean</a>)</code>
Name of torrent
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>tmdb_id</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean">boolean</a>)</code>
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Success/Failure response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='request'>
request
</h3>
</div>
<p>Request a movie or show from id. If authorization token is included the user will be linked
to the requested item.</p>
<div class='pre p1 fill-light mt0'>request</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>id</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number">number</a>)</code>
Movie or show id
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>type</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>)</code>
Movie or show type
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>authorization_token</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>?
= <code>undefined</code>)</code>
To identify the requesting user
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Success/Failure response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='elasticsearchmoviesandshows'>
elasticSearchMoviesAndShows
</h3>
</div>
<p>Search elastic indexes movies and shows by query. Doc includes Tmdb daily export of Movies and
Tv Shows. See tmdb docs for more info: <a href="https://developers.themoviedb.org/3/getting-started/daily-file-exports">https://developers.themoviedb.org/3/getting-started/daily-file-exports</a></p>
<div class='pre p1 fill-light mt0'>elasticSearchMoviesAndShows</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>query</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>)</code>
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
List of movies and shows matching query
</section>
</div>
</div>
<script src='assets/anchor.js'></script>
<script src='assets/split.js'></script>
<script src='assets/site.js'></script>
</body>
</html>

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,42 +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.15.3",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"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": "^0.25.0",
"documentation": "^11.0.0",
"file-loader": "^0.9.0",
"node-sass": "^4.5.0",
"sass-loader": "^5.0.1",
"vue-loader": "^10.0.0",
"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

@@ -0,0 +1,70 @@
#!/usr/bin/env node
/**
* Usage: node convert-svg-to-svelte.js [inputDir] [outputDir]
* Defaults: svgs src/icons
*/
import fs from "fs";
import path from "path";
const INPUT_DIR = process.argv[2] || "svgs";
const OUTPUT_DIR = process.argv[3] || "src/icons";
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
function processSvg(svgContent) {
// Strip XML/DOCTYPE
let out = svgContent
.replace(/<\?xml[\s\S]*?\?>\s*/i, "")
.replace(/<!DOCTYPE[\s\S]*?>\s*/i, "");
// Remove ALL comments
out = out.replace(/<!--[\s\S]*?-->\s*/g, "");
// Remove <g id="icomoon-ignore"></g> with any whitespace between tags
out = out.replace(/<g\s+id=(["'])icomoon-ignore\1\s*>\s*<\/g>\s*/gi, "");
// Ensure only width="100%" height="100%" on the <svg> tag
out = out.replace(/<svg\b[^>]*>/i, match => {
let tag = match
.replace(/\s+(width|height)\s*=\s*"[^"]*"/gi, "")
.replace(/\s+(width|height)\s*=\s*'[^']*'/gi, "");
return tag.replace(/>$/, ' width="100%" height="100%">');
});
// Prepend the single license comment
out =
"<!-- generated by icomoon.io - licensed Lindua icon -->\n" +
out.replace(/^\s+/, "");
// Wrap with <template> tags
out = "<template>\n" + out + "\n</template>";
return out;
}
function convertSvgs(inputDir = INPUT_DIR, outputDir = OUTPUT_DIR) {
if (!fs.existsSync(inputDir)) {
console.warn(`Input directory not found: ${inputDir}`);
return;
}
const files = fs
.readdirSync(inputDir)
.filter(f => f.toLowerCase().endsWith(".svg"));
files.forEach(file => {
const src = path.join(inputDir, file);
const baseName = file.replace(/\.svg$/i, "");
// Convert kebab-case to PascalCase (e.g., clipboard-text -> ClipboardText)
const pascalCase = baseName
.split("-")
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join("");
const destFileName = `Icon${pascalCase}.vue`;
const dest = path.join(outputDir, destFileName);
const svgContent = fs.readFileSync(src, "utf8");
const processed = processSvg(svgContent);
fs.writeFileSync(dest, processed, "utf8");
console.log(`Converted: ${file} -> ${path.basename(dest)}`);
});
}
convertSvgs();

63
scripts/icon-converter.js Normal file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* Usage: node convert-svg-to-svelte.js [inputDir] [outputDir]
* Defaults: ./svgs ./svelte
*/
import fs from "fs";
import path from "path";
const INPUT_DIR = process.argv[2] || "../svgs";
const OUTPUT_DIR = process.argv[3] || "../src/icons";
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
function processSvg(svgContent) {
// Strip XML/DOCTYPE
let out = svgContent
.replace(/<\?xml[\s\S]*?\?>\s*/i, "")
.replace(/<!DOCTYPE[\s\S]*?>\s*/i, "");
// Remove ALL comments
out = out.replace(/<!--[\s\S]*?-->\s*/g, "");
// Remove <g id="icomoon-ignore"></g> with any whitespace between tags
out = out.replace(/<g\s+id=(["'])icomoon-ignore\1\s*>\s*<\/g>\s*/gi, "");
// Ensure only width="100%" height="100%" on the <svg> tag
out = out.replace(/<svg\b[^>]*>/i, match => {
let tag = match
.replace(/\s+(width|height)\s*=\s*"[^"]*"/gi, "")
.replace(/\s+(width|height)\s*=\s*'[^']*'/gi, "");
return tag.replace(/>$/, ' width="100%" height="100%">');
});
// Prepend the single license comment
out =
"<!-- generated by icomoon.io - licensed Lindua icon -->\n" +
out.replace(/^\s+/, "");
// Wrap with <template> tags
out = "<template>\n" + out + "\n</template>";
return out;
}
function convertSvgs(inputDir = INPUT_DIR, outputDir = OUTPUT_DIR) {
if (!fs.existsSync(inputDir)) {
console.warn(`Input directory not found: ${inputDir}`);
return;
}
const files = fs
.readdirSync(inputDir)
.filter(f => f.toLowerCase().endsWith(".svg"));
files.forEach(file => {
const src = path.join(inputDir, file);
const dest = path.join(outputDir, file.replace(/\.svg$/i, ".vue"));
const svgContent = fs.readFileSync(src, "utf8");
const processed = processSvg(svgContent);
fs.writeFileSync(dest, processed, "utf8");
console.log(`Converted: ${file} -> ${path.basename(dest)}`);
});
}
convertSvgs();

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,194 +1,73 @@
<template>
<div id="app">
<div id="content">
<!-- Header and hamburger navigation -->
<navigation></navigation>
<NavigationHeader class="header" />
<!-- Header with search field -->
<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>
<div class="navigation-icons-gutter desktop-only">
<NavigationIcons />
</div>
<!-- Display the component assigned to the given route (default: home) -->
<router-view class="content"></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.vue'
import MoviePopup from '@/components/MoviePopup.vue'
import SearchInput from '@/components/SearchInput.vue'
<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
},
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);
padding-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";
*{
box-sizing: border-box;
}
html, body{
height: 100%;
}
body{
font-family: 'Roboto', sans-serif;
line-height: 1.6;
background: $c-light;
color: $c-dark;
&.hidden{
overflow: hidden;
}
}
input, textarea, button{
font-family: 'Roboto', sans-serif;
}
figure{
padding: 0;
margin: 0;
}
img{
display: block;
// max-width: 100%;
height: auto;
}
@import "scss/main";
@import "scss/media-queries";
.wrapper{
position: relative;
}
.header{
position: fixed;
background: $c-white;
z-index: 15;
display: flex;
flex-direction: column;
#content {
display: grid;
grid-template-rows: var(--header-size);
grid-template-columns: var(--header-size) 100%;
@include tablet-min{
width: calc(100% - 170px);
margin-left: 95px;
border-top: 0;
border-bottom: 0;
top: 0;
}
&__search{
display: flex;
position: relative;
z-index: 5;
width: 100%;
position: fixed;
top: 0;
right: 55px;
@include tablet-min{
position: relative;
height: 75px;
right: 0;
@include mobile {
grid-template-columns: 1fr;
}
&-input{
display: block;
.header {
position: fixed;
top: 0;
width: 100%;
padding: 15px 20px 15px 45px;
outline: none;
border: 0;
background-color: transparent;
color: $c-dark;
font-weight: 300;
font-size: 16px;
@include tablet-min{
padding: 15px 30px 15px 60px;
}
@include tablet-landscape-min{
padding: 15px 30px 15px 80px;
}
@include desktop-min{
padding: 15px 30px 15px 90px;
}
z-index: 15;
}
&-arrow {
height: 19px;
width: 30px;
display: flex;
align-self: center;
margin-right: 30px;
-moz-transition: all 0.5s ease;
-webkit-transition: all 0.5s ease;
transition: all 0.5s ease;
&.down {
-ms-transform: rotate(180deg);
-moz-transform: rotate(180deg);
-webkit-transform: rotate(180deg);
transform: rotate(180deg);
}
.navigation-icons-gutter {
position: fixed;
height: calc(100vh - var(--header-size));
margin: 0;
top: var(--header-size);
width: var(--header-size);
background-color: var(--background-color-secondary);
overflow-y: scroll;
}
&-input:focus + &-icon{
fill: $c-dark;
.content {
display: grid;
grid-column: 2 / 3;
width: calc(100% - var(--header-size));
grid-row: 2;
@include mobile {
grid-column: 1 / 3;
width: 100%;
}
}
}
}
// 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,255 +0,0 @@
import axios from 'axios'
import storage from '@/storage.js'
import config from '@/config.json'
import path from 'path'
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?
// - - - 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, credits=false) => {
const url = new URL('v2/movie', SEASONED_URL)
url.pathname = path.join(url.pathname, id.toString())
if (credits) {
url.searchParams.append('credits', true)
}
return axios.get(url.href)
.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, credits=false) => {
const url = new URL('v2/show', SEASONED_URL)
url.pathname = path.join(url.pathname, id.toString())
if (credits) {
url.searchParams.append('credits', true)
}
return axios.get(url.href)
.catch(error => { console.error(`api error getting show: ${id}`); throw error })
}
/**
* Fetches tmdb list by path.
* @param {string} listPath Path of list
* @param {number} [page=1]
* @returns {object} Tmdb list response
*/
const getTmdbListByPath = (listPath, page=1) => {
const url = new URL(listPath, SEASONED_URL)
url.searchParams.append('page', page)
// TODO - remove. this is temporary fix for user-requests endpoint (also import)
const headers = { authorization: storage.token }
return axios.get(url.href, { headers: headers })
.catch(error => { console.error(`api error getting list: ${listPath}, page: ${page}`); throw error })
}
/**
* Fetches tmdb movies and shows by query.
* @param {string} query
* @param {number} [page=1]
* @returns {object} Tmdb response
*/
const searchTmdb = (query, page=1) => {
const url = new URL('v2/search', SEASONED_URL)
url.searchParams.append('query', query)
url.searchParams.append('page', page)
return axios.get(url.href)
.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('v1/pirate/search', SEASONED_URL)
url.searchParams.append('query', query)
const headers = { authorization: storage.token }
return axios.get(url.href, { headers: headers })
.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 = {
magnet: magnet,
name: name,
tmdb_id: tmdb_id
}
const headers = { authorization: storage.token }
return axios.post(url.href, body, { headers: headers })
.catch(error => { console.error(`api error adding magnet: ${name}`); 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))
}
// - - - Authenticate with plex - - -
const plexAuthenticate = (username, password) => {
const url = new URL('https://plex.tv/users/sign_in.json')
url.searchParams.append('user[login]', username)
url.searchParams.append('user[password]', password)
const headers = {
'Content-Type': 'application/json',
'X-Plex-Platform': 'Linux',
'X-Plex-Version': 'v2.0.24',
'X-Plex-Platform-Version': '4.13.0-36-generic',
'X-Plex-Device-Name': 'Tautulli',
'X-Plex-Client-Identifier': '123'
}
return axios.post(url.href, { headers: headers })
.catch(error => { console.error(`api error authentication plex: ${username}`); throw error })
}
// - - - Random emoji - - -
const getEmoji = () => {
const url = path.join(SEASONED_URL, 'v1/emoji')
return axios.get(url)
.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, getTmdbListByPath, searchTmdb, searchTorrents, addMagnet, request, getRequestStatus, plexAuthenticate, getEmoji, elasticSearchMoviesAndShows }

599
src/api.ts Normal file
View File

@@ -0,0 +1,599 @@
/* 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
};
const getTmdbMovieDiscoverByName = async (
name: string,
page = 1
): Promise<IList> => {
const url = new URL(`/api/v2/movie/discover/${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,
getTmdbMovieDiscoverByName,
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,72 +0,0 @@
<template>
<section class="not-found">
<div class="not-found__content">
<h2 class="not-found__title">Page Not Found</h2>
</div>
</section>
</template>
<script>
import storage from '../storage.js'
export default {
created(){
document.title = 'Page Not Found' + storage.pageTitlePostfix;
}
}
</script>
<style lang="scss">
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.not-found{
width: 100%;
height: calc(100vh - 100px);
background: url('~assets/pulp-fiction.jpg') no-repeat 50% 50%;
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
@include tablet-min{
height: calc(100vh - 75px);
}
&:before{
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba($c-light, 0.7);
}
&-shortList{
width: 100%;
}
&__content{
width: 100%;
padding: 0 20px;
text-align: center;
@include tablet-min{
padding: 20px 0 0 0;
}
&-shortList {
width: 100%;
}
}
&__title{
font-size: 24px;
font-weight: 500;
color: $c-dark;
position: relative;
margin: 0;
@include tablet-min{
font-size: 28px;
}
}
&__button{
position: relative;
margin-top: 20px;
}
}
</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,28 +0,0 @@
<template>
<section>
<LandingBanner />
<movies-list v-for="item in homepageLists" :propList="item" :shortList="true"></movies-list>
</section>
</template>
<script>
import storage from '../storage.js'
import LandingBanner from '@/components/LandingBanner.vue'
import MoviesList from './MoviesList.vue'
export default {
name: 'home',
components: { LandingBanner, MoviesList },
data(){
return {
homepageLists: storage.homepageLists,
imageFile: 'dist/pulp-fiction.jpg'
}
},
created(){
document.title = 'TMDb';
storage.backTitle = document.title;
}
}
</script>

View File

@@ -1,112 +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 new movies or tv shows for plex</h1>
<strong class="subtitle">Made with Vue.js</strong>
<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: 'dist/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;
background-color: $c-dark;
@include tablet-min {
height: 284px;
}
&:before {
content: "";
position: absolute;
top: 0;
left: 0;
header {
width: 100%;
height: 100%;
background: rgba($c-light, 0.7);
}
.container {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.title {
font-weight: 500;
font-size: 22px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: $c-dark;
margin: 0;
@include tablet-min{
font-size: 28px;
}
}
transition: height 0.5s ease;
overflow: hidden;
--header-height: 25vh;
.subtitle {
display: block;
font-size: 14px;
font-weight: 300;
color: $c-dark;
margin: 5px 0;
@include tablet-min{
font-size: 16px;
}
}
height: var(--header-height);
> * {
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);
}
}
.link {
text-decoration: none;
color: $c-dark;
font-size: 13px;
font-weight: 300;
opacity: 0.7;
transition: opacity 0.5s ease;
&: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;
}
}
span {
display: inline-block;
vertical-align: middle;
}
&-icon {
display: inline-block;
vertical-align: middle;
margin-right: 2px;
width: 16px;
height: 15px;
fill: $c-dark;
.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;
}
}
}
}
</style>
</style>

View File

@@ -1,456 +0,0 @@
<template>
<section class="movie">
<!-- HEADER w/ POSTER -->
<header class="movie__header" :style="{ 'background-image': movie && backdrop !== null ? 'url(' + ASSET_URL + ASSET_SIZES[1] + backdrop + ')' : '' }">
<div class="movie__wrap movie__wrap--header">
<figure class="movie__poster">
<img v-if="movie && poster === null"
class="movies-item__img is-loaded"
alt="movie poster image"
src="~assets/no-image.png">
<img v-else-if="poster === undefined"
class="movies-item__img grey"
alt="movie poster image">
<!-- src="~assets/placeholder.png"> -->
<img v-else
class="movies-item__img is-loaded"
alt="movie poster image"
:src="ASSET_URL + ASSET_SIZES[0] + poster">
</figure>
<div class="movie__title">
<h1>{{ title }}</h1>
</div>
</div>
</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-action
:text="'Not yet in plex'" :iconRef="'#iconNot_exsits'"
:textActive="'Already in plex 🎉'" :iconRefActive="'#iconExists'"
:active="matched"></sidebar-action>
<sidebar-action
@click="sendRequest"
:text="'Request to be downloaded?'" :iconRef="'#iconSent'"
:textActive="'Requested to be downloaded'"
:active="requested"></sidebar-action>
<sidebar-action
v-if="admin" @click="showTorrents=!showTorrents"
:text="'Search for torrents'" :iconRef="'#icon_torrents'"
:active="showTorrents"></sidebar-action>
<sidebar-action
@click="openTmdb()"
:iconRef="'#icon_info'" :text="'See more info'"></sidebar-action>
</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">
<div class="movie__description" v-if="movie"> {{ movie.overview }}</div>
<!-- Loading placeholder -->
<div v-else class="movie__description">
<loading-placeholder :count="12" />
</div>
<div class="movie__details" v-if="movie">
<div v-if="movie.year" class="movie__details-block">
<h2 class="movie__details-title">Release Date</h2>
<div class="movie__details-text">{{ movie.year }}</div>
</div>
<div v-if="movie.rank" class="movie__details-block">
<h2 class="movie__details-title">Rating</h2>
<div class="movie__details-text">{{ movie.rank }}</div>
</div>
<div v-if="movie.type == 'show'" class="movie__details-block">
<h2 class="movie__details-title">Seasons</h2>
<div class="movie__details-text">{{ movie.seasons }}</div>
</div>
<div v-if="movie.genres" class="movie__details-block">
<h2 class="movie__details-title">Genres</h2>
<div class="movie__details-text">{{ nestedDataToString(movie.genres) }}</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.js'
import img from '@/directives/v-image.js'
import TorrentList from './TorrentList.vue'
import Person from './Person.vue'
import SidebarAction from './movie/SidebarAction.vue'
import LoadingPlaceholder from './ui/LoadingPlaceholder.vue'
import { getMovie, getShow, request, getRequestStatus } from '@/api.js'
export default {
props: ['id', 'type'],
components: { TorrentList, Person, LoadingPlaceholder, SidebarAction },
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'),
showTorrents: false
}
},
methods: {
parseResponse(resp) {
let movie = resp.data;
this.movie = { ...movie }
this.title = movie.title
this.poster = movie.poster
this.backdrop = movie.backdrop
this.matched = movie.existsInPlex
this.checkIfRequested(movie)
.then(status => this.requested = status)
document.title = movie.title + storage.pageTitlePostfix
},
async checkIfRequested(movie) {
return await getRequestStatus(movie.id, movie.type)
},
nestedDataToString(data) {
let nestedArray = []
data.forEach(item => nestedArray.push(item));
return nestedArray.join(', ');
},
sendRequest(){
request(this.id, this.type, storage.token)
.then(resp => {
if (resp.success) {
this.requested = true
}
})
},
openTmdb(){
const tmdbType = this.type === 'show' ? 'tv' : this.type
window.location.href = 'https://www.themoviedb.org/' + tmdbType + '/' + this.id
},
},
watch: {
id: function(val){
if (this.type === 'movie') {
this.fetchMovie(val);
} else {
this.fetchShow(val)
}
}
},
beforeDestroy() {
document.title = this.prevDocumentTitle
},
created(){
this.prevDocumentTitle = document.title
if (this.type === 'movie') {
getMovie(this.id)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: '404' });
})
} else {
getShow(this.id)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: '404' });
})
}
console.log('admin: ', this.admin)
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/loading-placeholder";
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.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;
}
}
}
&__header {
height: 250px;
position: relative;
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
background-color: $c-dark;
@include tablet-min {
height: 350px;
}
&:before {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
background: rgba($c-dark, 0.85);
}
}
&__poster {
display: none;
@include tablet-min {
background: $c-white;
height: 0;
display: block;
position: absolute;
width: calc(45% - 40px);
top: 40px;
left: 40px;
}
}
&__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: $c-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;
}
}
span {
display: block;
font-size: 14px;
font-weight: 300;
color: rgba($c-white, 0.7);
margin-top: 10px;
}
}
&__main {
background: $c-light;
min-height: calc(100vh - 250px);
@include tablet-min {
min-height: 0;
}
height: 100%;
}
&__actions {
text-align: center;
// min-height: 394px;
width: 100%;
order: 2;
padding: 20px;
border-top: 1px solid rgba($c-dark, 0.05);
@include tablet-min {
order: 1;
width: 45%;
padding: 185px 0 40px 40px;
border-top: 0;
}
&-link {
display: flex;
align-items: center;
text-decoration: none;
text-transform: uppercase;
color: rgba($c-dark, 0.5);
transition: color 0.5s ease;
font-size: 11px;
padding: 5px 0;
border-bottom: 1px solid rgba($c-dark, 0.05);
&:hover {
color: rgba($c-dark, 0.75);
}
&.active {
color: $c-dark;
}
&.pending {
color: #f8bd2d;
}
}
&-icon {
width: 18px;
height: 18px;
margin: 0 10px 0 0;
fill: rgba($c-dark, 0.5);
transition: fill 0.5s ease, transform 0.5s ease;
&.waiting {
transform: scale(0.8, 0.8);
}
&.pending {
fill: #f8bd2d;
}
}
&-link:hover &-icon {
fill: rgba($c-dark, 0.75);
cursor: pointer;
}
&-link.active &-icon {
fill: $c-green;
}
&-text {
display: block;
padding-top: 2px;
cursor: pointer;
margin:4.4px;
margin-left: -3px;
}
}
&__info {
width: 100%;
padding: 20px;
order: 1;
@include tablet-min {
order: 2;
padding: 40px;
width: 55%;
margin-left: 45%;
}
}
&__actions + &__info {
margin-left: 0;
}
&__description {
font-weight: 300;
font-size: 13px;
line-height: 1.8;
margin-bottom: 20px;
@include tablet-min {
margin-bottom: 30px;
font-size: 14px;
}
}
&__details {
&-block {
float: left;
}
&-block:not(:last-child) {
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: $c-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: $c-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.vue';
export default {
components: { Movie }
}
</script>

View File

@@ -1,87 +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.vue';
export default {
props: ['id', 'type'],
components: { Movie },
created(){
let that = this
window.addEventListener('keyup', function(e){
if (e.keyCode == 27) {
that.$popup.close()
}
})
}
}
</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($c-dark, 0.93);
-webkit-overflow-scrolling: touch;
overflow: auto;
&__box{
width: 100%;
max-width: 768px;
position: relative;
z-index: 5;
background: $c-dark;
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: $c-white;
}
&:before{
transform: rotate(45deg);
}
&:after{
transform: rotate(-45deg);
}
&:hover{
background: $c-green;
}
}
}
</style>

View File

@@ -1,328 +0,0 @@
<template>
<div>
<div class='movies-list' v-if="!error">
<header class='list-header'>
<h2 class='header__title'>{{ listTitle }}</h2>
<router-link class='header__view-more'
:to="'/list/' + list.route"
v-if='shortList'>
View All</router-link>
<div v-else style="line-height: 0;">
<span class='header__result-count' v-if="totalResults">{{ resultCount }} results</span>
<loading-placeholder v-else :count="1" lineClass='short nomargin'></loading-placeholder>
</div>
</header>
<!-- <ul class="filter">
<li class="filter-item" v-for="(item, index) in results" @click="applyFilter(item, index)" :class="{'active': item === selectedRelaseType}">{{ item.title }}</li>
</ul> -->
<ul class='results'>
<movies-list-item v-for='movie in results' :movie="movie" :shortList="shortList"></movies-list-item>
</ul>
<loader v-if="loader" />
<div class='end-section' v-if="!shortList">
<seasoned-button v-if="currentPage < totalPages" @click="loadMore">load more</seasoned-button>
</div>
</div>
<div v-else style="display: flex; height: 50vh; width: 100%; justify-content: center; align-items: center;">
<h1 v-if="error">{{ error }}</h1>
<h1 v-else>Unable to load list: {{ listTitle }}</h1>
</div>
</div>
</template>
<script>
import storage from '@/storage.js'
import MoviesListItem from '@/components/MoviesListItem.vue'
import SeasonedButton from '@/components/ui/SeasonedButton.vue'
import LoadingPlaceholder from '@/components/ui/LoadingPlaceholder.vue'
import Loader from '@/components/ui/Loader.vue'
import { searchTmdb, getTmdbListByPath } from '@/api.js'
export default {
props: {
shortList: {
type: Boolean,
default: false
},
propList: Object
},
components: { MoviesListItem, SeasonedButton, LoadingPlaceholder, Loader },
data() {
return {
listTitle: 'No listname found',
results: [],
currentPage: 1,
totalResults: 0,
totalPages: -1,
fetchingResults: false,
error: undefined,
loader: false,
filters: {
status: {
elms: ['all', 'requested', 'downloading', 'downloaded'],
selected: 0,
}
}
}
},
computed: {
resultCount() {
return this.totalResults.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ")
}
},
beforeMount() {
if (this.propList) {
this.list = this.propList
}
this.setPageFromUrlQuery()
this.parseURI()
},
mounted() {
setTimeout(() => {
if (this.results.length === 0 && this.error === undefined) {
this.loader = true
}
}, 200)
},
methods: {
setPageFromUrlQuery() {
if (this.$route.query.page)
this.currentPage = this.$route.query.page
console.log('url page param found', this.currentPage)
},
getListByName(name) {
return storage.homepageLists.filter(list => list.route === name)[0]
},
parseURI() {
const currentRouteName = this.$route.name
// route name is list - we are in a list view
if (currentRouteName === 'list') {
const nameParam = this.$route.params.name
if (this.getListByName(nameParam)) {
this.list = this.getListByName(nameParam)
this.listTitle = this.list.title
this.fetchListitems()
} else {
this.error = `Unable to load list: `
}
} // route name is search - we are searcing
else if (currentRouteName === 'search') {
if (this.$route.query.query) {
this.query = decodeURIComponent(this.$route.query.query)
this.listTitle = 'Search results: ' + this.query
this.fetchSearchItems()
} else {
this.error = 'Search query is not defined, please try again'
}
} // no matched route found - using prop to fetch list items
else {
this.listTitle = this.list.title
this.fetchListitems()
}
document.title = this.listTitle
},
// TODO these should receive a path not get it from list instance
fetchListitems() {
getTmdbListByPath(this.list.path, this.currentPage)
.then(this.parseResponse)
.catch(error => {
console.error(error)
this.error = 'Network error'
})
},
fetchSearchItems() {
searchTmdb(this.query, this.currentPage)
.then(this.parseResponse)
},
// TODO what parts are modular and what parts do we want the component to deal with
// if we pass in some object and then as we initialize we set to local variables.
// This way we call the http-api from outside and pass the response in to the component[0]
// Could also parse the response we are requesting then return a clean object we can
// pass down[1].
// [0] if this is done we should also take the page, total pages, total results and
// the list of results. Maybe also the title of the list or use local title as fallback?
// [1] an issue with this that duplicate code will be needed for doing the same with
// url params and paths.
// (What if we eliminated folder based routes and implemented the routes in hashes
// with single page applications today the navigation is simple enought that it
// would maybe not be needed to have a path-route but a hash-local.storage
// implementation; would allow sharing and remembering paths is just silly for most
// Single-Page-Applications that are tightly scoped applications)
parseResponse(response) {
const data = response.data
if (data.page > data.total_pages) {
console.error('You have reached the end')
this.error = 'You have reached the end'
return
}
if (this.results.length) {
this.results.push(...data.results)
} else {
this.results = this.shortList ? data.results.slice(0,12) : data.results
}
this.page = data.page
this.totalPages = data.total_pages
this.totalResults = data.total_results || data.results.length
this.loader = false
console.info(`Response from list: ${this.listTitle}`, { results: this.results, page: this.page, totalPages: this.totalPages, totalResults: this.totalResults })
},
loadMore(){
this.currentPage++;
console.log('path and name:', this.$route.path, this.$route.name)
let url = ''
if (this.$route.path.includes('list'))
url = `/#${this.$route.path}?page=${this.currentPage}`
else if (this.$route.path.includes('search'))
url = `/#/search?query=${this.query}&page=${this.currentPage}`
console.log('new url', url)
window.history.replaceState({}, 'foo', url)
this.parseURI()
},
// sort() {
// console.log(this.showFilters)
// },
// toggleFilter(item, index){
// this.showFilter = this.showFilter ? false : true;
// // this.results = this.results.filter(result => result.status != 'downloaded')
// },
// applyFilter(item, index) {
// this.filter = item;
// this.filters.status.selected = index;
// console.log('applied query filter: ', item, index)
// this.fetchCategory()
// }
},
watch: {
$route: function () {
console.log('updated route')
this.results = false
this.currentPage = 1
this.setPageFromUrlQuery()
this.parseURI()
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "./src/scss/elements";
.movies-list {
list-style: none;
display: flex;
flex-wrap: wrap;
padding: 15px;
.results {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
}
.list-header {
width: 100%;
display: flex;
flex-flow: row wrap;
align-items: center;
justify-content: space-between;
padding: 20px 10px;
@include tablet-min{
padding: 23px 15px;
}
@include tablet-landscape-min{
padding: 16px 25px;
}
@include desktop-min{
padding: 8px 30px;
}
.header__title {
line-height: 18px;
margin: 0;
font-size: 18px;
color: #081c24;
font-weight: 300;
// flex-basis: 50%;
text-transform: capitalize;
@include tablet-min{
font-size: 18px;
line-height: 18px;
}
}
.header__result-count {
font-size: 12px;
font-weight: 300;
letter-spacing: .5px;
color: rgba(8,28,36,.5);
text-align: right;
}
.header__view-more {
font-size: 13px;
font-weight: 300;
letter-spacing: .5px;
color: rgba($c-dark, 0.5);
text-decoration: none;
transition: color .5s ease;
cursor: pointer;
&:after{
content: " →";
}
&:hover{
color: $c-dark;
}
}
}
.end-section {
display: flex;
justify-content: center;
width: 100%;
margin: 1rem 0;
}
}
@import "./src/scss/media-queries";
.form__group-input {
padding: 10px 5px 10px 15px;
margin-left: 0;
height: 38px;
width: 150px;
font-size: 15px;
@include desktop-min {
width: 200px;
font-size: 17px;
}
}
</style>

View File

@@ -1,136 +0,0 @@
<template>
<li class="movies-item" :class="{'shortList': shortList}">
<a class="movies-item__link" :class="{'no-image': noImage}" @click.prevent="openMoviePopup(movie.id, movie.type)">
<figure class="movies-item__poster">
<img v-if="!noImage" class="movies-item__img" src="~assets/placeholder.png" v-img="poster()" alt="">
<img v-if="noImage" class="movies-item__img is-loaded" src="~assets/no-image.png" alt="">
</figure>
<div class="movies-item__content">
<p class="movies-item__title">{{ movie.title }}</p>
<p class="movies-item__title">{{ movie.year }}</p>
</div>
</a>
</li>
</template>
<script>
import img from '../directives/v-image.js'
export default {
props: ['movie', 'shortList'],
directives: {
img: img
},
data(){
return{
noImage: false
}
},
methods: {
// TODO handle missing images better and load diff sizes based on screen size
poster() {
if (this.movie.poster) {
return 'https://image.tmdb.org/t/p/w500' + this.movie.poster
} else {
this.noImage = true
}
},
openMoviePopup(id, type){
this.$popup.open(id, type)
}
}
}
</script>
<style lang="scss">
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.movies-item {
padding: 10px;
width: 50%;
@include tablet-min{
padding: 15px;
}
@include tablet-landscape-min{
padding: 20px;
width: 25%;
}
@include desktop-min{
padding: 30px;
width: 20%;
}
@include desktop-lg-min{
padding: 20px;
width: 16.5%;
}
&.shortList {
display: none;
&:nth-child(-n+6) { // show first 6
display: block;
}
@include tablet-landscape-min{
&:nth-child(-n+8) { // show first 8
display: block;
}
}
@include desktop-min{
&:nth-child(-n+10) { // show first 10
display: block;
}
}
@include desktop-lg-min{
display: block; // show all
}
}
&__link{
text-decoration: none;
color: rgba($c-dark, 0.5);
font-weight: 300;
}
&__content{
padding-top: 15px;
}
&__poster{
transition: transform 0.5s ease, box-shadow 0.3s ease;
transform: translateZ(0);
background: $c-white;
}
&__img{
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);
}
}
&__link:not(.no-image):hover &__poster{
transform: scale(1.03);
box-shadow: 0 0 10px rgba($c-dark, 0.1);
}
&__title{
margin: 0;
font-size: 11px;
letter-spacing: 0.5px;
transition: color 0.5s ease;
@include mobile-ls-min{
font-size: 12px;
}
@include tablet-min{
font-size: 14px;
}
}
&__link:hover &__title{
color: $c-dark;
}
}
</style>

View File

@@ -1,307 +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 class="bar"></div>
<div class="bar"></div>
<div 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">
<!-- <img :src="item.icon" class="nav__link-icon"> -->
<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.js'
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(){
eventHub.$on('setUserStatus', this.setUserStatus);
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.spacer {
@include mobile-only {
width: 100%;
height: $header-size-mobile;
}
}
.nav {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 50px;
background: $c-white;
z-index: 10;
display: block;
@include tablet-min{
width: 95px;
height: 100vh;
}
&__logo{
width: 55px;
height: $header-size-mobile;
display: flex;
align-items: center;
justify-content: center;
background: $c-dark;
@include tablet-min{
width: 95px;
height: $header-size;
}
&-image{
width: 35px;
height: 31px;
fill: $c-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;
background: $c-white;
z-index: 10;
border-left: 1px solid $c-light;
@include tablet-min{
display: none;
}
.bar{
position: absolute;
width: 23px;
height: 1px;
background: rgba($c-dark, 0.5);
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;
background: transparent;
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);
}
}
}
}
&__list{
list-style: none;
padding: 0;
margin: 0;
text-align: center;
width: 100%;
position: fixed;
left: 0;
top: 50px;
background: rgba($c-white, 0.98);
border-top: 1px solid $c-light;
@include mobile-only{
font-size: 0;
opacity: 0;
visibility: hidden;
height: calc(100vh - 50px);
transition: all 0.5s ease;
text-align: left;
&--active{
opacity: 1;
visibility: visible;
}
}
@include tablet-min{
display: flex;
background: transparent;
position: relative;
display: block;
width: 100%;
border-top: 0;
top: 0;
}
}
&__item{
@include mobile-only{
display: inline-block;
text-align: center;
width: 50%;
border-bottom: 1px solid $c-light;
&:nth-child(odd){
border-right: 1px solid $c-light;
}
}
@include tablet-min{
width: 100%;
border-bottom: 1px solid $c-light;
&--profile{
position: fixed;
right: 0;
top: 0;
width: $header-size;
height: $header-size;
border-bottom: 0;
border-left: 1px solid $c-light;
}
}
}
&__link{
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;
color: rgba($c-dark, 0.7);
transition: color 0.5s ease, background 0.5s ease;
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: $c-white;
}
}
&-icon{
width: 20px;
height: 20px;
fill: rgba($c-dark, 0.7);
transition: fill 0.5s ease;
@include tablet-min{
width: 20px;
height: 20px;
margin-bottom: 5px;
}
}
&-title{
margin-top: 5px;
display: block;
width: 100%;
}
&:hover{
color: $c-dark;
}
&:hover &-icon{
fill: $c-dark;
}
&.is-active{
color: $c-dark;
background: $c-light;
}
&.is-active &-icon{
fill: $c-dark;
}
}
}
</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,126 +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="showSettings = !showSettings">{{ showSettings ? 'hide settings' : 'show settings' }}</seasoned-button>
<seasoned-button @click="logOut">Log out</seasoned-button>
</div>
</header>
<settings v-if="showSettings"></settings>
<movies-list :propList="user_requestsList"></movies-list>
</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.js'
import MoviesList from '@/components/MoviesList.vue'
import Settings from '@/components/Settings.vue'
import SeasonedButton from '@/components/ui/SeasonedButton.vue'
import { getEmoji } from '@/api.js'
// import CreatedLists from './CreatedLists.vue'
export default {
components: { MoviesList, Settings, SeasonedButton },
data(){
return{
userLoggedIn: '',
userName: '',
emoji: '',
showSettings: false,
user_requestsList: storage.user_requestsList
}
},
methods: {
createSession(token){
axios.get(`https://api.themoviedb.org/3/authentication/session/new?api_key=${storage.apiKey}&request_token=${token}`)
.then(function(resp){
let data = resp.data;
if(data.success){
let id = data.session_id;
localStorage.setItem('session_id', id);
eventHub.$emit('setUserStatus');
this.userLoggedIn = true;
this.getUserInfo();
}
}.bind(this));
},
getUserInfo(){
this.userName = localStorage.getItem('username');
},
toggleSettings() {
this.showSettings = this.showSettings ? false : true;
},
logOut(){
localStorage.clear();
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'home' });
}
},
created(){
document.title = 'Profile' + storage.pageTitlePostfix;
storage.backTitle = document.title;
if(!localStorage.getItem('token')){
this.userLoggedIn = false;
} else {
this.userLoggedIn = true;
this.getUserInfo();
getEmoji()
.then(resp => this.emoji = resp.data.emoji )
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
// DUPLICATE CODE
.profile{
&__header{
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
border-bottom: 1px solid rgba($c-dark, 0.05);
@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: $c-dark;
font-weight: 300;
@include tablet-min{
font-size: 18px;
line-height: 18px;
}
}
}
</style>

View File

@@ -1,202 +0,0 @@
<template>
<section class="profile">
<div class="profile__content">
<h2 class='settings__header'>Register new user</h2>
<form class="form">
<seasoned-input text="username" icon="Email"
@inputValue="setValue('username', $event)"></seasoned-input>
<seasoned-input text="password" icon="Keyhole" type="password"
@inputValue="setValue('password', $event)"></seasoned-input>
<seasoned-input text="repeat password" icon="Keyhole" type="password"
@inputValue="setValue('passwordRepeat', $event)"></seasoned-input>
<transition name="message-fade">
<div class="message" :class="messageClass" v-if="showMessage">
<span class="message-text">{{ messageText }}</span>
<span class="message-dismiss" v-on:click="dismissMessage">X</span>
</div>
</transition>
<div class="form__group">
<seasoned-button @click="requestNewUser">Register</seasoned-button>
</div>
</form>
<div class="form__group">
<router-link class="form__group-link" :to="{name: 'signin'}" exact title="Sign in here">
<span class="form__group-signin">Sign in here</span>
</router-link>
</div>
</div>
<section class="not-found" v-if="userLoggedIn === false">
<div class="not-found__content">
<h2 class="not-found__title">Authentication Request Failed</h2>
<button class="not-found__button button">Log In</button>
</div>
</section>
</section>
</template>
<script>
import axios from 'axios'
import storage from '@/storage.js'
import SeasonedButton from '@/components/ui/SeasonedButton.vue'
import SeasonedInput from '@/components/ui/SeasonedInput.vue'
export default {
components: { SeasonedButton, SeasonedInput },
data(){
return{
userLoggedIn: '',
username: undefined,
password: undefined,
passwordRepeat: undefined,
showMessage: false,
messageClass: 'message-success',
messageText: 'hello world'
}
},
methods: {
requestNewUser(){
let username = this.username
let password = this.password
let password_re = this.passwordRepeat
let verifyCredentials = this.checkCredentials(username, password, password_re);
if (verifyCredentials.verified) {
axios.post(`https://api.kevinmidboe.com/api/v1/user`, {
username: username,
password: password
})
.then(function(resp) {
let data = resp.data;
if (data.success){
this.msg(data.message, 'success');
localStorage.setItem('token', data.token);
localStorage.setItem('username', username);
localStorage.setItem('admin', data.admin)
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'profile' })
}
}.bind(this))
.catch(function(error){
this.msg(error.response.data.error, 'warning')
}.bind(this));
}
else {
this.msg(verifyCredentials.reason, 'warning');
}
},
checkCredentials(username, password, password_re) {
if (password !== password_re) {
return {
verified: false,
reason: 'Passwords do not match'
}
}
else if (username === undefined) {
return {
verified: false,
reason: 'Please insert username'
}
}
else {
return {
verified: true,
reason: 'Verified credentials'
}
}
},
msg(text, status){
if (status === 'warning')
this.messageClass = 'message-warning';
else if (status === 'success')
this.messageClass = 'message-success';
else
this.messageClass = 'message-info';
this.messageText = text;
this.showMessage = true;
// setTimeout(() => this.showMessage = false, 3500);
},
dismissMessage(){
this.showMessage = false;
},
setValue(l, t) {
this[l] = t
},
logOut(){
localStorage.clear();
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'home' });
}
},
created(){
document.title = 'Profile' + storage.pageTitlePostfix;
storage.backTitle = document.title;
},
mounted(){
// this.$refs.email.focus();
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "./src/scss/message";
// DUPLICATE CODE
.settings {
padding: 35px;
&__header {
margin: 0;
line-height: 16px;
color: $c-dark;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
}
}
.profile__content {
padding: 35px;
display: flex;
justify-content: center;
flex-direction: column;
}
.center {
justify-content: center;
}
.form {
// TODO, fix this. if single child it adds weird margin
> 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;
// }
}
}
}
</style>

View File

@@ -0,0 +1,76 @@
<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";
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 "scss/media-queries";
@import "scss/main";
.no-results {
width: 100%;
display: block;
text-align: center;
margin: 1.5rem;
font-size: 1.2rem;
}
.results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
grid-auto-rows: auto;
margin: 0;
padding: 0;
list-style: none;
@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));
}
}
}
</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,277 +0,0 @@
<template>
<div>
<div class="search">
<input
type="text"
placeholder="Search for a movie or show"
autocorrect="off"
autocapitalize="off"
v-model="query"
@input="handleInput"
@click="focus = true"
@keydown.escape="handleEscape"
@keyup.enter="handleSubmit"
@keydown.up="navigateUp"
@keydown.down="navigateDown" />
<svg class="search--icon"><use xlink:href="#iconSearch"></use></svg>
</div>
<transition name="fade">
<div class="dropdown" v-if="!disabled && focus && query.length > 0">
<div class="dropdown--results">
<ul v-for="(item, index) in elasticSearchResults"
@click="$popup.open(item.id, item.type)"
:class="{ active: index + 1 === selectedResult}">
{{ item.name }}
</ul>
</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.vue'
import { elasticSearchMoviesAndShows } from '@/api.js'
import config from '@/config.json'
export default {
name: 'SearchInput',
components: {
SeasonedButton
},
props: ['value'],
data() {
return {
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
}
}
},
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--
},
handleInput(e){
this.selectedResult = 0
this.$emit('input', this.query);
if (! this.focus) {
this.focus = true;
}
elasticSearchMoviesAndShows(this.query)
.then(resp => {
const data = resp.data.hits.hits
this.elasticSearchResults = data.map(item => {
const index = item._index.slice(0, -1)
if (index === 'movie') {
return {
name: item._source.original_title,
id: item._source.id,
type: index
}
} else if (index === 'show') {
return {
name: item._source.original_name,
id: item._source.id,
type: index
}
}
})
console.log(this.elasticSearchResults)
})
},
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, "+"'))
this.$router.push({ name: 'search', query: { query: encodedQuery }});
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;
}
.dropdown {
width: 100%;
position: relative;
display: flex;
flex-wrap: wrap;
z-index: 5;
min-height: $header-size;
right: 0px;
background-color: white;
@include mobile-only {
position: fixed;
top: 50px;
padding-top: 20px;
width: calc(100%);
}
&--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;
&.active, &:hover, &:active {
color: $c-dark;
border-bottom: 2px solid black;
}
}
}
}
.search {
height: $header-size-mobile;
display: flex;
position: fixed;
flex-wrap: wrap;
z-index: 5;
// TODO check if this is for mobile
width: calc(100% - 110px);
// width: 100%;
top: 0;
right: 55px;
@include tablet-min{
position: relative;
height: $header-size;
width: 100%;
right: 0px;
}
input {
// height: 75px;
display: block;
width: 100%;
padding: 13px 20px 13px 45px;
outline: none;
border: 0;
background-color: transparent;
color: $c-dark;
font-weight: 300;
font-size: 19px;
@include tablet-min {
padding: 13px 30px 13px 60px;
}
}
&--icon{
width: 20px;
height: 20px;
fill: rgba($c-dark, 0.5);
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,156 +0,0 @@
<template>
<section class="profile">
<div class="profile__content" v-if="userLoggedIn">
<section class='settings'>
<h3 class='settings__header'>Plex account</h3>
<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 text="plex username" icon="Email"
@inputValue="setValue('plexUsername', $event)"/>
<seasoned-input text="plex password" icon="Keyhole" type="password"
@inputValue="setValue('plexPassword', $event)"/>
<seasoned-button @click="authenticatePlex">link plex account</seasoned-button>
</form>
<hr class='setting__divider'>
<h3 class='settings__header'>Change password</h3>
<form class="form">
<seasoned-input text="new password" icon="Keyhole" type="password"
@inputValue="setValue('newPass', $event)"/>
<seasoned-input text="repeat new password" icon="Keyhole" type="password"
@inputValue="setValue('newPassConfirm', $event)"/>
<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 storage from '@/storage.js'
import SeasonedInput from '@/components/ui/SeasonedInput.vue'
import SeasonedButton from '@/components/ui/SeasonedButton.vue'
import { plexAuthenticate } from '@/api.js'
export default {
components: { SeasonedInput, SeasonedButton },
data(){
return{
userLoggedIn: '',
plexUsername: undefined,
plexPassword: undefined,
newPass: undefined,
newPassConfirm: undefined
}
},
methods: {
setValue(l, t) {
console.log('l, t', l, t)
this[l] = t
},
changePassword() {
return
},
authenticatePlex() {
let username = this.plexUsername
let password = this.plexPassword
plexAuthenticate(username, password)
.then((resp) => {
let data = resp.data;
console.log('response from plex:', data.user)
})
.catch((error) => {
console.log('error: ', error)
})
}
},
created(){
document.title = 'Settings' + storage.pageTitlePostfix;
storage.backTitle = document.title;
if (localStorage.getItem('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: 35px;
&__header {
margin: 0;
line-height: 16px;
color: $c-dark;
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 rgba(8, 28, 36, 0.05);
margin-top: 30px;
margin-bottom: 70px;
margin-left: 20px;
width: 96%;
text-align: left;
}
span {
font-weight: 200;
size: 16px;
}
}
</style>

View File

@@ -1,158 +0,0 @@
<template>
<section class="profile">
<div class="profile__content">
<h2 class='settings__header'>Sign in</h2>
<form class="form">
<div class="form__buffer"></div>
<seasoned-input text="username" icon="Email" type="username"
@inputValue="setValue('username', $event)" />
<seasoned-input text="username" icon="Keyhole" type="password"
@inputValue="setValue('password', $event)" />
<seasoned-button @click="signin">sign in</seasoned-button>
<transition name="message-fade">
<div class="message" :class="messageClass" v-if="showMessage">
<span class="message-text">{{ messageText }}</span>
<span class="message-dismiss" @click="showMessage=false">X</span>
</div>
</transition>
</form>
<div class="form__group">
<router-link class="form__group-link" :to="{name: 'register'}" exact title="Sign in here">
<span class="form__group-signin">Don't have a user? Register here</span>
</router-link>
</div>
</div>
</section>
</template>
<script>
import axios from 'axios'
import storage from '../storage.js'
import SeasonedInput from '@/components/ui/SeasonedInput.vue'
import SeasonedButton from '@/components/ui/SeasonedButton.vue'
export default {
components: { SeasonedInput, SeasonedButton },
data(){
return{
userLoggedIn: '',
showMessage: false,
messageClass: 'message-success',
messageText: 'hello world',
username: undefined,
password: undefined
}
},
methods: {
setValue(l, t) {
this[l] = t
},
signin(){
let username = this.username;
let password = this.password;
axios.post(`https://api.kevinmidboe.com/api/v1/user/login`, {
username: username,
password: password
})
.then(function (resp){
let data = resp.data;
if (data.success){
localStorage.setItem('token', data.token);
localStorage.setItem('username', username);
localStorage.setItem('admin', data.admin);
this.userLoggedIn = true;
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'profile' })
}
}.bind(this))
.catch(function (error){
if (error.message.endsWith('401'))
this.msg('Incorrect username or password ', 'warning')
else
this.msg(error.message, 'warning')
}.bind(this));
},
msg(text, status){
if (status === 'warning')
this.messageClass = 'message-warning';
else if (status === 'success')
this.messageClass = 'message-success';
else
this.messageClass = 'message-info';
this.messageText = text;
this.showMessage = true;
// setTimeout(() => this.showMessage = false, 3500);
},
toggleView(){
this.register = false;
},
},
created(){
document.title = 'Sign in' + storage.pageTitlePostfix;
storage.backTitle = document.title;
if (this.userLoggedIn == true) {
this.$router.push({ name: 'profile' })
}
},
mounted(){
// this.$refs.email.focus();
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/message";
// DUPLICATE CODE
.settings {
padding: 35px;
&__header {
margin: 0;
line-height: 16px;
color: $c-dark;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
}
}
.profile__content {
padding: 35px;
display: flex;
justify-content: center;
flex-direction: column;
}
.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;
// }
}
}
}
</style>

View File

@@ -1,386 +0,0 @@
<template>
<div v-if="show">
<h2 class="title">torrents: {{ query }}</h2>
<div v-if="listLoaded">
<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>
<table>
<tr class="table__header noselect">
<th @click="sortTable('name')">
<span>Name</span>
<span v-if="prevCol === 'name' && direction"></span>
<span v-if="prevCol === 'name' && !direction"></span>
</th>
<th @click="sortTable('seed')">
<span>Seed</span>
<span v-if="prevCol === 'seed' && direction"></span>
<span v-if="prevCol === 'seed' && !direction"></span>
</th>
<th @click="sortTable('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>
<i v-else class="torrentloader"></i>
</div>
</template>
<script>
import storage from '@/storage.js'
import { sortableSize } from '@/utils.js'
import { searchTorrents, addMagnet } from '@/api.js'
export default {
props: {
query: {
type: String,
require: true
},
tmdb_id: {
type: Number,
require: true
},
tmdb_type: String,
admin: String,
show: Boolean
},
data() {
return {
listLoaded: false,
torrents: undefined,
torrentResponse: undefined,
currentPage: 0,
prevCol: '',
direction: false,
release_types: ['all'],
selectedRelaseType: 'all'
}
},
beforeMount() {
if (localStorage.getItem('admin')) {
this.fetchTorrents()
}
},
methods: {
expand(event, name) {
const existingExpandedElement = document.getElementsByClassName('expanded')[0]
if (existingExpandedElement) {
console.log('exists')
const expandedSibling = event.target.parentNode.nextSibling.className === 'expanded'
existingExpandedElement.remove()
if (expandedSibling) {
console.log('sibling is here')
return
}
}
console.log('expand event', event)
const nameRow = document.createElement('tr')
const nameCol = document.createElement('td')
nameRow.className = 'expanded'
nameCol.innerText = name
nameRow.appendChild(nameCol)
event.target.parentNode.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
}
console.log('col and more', col, sameDirection)
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)
},
fetchTorrents(){
searchTorrents(this.query, 'all', this.currentPage, storage.token)
.then(resp => {
let data = resp.data;
console.log('data results', data.results);
this.torrentResponse = data.results;
this.torrents = data.results;
this.listLoaded = true;
})
.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">
@import "./src/scss/variables";
.expanded {
display: flex;
margin: 0 1rem;
max-width: 100%;
border-left: 1px solid rgba($c-dark, 0.5);
border-right: 1px solid rgba($c-dark, 0.5);
border-bottom: 1px solid rgba($c-dark, 0.5);
td {
// border-left: 1px solid $c-dark;
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";
.title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
text-align: center;
font-size: 14px;
color: $c-green;
padding-bottom: 20px;
@include tablet-min{
font-size: 16px;
}
}
table {
border-collapse: collapse;
width: 100%;
table-layout: fixed;
}
.table__content, .table__header {
display: flex;
padding: 0;
margin: 0 1rem;
border-left: 1px solid rgba($c-dark, 0.8);
border-right: 1px solid rgba($c-dark, 0.8);
border-bottom: 1px solid rgba($c-dark, 0.8);
th, td {
display: flex;
flex-direction: column;
flex-basis: 100%;
padding: 0.4rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
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 rgba($c-dark, 0.8);
}
}
.table__content:last-child {
margin-bottom: 1rem;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
}
.table__header {
background-color: white;
color: $c-dark;
text-transform: uppercase;
cursor: pointer;
border-top: 1px solid rgba($c-dark, 0.8);
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 rgba($c-dark, 0.8);
}
}
.download {
&__icon {
fill: rgba($c-dark, 0.6);
height: 1.2rem;
&:hover {
fill: $c-dark;
cursor: pointer;
}
}
&.active &__icon {
fill: $c-green;
}
}
.torrentloader{
animation: load 1s linear infinite;
border: 2px solid $c-dark;
border-radius: 50%;
display: block;
height: 30px;
left: 50%;
margin: 2rem auto;
width: 30px;
&:after {
border: 5px solid $c-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

@@ -1,95 +0,0 @@
<template>
<div class="action">
<a class="action-link" :class="{'active': active}" @click="$emit('click')">
<svg class="action-icon">
<use v-if="active && iconRefActive" :xlink:href="iconRefActive"></use>
<use v-else :xlink:href="iconRef"></use>
</svg>
<span class="action-text">{{ active && textActive ? textActive : text }}</span>
</a>
</div>
</template>
<script>
export default {
props: {
iconRef: {
type: String,
required: true
},
iconRefActive: {
type: String,
required: false
},
active: {
type: Boolean,
default: false,
},
text: {
type: String,
required: true
},
textActive: {
type: String,
required: false
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/loading-placeholder";
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.action {
&-link {
display: flex;
align-items: center;
text-decoration: none;
text-transform: uppercase;
color: rgba($c-dark, 0.5);
transition: color 0.5s ease;
font-size: 11px;
padding: 5px 0;
border-bottom: 1px solid rgba($c-dark, 0.05);
&:hover {
color: rgba($c-dark, 0.75);
}
&.active {
color: $c-dark;
}
&.pending {
color: #f8bd2d;
}
}
&-icon {
width: 18px;
height: 18px;
margin: 0 10px 0 0;
fill: rgba($c-dark, 0.5);
transition: fill 0.5s ease, transform 0.5s ease;
&.waiting {
transform: scale(0.8, 0.8);
}
&.pending {
fill: #f8bd2d;
}
}
&-link:hover &-icon {
fill: rgba($c-dark, 0.75);
cursor: pointer;
}
&-link.active &-icon {
fill: $c-green;
}
&-text {
display: block;
padding-top: 2px;
cursor: pointer;
margin:4.4px;
margin-left: -3px;
}
}
</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>

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