Merge pull request #762 from sbondCo/svelte-5

Svelte 5, new code format rules for frontend, some general overdue refactoring
This commit is contained in:
Mr
2025-01-17 18:10:59 +00:00
committed by GitHub
145 changed files with 27799 additions and 26014 deletions
+25 -25
View File
@@ -1,27 +1,27 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
extends: ["plugin:svelte/recommended"],
plugins: ["@typescript-eslint"],
ignorePatterns: ["*.cjs", "*.config.js"],
overrides: [
{
files: ["*.svelte"],
parser: "svelte-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser"
}
}
],
parserOptions: {
project: "./tsconfig.json",
extraFileExtensions: [".svelte"],
sourceType: "module",
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: false
}
root: true,
parser: "@typescript-eslint/parser",
extends: ["plugin:svelte/recommended"],
plugins: ["@typescript-eslint"],
ignorePatterns: ["*.cjs", "*.config.js"],
overrides: [
{
files: ["*.svelte"],
parser: "svelte-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
},
},
],
parserOptions: {
project: "./tsconfig.json",
extraFileExtensions: [".svelte"],
sourceType: "module",
ecmaVersion: 2020,
},
env: {
browser: true,
es2017: true,
node: false,
},
};
+4
View File
@@ -6,6 +6,10 @@ node_modules
.env
.env.*
!.env.example
/.vscode
doc/.docusaurus/
doc/build/
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
+16 -16
View File
@@ -1,18 +1,18 @@
{
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"printWidth": 100,
"useTabs": false,
"trailingComma": "none",
"arrowParens": "always",
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"printWidth": 80,
"useTabs": true,
"trailingComma": "all",
"arrowParens": "always",
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+13 -13
View File
@@ -1,15 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${fileDirname}"
}
]
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${fileDirname}"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"editor.insertSpaces": false
}
+1 -1
View File
@@ -1,3 +1,3 @@
module.exports = {
presets: [require.resolve("@docusaurus/core/lib/babel/preset")]
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
};
+6 -6
View File
@@ -1,8 +1,8 @@
{
"label": "Importing",
"position": 3,
"link": {
"type": "generated-index",
"description": "Guides for importing your data from other services."
}
"label": "Importing",
"position": 3,
"link": {
"type": "generated-index",
"description": "Guides for importing your data from other services."
}
}
+6 -6
View File
@@ -1,8 +1,8 @@
{
"label": "Installation",
"position": 2,
"link": {
"type": "generated-index",
"description": "Choose how you would like to install Watcharr."
}
"label": "Installation",
"position": 2,
"link": {
"type": "generated-index",
"description": "Choose how you would like to install Watcharr."
}
}
+6 -6
View File
@@ -1,8 +1,8 @@
{
"label": "Server Configuration",
"position": 2,
"link": {
"type": "generated-index",
"description": "Learn how to configure your server."
}
"label": "Server Configuration",
"position": 2,
"link": {
"type": "generated-index",
"description": "Learn how to configure your server."
}
}
+6 -6
View File
@@ -1,8 +1,8 @@
{
"label": "Usage",
"position": 3,
"link": {
"type": "generated-index",
"description": "Hopefully Watcharr is easy enough to get started with and use, but incase you are stuck, one of these usage guides may help."
}
"label": "Usage",
"position": 3,
"link": {
"type": "generated-index",
"description": "Hopefully Watcharr is easy enough to get started with and use, but incase you are stuck, one of these usage guides may help."
}
}
+91 -91
View File
@@ -7,104 +7,104 @@ const darkTheme = themes.dracula;
/** @type {import('@docusaurus/types').Config} */
const config = {
title: "Watcharr Docs",
tagline:
"Open source, self-hostable watched list for all your content with user authentication, modern and clean UI and a very simple setup. ",
favicon: "img/favicon.png",
title: "Watcharr Docs",
tagline:
"Open source, self-hostable watched list for all your content with user authentication, modern and clean UI and a very simple setup. ",
favicon: "img/favicon.png",
// Set the production url of your site here
url: "https://watcharr.app",
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
baseUrl: "/",
// Set the production url of your site here
url: "https://watcharr.app",
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
baseUrl: "/",
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
organizationName: "sbondCo", // Usually your GitHub org/user name.
projectName: "Watcharr", // Usually your repo name.
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
organizationName: "sbondCo", // Usually your GitHub org/user name.
projectName: "Watcharr", // Usually your repo name.
onBrokenLinks: "throw",
onBrokenMarkdownLinks: "warn",
onBrokenLinks: "throw",
onBrokenMarkdownLinks: "warn",
trailingSlash: false,
trailingSlash: false,
// Even if you don't use internalization, you can use this field to set useful
// metadata like html lang. For example, if your site is Chinese, you may want
// to replace "en" with "zh-Hans".
i18n: {
defaultLocale: "en",
locales: ["en"]
},
// Even if you don't use internalization, you can use this field to set useful
// metadata like html lang. For example, if your site is Chinese, you may want
// to replace "en" with "zh-Hans".
i18n: {
defaultLocale: "en",
locales: ["en"],
},
presets: [
[
"classic",
/** @type {import('@docusaurus/preset-classic').Options} */
({
docs: {
sidebarPath: require.resolve("./sidebars.js"),
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
},
blog: {
showReadingTime: true,
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
},
theme: {
customCss: [
"./node_modules/viewerjs/dist/viewer.css",
require.resolve("./src/css/custom.css")
]
}
})
]
],
presets: [
[
"classic",
/** @type {import('@docusaurus/preset-classic').Options} */
({
docs: {
sidebarPath: require.resolve("./sidebars.js"),
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc",
},
blog: {
showReadingTime: true,
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc",
},
theme: {
customCss: [
"./node_modules/viewerjs/dist/viewer.css",
require.resolve("./src/css/custom.css"),
],
},
}),
],
],
themeConfig:
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
({
image: "img/social-card.png",
navbar: {
title: "Watcharr",
logo: {
alt: "Watcharr Logo",
src: "img/favicon.png"
},
items: [
{
type: "docSidebar",
sidebarId: "tutorialSidebar",
position: "left",
label: "Docs"
},
{
type: "docsVersionDropdown"
},
{
href: "https://beta.watcharr.app",
label: "Demo",
position: "right"
},
{
href: "https://github.com/sbondCo/Watcharr",
label: "GitHub",
position: "right"
}
]
},
prism: {
theme: lightTheme,
darkTheme: darkTheme
},
colorMode: {
defaultMode: "light",
// Dark theme currently disabled.. no time to fix the icons etc.
disableSwitch: true
}
})
themeConfig:
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
({
image: "img/social-card.png",
navbar: {
title: "Watcharr",
logo: {
alt: "Watcharr Logo",
src: "img/favicon.png",
},
items: [
{
type: "docSidebar",
sidebarId: "tutorialSidebar",
position: "left",
label: "Docs",
},
{
type: "docsVersionDropdown",
},
{
href: "https://beta.watcharr.app",
label: "Demo",
position: "right",
},
{
href: "https://github.com/sbondCo/Watcharr",
label: "GitHub",
position: "right",
},
],
},
prism: {
theme: lightTheme,
darkTheme: darkTheme,
},
colorMode: {
defaultMode: "light",
// Dark theme currently disabled.. no time to fix the icons etc.
disableSwitch: true,
},
}),
};
module.exports = config;
+42 -42
View File
@@ -1,44 +1,44 @@
{
"name": "watcharr-doc",
"version": "0.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/preset-classic": "3.1.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.1.0",
"prism-react-renderer": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"viewerjs": "^1.11.6"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.1.1"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"engines": {
"node": ">=18.0"
}
"name": "watcharr-doc",
"version": "0.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/preset-classic": "3.1.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.1.0",
"prism-react-renderer": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"viewerjs": "^1.11.6"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.1.1"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"engines": {
"node": ">=18.0"
}
}
+4 -4
View File
@@ -13,11 +13,11 @@
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
// By default, Docusaurus generates a sidebar from the docs folder structure
tutorialSidebar: [{ type: "autogenerated", dirName: "." }]
// By default, Docusaurus generates a sidebar from the docs folder structure
tutorialSidebar: [{ type: "autogenerated", dirName: "." }],
// But you can create a sidebar manually
/*
// But you can create a sidebar manually
/*
tutorialSidebar: [
'intro',
'hello',
+52 -49
View File
@@ -3,59 +3,62 @@ import clsx from "clsx";
import styles from "./styles.module.css";
const FeatureList = [
{
title: "Free and Open",
Svg: require("@site/static/img/icon/code-slash.svg").default,
description: (
<>
Watcharr is free and open source software distributed under the MIT license. Feel free to
browse, modify or contribute!
</>
)
},
{
title: "Simple",
Svg: require("@site/static/img/icon/happy.svg").default,
description: (
<>
Watcharr is incredibly easy to dive into. Install without hassle and get started with the
seamless and intuitive user experience.
</>
)
},
{
title: "Built With Go and Svelte",
Svg: require("@site/static/img/icon/go.svg").default,
description: (
<>Don't know what else to add here, so now you know Watcharr is built with Go and Svelte.</>
)
}
{
title: "Free and Open",
Svg: require("@site/static/img/icon/code-slash.svg").default,
description: (
<>
Watcharr is free and open source software distributed under the MIT
license. Feel free to browse, modify or contribute!
</>
),
},
{
title: "Simple",
Svg: require("@site/static/img/icon/happy.svg").default,
description: (
<>
Watcharr is incredibly easy to dive into. Install without hassle and get
started with the seamless and intuitive user experience.
</>
),
},
{
title: "Built With Go and Svelte",
Svg: require("@site/static/img/icon/go.svg").default,
description: (
<>
Don't know what else to add here, so now you know Watcharr is built with
Go and Svelte.
</>
),
},
];
function Feature({ Svg, title, description }) {
return (
<div className={clsx("col col--4")}>
<div className="text--center">
<Svg className={styles.featureSvg} role="img" />
</div>
<div className="text--center padding-horiz--md">
<h3>{title}</h3>
<p>{description}</p>
</div>
</div>
);
return (
<div className={clsx("col col--4")}>
<div className="text--center">
<Svg className={styles.featureSvg} role="img" />
</div>
<div className="text--center padding-horiz--md">
<h3>{title}</h3>
<p>{description}</p>
</div>
</div>
);
}
export default function HomepageFeatures() {
return (
<section className={styles.features}>
<div className="container">
<div className="row">
{FeatureList.map((props, idx) => (
<Feature key={idx} {...props} />
))}
</div>
</div>
</section>
);
return (
<section className={styles.features}>
<div className="container">
<div className="row">
{FeatureList.map((props, idx) => (
<Feature key={idx} {...props} />
))}
</div>
</div>
</section>
);
}
@@ -1,11 +1,11 @@
.features {
display: flex;
align-items: center;
padding: 2rem 0;
width: 100%;
display: flex;
align-items: center;
padding: 2rem 0;
width: 100%;
}
.featureSvg {
height: 200px;
width: 200px;
height: 200px;
width: 200px;
}
+60 -60
View File
@@ -4,68 +4,68 @@ import clsx from "clsx";
import Viewer from "viewerjs";
function TwoColFeat({ title, description, imgSrc }) {
return (
<div className={clsx(styles.twoCol)}>
<div>
<h2>{title}</h2>
<p>{description}</p>
</div>
<div>
<img src={imgSrc} alt="" loading="lazy" />
</div>
</div>
);
return (
<div className={clsx(styles.twoCol)}>
<div>
<h2>{title}</h2>
<p>{description}</p>
</div>
<div>
<img src={imgSrc} alt="" loading="lazy" />
</div>
</div>
);
}
export function HomepageTwoColFeats() {
useEffect(() => {
const ctr = document.getElementById("homepage-two-col-feats");
console.debug("Starting image viewer", ctr);
new Viewer(ctr, {
navbar: 0,
toolbar: {
zoomIn: 2,
zoomOut: 2,
next: 2,
prev: 2
},
title: 0,
className: "twoColViewer"
});
}, []);
useEffect(() => {
const ctr = document.getElementById("homepage-two-col-feats");
console.debug("Starting image viewer", ctr);
new Viewer(ctr, {
navbar: 0,
toolbar: {
zoomIn: 2,
zoomOut: 2,
next: 2,
prev: 2,
},
title: 0,
className: "twoColViewer",
});
}, []);
return (
<section id="homepage-two-col-feats" className={styles.twoColFeats}>
<TwoColFeat
title="Neat UI"
description="We just look lightweight."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png"
/>
<TwoColFeat
title="Easy To Use"
description="Not much to it, other than ease of use."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage-poster-change-status.png"
/>
<TwoColFeat
title="In-Depth Details"
description="About your favourite content."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page.png"
/>
<TwoColFeat
title="Personal Stats and Configuration"
description="Make it how you like."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/user-profile.png"
/>
<TwoColFeat
title="Discovery"
description="Find the latest trending content."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/discover-page.png"
/>
<TwoColFeat
title="Dark Theme"
description="For you who are still unsatisfied. ;("
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page-dark.png"
/>
</section>
);
return (
<section id="homepage-two-col-feats" className={styles.twoColFeats}>
<TwoColFeat
title="Neat UI"
description="We just look lightweight."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png"
/>
<TwoColFeat
title="Easy To Use"
description="Not much to it, other than ease of use."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage-poster-change-status.png"
/>
<TwoColFeat
title="In-Depth Details"
description="About your favourite content."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page.png"
/>
<TwoColFeat
title="Personal Stats and Configuration"
description="Make it how you like."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/user-profile.png"
/>
<TwoColFeat
title="Discovery"
description="Find the latest trending content."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/discover-page.png"
/>
<TwoColFeat
title="Dark Theme"
description="For you who are still unsatisfied. ;("
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page-dark.png"
/>
</section>
);
}
+34 -34
View File
@@ -1,53 +1,53 @@
.twoColFeats {
display: flex;
align-items: center;
margin-left: auto;
margin-right: auto;
flex-flow: column;
width: 100%;
max-width: 1300px;
display: flex;
align-items: center;
margin-left: auto;
margin-right: auto;
flex-flow: column;
width: 100%;
max-width: 1300px;
}
.twoCol {
display: flex;
flex-flow: row;
width: 100%;
padding: 2.5rem 5rem;
gap: 50px;
background-color: rgb(236, 236, 236);
display: flex;
flex-flow: row;
width: 100%;
padding: 2.5rem 5rem;
gap: 50px;
background-color: rgb(236, 236, 236);
}
.twoCol:nth-child(even) {
flex-flow: row-reverse;
background-color: transparent;
flex-flow: row-reverse;
background-color: transparent;
}
.twoCol > div {
width: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
width: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
}
.twoCol > div > img {
width: 100%;
border-radius: 12px;
box-shadow: 0px 0px 5px rgb(121, 121, 121);
cursor: pointer;
width: 100%;
border-radius: 12px;
box-shadow: 0px 0px 5px rgb(121, 121, 121);
cursor: pointer;
}
@media screen and (max-width: 850px) {
.twoCol,
.twoCol:nth-child(even) {
flex-flow: column;
}
.twoCol,
.twoCol:nth-child(even) {
flex-flow: column;
}
.twoCol > div {
width: 100%;
}
.twoCol > div {
width: 100%;
}
.twoCol > div > img {
max-width: 500px;
}
.twoCol > div > img {
max-width: 500px;
}
}
+22 -22
View File
@@ -6,39 +6,39 @@
/* You can override the default Infima variables here. */
:root {
--ifm-color-primary: #2e8555;
--ifm-color-primary-dark: #29784c;
--ifm-color-primary-darker: #277148;
--ifm-color-primary-darkest: #205d3b;
--ifm-color-primary-light: #33925d;
--ifm-color-primary-lighter: #359962;
--ifm-color-primary-lightest: #3cad6e;
--ifm-code-font-size: 95%;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
--ifm-color-primary: #2e8555;
--ifm-color-primary-dark: #29784c;
--ifm-color-primary-darker: #277148;
--ifm-color-primary-darkest: #205d3b;
--ifm-color-primary-light: #33925d;
--ifm-color-primary-lighter: #359962;
--ifm-color-primary-lightest: #3cad6e;
--ifm-code-font-size: 95%;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
}
/* For readability concerns, you should choose a lighter palette in dark mode. */
[data-theme="dark"] {
--ifm-color-primary: #25c2a0;
--ifm-color-primary-dark: #21af90;
--ifm-color-primary-darker: #1fa588;
--ifm-color-primary-darkest: #1a8870;
--ifm-color-primary-light: #29d5b0;
--ifm-color-primary-lighter: #32d8b4;
--ifm-color-primary-lightest: #4fddbf;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
--ifm-color-primary: #25c2a0;
--ifm-color-primary-dark: #21af90;
--ifm-color-primary-darker: #1fa588;
--ifm-color-primary-darkest: #1a8870;
--ifm-color-primary-light: #29d5b0;
--ifm-color-primary-lighter: #32d8b4;
--ifm-color-primary-lightest: #4fddbf;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
}
.twoColViewer .viewer-close {
right: 10px;
top: 10px;
width: 50px;
height: 50px;
right: 10px;
top: 10px;
width: 50px;
height: 50px;
}
/* Stupid poopin fix for viewerjs because whatever is bundling the css
isn't keeping the order of this correctly. All this time wasted and it
was just opaque ;( BUT ONLY IN THE BUILD AAAAAAAAAAAAAAAAAAAAAA */
.viewer-in {
opacity: 1 !important;
opacity: 1 !important;
}
+40 -37
View File
@@ -10,44 +10,47 @@ import clsx from "clsx";
import { HomepageTwoColFeats } from "../components/TwoColFeat";
function HomepageHeader() {
const { siteConfig } = useDocusaurusContext();
return (
<header className="hero shadow--lw">
<div className={styles.heroContainer}>
<div className={styles.heroWrapper}>
<img src={LogoCol} />
<h1 className="hero__title">{siteConfig.title}</h1>
<p className="hero__subtitle">{siteConfig.tagline}</p>
<div className={styles.buttons}>
<Link className="button button--secondary button--lg" to="/docs/introduction">
Get Started
</Link>
<Link
className="button button--secondary button--outline button--lg"
to="https://beta.watcharr.app"
target="_blank"
>
Demo
</Link>
</div>
</div>
</div>
</header>
);
const { siteConfig } = useDocusaurusContext();
return (
<header className="hero shadow--lw">
<div className={styles.heroContainer}>
<div className={styles.heroWrapper}>
<img src={LogoCol} />
<h1 className="hero__title">{siteConfig.title}</h1>
<p className="hero__subtitle">{siteConfig.tagline}</p>
<div className={styles.buttons}>
<Link
className="button button--secondary button--lg"
to="/docs/introduction"
>
Get Started
</Link>
<Link
className="button button--secondary button--outline button--lg"
to="https://beta.watcharr.app"
target="_blank"
>
Demo
</Link>
</div>
</div>
</div>
</header>
);
}
export default function Home() {
const { siteConfig } = useDocusaurusContext();
return (
<Layout
title={`${siteConfig.title}`}
description="Description will go into a meta tag in <head />"
>
<HomepageHeader />
<main>
<HomepageFeatures />
<HomepageTwoColFeats />
</main>
</Layout>
);
const { siteConfig } = useDocusaurusContext();
return (
<Layout
title={`${siteConfig.title}`}
description="Description will go into a meta tag in <head />"
>
<HomepageHeader />
<main>
<HomepageFeatures />
<HomepageTwoColFeats />
</main>
</Layout>
);
}
+18 -18
View File
@@ -4,31 +4,31 @@
*/
.heroContainer {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.heroWrapper {
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
max-width: 600px;
padding-left: 2rem;
padding-right: 2rem;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
max-width: 600px;
padding-left: 2rem;
padding-right: 2rem;
text-align: center;
}
.heroWrapper img {
width: 30vh;
max-width: 250px;
width: 30vh;
max-width: 250px;
}
.buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
+8433 -8398
View File
File diff suppressed because it is too large Load Diff
+39 -39
View File
@@ -1,41 +1,41 @@
{
"name": "watcharr",
"version": "1.44.2",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"server": "cd ./server && MODE=DEV go run ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.3",
"@sveltejs/kit": "^2.15.1",
"@types/papaparse": "^5.3.15",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",
"@vite-pwa/sveltekit": "^0.6.6",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.43.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.2.6",
"svelte": "^4.2.19",
"svelte-check": "^4.0.2",
"svelte-eslint-parser": "^0.41.0",
"svelte-preprocess": "^6.0.2",
"tslib": "^2.7.0",
"typescript": "^5.7.2",
"vite": "^5.4.6"
},
"type": "module",
"dependencies": {
"axios": "^1.7.4",
"blurhash": "^2.0.5",
"papaparse": "^5.4.1"
}
"name": "watcharr",
"version": "1.44.2",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"server": "cd ./server && MODE=DEV go run ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.3",
"@sveltejs/kit": "^2.15.1",
"@types/papaparse": "^5.3.15",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",
"@vite-pwa/sveltekit": "^0.6.6",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.45.1",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.2.6",
"sass": "^1.83.0",
"svelte": "^5.17.3",
"svelte-check": "^4.1.3",
"svelte-eslint-parser": "^0.42.0",
"svelte-preprocess": "^6.0.3",
"typescript": "^5.7.2",
"vite": "^6.0.7"
},
"type": "module",
"dependencies": {
"axios": "^1.7.4",
"blurhash": "^2.0.5",
"papaparse": "^5.4.1"
}
}
+4 -4
View File
@@ -19,16 +19,16 @@ type TrustedHeaderAuthSetting struct {
// easily without having to remove configuration.
// To be actually enabled, HEADER_NAME must also
// be set.
Enabled bool `json:"enabled,omitempty"`
Enabled bool `json:"enabled"`
// Required: What is the name of the trusted header
// that will contain the logged in users username?
HeaderName string `json:"headerName,omitempty"`
HeaderName string `json:"headerName"`
// Should the frontend attempt auto login if
// trusted header auth is enabled.
AutoLogin bool `json:"autoLogin,omitempty"`
AutoLogin bool `json:"autoLogin"`
// Where can we redirect the user to logout
// of the auth service?
LogoutUrl string `json:"logoutUrl,omitempty"`
LogoutUrl string `json:"logoutUrl"`
}
type TrustedHeaderAuthLogoutDetailsResponse struct {
+6 -6
View File
@@ -1,12 +1,12 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};
+11 -11
View File
@@ -1,14 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo-col.png" />
<meta name="viewport" content="width=device-width" />
<title>Watcharr</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo-col.png" />
<meta name="viewport" content="width=device-width" />
<title>Watcharr</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
import type { ClientInit } from "@sveltejs/kit";
export const init: ClientInit = async () => {
console.info(
`%cWATCHARR v${__WATCHARR_VERSION__}`,
"background: white;color: black;font-size: large;padding: 3px 5px;",
);
};
+296 -290
View File
@@ -1,320 +1,326 @@
<script lang="ts">
import type { Activity } from "@/types";
import { getOrdinalSuffix, months, seasonAndEpToReadable } from "./util/helpers";
import ActivityEditor from "./ActivityEditor.svelte";
import Icon from "./Icon.svelte";
import tooltip from "./actions/tooltip";
import type { Activity } from "@/types";
import {
getOrdinalSuffix,
months,
seasonAndEpToReadable,
} from "./util/helpers";
import ActivityEditor from "./ActivityEditor.svelte";
import Icon from "./Icon.svelte";
import tooltip from "./actions/tooltip";
export let activity: Activity[] | undefined;
export let wListId: number;
interface Props {
activity: Activity[] | undefined;
wListId: number;
}
let clickedActivity: Activity;
let groupedActivities: { [index: string]: any };
let isActivityEditorVisible: boolean;
let { activity, wListId }: Props = $props();
$: {
groupedActivities = getGroupedActivity(activity);
}
let clickedActivity: Activity | undefined = $state();
let groupedActivities: { [index: string]: any } = $derived(
getGroupedActivity(activity),
);
function getMsg(a: Activity) {
switch (a?.type) {
case "ADDED_WATCHED":
if (a.data) {
const data = JSON.parse(a.data);
return `Added to Watched List${data?.status ? ` as ${toFullTitleCase(data.status)}` : ""}${
data?.rating ? ` with ${data.rating} Stars` : ""
}`;
}
return "Added to Watched List";
case "REMOVED_WATCHED":
return "Removed from Watched List";
case "RATING_CHANGED":
if (a.data) {
return `Rating Changed to ${a.data}`;
}
return "Rating Changed";
case "STATUS_CHANGED":
if (a.data) {
return `Status Changed to ${toFullTitleCase(a.data)}`;
}
return "Status Changed";
case "STATUS_CHANGED_AUTO":
if (a.data) {
const data = JSON.parse(a.data);
return `Status Changed to ${toFullTitleCase(data.status)}`;
}
return "Status Changed";
case "THOUGHTS_CHANGED":
return "Thoughts Changed";
case "THOUGHTS_REMOVED":
return "Thoughts Removed";
case "IMPORTED_WATCHED":
return "Imported";
case "IMPORTED_WATCHED_JF":
case "IMPORTED_WATCHED_PLEX":
return "Synced";
case "IMPORTED_RATING":
if (a.data) {
const data = JSON.parse(a.data);
if (data.rating) {
return `Rating Changed to ${data.rating}`;
} else {
return "Added to Watchlist with No Rating";
}
}
return "Imported Rating";
case "IMPORTED_ADDED_WATCHED":
case "IMPORTED_ADDED_WATCHED_JF":
case "IMPORTED_ADDED_WATCHED_PLEX":
return "Imported Watch Date";
case "SEASON_ADDED":
case "SEASON_ADDED_AUTO":
if (a.data) {
const data = JSON.parse(a.data);
return `Season ${data.season} Added as ${toFullTitleCase(data.status)}`;
}
return "Season Added";
case "SEASON_ADDED_JF":
case "SEASON_ADDED_PLEX":
if (a.data) {
const data = JSON.parse(a.data);
return `Season ${data.season} Synced as ${toFullTitleCase(data.status)}`;
}
return "Season Synced";
case "SEASON_RATING_CHANGED":
if (a.data) {
const data = JSON.parse(a.data);
return `Changed Season ${data.season} Rating to ${data.rating}`;
}
return "Season Rating Changed";
case "SEASON_STATUS_CHANGED":
case "SEASON_STATUS_CHANGED_AUTO":
if (a.data) {
const data = JSON.parse(a.data);
return `Changed Season ${data.season} Status to ${toFullTitleCase(data.status)}`;
}
return "Season Status Changed";
case "SEASON_REMOVED":
if (a.data) {
const data = JSON.parse(a.data);
return `Season ${data.season} Status Removed`;
}
return "Season Removed";
case "EPISODE_ADDED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Added ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
}
return "Episode Added";
case "EPISODE_ADDED_JF":
case "EPISODE_ADDED_PLEX":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Synced ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
}
return "Episode Synced";
case "EPISODE_RATING_CHANGED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Rating Changed to ${data.rating}`;
}
return "Episode Rating Changed";
case "EPISODE_STATUS_CHANGED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Status Changed to ${toFullTitleCase(data.status)}`;
}
return "Episode Status Changed";
case "EPISODE_REMOVED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Removed`;
}
return "Episode Removed";
default:
return a.type;
}
}
function getMsg(a: Activity) {
switch (a?.type) {
case "ADDED_WATCHED":
if (a.data) {
const data = JSON.parse(a.data);
return `Added to Watched List${data?.status ? ` as ${toFullTitleCase(data.status)}` : ""}${
data?.rating ? ` with ${data.rating} Stars` : ""
}`;
}
return "Added to Watched List";
case "REMOVED_WATCHED":
return "Removed from Watched List";
case "RATING_CHANGED":
if (a.data) {
return `Rating Changed to ${a.data}`;
}
return "Rating Changed";
case "STATUS_CHANGED":
if (a.data) {
return `Status Changed to ${toFullTitleCase(a.data)}`;
}
return "Status Changed";
case "STATUS_CHANGED_AUTO":
if (a.data) {
const data = JSON.parse(a.data);
return `Status Changed to ${toFullTitleCase(data.status)}`;
}
return "Status Changed";
case "THOUGHTS_CHANGED":
return "Thoughts Changed";
case "THOUGHTS_REMOVED":
return "Thoughts Removed";
case "IMPORTED_WATCHED":
return "Imported";
case "IMPORTED_WATCHED_JF":
case "IMPORTED_WATCHED_PLEX":
return "Synced";
case "IMPORTED_RATING":
if (a.data) {
const data = JSON.parse(a.data);
if (data.rating) {
return `Rating Changed to ${data.rating}`;
} else {
return "Added to Watchlist with No Rating";
}
}
return "Imported Rating";
case "IMPORTED_ADDED_WATCHED":
case "IMPORTED_ADDED_WATCHED_JF":
case "IMPORTED_ADDED_WATCHED_PLEX":
return "Imported Watch Date";
case "SEASON_ADDED":
case "SEASON_ADDED_AUTO":
if (a.data) {
const data = JSON.parse(a.data);
return `Season ${data.season} Added as ${toFullTitleCase(data.status)}`;
}
return "Season Added";
case "SEASON_ADDED_JF":
case "SEASON_ADDED_PLEX":
if (a.data) {
const data = JSON.parse(a.data);
return `Season ${data.season} Synced as ${toFullTitleCase(data.status)}`;
}
return "Season Synced";
case "SEASON_RATING_CHANGED":
if (a.data) {
const data = JSON.parse(a.data);
return `Changed Season ${data.season} Rating to ${data.rating}`;
}
return "Season Rating Changed";
case "SEASON_STATUS_CHANGED":
case "SEASON_STATUS_CHANGED_AUTO":
if (a.data) {
const data = JSON.parse(a.data);
return `Changed Season ${data.season} Status to ${toFullTitleCase(data.status)}`;
}
return "Season Status Changed";
case "SEASON_REMOVED":
if (a.data) {
const data = JSON.parse(a.data);
return `Season ${data.season} Status Removed`;
}
return "Season Removed";
case "EPISODE_ADDED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Added ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
}
return "Episode Added";
case "EPISODE_ADDED_JF":
case "EPISODE_ADDED_PLEX":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Synced ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
}
return "Episode Synced";
case "EPISODE_RATING_CHANGED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Rating Changed to ${data.rating}`;
}
return "Episode Rating Changed";
case "EPISODE_STATUS_CHANGED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Status Changed to ${toFullTitleCase(data.status)}`;
}
return "Episode Status Changed";
case "EPISODE_REMOVED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Removed`;
}
return "Episode Removed";
default:
return a.type;
}
}
function toFullTitleCase(text: string | undefined) {
if (text) {
return text
.split(" ")
.map((l) => l[0].toUpperCase() + l.substring(1).toLowerCase())
.join(" ");
}
return "Unknown";
}
function toFullTitleCase(text: string | undefined) {
if (text) {
return text
.split(" ")
.map((l) => l[0].toUpperCase() + l.substring(1).toLowerCase())
.join(" ");
}
return "Unknown";
}
function toDayTime(d: Date) {
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} at ${d.toLocaleTimeString()}`;
}
function toDayTime(d: Date) {
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} at ${d.toLocaleTimeString()}`;
}
/**
* Get what will be the visible date the activity was 'created'.
* @returns customDate if defined or createdAt if not.
*/
function getCreatedAtVis(a: Activity) {
return Date.parse(a.customDate ?? a.createdAt);
}
/**
* Get what will be the visible date the activity was 'created'.
* @returns customDate if defined or createdAt if not.
*/
function getCreatedAtVis(a: Activity) {
return Date.parse(a.customDate ?? a.createdAt);
}
function getGroupedActivity(activities?: Activity[]) {
activities = activities?.filter((a) => a.type);
const a = activities?.sort((a, b) => getCreatedAtVis(b) - getCreatedAtVis(a));
let grouped: { [index: string]: any } = {};
if (a) {
for (let i = 0; i < a.length; i++) {
const activity = a[i];
const date = new Date(getCreatedAtVis(activity));
const key = `${months[date.getMonth()]} ${date.getFullYear()}`;
if (grouped[key]) {
grouped[key].push(activity);
} else {
grouped[key] = [activity];
}
}
}
return grouped;
}
function getGroupedActivity(activities?: Activity[]) {
activities = activities?.filter((a) => a.type);
const a = activities?.sort(
(a, b) => getCreatedAtVis(b) - getCreatedAtVis(a),
);
let grouped: { [index: string]: any } = {};
if (a) {
for (let i = 0; i < a.length; i++) {
const activity = a[i];
const date = new Date(getCreatedAtVis(activity));
const key = `${months[date.getMonth()]} ${date.getFullYear()}`;
if (grouped[key]) {
grouped[key].push(activity);
} else {
grouped[key] = [activity];
}
}
}
return grouped;
}
function openEditor(a: Activity) {
clickedActivity = a;
isActivityEditorVisible = true;
return;
}
function openEditor(a: Activity) {
clickedActivity = a;
return;
}
function getActivityDataParsed(a: Activity) {
try {
if (a.data) {
return JSON.parse(a.data);
}
} catch (err) {
console.error("getActivityDataParsed: Failed!", err);
}
}
function getActivityDataParsed(a: Activity) {
try {
if (a.data) {
return JSON.parse(a.data);
}
} catch (err) {
console.error("getActivityDataParsed: Failed!", err);
}
}
</script>
{#if isActivityEditorVisible}
<ActivityEditor
watchedId={wListId}
activity={clickedActivity}
activityMessage={getMsg(clickedActivity)}
onClose={() => (isActivityEditorVisible = false)}
/>
{#if clickedActivity}
<ActivityEditor
watchedId={wListId}
activity={clickedActivity}
activityMessage={getMsg(clickedActivity)}
onClose={() => (clickedActivity = undefined)}
/>
{/if}
<div class="activity">
<h2>Activity</h2>
{#if groupedActivities && Object.keys(groupedActivities).length > 0}
<ul>
{#each Object.keys(groupedActivities) as k}
<h3>{k}</h3>
<h2>Activity</h2>
{#if groupedActivities && Object.keys(groupedActivities).length > 0}
<ul>
{#each Object.keys(groupedActivities) as k}
<h3>{k}</h3>
{#each groupedActivities[k] as a}
{@const d = new Date(getCreatedAtVis(a))}
<li>
<button class="unset" on:click={() => openEditor(a)}>
<span title={d.toDateString()}>{toDayTime(d)}</span>
<span>{getMsg(a)}</span>
</button>
{#if a.type?.endsWith("_AUTO")}
{@const data = getActivityDataParsed(a)}
<i
use:tooltip={{
text:
data && data.reason
? `Automated because ${data.reason}`
: "Completed by an automation.",
pos: "bot"
}}
style="width: 20px; height: 20px;"
>
<Icon i="sparkles" wh={20} />
</i>
{/if}
</li>
{/each}
{/each}
</ul>
{:else}
<span>You Have No Activity!</span>
{/if}
{#each groupedActivities[k] as a}
{@const d = new Date(getCreatedAtVis(a))}
<li>
<button class="plain" onclick={() => openEditor(a)}>
<span title={d.toDateString()}>{toDayTime(d)}</span>
<span>{getMsg(a)}</span>
</button>
{#if a.type?.endsWith("_AUTO")}
{@const data = getActivityDataParsed(a)}
<i
use:tooltip={{
text:
data && data.reason
? `Automated because ${data.reason}`
: "Completed by an automation.",
pos: "bot",
}}
style="width: 20px; height: 20px;"
>
<Icon i="sparkles" wh={20} />
</i>
{/if}
</li>
{/each}
{/each}
</ul>
{:else}
<span>You Have No Activity!</span>
{/if}
</div>
<style lang="scss">
.activity {
width: 100%;
.activity {
width: 100%;
ul {
display: flex;
flex-flow: column;
gap: 8px;
margin-top: 8px;
margin-left: calc(30px + 8px);
list-style: none;
max-height: 250px;
overflow-y: auto;
overflow-x: hidden;
ul {
display: flex;
flex-flow: column;
gap: 8px;
margin-top: 8px;
margin-left: calc(30px + 8px);
list-style: none;
max-height: 250px;
overflow-y: auto;
overflow-x: hidden;
h3 {
position: sticky;
top: 0;
font-size: 16px;
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
padding-bottom: 1px;
background-color: $bg-color;
}
h3 {
position: sticky;
top: 0;
font-size: 16px;
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
padding-bottom: 1px;
background-color: $bg-color;
}
li {
display: flex;
flex-flow: row;
gap: 8px;
align-items: center;
li {
display: flex;
flex-flow: row;
gap: 8px;
align-items: center;
button {
all: unset;
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
width: max-content;
max-width: 100%;
cursor: pointer;
}
button {
all: unset;
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
width: max-content;
max-width: 100%;
cursor: pointer;
}
span {
width: max-content;
margin-left: 0px;
span {
width: max-content;
margin-left: 0px;
&:first-child {
min-width: max-content;
}
&:first-child {
min-width: max-content;
}
&:last-of-type {
background-color: $accent-color;
color: $text-color;
border-radius: 8px;
padding: 10px 12px;
}
}
}
}
&:last-of-type {
background-color: $accent-color;
color: $text-color;
border-radius: 8px;
padding: 10px 12px;
}
}
}
}
span {
margin-left: calc(30px);
width: 100%;
display: flex;
justify-content: center;
}
}
span {
margin-left: calc(30px);
width: 100%;
display: flex;
justify-content: center;
}
}
h2 {
font-size: 30px;
font-weight: bold;
margin-left: 30px;
}
h2 {
font-size: 30px;
font-weight: bold;
margin-left: 30px;
}
</style>
+120 -111
View File
@@ -1,131 +1,140 @@
<script lang="ts">
import { updateActivity, removeActivity } from "@/lib/util/api";
import Modal from "./Modal.svelte";
import type { Activity } from "@/types";
import { notify } from "./util/notify";
import { updateActivity, removeActivity } from "@/lib/util/api";
import Modal from "./Modal.svelte";
import type { Activity } from "@/types";
import { notify } from "./util/notify";
export let watchedId: number;
export let activity: Activity;
export let activityMessage: string;
export let onClose: () => void;
interface Props {
watchedId: number;
activity: Activity;
activityMessage: string;
onClose: () => void;
}
let isDateTimeChanged: boolean;
let isDateTimeValid = true;
let currentDateObject = new Date(Date.parse(activity.customDate ?? activity.createdAt));
let currentDateString = dateToInputDateString(currentDateObject);
let currentTimeString = dateToInputTimeString(currentDateObject);
let selectedDateString = currentDateString;
let selectedTimeString = currentTimeString;
let { watchedId, activity, activityMessage, onClose }: Props = $props();
$: {
isDateTimeChanged =
currentDateString != selectedDateString || currentTimeString != selectedTimeString;
}
let isDateTimeChanged: boolean = $derived(
currentDateString != selectedDateString ||
currentTimeString != selectedTimeString,
);
let isDateTimeValid = $state(true);
let currentDateObject = new Date(
Date.parse(activity.customDate ?? activity.createdAt),
);
let currentDateString = dateToInputDateString(currentDateObject);
let currentTimeString = dateToInputTimeString(currentDateObject);
let selectedDateString = $state(currentDateString);
let selectedTimeString = $state(currentTimeString);
function dateToInputDateString(date: Date) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
function dateToInputDateString(date: Date) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
function dateToInputTimeString(date: Date) {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
function dateToInputTimeString(date: Date) {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
function validateNewDate() {
try {
const epochMillis = Date.parse(`${selectedDateString} ${selectedTimeString}`);
const dateObj = new Date(epochMillis);
if (isNaN(dateObj.getTime())) {
isDateTimeValid = false;
return;
}
isDateTimeValid = true;
return dateObj;
} catch (err) {
console.error("ActivityEditor: validateNewDate failed!", err);
isDateTimeValid = false;
}
}
function validateNewDate() {
try {
const epochMillis = Date.parse(
`${selectedDateString} ${selectedTimeString}`,
);
const dateObj = new Date(epochMillis);
if (isNaN(dateObj.getTime())) {
isDateTimeValid = false;
return;
}
isDateTimeValid = true;
return dateObj;
} catch (err) {
console.error("ActivityEditor: validateNewDate failed!", err);
isDateTimeValid = false;
}
}
function update() {
const dateObj = validateNewDate();
if (dateObj && isDateTimeValid && isDateTimeChanged) {
updateActivity(watchedId, activity.id, dateObj);
onClose();
return;
}
notify({ text: "Unable to try updating!", type: "error" });
console.error(
"ActivityEditor: Can't try updating, data missing/invalid:",
dateObj,
isDateTimeValid,
isDateTimeChanged
);
}
function update() {
const dateObj = validateNewDate();
if (dateObj && isDateTimeValid && isDateTimeChanged) {
updateActivity(watchedId, activity.id, dateObj);
onClose();
return;
}
notify({ text: "Unable to try updating!", type: "error" });
console.error(
"ActivityEditor: Can't try updating, data missing/invalid:",
dateObj,
isDateTimeValid,
isDateTimeChanged,
);
}
function remove() {
removeActivity(watchedId, activity.id);
onClose();
}
function remove() {
removeActivity(watchedId, activity.id);
onClose();
}
</script>
<Modal title="Edit Activity" desc={activityMessage} maxWidth="400px" {onClose}>
<div class="centered">
<h3>Date</h3>
<input
id="activity-date"
type="date"
bind:value={selectedDateString}
on:change={validateNewDate}
class:invalid={!isDateTimeValid}
/>
<h3>Time</h3>
<input
id="activity-time"
type="time"
bind:value={selectedTimeString}
on:change={validateNewDate}
/>
<div class="centered">
<h3>Date</h3>
<input
id="activity-date"
type="date"
bind:value={selectedDateString}
onchange={validateNewDate}
class:invalid={!isDateTimeValid}
/>
<h3>Time</h3>
<input
id="activity-time"
type="time"
bind:value={selectedTimeString}
onchange={validateNewDate}
/>
<div class="button-row">
<button class="danger" on:click={remove}>Delete</button>
<div>
<button on:click={update} disabled={!(isDateTimeChanged && isDateTimeValid)}>Update</button>
</div>
</div>
</div>
<div class="button-row">
<button class="danger" onclick={remove}>Delete</button>
<div>
<button
onclick={update}
disabled={!(isDateTimeChanged && isDateTimeValid)}>Update</button
>
</div>
</div>
</div>
</Modal>
<style lang="scss">
.centered {
display: flex;
flex-flow: column;
gap: 10px;
height: 100%;
.centered {
display: flex;
flex-flow: column;
gap: 10px;
height: 100%;
h3 {
font-size: 16px;
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
}
h3 {
font-size: 16px;
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
}
.button-row {
display: flex;
flex-flow: row;
justify-content: space-between;
margin-top: 10px;
.button-row {
display: flex;
flex-flow: row;
justify-content: space-between;
margin-top: 10px;
button {
margin-top: auto;
width: max-content;
}
}
}
button {
margin-top: auto;
width: max-content;
}
}
}
</style>
+79 -70
View File
@@ -1,87 +1,96 @@
<script lang="ts">
export let name: string;
export let value: boolean = false;
export let toggled: (on: boolean) => void = () => {};
export let disabled = false;
interface Props {
name: string;
value?: boolean;
toggled?: (on: boolean) => void;
disabled?: boolean;
}
let actualDisabled = false;
let {
name,
value = $bindable(),
toggled = () => {},
disabled = false,
}: Props = $props();
$: {
// In cases where we disable and enable the checkbox super fast
// it ends up looking like a jittery mess. To circumvent this
// issue, we add a small delay before undisabling.
// (eg. fast internet and net request dis and re enables it quickly)
if (disabled) {
actualDisabled = true;
} else {
setTimeout(() => {
actualDisabled = disabled;
}, 150);
}
}
let actualDisabled = $state(false);
function checkboxChange(e: Event) {
toggled((e.target as HTMLInputElement).checked);
}
$effect(() => {
// In cases where we disable and enable the checkbox super fast
// it ends up looking like a jittery mess. To circumvent this
// issue, we add a small delay before undisabling.
// (eg. fast internet and net request dis and re enables it quickly)
if (disabled) {
actualDisabled = true;
} else {
setTimeout(() => {
actualDisabled = disabled;
}, 150);
}
});
function checkboxChange(e: Event) {
toggled((e.target as HTMLInputElement).checked);
}
</script>
<div class="toggle-pill-color">
<input
bind:checked={value}
disabled={actualDisabled}
type="checkbox"
id={name}
on:change={checkboxChange}
/>
<label for={name}></label>
<input
bind:checked={value}
disabled={actualDisabled}
type="checkbox"
id={name}
onchange={checkboxChange}
/>
<label for={name}></label>
</div>
<style lang="scss">
.toggle-pill-color {
width: min-content;
.toggle-pill-color {
width: min-content;
input {
display: none;
input {
display: none;
&:checked + label {
background: $success;
&:checked + label {
background: $success;
&::before {
left: 1.6em;
}
}
&::before {
left: 1.6em;
}
}
&:disabled + label {
opacity: 0.5;
cursor: not-allowed;
}
}
&:disabled + label {
opacity: 0.5;
cursor: not-allowed;
}
}
label {
display: block;
position: relative;
width: 3em;
height: 1.6em;
border-radius: 1em;
background: $error;
cursor: pointer;
user-select: none;
transition:
opacity 100ms ease-in-out,
opacity 100ms ease-in-out;
label {
display: block;
position: relative;
width: 3em;
height: 1.6em;
border-radius: 1em;
background: $error;
cursor: pointer;
user-select: none;
transition:
opacity 100ms ease-in-out,
opacity 100ms ease-in-out;
&::before {
content: "";
display: block;
width: 1.2em;
height: 1.2em;
border-radius: 1em;
background: #fff;
position: absolute;
left: 0.2em;
top: 0.2em;
transition: all 0.2s ease-in-out;
}
}
}
&::before {
content: "";
display: block;
width: 1.2em;
height: 1.2em;
border-radius: 1em;
background: #fff;
position: absolute;
left: 0.2em;
top: 0.2em;
transition: all 0.2s ease-in-out;
}
}
}
</style>
+23 -15
View File
@@ -1,25 +1,33 @@
<!-- Uses Native Color Input -->
<script lang="ts">
export let value = "#000000";
export let style = "";
interface Props {
value?: string;
style?: string;
}
let { value = $bindable("#000000"), style = "" }: Props = $props();
</script>
<button class="plain" style={`background-color: ${value};${style}`}>
<input type="color" bind:value />
<button
aria-label="Open color picker"
class="plain"
style={`background-color: ${value};${style}`}
>
<input type="color" bind:value />
</button>
<style lang="scss">
button {
width: 100%;
height: 100%;
border-radius: 10px;
outline: 1px solid $accent-color;
cursor: pointer;
}
button {
width: 100%;
height: 100%;
border-radius: 10px;
outline: 1px solid $accent-color;
cursor: pointer;
}
input {
opacity: 0;
cursor: pointer;
}
input {
opacity: 0;
cursor: pointer;
}
</style>
+171 -151
View File
@@ -1,182 +1,202 @@
<script lang="ts">
import type { DropDownItem } from "@/types";
import Icon from "./Icon.svelte";
import { onMount } from "svelte";
import type { DropDownItem } from "@/types";
import Icon from "./Icon.svelte";
import { onMount } from "svelte";
export let options: string[] | DropDownItem[];
// If we are using DropDownItems[] as options.
export let isDropDownItem = false;
export let active: string | number | undefined = undefined;
export let placeholder: string;
export let blendIn: boolean = false;
export let disabled = false;
export let onChange: () => void = () => {};
export let showActiveElementsInOptions: boolean = false;
interface Props {
options: string[] | DropDownItem[];
// If we are using DropDownItems[] as options.
isDropDownItem?: boolean;
active?: string | number | undefined;
placeholder: string;
blendIn?: boolean;
disabled?: boolean;
onChange?: () => void;
showActiveElementsInOptions?: boolean;
}
let activeValue: string;
let open = false;
let ulElement: HTMLUListElement;
let mainElement: HTMLDivElement;
let {
options,
isDropDownItem = false,
active = $bindable(undefined),
placeholder,
blendIn = false,
disabled = false,
onChange = () => {},
showActiveElementsInOptions = false,
}: Props = $props();
function handleKeyPress(event: KeyboardEvent) {
if (!open || disabled) return; // Don't handle if closed or disabled
let activeValue: string | undefined = $state();
let open = $state(false);
let ulElement: HTMLUListElement | undefined = $state();
let mainElement: HTMLDivElement | undefined = $state();
const pressedLetter = event.key.toLowerCase();
function handleKeyPress(event: KeyboardEvent) {
if (!open || disabled) return; // Don't handle if closed or disabled
// Filter options that start with the pressed letter (ignoring case)
const filteredOptions = options.filter((o) =>
typeof o === "string"
? o.toLowerCase().startsWith(pressedLetter)
: o.value.toLowerCase().startsWith(pressedLetter)
);
const pressedLetter = event.key.toLowerCase();
// If there are filtered options, select the first one
let f: string | number | undefined;
if (filteredOptions.length > 0) {
f = typeof filteredOptions[0] === "string" ? filteredOptions[0] : filteredOptions[0].id;
}
// Filter options that start with the pressed letter (ignoring case)
const filteredOptions = options.filter((o) =>
typeof o === "string"
? o.toLowerCase().startsWith(pressedLetter)
: o.value.toLowerCase().startsWith(pressedLetter),
);
// Find first button with text content starting with letter pressed and scroll it into view.
const btns = ulElement?.querySelectorAll("button");
for (let i = 0; i < btns.length; i++) {
const btn = btns[i];
if (btn.textContent?.toLowerCase()?.startsWith(pressedLetter)) {
btn.scrollIntoView({ behavior: "smooth", block: "start" });
break;
}
}
}
// If there are filtered options, select the first one
let f: string | number | undefined;
if (filteredOptions.length > 0) {
f =
typeof filteredOptions[0] === "string"
? filteredOptions[0]
: filteredOptions[0].id;
}
$: {
if (typeof active === "string" && !isDropDownItem) {
activeValue = active;
} else {
const v = options.find((o) => (typeof o !== "string" ? o.id === active : false));
if (v && typeof v !== "string") activeValue = v.value;
}
}
// Find first button with text content starting with letter pressed and scroll it into view.
const btns = ulElement?.querySelectorAll("button");
if (btns) {
for (let i = 0; i < btns.length; i++) {
const btn = btns[i];
if (btn.textContent?.toLowerCase()?.startsWith(pressedLetter)) {
btn.scrollIntoView({ behavior: "smooth", block: "start" });
break;
}
}
}
}
onMount(() => {
mainElement.addEventListener("keypress", handleKeyPress);
$effect(() => {
if (typeof active === "string" && !isDropDownItem) {
activeValue = active;
} else {
const v = options.find((o) =>
typeof o !== "string" ? o.id === active : false,
);
if (v && typeof v !== "string") activeValue = v.value;
}
});
return () => {
mainElement.removeEventListener("keypress", handleKeyPress);
};
});
onMount(() => {
mainElement?.addEventListener("keypress", handleKeyPress);
return () => {
mainElement?.removeEventListener("keypress", handleKeyPress);
};
});
</script>
<div
class={[
open ? "is-open" : "",
typeof active === "undefined" ? "placeholder-shown" : "",
blendIn ? "blend-in" : ""
].join(" ")}
bind:this={mainElement}
class={[
open ? "is-open" : "",
typeof active === "undefined" ? "placeholder-shown" : "",
blendIn ? "blend-in" : "",
].join(" ")}
bind:this={mainElement}
>
<button on:click={() => (open = !open)} {disabled}>
{activeValue ? activeValue : placeholder}
<Icon i="chevron" facing={open ? "up" : "down"} />
</button>
<ul bind:this={ulElement}>
{#each showActiveElementsInOptions ? options : options.filter( (o) => (typeof o === "string" ? o !== active : o.id !== active) ) as o}
<li>
<button
class="plain"
on:click={() => {
active = typeof o == "string" ? o : o.id;
open = false;
onChange();
}}
>
{typeof o == "string" ? o : o.value}
</button>
</li>
{/each}
</ul>
<button onclick={() => (open = !open)} {disabled}>
{activeValue ? activeValue : placeholder}
<Icon i="chevron" facing={open ? "up" : "down"} />
</button>
<ul bind:this={ulElement}>
{#each showActiveElementsInOptions ? options : options.filter( (o) => (typeof o === "string" ? o !== active : o.id !== active), ) as o}
<li>
<button
class="plain"
onclick={() => {
active = typeof o == "string" ? o : o.id;
open = false;
onChange();
}}
>
{typeof o == "string" ? o : o.value}
</button>
</li>
{/each}
</ul>
</div>
<style lang="scss">
div {
position: relative;
div {
position: relative;
&.placeholder-shown {
& > button {
color: #8e8e8e;
&.placeholder-shown {
& > button {
color: #8e8e8e;
&:hover:not(:disabled) {
color: $bg-color;
}
}
}
&:hover:not(:disabled) {
color: $bg-color;
}
}
}
&.blend-in {
& > button {
border-color: transparent;
background-color: transparent;
&.blend-in {
& > button {
border-color: transparent;
background-color: transparent;
&:hover:not(:disabled),
&:focus-visible:not(:disabled) {
background-color: $text-color;
}
}
}
&:hover:not(:disabled),
&:focus-visible:not(:disabled) {
background-color: $text-color;
}
}
}
&.is-open {
ul {
display: flex;
}
&.is-open {
ul {
display: flex;
}
& > button {
border-color: $text-color;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
& > button {
border-color: $text-color;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
button {
gap: 3px;
text-transform: capitalize;
button {
gap: 3px;
text-transform: capitalize;
:global(svg) {
margin-left: auto;
}
}
:global(svg) {
margin-left: auto;
}
}
ul {
display: none;
flex-flow: column;
position: absolute;
list-style: none;
width: 100%;
font-size: 14px;
border: 2px solid $text-color;
border-top: 0;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
background-color: $bg-color;
z-index: 99;
max-height: 20vh;
overflow-y: auto;
ul {
display: none;
flex-flow: column;
position: absolute;
list-style: none;
width: 100%;
font-size: 14px;
border: 2px solid $text-color;
border-top: 0;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
background-color: $bg-color;
z-index: 99;
max-height: 20vh;
overflow-y: auto;
li {
width: 100%;
li {
width: 100%;
button {
padding: 5px 10px;
width: 100%;
text-align: start;
text-transform: capitalize;
transition: background-color 100ms ease;
button {
padding: 5px 10px;
width: 100%;
text-align: start;
text-transform: capitalize;
transition: background-color 100ms ease;
&:hover:not(:disabled),
&:focus-visible:not(:disabled) {
background-color: $text-color;
color: $bg-color;
fill: $bg-color;
opacity: 1;
}
}
}
}
}
&:hover:not(:disabled),
&:focus-visible:not(:disabled) {
background-color: $text-color;
color: $bg-color;
fill: $bg-color;
opacity: 1;
}
}
}
}
}
</style>
+116 -103
View File
@@ -1,121 +1,134 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Icon as IconT } from "@/types";
import Icon from "./Icon.svelte";
import { onMount } from "svelte";
import type { Icon as IconT } from "@/types";
import Icon from "./Icon.svelte";
export let text: string;
export let icon: IconT = "document";
export let filesSelected: (f?: FileList | null) => void;
export let allowSelectMultipleFiles = false;
interface Props {
text: string;
icon?: IconT;
filesSelected: (f?: FileList | null) => void;
allowSelectMultipleFiles?: boolean;
}
let fileInput: HTMLInputElement;
let dragEnterTarget: EventTarget | null;
let isDragOver = false;
let {
text,
icon = "document",
filesSelected,
allowSelectMultipleFiles = false,
}: Props = $props();
function importFile() {
fileInput.click();
}
let fileInput: HTMLInputElement = $state();
let dragEnterTarget: EventTarget | null = $state();
let isDragOver = $state(false);
onMount(() => {
if (fileInput) {
fileInput.addEventListener("change", () => {
filesSelected(fileInput.files);
});
}
});
function importFile() {
fileInput.click();
}
onMount(() => {
if (fileInput) {
fileInput.addEventListener("change", () => {
filesSelected(fileInput.files);
});
}
});
</script>
<div class="drop-file-btn">
<button
on:click={importFile}
on:dragover={(ev) => {
ev.preventDefault();
ev.stopPropagation();
}}
on:dragenter={(ev) => {
ev.preventDefault();
ev.stopPropagation();
dragEnterTarget = ev.target;
console.log("enter");
isDragOver = true;
}}
on:dragleave={(ev) => {
ev.preventDefault();
ev.stopPropagation();
if (dragEnterTarget === ev.target) {
console.log("leave");
isDragOver = false;
}
}}
on:drop={(ev) => {
ev.preventDefault();
ev.stopPropagation();
filesSelected(ev.dataTransfer?.files);
}}
class={isDragOver ? "dragging-over" : ""}
>
<Icon i={isDragOver ? "add" : icon} wh="100%" />
<div>
<h4 class="norm">
{#if isDragOver}
Import {text}
{:else}
{text}
{/if}
</h4>
</div>
</button>
<input type="file" multiple={allowSelectMultipleFiles} bind:this={fileInput} />
<button
onclick={importFile}
ondragover={(ev) => {
ev.preventDefault();
ev.stopPropagation();
}}
ondragenter={(ev) => {
ev.preventDefault();
ev.stopPropagation();
dragEnterTarget = ev.target;
console.log("enter");
isDragOver = true;
}}
ondragleave={(ev) => {
ev.preventDefault();
ev.stopPropagation();
if (dragEnterTarget === ev.target) {
console.log("leave");
isDragOver = false;
}
}}
ondrop={(ev) => {
ev.preventDefault();
ev.stopPropagation();
filesSelected(ev.dataTransfer?.files);
}}
class={isDragOver ? "dragging-over" : ""}
>
<Icon i={isDragOver ? "add" : icon} wh="100%" />
<div>
<h4 class="norm">
{#if isDragOver}
Import {text}
{:else}
{text}
{/if}
</h4>
</div>
</button>
<input
type="file"
multiple={allowSelectMultipleFiles}
bind:this={fileInput}
/>
</div>
<style lang="scss">
.drop-file-btn {
button {
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
gap: 10px;
height: 180px;
padding: 20px;
background-color: $accent-color;
border: unset;
border-radius: 10px;
user-select: none;
transition: 180ms ease-in-out;
.drop-file-btn {
button {
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
gap: 10px;
height: 180px;
padding: 20px;
background-color: $accent-color;
border: unset;
border-radius: 10px;
user-select: none;
transition: 180ms ease-in-out;
:global {
#reel path {
transition: 180ms ease-in-out;
:global {
#reel path {
transition: 180ms ease-in-out;
&:first-of-type {
fill: transparent;
}
&:first-of-type {
fill: transparent;
}
&:last-of-type {
fill: $text-color;
}
}
}
&:last-of-type {
fill: $text-color;
}
}
}
&:hover,
&.dragging-over {
color: $bg-color;
background-color: $accent-color-hover;
&:hover,
&.dragging-over {
color: $bg-color;
background-color: $accent-color-hover;
:global(#reel path:last-of-type) {
fill: $bg-color;
}
}
}
:global(#reel path:last-of-type) {
fill: $bg-color;
}
}
}
input[type="file"] {
width: 0px;
overflow: hidden;
border: unset;
background-color: transparent;
position: absolute;
top: -500px;
}
}
input[type="file"] {
width: 0px;
overflow: hidden;
border: unset;
background-color: transparent;
position: absolute;
top: -500px;
}
}
</style>
+38 -34
View File
@@ -1,44 +1,48 @@
<script lang="ts">
export let pretty: string;
export let error: any;
export let onRetry: () => void | undefined = undefined!;
interface Props {
pretty: string;
error: any;
onRetry?: () => void | undefined;
}
let { pretty, error, onRetry = undefined! }: Props = $props();
</script>
<div>
<div>
<strong>{pretty}</strong>
{#if error?.message}
<p>{error.message}</p>
{#if error.response?.data?.error}
<p>{error.response.data.error}</p>
{/if}
{:else}
<p>{JSON.stringify(error)}</p>
{/if}
{#if onRetry}
<button on:click={onRetry}>Try Again</button>
{/if}
</div>
<div>
<strong>{pretty}</strong>
{#if error?.message}
<p>{error.message}</p>
{#if error.response?.data?.error}
<p>{error.response.data.error}</p>
{/if}
{:else}
<p>{JSON.stringify(error)}</p>
{/if}
{#if onRetry}
<button onclick={onRetry}>Try Again</button>
{/if}
</div>
</div>
<style lang="scss">
div {
display: flex;
justify-content: center;
width: 100%;
div {
display: flex;
justify-content: center;
width: 100%;
div {
display: flex;
justify-content: center;
flex-flow: column;
max-width: 500px;
gap: 5px;
text-transform: capitalize;
font-size: 16px;
div {
display: flex;
justify-content: center;
flex-flow: column;
max-width: 500px;
gap: 5px;
text-transform: capitalize;
font-size: 16px;
p {
font-size: 12px;
}
}
}
p {
font-size: 12px;
}
}
}
</style>
+36 -31
View File
@@ -1,41 +1,46 @@
<script lang="ts">
export let title: string | undefined;
interface Props {
title: string | undefined;
children?: import("svelte").Snippet;
}
let { title, children }: Props = $props();
</script>
<div>
{#if title}
<h2>{title}</h2>
{/if}
<ul>
<slot />
</ul>
{#if title}
<h2>{title}</h2>
{/if}
<ul>
{@render children?.()}
</ul>
</div>
<style lang="scss">
div {
width: 100%;
overflow-x: auto;
}
div {
width: 100%;
overflow-x: auto;
}
h2 {
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 30px;
font-weight: bold;
margin-left: 30px;
position: sticky;
left: 0;
}
h2 {
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 30px;
font-weight: bold;
margin-left: 30px;
position: sticky;
left: 0;
}
ul {
display: flex;
flex-flow: row;
gap: 10px;
list-style: none;
padding: 15px 30px;
justify-content: space-between;
}
ul {
display: flex;
flex-flow: row;
gap: 10px;
list-style: none;
padding: 15px 30px;
justify-content: space-between;
}
</style>
+583 -391
View File
File diff suppressed because it is too large Load Diff
+221 -192
View File
@@ -1,212 +1,241 @@
<script lang="ts">
import Icon from "@/lib/Icon.svelte";
import Modal from "@/lib/Modal.svelte";
import Spinner from "@/lib/Spinner.svelte";
import { notify } from "@/lib/util/notify";
import { JobStatus, type GetJobResponse, type JobCreatedResponse } from "@/types";
import axios from "axios";
import { onDestroy, onMount } from "svelte";
import { watchedList } from "@/store";
import Icon from "@/lib/Icon.svelte";
import Modal from "@/lib/Modal.svelte";
import Spinner from "@/lib/Spinner.svelte";
import { notify } from "@/lib/util/notify";
import {
JobStatus,
type GetJobResponse,
type JobCreatedResponse,
} from "@/types";
import axios from "axios";
import { onDestroy, onMount } from "svelte";
import { store } from "@/store.svelte";
export let modalTitle: string;
// Promise that returns the job id to watch.
export let getJobId: () => Promise<{ jobId: string } | undefined>;
export let onClose: () => void;
export let messages: {
starting: string;
};
interface Props {
modalTitle: string;
// Promise that returns the job id to watch.
getJobId: () => Promise<{ jobId: string } | undefined>;
onClose: () => void;
messages: {
starting: string;
};
}
let step: "starting" | "errored" | "job-running" | "done" | "modal-closing" = "starting";
let jobId: string | undefined;
let currentTask: string | undefined;
let latestJobStatus: GetJobResponse | undefined;
let jobFailError: string | undefined;
let { modalTitle, getJobId, onClose, messages }: Props = $props();
async function startSync() {
try {
const r = await getJobId();
console.log("startSync: Response:", r);
if (!r?.jobId) {
step = "errored";
console.error("startSync: No jobId returned!");
return;
}
jobId = r.jobId;
step = "job-running";
startJobWatcher();
} catch (err: any) {
console.error("startSync failed!", err);
step = "errored";
jobFailError = err?.response ? err?.response?.data?.error : String(err);
}
}
let step: "starting" | "errored" | "job-running" | "done" | "modal-closing" =
$state("starting");
let jobId: string | undefined;
let currentTask: string | undefined = $state();
let latestJobStatus: GetJobResponse | undefined = $state();
let jobFailError: string | undefined = $state();
async function startJobWatcher() {
if (!jobId) {
console.error("startJobWatcher: No Job Id");
notify({ text: "Unable to start job watcher, no job id.", type: "error" });
return;
}
console.log("startJobWatcher: Starting..");
let seqfailedJobReqs = 0;
while (step === "job-running") {
try {
const r = await axios.get<GetJobResponse>(`/job/${jobId}`);
console.log("jobWatcher: Got job data:", r.data);
latestJobStatus = r.data;
currentTask = r.data?.currentTask;
if (r.data?.status === JobStatus.DONE) {
step = "done";
} else if (r.data?.status === JobStatus.CANCELLED) {
step = "errored";
}
// If we get here without erroring, we can reset it to 0.
seqfailedJobReqs = 0;
} catch (err) {
console.error("jobWatcher: Get job request failed!", seqfailedJobReqs, err);
seqfailedJobReqs++;
}
if (seqfailedJobReqs >= 10) {
console.error("jobWatcher: Failed 10 times in a row!");
notify({
text: "Status checker has failed 10 times in a row!",
type: "error",
time: 30000
});
step = "errored";
break;
}
await new Promise((r) => setTimeout(r, 1000));
}
if (step !== "modal-closing") {
// Update our watched list
const nid = notify({ text: "Fetching updated watched list.", type: "loading" });
try {
const w = await axios.get("/watched");
if (w?.data?.length > 0) {
watchedList.update((wl) => (wl = w.data));
}
notify({ id: nid, text: "Fetched updated watched list.", type: "success" });
} catch (err) {
console.error("jobWatcher: Getting updated watched list failed!", err);
notify({ id: nid, text: "Getting updated watched list failed!", type: "error" });
}
}
}
async function startSync() {
try {
const r = await getJobId();
console.log("startSync: Response:", r);
if (!r?.jobId) {
step = "errored";
console.error("startSync: No jobId returned!");
return;
}
jobId = r.jobId;
step = "job-running";
startJobWatcher();
} catch (err: any) {
console.error("startSync failed!", err);
step = "errored";
jobFailError = err?.response ? err?.response?.data?.error : String(err);
}
}
function modalClose() {
if (step === "job-running") {
notify({
text: "Sync will continue in the background.. please refresh the page periodically to view your updated list or come back later.",
time: 10000
});
}
step = "modal-closing";
onClose();
}
async function startJobWatcher() {
if (!jobId) {
console.error("startJobWatcher: No Job Id");
notify({
text: "Unable to start job watcher, no job id.",
type: "error",
});
return;
}
console.log("startJobWatcher: Starting..");
let seqfailedJobReqs = 0;
while (step === "job-running") {
try {
const r = await axios.get<GetJobResponse>(`/job/${jobId}`);
console.log("jobWatcher: Got job data:", r.data);
latestJobStatus = r.data;
currentTask = r.data?.currentTask;
if (r.data?.status === JobStatus.DONE) {
step = "done";
} else if (r.data?.status === JobStatus.CANCELLED) {
step = "errored";
}
// If we get here without erroring, we can reset it to 0.
seqfailedJobReqs = 0;
} catch (err) {
console.error(
"jobWatcher: Get job request failed!",
seqfailedJobReqs,
err,
);
seqfailedJobReqs++;
}
if (seqfailedJobReqs >= 10) {
console.error("jobWatcher: Failed 10 times in a row!");
notify({
text: "Status checker has failed 10 times in a row!",
type: "error",
time: 30000,
});
step = "errored";
break;
}
await new Promise((r) => setTimeout(r, 1000));
}
if (step !== "modal-closing") {
// Update our watched list
const nid = notify({
text: "Fetching updated watched list.",
type: "loading",
});
try {
const w = await axios.get("/watched");
if (w?.data?.length > 0) {
store.watchedList = w.data;
}
notify({
id: nid,
text: "Fetched updated watched list.",
type: "success",
});
} catch (err) {
console.error("jobWatcher: Getting updated watched list failed!", err);
notify({
id: nid,
text: "Getting updated watched list failed!",
type: "error",
});
}
}
}
onMount(() => {
startSync();
});
function modalClose() {
if (step === "job-running") {
notify({
text: "Sync will continue in the background.. please refresh the page periodically to view your updated list or come back later.",
time: 10000,
});
}
step = "modal-closing";
onClose();
}
onDestroy(() => {
step = "starting";
jobId = undefined;
currentTask = undefined;
latestJobStatus = undefined;
});
onMount(() => {
startSync();
});
onDestroy(() => {
step = "starting";
jobId = undefined;
currentTask = undefined;
latestJobStatus = undefined;
});
</script>
<Modal title={modalTitle} maxWidth="700px" onClose={modalClose}>
<div class="ctr">
{#if step === "done"}
<Icon i="check" wh={60} />
{:else if step === "errored"}
<Icon i="close" wh={70} />
{:else}
<Spinner />
{/if}
<div>
{#if step === "starting"}
<h4 class="norm">Starting</h4>
<span>{messages.starting}</span>
{:else if step === "job-running"}
<h4 class="norm">Running</h4>
{#if currentTask}
<span>{currentTask}</span>
{/if}
{:else if step === "done"}
{#if !latestJobStatus?.errors || latestJobStatus?.errors?.length <= 0}
<h4 class="norm">Finished</h4>
<span>We have finished. Looks like there were no errors!</span>
{:else}
<h4 class="norm">
Finished With {latestJobStatus?.errors?.length} Error{latestJobStatus?.errors
?.length === 1
? ""
: "s"}
</h4>
<span>Job finished, but with errors:</span>
<ul>
{#each latestJobStatus?.errors as e}
<li>{e}</li>
{/each}
</ul>
{/if}
{:else if step === "errored"}
<h4 class="norm">We Errored!</h4>
{#if jobFailError}
<span>{jobFailError}</span>
{:else}
<span>We errored before starting the job or the job was cancelled.</span>
{/if}
{#if latestJobStatus?.errors && latestJobStatus?.errors?.length > 0}
<ul>
{#each latestJobStatus?.errors as e}
<li>{e}</li>
{/each}
</ul>
{/if}
{:else}
<h4 class="norm">Unknown State!</h4>
<span>We're not sure of the current job status.</span>
{/if}
</div>
</div>
<div class="ctr">
{#if step === "done"}
<Icon i="check" wh={60} />
{:else if step === "errored"}
<Icon i="close" wh={70} />
{:else}
<Spinner />
{/if}
<div>
{#if step === "starting"}
<h4 class="norm">Starting</h4>
<span>{messages.starting}</span>
{:else if step === "job-running"}
<h4 class="norm">Running</h4>
{#if currentTask}
<span>{currentTask}</span>
{/if}
{:else if step === "done"}
{#if !latestJobStatus?.errors || latestJobStatus?.errors?.length <= 0}
<h4 class="norm">Finished</h4>
<span>We have finished. Looks like there were no errors!</span>
{:else}
<h4 class="norm">
Finished With {latestJobStatus?.errors?.length} Error{latestJobStatus
?.errors?.length === 1
? ""
: "s"}
</h4>
<span>Job finished, but with errors:</span>
<ul>
{#each latestJobStatus?.errors as e}
<li>{e}</li>
{/each}
</ul>
{/if}
{:else if step === "errored"}
<h4 class="norm">We Errored!</h4>
{#if jobFailError}
<span>{jobFailError}</span>
{:else}
<span
>We errored before starting the job or the job was cancelled.</span
>
{/if}
{#if latestJobStatus?.errors && latestJobStatus?.errors?.length > 0}
<ul>
{#each latestJobStatus?.errors as e}
<li>{e}</li>
{/each}
</ul>
{/if}
{:else}
<h4 class="norm">Unknown State!</h4>
<span>We're not sure of the current job status.</span>
{/if}
</div>
</div>
</Modal>
<style lang="scss">
.ctr {
display: flex;
flex-flow: row;
gap: 20px;
justify-content: start;
align-items: start;
margin-top: 25px;
margin-bottom: 15px;
margin-left: 15px;
.ctr {
display: flex;
flex-flow: row;
gap: 20px;
justify-content: start;
align-items: start;
margin-top: 25px;
margin-bottom: 15px;
margin-left: 15px;
& > div:last-of-type {
display: flex;
flex-flow: column;
gap: 8px;
padding: 8px 0;
& > div:last-of-type {
display: flex;
flex-flow: column;
gap: 8px;
padding: 8px 0;
& > span {
font-style: italic;
& > span {
font-style: italic;
&::first-letter {
text-transform: uppercase;
}
}
&::first-letter {
text-transform: uppercase;
}
}
& > ul {
padding-left: 25px;
& > ul {
padding-left: 25px;
li::first-letter {
text-transform: uppercase;
}
}
}
}
li::first-letter {
text-transform: uppercase;
}
}
}
}
</style>
+56 -53
View File
@@ -1,65 +1,68 @@
<script lang="ts">
import type { ListBoxItem } from "@/types";
import Checkbox from "./Checkbox.svelte";
import type { ListBoxItem } from "@/types";
import Checkbox from "./Checkbox.svelte";
export let options: ListBoxItem[];
interface Props {
options: ListBoxItem[];
/**
* If a checkbox to select all checkboxes is wanted,
* provide the text to display besides it.
*/
allCheckBox?: string | undefined;
}
/**
* If a checkbox to select all checkboxes is wanted,
* provide the text to display besides it.
*/
export let allCheckBox: string | undefined = undefined;
let { options = $bindable(), allCheckBox = undefined }: Props = $props();
let allCheckBoxValue: boolean = false;
let allCheckBoxValue: boolean = $state(false);
</script>
<div>
{#if allCheckBox}
<div>
<Checkbox
name={allCheckBox}
bind:value={allCheckBoxValue}
toggled={(on) => {
for (let i = 0; i < options.length; i++) {
const e = options[i];
e.value = on;
}
options = options;
}}
/>
<span>{allCheckBox}</span>
</div>
{/if}
{#each options as o}
<div>
<Checkbox
name={o.displayValue}
bind:value={o.value}
toggled={(on) => {
if (!on) {
allCheckBoxValue = false;
} else if (!allCheckBoxValue) {
if (!options.some((o) => !o.value)) {
allCheckBoxValue = true;
}
}
}}
/>
<span>{o.displayValue}</span>
</div>
{/each}
{#if allCheckBox}
<div>
<Checkbox
name={allCheckBox}
bind:value={allCheckBoxValue}
toggled={(on) => {
for (let i = 0; i < options.length; i++) {
const e = options[i];
e.value = on;
}
options = options;
}}
/>
<span>{allCheckBox}</span>
</div>
{/if}
{#each options as o}
<div>
<Checkbox
name={o.displayValue}
bind:value={o.value}
toggled={(on) => {
if (!on) {
allCheckBoxValue = false;
} else if (!allCheckBoxValue) {
if (!options.some((o) => !o.value)) {
allCheckBoxValue = true;
}
}
}}
/>
<span>{o.displayValue}</span>
</div>
{/each}
</div>
<style lang="scss">
div {
display: flex;
flex-flow: column;
gap: 10px;
div {
display: flex;
flex-flow: column;
gap: 10px;
& > div {
display: flex;
flex-flow: row;
gap: 10px;
}
}
& > div {
display: flex;
flex-flow: row;
gap: 10px;
}
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<!-- A menu designed to pop up around a
button after it is pressed. -->
<script lang="ts">
import stayInView from "./actions/stayInView";
export interface MenuConfig {
width?: string;
top?: string;
right?: string;
arrowRight?: string;
arrowLeft?: string;
arrowColor?: string;
}
interface Props {
children: import("svelte").Snippet;
conf?: MenuConfig;
}
let { children, conf }: Props = $props();
</script>
<div
class="menu"
style={`--w: ${conf?.width || "125px"}; --r: ${
conf?.right || "10px"
}; --t: ${conf?.top || "55px"}; --ar: ${
conf?.arrowRight || "unset"
}; --al: ${conf?.arrowLeft || "unset"}; --ac: ${conf?.arrowColor || "unset"};`}
use:stayInView={{ elToShiftSelector: "& > .arrow" }}
>
<i class="arrow"></i>
<div>
{@render children?.()}
</div>
</div>
<style lang="scss">
.menu {
display: flex;
flex-flow: column;
position: absolute;
right: var(--r);
top: var(--t);
width: var(--w);
border: 3px solid $text-color;
border-radius: 10px;
background-color: $bg-color;
list-style: none;
z-index: 50;
// The lil arrow, adjust its pos under each specific menu.
&:not(:has(> .arrow)):before,
:global(.arrow) {
// Works by showing arrow in ::before pseudoelement,
// or if more control is needed, applies to any .arrow
// element.
content: "";
position: absolute;
bottom: 100%;
width: 0;
border-top: 10px solid transparent;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid var(--ac, $text-color);
right: var(--ar);
left: var(--al);
pointer-events: none;
}
& > div {
display: flex;
flex-flow: column;
padding: 10px;
width: 100%;
max-height: calc(100dvh - 65px);
overflow: auto;
:global {
h5 {
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
}
button,
a {
font-size: 14px;
padding: 8px 16px;
text-align: center;
cursor: pointer;
transition: background-color 200ms ease;
&:hover,
&:focus-visible {
background-color: $text-color;
color: $bg-color;
}
}
span {
margin-top: 8px;
font-size: 11px;
color: gray;
text-align: center;
}
}
}
}
</style>
+94 -81
View File
@@ -1,97 +1,110 @@
<script lang="ts">
import Icon from "./Icon.svelte";
import Icon from "./Icon.svelte";
export let title: string;
export let desc: string | undefined = undefined;
export let onClose: (() => void) | undefined = undefined;
export let maxWidth = "1000px";
export let error: string | undefined = undefined; // TODO This property is new, mimics what we do with other modals by showing an error at top.. we could migrate to use this in other places.
interface Props {
title: string;
desc?: string | undefined;
onClose?: (() => void) | undefined;
maxWidth?: string;
error?: string | undefined; // TODO This property is new, mimics what we do with other modals by showing an error at top.. we could migrate to use this in other places.
children?: import("svelte").Snippet;
}
let {
title,
desc = undefined,
onClose = undefined,
maxWidth = "1000px",
error = undefined,
children,
}: Props = $props();
</script>
<div class="backdrop"></div>
<div class="modal">
<div style="max-width:{maxWidth};">
{#if typeof onClose !== "undefined"}
<button class="close" on:click={onClose}><Icon i="close" wh="20" /></button>
{/if}
<h3 class="norm">{title}</h3>
{#if desc}
<h5 class="norm">{desc}</h5>
{/if}
{#if error}
<span class="error">{error}</span>
{/if}
<slot />
</div>
<div style="max-width:{maxWidth};">
{#if typeof onClose !== "undefined"}
<button class="close" onclick={onClose}><Icon i="close" wh="20" /></button
>
{/if}
<h3 class="norm">{title}</h3>
{#if desc}
<h5 class="norm">{desc}</h5>
{/if}
{#if error}
<span class="error">{error}</span>
{/if}
{@render children?.()}
</div>
</div>
<style lang="scss">
.backdrop {
position: absolute;
top: 0;
left: 0;
width: 100dvw;
height: 100dvh;
backdrop-filter: blur(2px) saturate(180%);
background-color: color-mix(in srgb, black 85%, transparent);
position: fixed;
z-index: 99998;
}
.backdrop {
position: absolute;
top: 0;
left: 0;
width: 100dvw;
height: 100dvh;
backdrop-filter: blur(2px) saturate(180%);
background-color: color-mix(in srgb, black 85%, transparent);
position: fixed;
z-index: 99998;
}
.modal {
display: flex;
justify-content: center;
align-items: center;
width: 100dvw;
height: 100dvh;
top: 0;
left: 0;
position: fixed;
z-index: 99998;
.modal {
display: flex;
justify-content: center;
align-items: center;
width: 100dvw;
height: 100dvh;
top: 0;
left: 0;
position: fixed;
z-index: 99998;
& > div {
position: relative;
display: flex;
flex-flow: column;
min-width: 300px;
width: 100%;
max-height: calc(100% - 20px);
background-color: $bg-color;
border-radius: 10px;
padding: 15px 20px;
margin: 20px 100px;
transition: margin 100ms ease;
outline: 2px solid $text-color;
overflow: auto;
& > div {
position: relative;
display: flex;
flex-flow: column;
min-width: 300px;
width: 100%;
max-height: calc(100% - 20px);
background-color: $bg-color;
border-radius: 10px;
padding: 15px 20px;
margin: 20px 100px;
transition: margin 100ms ease;
outline: 2px solid $text-color;
overflow: auto;
h5 {
margin-bottom: 15px;
}
h5 {
margin-bottom: 15px;
}
button.close {
position: absolute;
top: 8px;
right: 8px;
width: max-content;
padding: 3px 5px;
}
button.close {
position: absolute;
top: 8px;
right: 8px;
width: max-content;
padding: 3px 5px;
}
@media screen and (max-width: 680px) {
margin: 20px;
}
@media screen and (max-width: 680px) {
margin: 20px;
}
.error {
position: sticky;
top: 0;
display: flex;
justify-content: center;
width: 100%;
padding: 10px;
background-color: rgb(221, 48, 48);
text-transform: capitalize;
color: white;
margin-bottom: 15px;
}
}
}
.error {
position: sticky;
top: 0;
display: flex;
justify-content: center;
width: 100%;
padding: 10px;
background-color: rgb(221, 48, 48);
text-transform: capitalize;
color: white;
margin-bottom: 15px;
}
}
}
</style>
+16 -12
View File
@@ -1,21 +1,25 @@
<script lang="ts">
export let title: string;
export let desc: string;
export let type: "warn";
interface Props {
title: string;
desc: string;
type: "warn";
}
let { title, desc, type }: Props = $props();
</script>
<div class={type}>
<h4 class="norm">{title}</h4>
<p>{desc}</p>
<h4 class="norm">{title}</h4>
<p>{desc}</p>
</div>
<style lang="scss">
div {
padding: 10px 12px;
border-radius: 10px;
div {
padding: 10px 12px;
border-radius: 10px;
&.warn {
background-color: $warn;
}
}
&.warn {
background-color: $warn;
}
}
</style>
+36 -32
View File
@@ -1,42 +1,46 @@
<script lang="ts">
export let pretty: string;
export let error: any;
interface Props {
pretty: string;
error: any;
}
let { pretty, error }: Props = $props();
</script>
<div>
<div>
<strong>{pretty}</strong>
{#if error?.message}
<p>{error.message}</p>
{#if error.response}
<p>{error.response.data.error}</p>
{/if}
{:else}
<p>{JSON.stringify(error)}</p>
{/if}
</div>
<div>
<strong>{pretty}</strong>
{#if error?.message}
<p>{error.message}</p>
{#if error.response}
<p>{error.response.data.error}</p>
{/if}
{:else}
<p>{JSON.stringify(error)}</p>
{/if}
</div>
</div>
<style lang="scss">
div {
display: flex;
justify-content: center;
width: 100%;
div {
display: flex;
justify-content: center;
width: 100%;
div {
display: flex;
justify-content: center;
flex-flow: column;
max-width: 500px;
gap: 15px;
div {
display: flex;
justify-content: center;
flex-flow: column;
max-width: 500px;
gap: 15px;
p {
color: white;
background-color: rgb(66, 60, 60);
padding: 10px;
border-radius: 8px;
text-transform: capitalize;
}
}
}
p {
color: white;
background-color: rgb(66, 60, 60);
padding: 10px;
border-radius: 8px;
text-transform: capitalize;
}
}
}
</style>
+36 -28
View File
@@ -1,37 +1,45 @@
<script lang="ts">
import axios from "axios";
import DropDown from "./DropDown.svelte";
import type { DropDownItem, TMDBRegions } from "@/types";
import Error from "./Error.svelte";
import axios from "axios";
import DropDown from "./DropDown.svelte";
import type { DropDownItem, TMDBRegions } from "@/types";
import Error from "./Error.svelte";
export let selectedCountry: string = "US";
export let disabled = false;
export let onChange: (country: string) => void;
interface Props {
selectedCountry?: string;
disabled?: boolean;
onChange: (country: string) => void;
}
let mappedCountries: DropDownItem[];
let {
selectedCountry = $bindable("US"),
disabled = false,
onChange,
}: Props = $props();
async function getCountries() {
const c = (await axios.get(`/content/regions`)).data as TMDBRegions;
mappedCountries = c.results
.map((cc) => {
return {
id: cc.iso_3166_1,
value: cc.english_name
} as DropDownItem;
})
.sort((a, b) => a.value.localeCompare(b.value));
}
let mappedCountries: DropDownItem[] = $state();
async function getCountries() {
const c = (await axios.get(`/content/regions`)).data as TMDBRegions;
mappedCountries = c.results
.map((cc) => {
return {
id: cc.iso_3166_1,
value: cc.english_name,
} as DropDownItem;
})
.sort((a, b) => a.value.localeCompare(b.value));
}
</script>
{#await getCountries() then}
<DropDown
placeholder="Select a country"
bind:active={selectedCountry}
options={mappedCountries}
onChange={() => onChange(selectedCountry)}
isDropDownItem={true}
{disabled}
/>
<DropDown
placeholder="Select a country"
bind:active={selectedCountry}
options={mappedCountries}
onChange={() => onChange(selectedCountry)}
isDropDownItem={true}
{disabled}
/>
{:catch err}
<Error error={err} pretty="Failed to load countries!" />
<Error error={err} pretty="Failed to load countries!" />
{/await}
+343 -319
View File
@@ -1,354 +1,378 @@
<script lang="ts">
import type {
TMDBSeasonDetails,
TMDBShowSeason,
Watched,
WatchedSeasonAddResponse,
WatchedStatus
} from "@/types";
import axios from "axios";
import Spinner from "./Spinner.svelte";
import Error from "./Error.svelte";
import SeasonsListEpisode from "./SeasonsListEpisode.svelte";
import PosterStatus from "./poster/PosterStatus.svelte";
import { notify } from "./util/notify";
import { get } from "svelte/store";
import { watchedList } from "@/store";
import PosterRating from "./poster/PosterRating.svelte";
import type {
TMDBSeasonDetails,
TMDBShowSeason,
Watched,
WatchedSeasonAddResponse,
WatchedStatus,
} from "@/types";
import axios from "axios";
import Spinner from "./Spinner.svelte";
import Error from "./Error.svelte";
import SeasonsListEpisode from "./SeasonsListEpisode.svelte";
import PosterStatus from "./poster/PosterStatus.svelte";
import { notify } from "./util/notify";
import { get } from "svelte/store";
import { store } from "@/store.svelte";
import PosterRating from "./poster/PosterRating.svelte";
export let tvId: number;
export let seasons: TMDBShowSeason[];
export let watchedItem: Watched | undefined;
interface Props {
tvId: number;
seasons: TMDBShowSeason[];
watchedItem: Watched | undefined;
}
let activeSeason =
typeof watchedItem?.lastViewedSeason === "number" ? watchedItem?.lastViewedSeason : 1;
let seasonDetailsReq: Promise<TMDBSeasonDetails>;
let { tvId, seasons, watchedItem = $bindable() }: Props = $props();
async function sdr(seasonNum: number) {
const resp = await axios.get(`/content/tv/${tvId}/season/${seasonNum}`, {
params: {
watchedId: watchedItem?.id
}
});
try {
if (watchedItem?.id) {
// If we sent a watched id, expect a 'watcharr-lastviewedseason-saved' header in the response.
const hVal = resp.headers["watcharr-lastviewedseason-saved"];
if (hVal) {
watchedItem.lastViewedSeason = seasonNum;
watchedList.update((w) => w);
} else {
console.error(
"SeasonList: sdr: No header in response indicating that the lastviewedseason was saved."
);
notify({ type: "error", text: "Failed when saving last viewed season" });
}
}
} catch (err) {
console.error("SeasonList: sdr: Failed to process lastviewedseason-saved header.", err);
}
return resp.data as TMDBSeasonDetails;
}
let activeSeason = $state(
typeof watchedItem?.lastViewedSeason === "number"
? watchedItem?.lastViewedSeason
: 1,
);
let seasonDetailsReq: Promise<TMDBSeasonDetails> = $derived(
sdr(activeSeason),
);
// Add/update watched season
function updateWatchedSeason(seasonNumber: number, status?: WatchedStatus, rating?: number) {
if (!watchedItem) {
console.error("updateWatchedSeason: No watched item.");
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.post<WatchedSeasonAddResponse>(`/watched/season`, {
watchedId: watchedItem.id,
seasonNumber: seasonNumber,
status,
rating
})
.then((r) => {
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error"
});
return;
}
if (r.status === 200) {
wEntry.watchedSeasons = r.data.watchedSeasons;
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data.addedActivity);
} else {
wEntry.activity = [r.data.addedActivity];
}
watchedList.update((w) => w);
notify({ id: nid, text: `Saved!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Update!", type: "error" });
});
}
async function sdr(seasonNum: number) {
const resp = await axios.get(`/content/tv/${tvId}/season/${seasonNum}`, {
params: {
watchedId: watchedItem?.id,
},
});
try {
if (watchedItem?.id) {
// If we sent a watched id, expect a 'watcharr-lastviewedseason-saved' header in the response.
const hVal = resp.headers["watcharr-lastviewedseason-saved"];
if (hVal) {
watchedItem.lastViewedSeason = seasonNum;
// watchedList.update((w) => w);
} else {
console.error(
"SeasonList: sdr: No header in response indicating that the lastviewedseason was saved.",
);
notify({
type: "error",
text: "Failed when saving last viewed season",
});
}
}
} catch (err) {
console.error(
"SeasonList: sdr: Failed to process lastviewedseason-saved header.",
err,
);
}
return resp.data as TMDBSeasonDetails;
}
function handleStatusClick(type: WatchedStatus | "DELETE", seasonNumber: number) {
if (!watchedItem) {
console.error("handleStatusClick: No watched item.");
return;
}
if (type === "DELETE") {
const ws = watchedItem.watchedSeasons?.find((s) => s.seasonNumber === seasonNumber);
if (!ws) {
notify({ text: "Failed to find watched season id. Please try refreshing.", type: "error" });
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.delete(`/watched/season/${ws.id}`)
.then((r) => {
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error"
});
return;
}
if (r.status === 200) {
wEntry.watchedSeasons = wEntry.watchedSeasons?.filter((s) => s.id !== ws.id);
if (r.data) {
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data);
} else {
wEntry.activity = [r.data];
}
}
watchedList.update((w) => w);
notify({ id: nid, text: `Removed!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Remove!", type: "error" });
});
return;
}
updateWatchedSeason(seasonNumber, type);
}
// Add/update watched season
function updateWatchedSeason(
seasonNumber: number,
status?: WatchedStatus,
rating?: number,
) {
if (!watchedItem) {
console.error("updateWatchedSeason: No watched item.");
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.post<WatchedSeasonAddResponse>(`/watched/season`, {
watchedId: watchedItem.id,
seasonNumber: seasonNumber,
status,
rating,
})
.then((r) => {
// const wList = get(watchedList);
const wEntry = store.watchedList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error",
});
return;
}
if (r.status === 200) {
wEntry.watchedSeasons = r.data.watchedSeasons;
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data.addedActivity);
} else {
wEntry.activity = [r.data.addedActivity];
}
notify({ id: nid, text: `Saved!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Update!", type: "error" });
});
}
function handleStarClick(rating: number, seasonNumber: number) {
updateWatchedSeason(seasonNumber, undefined, rating);
}
function handleStatusClick(
type: WatchedStatus | "DELETE",
seasonNumber: number,
) {
if (!watchedItem) {
console.error("handleStatusClick: No watched item.");
return;
}
if (type === "DELETE") {
const ws = watchedItem.watchedSeasons?.find(
(s) => s.seasonNumber === seasonNumber,
);
if (!ws) {
notify({
text: "Failed to find watched season id. Please try refreshing.",
type: "error",
});
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.delete(`/watched/season/${ws.id}`)
.then((r) => {
const wEntry = store.watchedList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error",
});
return;
}
if (r.status === 200) {
wEntry.watchedSeasons = wEntry.watchedSeasons?.filter(
(s) => s.id !== ws.id,
);
if (r.data) {
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data);
} else {
wEntry.activity = [r.data];
}
}
notify({ id: nid, text: `Removed!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Remove!", type: "error" });
});
return;
}
updateWatchedSeason(seasonNumber, type);
}
$: {
seasonDetailsReq = sdr(activeSeason);
}
function handleStarClick(rating: number, seasonNumber: number) {
updateWatchedSeason(seasonNumber, undefined, rating);
}
</script>
<div class="ctr">
<ul class="seasons">
{#each seasons as season}
<button
class={`plain${activeSeason === season.season_number ? " active" : ""}`}
on:click={() => {
activeSeason = season.season_number;
}}
>
<h1>{season.name}</h1>
{#if season.air_date}
<h2>{new Date(Date.parse(season.air_date)).getFullYear()}</h2>
{:else if season.season_number > 0}
<h2>TBD</h2>
{/if}
</button>
{/each}
<div class="last" />
</ul>
<ul class="seasons">
{#each seasons as season}
<button
class={`plain${activeSeason === season.season_number ? " active" : ""}`}
onclick={() => {
activeSeason = season.season_number;
}}
>
<h1>{season.name}</h1>
{#if season.air_date}
<h2>{new Date(Date.parse(season.air_date)).getFullYear()}</h2>
{:else if season.season_number > 0}
<h2>TBD</h2>
{/if}
</button>
{/each}
<div class="last"></div>
</ul>
<div class="episodes">
{#await seasonDetailsReq}
<Spinner />
{:then season}
<div class="episodes-topbar">
<h3>{season.name}</h3>
{#if watchedItem}
{@const ws = watchedItem?.watchedSeasons?.find(
(s) => s.seasonNumber === season.season_number
)}
{#if ws}
<div class="rating">
<PosterRating
rating={ws?.rating}
btnTooltip="Season Rating"
handleStarClick={(r) => handleStarClick(r, season.season_number)}
minimal={true}
direction="bot"
/>
</div>
{/if}
<div class="status">
<PosterStatus
status={ws?.status}
btnTooltip="Season Status"
handleStatusClick={(t) => handleStatusClick(t, season.season_number)}
direction="bot"
width="100%"
small
/>
</div>
{/if}
</div>
{#if season?.episodes?.length > 0}
<ul>
{#each season.episodes as ep}
<SeasonsListEpisode {ep} {watchedItem} />
{/each}
</ul>
{:else}
<h3 class="norm">No episodes in this season yet!</h3>
{/if}
{:catch err}
<Error pretty="Failed to load season details!" error={err} />
{/await}
</div>
<div class="episodes">
{#await seasonDetailsReq}
<Spinner />
{:then season}
<div class="episodes-topbar">
<h3>{season.name}</h3>
{#if watchedItem}
{@const ws = watchedItem?.watchedSeasons?.find(
(s) => s.seasonNumber === season.season_number,
)}
{#if ws}
<div class="rating">
<PosterRating
rating={ws?.rating}
btnTooltip="Season Rating"
handleStarClick={(r) =>
handleStarClick(r, season.season_number)}
minimal={true}
direction="bot"
/>
</div>
{/if}
<div class="status">
<PosterStatus
status={ws?.status}
btnTooltip="Season Status"
handleStatusClick={(t) =>
handleStatusClick(t, season.season_number)}
direction="bot"
width="100%"
small
/>
</div>
{/if}
</div>
{#if season?.episodes?.length > 0}
<ul>
{#each season.episodes as ep}
<SeasonsListEpisode {ep} {watchedItem} />
{/each}
</ul>
{:else}
<h3 class="norm">No episodes in this season yet!</h3>
{/if}
{:catch err}
<Error pretty="Failed to load season details!" error={err} />
{/await}
</div>
</div>
<style lang="scss">
.ctr {
display: flex;
flex-flow: row;
gap: 20px;
width: 100%;
}
.ctr {
display: flex;
flex-flow: row;
gap: 20px;
width: 100%;
}
.episodes {
width: 100%;
.episodes {
width: 100%;
ul {
display: flex;
flex-flow: column;
list-style: none;
gap: 20px;
}
}
ul {
display: flex;
flex-flow: column;
list-style: none;
gap: 20px;
}
}
ul.seasons {
display: flex;
flex-flow: column;
list-style: none;
gap: 8px;
min-width: fit-content;
height: 100vh;
overflow: auto;
position: sticky;
top: 0px;
transition: top 200ms ease-in-out;
ul.seasons {
display: flex;
flex-flow: column;
list-style: none;
gap: 8px;
min-width: fit-content;
height: 100vh;
overflow: auto;
position: sticky;
top: 0px;
transition: top 200ms ease-in-out;
button {
display: flex;
flex-flow: row;
flex-wrap: wrap;
gap: 0 18px;
align-items: center;
padding: 10px;
border: 2px solid #302d2d;
border-radius: 8px;
cursor: pointer;
max-width: 220px;
transition: background-color 100ms ease;
button {
display: flex;
flex-flow: row;
flex-wrap: wrap;
gap: 0 18px;
align-items: center;
padding: 10px;
border: 2px solid #302d2d;
border-radius: 8px;
cursor: pointer;
max-width: 220px;
transition: background-color 100ms ease;
&:first-of-type {
margin-top: 10px;
}
&:first-of-type {
margin-top: 10px;
}
h1 {
font-size: 18px;
font-family: sans-serif;
}
h1 {
font-size: 18px;
font-family: sans-serif;
}
h2 {
font-size: 12px;
font-family: sans-serif;
margin-left: auto;
}
h2 {
font-size: 12px;
font-family: sans-serif;
margin-left: auto;
}
&:hover,
&.active {
color: white;
background-color: black;
}
&:hover,
&.active {
color: white;
background-color: black;
}
&.active {
position: sticky;
top: 10px;
bottom: 10px;
}
}
&.active {
position: sticky;
top: 10px;
bottom: 10px;
}
}
/* hack to get extra scroll space under last el */
.last {
padding: 1px;
}
}
/* hack to get extra scroll space under last el */
.last {
padding: 1px;
}
}
.episodes-topbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
min-height: 40px;
.episodes-topbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
min-height: 40px;
div {
transition: width 100ms ease;
div {
transition: width 100ms ease;
&:first-of-type {
margin-left: auto;
}
&:first-of-type {
margin-left: auto;
}
&.rating {
height: 40px;
min-height: 40px;
}
&.rating {
height: 40px;
min-height: 40px;
}
&.status {
width: 45px;
min-height: 40px;
height: 40px;
overflow: visible;
}
}
}
&.status {
width: 45px;
min-height: 40px;
height: 40px;
overflow: visible;
}
}
}
@media screen and (min-width: 960px) {
:global(body.nav-shown) ul.seasons {
top: $nav-height;
height: calc(100vh - $nav-height);
}
}
@media screen and (min-width: 960px) {
:global(body.nav-shown) ul.seasons {
top: $nav-height;
height: calc(100vh - $nav-height);
}
}
@media screen and (max-width: 960px) {
.ctr {
flex-flow: column;
}
@media screen and (max-width: 960px) {
.ctr {
flex-flow: column;
}
ul.seasons {
flex-flow: row;
flex-wrap: wrap;
position: unset;
height: unset;
justify-content: center;
ul.seasons {
flex-flow: row;
flex-wrap: wrap;
position: unset;
height: unset;
justify-content: center;
button {
&:first-of-type {
margin-top: unset;
}
&.active {
position: unset;
}
}
}
}
button {
&:first-of-type {
margin-top: unset;
}
&.active {
position: unset;
}
}
}
}
</style>
+382 -367
View File
@@ -1,408 +1,423 @@
<script lang="ts">
import type {
TMDBSeasonDetailsEpisode,
WatchedStatus,
WatchedEpisodeAddResponse,
Watched
} from "@/types";
import Icon from "./Icon.svelte";
import { userSettings } from "@/store";
import PosterRating from "./poster/PosterRating.svelte";
import PosterStatus from "./poster/PosterStatus.svelte";
import axios from "axios";
import { notify } from "./util/notify";
import { get } from "svelte/store";
import { watchedList } from "@/store";
import type {
TMDBSeasonDetailsEpisode,
WatchedStatus,
WatchedEpisodeAddResponse,
Watched,
} from "@/types";
import Icon from "./Icon.svelte";
import PosterRating from "./poster/PosterRating.svelte";
import PosterStatus from "./poster/PosterStatus.svelte";
import axios from "axios";
import { notify } from "./util/notify";
import { get } from "svelte/store";
import { store } from "@/store.svelte";
$: settings = $userSettings;
// let settings = $derived($userSettings);
export let ep: TMDBSeasonDetailsEpisode;
export let watchedItem: Watched | undefined;
interface Props {
ep: TMDBSeasonDetailsEpisode;
watchedItem: Watched | undefined;
}
let isHidden = false;
let { ep, watchedItem }: Props = $props();
$: {
if (settings) isHidden = settings.hideSpoilers;
}
let isHidden: boolean = $state(!!store?.userSettings?.hideSpoilers);
function updateWatchedEpisode(status?: WatchedStatus, rating?: number) {
if (!watchedItem) {
console.error("SeasonListEpisode: updateWatchedEpisode: No watched item.");
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.post<WatchedEpisodeAddResponse>(`/watched/episode`, {
watchedId: watchedItem.id,
seasonNumber: ep.season_number,
episodeNumber: ep.episode_number,
status,
rating
})
.then((r) => {
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error"
});
return;
}
if (r.status === 200) {
wEntry.watchedEpisodes = r.data.watchedEpisodes;
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data.addedActivity);
} else {
wEntry.activity = [r.data.addedActivity];
}
try {
const epHookResp = r?.data?.episodeStatusChangedHookResponse;
if (epHookResp && Object.keys(epHookResp).length > 0) {
if (epHookResp.errors && epHookResp.errors.length > 0) {
console.error(
"episodeStatusChangedHookResponse contained errors! All possible automations may not have been completed.",
epHookResp.errors
);
notify({
type: "error",
text: "Some automations have failed, check console for more info."
});
}
if (epHookResp.addedActivities && epHookResp.addedActivities.length > 0) {
wEntry.activity.push(...epHookResp.addedActivities);
}
if (epHookResp.watchedSeason) {
if (!wEntry.watchedSeasons) {
wEntry.watchedSeasons = [epHookResp.watchedSeason];
} else {
const watchedSeasonIdx = wEntry.watchedSeasons.findIndex(
(s) => s.id === epHookResp.watchedSeason?.id
);
if (watchedSeasonIdx === -1) {
wEntry.watchedSeasons.push(epHookResp.watchedSeason);
} else {
wEntry.watchedSeasons[watchedSeasonIdx] = epHookResp.watchedSeason;
}
}
}
if (epHookResp.newShowStatus) {
wEntry.status = epHookResp.newShowStatus;
}
}
} catch (err) {
console.error("Failed to process episodeStatusChangedHookResponse", err);
notify({
type: "error",
text: "Failed to process automation response, check console for more info."
});
}
watchedList.update((w) => w);
notify({ id: nid, text: `Saved!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Update!", type: "error" });
});
}
function updateWatchedEpisode(status?: WatchedStatus, rating?: number) {
if (!watchedItem) {
console.error(
"SeasonListEpisode: updateWatchedEpisode: No watched item.",
);
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.post<WatchedEpisodeAddResponse>(`/watched/episode`, {
watchedId: watchedItem.id,
seasonNumber: ep.season_number,
episodeNumber: ep.episode_number,
status,
rating,
})
.then((r) => {
const wEntry = store.watchedList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error",
});
return;
}
if (r.status === 200) {
wEntry.watchedEpisodes = r.data.watchedEpisodes;
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data.addedActivity);
} else {
wEntry.activity = [r.data.addedActivity];
}
try {
const epHookResp = r?.data?.episodeStatusChangedHookResponse;
if (epHookResp && Object.keys(epHookResp).length > 0) {
if (epHookResp.errors && epHookResp.errors.length > 0) {
console.error(
"episodeStatusChangedHookResponse contained errors! All possible automations may not have been completed.",
epHookResp.errors,
);
notify({
type: "error",
text: "Some automations have failed, check console for more info.",
});
}
if (
epHookResp.addedActivities &&
epHookResp.addedActivities.length > 0
) {
wEntry.activity.push(...epHookResp.addedActivities);
}
if (epHookResp.watchedSeason) {
if (!wEntry.watchedSeasons) {
wEntry.watchedSeasons = [epHookResp.watchedSeason];
} else {
const watchedSeasonIdx = wEntry.watchedSeasons.findIndex(
(s) => s.id === epHookResp.watchedSeason?.id,
);
if (watchedSeasonIdx === -1) {
wEntry.watchedSeasons.push(epHookResp.watchedSeason);
} else {
wEntry.watchedSeasons[watchedSeasonIdx] =
epHookResp.watchedSeason;
}
}
}
if (epHookResp.newShowStatus) {
wEntry.status = epHookResp.newShowStatus;
}
}
} catch (err) {
console.error(
"Failed to process episodeStatusChangedHookResponse",
err,
);
notify({
type: "error",
text: "Failed to process automation response, check console for more info.",
});
}
notify({ id: nid, text: `Saved!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Update!", type: "error" });
});
}
function handleStatusClick(type: WatchedStatus | "DELETE") {
if (!watchedItem) {
console.error("SeasonListEpisode: handleStatusClick: No watched item.");
return;
}
if (type === "DELETE") {
const ws = watchedItem.watchedEpisodes?.find(
(s) => s.seasonNumber === ep.season_number && s.episodeNumber === ep.episode_number
);
if (!ws) {
notify({
text: "Failed to find watched episode id. Please try refreshing.",
type: "error"
});
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.delete(`/watched/episode/${ws.id}`)
.then((r) => {
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error"
});
return;
}
if (r.status === 200) {
wEntry.watchedEpisodes = wEntry.watchedEpisodes?.filter((s) => s.id !== ws.id);
if (r.data) {
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data);
} else {
wEntry.activity = [r.data];
}
}
watchedList.update((w) => w);
notify({ id: nid, text: `Removed!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Remove!", type: "error" });
});
return;
}
updateWatchedEpisode(type);
}
function handleStatusClick(type: WatchedStatus | "DELETE") {
if (!watchedItem) {
console.error("SeasonListEpisode: handleStatusClick: No watched item.");
return;
}
if (type === "DELETE") {
const ws = watchedItem.watchedEpisodes?.find(
(s) =>
s.seasonNumber === ep.season_number &&
s.episodeNumber === ep.episode_number,
);
if (!ws) {
notify({
text: "Failed to find watched episode id. Please try refreshing.",
type: "error",
});
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.delete(`/watched/episode/${ws.id}`)
.then((r) => {
const wEntry = store.watchedList.find((w) => w.id === watchedItem.id);
if (!wEntry) {
notify({
id: nid,
text: `Request succeeded, but failed to find local data. Please refresh.`,
type: "error",
});
return;
}
if (r.status === 200) {
wEntry.watchedEpisodes = wEntry.watchedEpisodes?.filter(
(s) => s.id !== ws.id,
);
if (r.data) {
if (wEntry.activity?.length > 0) {
wEntry.activity.push(r.data);
} else {
wEntry.activity = [r.data];
}
}
notify({ id: nid, text: `Removed!`, type: "success" });
}
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Remove!", type: "error" });
});
return;
}
updateWatchedEpisode(type);
}
function handleStarClick(rating: number) {
updateWatchedEpisode(undefined, rating);
}
function handleStarClick(rating: number) {
updateWatchedEpisode(undefined, rating);
}
</script>
<li class={isHidden ? "dont-spoil" : ""}>
{#if ep.still_path}
<img src={`https://www.themoviedb.org/t/p/w227_and_h127_bestv2/${ep.still_path}`} alt="" />
{:else}
<div class="no-still" />
{/if}
<div class="info">
<div>
<span>
<b>{ep.episode_number}</b>
<span class="episode-name">{ep.name}</span>
{#if ep.runtime}
<span class="episode-runtime" title="This episode has a runtime of {ep.runtime} minutes."
>{ep.runtime} min</span
>
{/if}
</span>
<span
class="rating"
title={`TMDB Rating: ${ep.vote_average} out of 10 (based on ${ep.vote_count} votes)`}
>
<span>*</span>
{Math.round(ep.vote_average * 10) / 10}
</span>
</div>
<span class="overview">{ep.overview}</span>
</div>
{#if watchedItem}
{@const we = watchedItem.watchedEpisodes?.find(
(s) => s.seasonNumber === ep.season_number && s.episodeNumber === ep.episode_number
)}
<div class="status-rating-ctr">
<div class="rating" style={"width: 45px"}>
<PosterRating
rating={we?.rating}
btnTooltip={`Episode ${ep.episode_number} Rating`}
handleStarClick={(r) => handleStarClick(r)}
minimal={true}
direction="bot"
hideStarWhenRated
/>
</div>
<div class="status">
<PosterStatus
status={we?.status}
btnTooltip={`Episode ${ep.episode_number} Status`}
handleStatusClick={(t) => handleStatusClick(t)}
direction="bot"
width="100%"
small
/>
</div>
</div>
{/if}
{#if isHidden}
<button class="plain spoiler-text" on:click={() => (isHidden = false)}>
<Icon i="eye-closed" wh={34} />
<span>Click To Reveal</span>
</button>
{/if}
{#if ep.still_path}
<img
src={`https://www.themoviedb.org/t/p/w227_and_h127_bestv2/${ep.still_path}`}
alt=""
/>
{:else}
<div class="no-still"></div>
{/if}
<div class="info">
<div>
<span>
<b>{ep.episode_number}</b>
<span class="episode-name">{ep.name}</span>
{#if ep.runtime}
<span
class="episode-runtime"
title="This episode has a runtime of {ep.runtime} minutes."
>{ep.runtime} min</span
>
{/if}
</span>
<span
class="rating"
title={`TMDB Rating: ${ep.vote_average} out of 10 (based on ${ep.vote_count} votes)`}
>
<span>*</span>
{Math.round(ep.vote_average * 10) / 10}
</span>
</div>
<span class="overview">{ep.overview}</span>
</div>
{#if watchedItem}
{@const we = watchedItem.watchedEpisodes?.find(
(s) =>
s.seasonNumber === ep.season_number &&
s.episodeNumber === ep.episode_number,
)}
<div class="status-rating-ctr">
<div class="rating" style={"width: 45px"}>
<PosterRating
rating={we?.rating}
btnTooltip={`Episode ${ep.episode_number} Rating`}
handleStarClick={(r) => handleStarClick(r)}
minimal={true}
direction="bot"
hideStarWhenRated
/>
</div>
<div class="status">
<PosterStatus
status={we?.status}
btnTooltip={`Episode ${ep.episode_number} Status`}
handleStatusClick={(t) => handleStatusClick(t)}
direction="bot"
width="100%"
small
/>
</div>
</div>
{/if}
{#if isHidden}
<button class="plain spoiler-text" onclick={() => (isHidden = false)}>
<Icon i="eye-closed" wh={34} />
<span>Click To Reveal</span>
</button>
{/if}
</li>
<style lang="scss">
li {
display: flex;
flex-flow: row;
gap: 8px;
position: relative;
li {
display: flex;
flex-flow: row;
gap: 8px;
position: relative;
img,
.no-still {
width: 227px;
min-width: 227px;
height: 127px;
min-height: 127px;
border-radius: 10px;
background-color: rgb(0, 0, 0);
object-fit: fill;
img,
.no-still {
width: 227px;
min-width: 227px;
height: 127px;
min-height: 127px;
border-radius: 10px;
background-color: rgb(0, 0, 0);
object-fit: fill;
@media screen and (max-width: 590px) {
width: 80%;
height: auto;
}
@media screen and (max-width: 590px) {
width: 80%;
height: auto;
}
@media screen and (max-width: 450px) {
width: 100%;
}
}
@media screen and (max-width: 450px) {
width: 100%;
}
}
.info {
display: flex;
flex-flow: column;
.info {
display: flex;
flex-flow: column;
& > div {
display: flex;
flex-flow: row;
align-items: center;
& > div {
display: flex;
flex-flow: row;
align-items: center;
.episode-name,
.episode-runtime {
text-transform: lowercase;
font-variant: small-caps;
font-weight: bold;
font-size: 16px;
}
.episode-name,
.episode-runtime {
text-transform: lowercase;
font-variant: small-caps;
font-weight: bold;
font-size: 16px;
}
.episode-runtime {
font-size: 14px;
padding: 0 2px;
}
.episode-runtime {
font-size: 14px;
padding: 0 2px;
}
.rating {
display: flex;
align-items: start;
justify-content: center;
font-size: 15px;
color: $rating-color;
font-weight: bolder;
overflow: hidden;
.rating {
display: flex;
align-items: start;
justify-content: center;
font-size: 15px;
color: $rating-color;
font-weight: bolder;
overflow: hidden;
span {
margin-top: 2px;
font-family: "Rampart One";
-webkit-text-stroke: 1px $rating-color;
font-size: 25px;
line-height: 0.7;
}
}
}
}
span {
margin-top: 2px;
font-family: "Rampart One";
-webkit-text-stroke: 1px $rating-color;
font-size: 25px;
line-height: 0.7;
}
}
}
}
.status-rating-ctr {
display: flex;
align-items: center;
flex-flow: column-reverse;
gap: 10px;
margin-bottom: auto;
min-height: 40px;
margin-left: auto;
.status-rating-ctr {
display: flex;
align-items: center;
flex-flow: column-reverse;
gap: 10px;
margin-bottom: auto;
min-height: 40px;
margin-left: auto;
div {
transition: width 100ms ease;
div {
transition: width 100ms ease;
&:first-of-type {
margin-left: auto;
}
&:first-of-type {
margin-left: auto;
}
&.rating {
height: 40px;
min-height: 40px;
}
&.rating {
height: 40px;
min-height: 40px;
}
&.status {
width: 45px;
min-height: 40px;
height: 40px;
overflow: visible;
}
}
}
&.status {
width: 45px;
min-height: 40px;
height: 40px;
overflow: visible;
}
}
}
span {
padding: 3px 5px;
span {
padding: 3px 5px;
@media screen and (max-width: 590px) {
text-align: center;
}
}
@media screen and (max-width: 590px) {
text-align: center;
}
}
.spoiler-text {
display: flex;
flex-flow: column;
align-items: center;
justify-content: center;
gap: 8px;
position: absolute;
width: 100%;
height: 100%;
font-weight: bolder;
font-size: 20px;
fill: $text-color;
opacity: 0;
transition:
visibility 150ms ease-in,
opacity 150ms ease-in;
cursor: pointer;
.spoiler-text {
display: flex;
flex-flow: column;
align-items: center;
justify-content: center;
gap: 8px;
position: absolute;
width: 100%;
height: 100%;
font-weight: bolder;
font-size: 20px;
fill: $text-color;
opacity: 0;
transition:
visibility 150ms ease-in,
opacity 150ms ease-in;
cursor: pointer;
span {
text-shadow: 0 0 6px $bg-color;
}
span {
text-shadow: 0 0 6px $bg-color;
}
:global(svg) {
filter: drop-shadow(0 0 8px $bg-color);
}
:global(svg) {
filter: drop-shadow(0 0 8px $bg-color);
}
&:hover,
&:active,
&:focus {
opacity: 1;
}
}
&:hover,
&:active,
&:focus {
opacity: 1;
}
}
img,
.episode-name,
.rating,
.overview {
transition: filter 150ms ease-out;
}
img,
.episode-name,
.rating,
.overview {
transition: filter 150ms ease-out;
}
&.dont-spoil {
.episode-name,
.rating,
.overview {
filter: blur(4px);
}
&.dont-spoil {
.episode-name,
.rating,
.overview {
filter: blur(4px);
}
img {
filter: blur(6px);
}
}
}
img {
filter: blur(6px);
}
}
}
@media screen and (max-width: 590px) {
li {
align-items: center;
flex-flow: column;
width: 100%;
height: 100%;
@media screen and (max-width: 590px) {
li {
align-items: center;
flex-flow: column;
width: 100%;
height: 100%;
.status-rating-ctr {
flex-flow: row;
justify-content: center;
margin-left: unset;
}
}
.status-rating-ctr {
flex-flow: row;
justify-content: center;
margin-left: unset;
}
}
.rating {
margin-left: auto;
}
}
.rating {
margin-left: auto;
}
}
</style>
+34 -31
View File
@@ -1,38 +1,41 @@
<div class="container">
<div class="spinner" />
<div class="spinner"></div>
</div>
<style lang="scss">
.container {
display: flex;
align-items: center;
justify-content: center;
}
.container {
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
width: 80px;
aspect-ratio: 1;
border: 10px solid $bg-color;
background:
radial-gradient(farthest-side, $text-color 98%, $bg-color) 0 0/20px 20px,
radial-gradient(farthest-side, $text-color 98%, $bg-color) 100% 0/20px 20px,
radial-gradient(farthest-side, $text-color 98%, $bg-color) 100% 100%/20px 20px,
radial-gradient(farthest-side, $text-color 98%, $bg-color) 0 100%/20px 20px,
linear-gradient($text-color 0 0) center/40px 40px,
$bg-color;
background-repeat: no-repeat;
filter: blur(4px) contrast(10);
animation: spin 800ms infinite;
}
.spinner {
width: 80px;
aspect-ratio: 1;
border: 10px solid $bg-color;
background:
radial-gradient(farthest-side, $text-color 98%, $bg-color) 0 0/20px 20px,
radial-gradient(farthest-side, $text-color 98%, $bg-color) 100% 0/20px
20px,
radial-gradient(farthest-side, $text-color 98%, $bg-color) 100% 100%/20px
20px,
radial-gradient(farthest-side, $text-color 98%, $bg-color) 0 100%/20px
20px,
linear-gradient($text-color 0 0) center/40px 40px,
$bg-color;
background-repeat: no-repeat;
filter: blur(4px) contrast(10);
animation: spin 800ms infinite;
}
@keyframes spin {
100% {
background-position:
100% 0,
100% 100%,
0 100%,
0 0,
center;
}
}
@keyframes spin {
100% {
background-position:
100% 0,
100% 100%,
0 100%,
0 0,
center;
}
}
</style>
+23 -19
View File
@@ -1,27 +1,31 @@
<script lang="ts">
export let style: string = "";
interface Props {
style?: string;
}
let { style = "" }: Props = $props();
</script>
<div class="loader" {style}></div>
<style lang="scss">
.loader {
width: 16px;
height: 12px;
border: 2px solid #000;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
.loader {
width: 16px;
height: 12px;
border: 2px solid #000;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
+76 -57
View File
@@ -1,68 +1,87 @@
<script lang="ts">
import Icon from "./Icon.svelte";
import type { WatchedStatus } from "../types";
import tooltip from "./actions/tooltip";
import { toUnderstandableStatus } from "./util/helpers";
import Icon from "./Icon.svelte";
import type { WatchedStatus } from "../types";
import tooltip from "./actions/tooltip";
import { toUnderstandableStatus } from "./util/helpers";
export let status: WatchedStatus | undefined;
export let isForGame: boolean = false;
export let onChange: (newStatus: WatchedStatus) => void;
interface Props {
status: WatchedStatus | undefined;
isForGame?: boolean;
onChange: (newStatus: WatchedStatus) => void;
}
function handleStatusClick(s: WatchedStatus) {
if (s === status) return;
onChange(s);
}
let { status, isForGame = false, onChange }: Props = $props();
function handleStatusClick(s: WatchedStatus) {
if (s === status) return;
onChange(s);
}
</script>
<div class="status">
<button
class={status && status !== "PLANNED" ? "not-active" : ""}
on:click={() => handleStatusClick("PLANNED")}
use:tooltip={{ text: toUnderstandableStatus("PLANNED", isForGame), pos: "top" }}
>
<Icon i="calendar" />
</button>
<button
class={status && status !== "WATCHING" ? "not-active" : ""}
on:click={() => handleStatusClick("WATCHING")}
use:tooltip={{ text: toUnderstandableStatus("WATCHING", isForGame), pos: "top" }}
>
<Icon i="clock" />
</button>
<button
class={status && status !== "FINISHED" ? "not-active" : ""}
on:click={() => handleStatusClick("FINISHED")}
use:tooltip={{ text: toUnderstandableStatus("FINISHED", isForGame), pos: "top" }}
>
<Icon i="check" />
</button>
<button
class={status && status !== "HOLD" ? "not-active" : ""}
on:click={() => handleStatusClick("HOLD")}
use:tooltip={{ text: toUnderstandableStatus("HOLD", isForGame), pos: "top" }}
>
<Icon i="pause" />
</button>
<button
class={status && status !== "DROPPED" ? "not-active" : ""}
on:click={() => handleStatusClick("DROPPED")}
use:tooltip={{ text: toUnderstandableStatus("DROPPED", isForGame), pos: "top" }}
>
<Icon i="thumb-down" />
</button>
<button
class={status && status !== "PLANNED" ? "not-active" : ""}
onclick={() => handleStatusClick("PLANNED")}
use:tooltip={{
text: toUnderstandableStatus("PLANNED", isForGame),
pos: "top",
}}
>
<Icon i="calendar" />
</button>
<button
class={status && status !== "WATCHING" ? "not-active" : ""}
onclick={() => handleStatusClick("WATCHING")}
use:tooltip={{
text: toUnderstandableStatus("WATCHING", isForGame),
pos: "top",
}}
>
<Icon i="clock" />
</button>
<button
class={status && status !== "FINISHED" ? "not-active" : ""}
onclick={() => handleStatusClick("FINISHED")}
use:tooltip={{
text: toUnderstandableStatus("FINISHED", isForGame),
pos: "top",
}}
>
<Icon i="check" />
</button>
<button
class={status && status !== "HOLD" ? "not-active" : ""}
onclick={() => handleStatusClick("HOLD")}
use:tooltip={{
text: toUnderstandableStatus("HOLD", isForGame),
pos: "top",
}}
>
<Icon i="pause" />
</button>
<button
class={status && status !== "DROPPED" ? "not-active" : ""}
onclick={() => handleStatusClick("DROPPED")}
use:tooltip={{
text: toUnderstandableStatus("DROPPED", isForGame),
pos: "top",
}}
>
<Icon i="thumb-down" />
</button>
</div>
<style lang="scss">
.status {
display: flex;
flex-flow: row;
gap: 10px;
width: 100%;
height: 100%;
.status {
display: flex;
flex-flow: row;
gap: 10px;
width: 100%;
height: 100%;
button {
font-size: 10px;
padding: 5px 10px;
}
}
button {
font-size: 10px;
padding: 5px 10px;
}
}
</style>
+56 -52
View File
@@ -1,66 +1,70 @@
<script lang="ts">
import type { PublicUser } from "@/types";
import type { PublicUser } from "@/types";
export let users: PublicUser[];
interface Props {
users: PublicUser[];
}
let { users }: Props = $props();
</script>
<div>
<h2>Users</h2>
<ul>
{#each users as user}
<li title={user.username}>
<a href="/lists/{user.id}/{user.username}">
<span>{user.username}</span>
</a>
</li>
{/each}
</ul>
<h2>Users</h2>
<ul>
{#each users as user}
<li title={user.username}>
<a href="/lists/{user.id}/{user.username}">
<span>{user.username}</span>
</a>
</li>
{/each}
</ul>
</div>
<style lang="scss">
div {
display: flex;
flex-flow: column;
margin: 5px 15px;
}
div {
display: flex;
flex-flow: column;
margin: 5px 15px;
}
ul {
display: flex;
flex-flow: row;
gap: 10px;
list-style: none;
overflow: auto;
padding: 15px;
ul {
display: flex;
flex-flow: row;
gap: 10px;
list-style: none;
overflow: auto;
padding: 15px;
li {
display: flex;
max-width: 200px;
li {
display: flex;
max-width: 200px;
a {
display: flex;
flex-flow: column;
gap: 8px;
width: 100%;
height: 100%;
padding: 3px 8px;
padding: 10px 20px;
border-radius: 8px;
background-color: $accent-color;
transition:
background-color 150ms ease,
color 150ms ease;
a {
display: flex;
flex-flow: column;
gap: 8px;
width: 100%;
height: 100%;
padding: 3px 8px;
padding: 10px 20px;
border-radius: 8px;
background-color: $accent-color;
transition:
background-color 150ms ease,
color 150ms ease;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
color: $bg-color;
background-color: $accent-color-hover;
}
}
}
}
&:hover {
color: $bg-color;
background-color: $accent-color-hover;
}
}
}
}
</style>
+311 -247
View File
@@ -1,264 +1,328 @@
<script lang="ts">
import { goto } from "$app/navigation";
import Icon from "@/lib/Icon.svelte";
import Poster from "@/lib/poster/Poster.svelte";
import PosterList from "@/lib/poster/PosterList.svelte";
import {
activeFilters,
activeSort,
clearActiveFilters,
serverFeatures,
userSettings
} from "@/store";
import type { Watched } from "@/types";
import GamePoster from "./poster/GamePoster.svelte";
import { get } from "svelte/store";
import { getLatestWatchedInTv } from "./util/helpers";
import { notify } from "./util/notify";
import { goto } from "$app/navigation";
import Icon from "@/lib/Icon.svelte";
import Poster from "@/lib/poster/Poster.svelte";
import PosterList from "@/lib/poster/PosterList.svelte";
import { store, clearActiveFilters } from "@/store.svelte";
import type { Watched } from "@/types";
import GamePoster from "./poster/GamePoster.svelte";
import { getLatestWatchedInTv } from "./util/helpers";
import { notify } from "./util/notify";
import { untrack } from "svelte";
export let list: Watched[];
export let isPublicList: boolean = false;
interface Props {
list: Watched[];
isPublicList?: boolean;
}
$: sort = $activeSort;
$: filters = $activeFilters;
$: watched = list;
$: settings = $userSettings;
$: features = $serverFeatures;
let { list, isPublicList = false }: Props = $props();
/**
* Checks if content has been watched previously
* by analyzing the watched entrys activity (with
* the latest AI improvements added in of course.)
*/
function contentWatchedPreviously(w: Watched) {
let wp = false;
const relatedActivity = w.activity.filter(
(a) =>
a.type === "ADDED_WATCHED" ||
a.type === "IMPORTED_ADDED_WATCHED" ||
a.type === "IMPORTED_WATCHED" ||
a.type === "STATUS_CHANGED"
);
for (let i = 0; i < relatedActivity.length; i++) {
const ra = relatedActivity[i];
if (ra.type === "IMPORTED_ADDED_WATCHED") {
wp = true;
break;
} else if (ra.type === "ADDED_WATCHED" || ra.type === "IMPORTED_WATCHED") {
const data = JSON.parse(ra.data);
if (data?.status == "FINISHED") {
wp = true;
break;
}
} else if (ra.type === "STATUS_CHANGED") {
if (ra.data === "FINISHED") {
wp = true;
break;
}
}
}
return wp;
}
let sort = $derived(store.activeSort);
let filters = $derived(store.activeFilters);
let settings = $derived(store.userSettings);
let watched: Watched[] = $state([]);
// Monsterous code for filters. Soz.
$: (watched, filters, sort), filt();
$effect(() => {
if (list) {
watched = list;
}
});
function filt() {
try {
// Set watched to list and sort it.
watched = list
.sort((a, b) => {
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
} else if (sort[0] === "ALPHA") {
const atitle = a.content ? a.content.title : a.game ? a.game.name : "";
const btitle = b.content ? b.content.title : b.game ? b.game.name : "";
if (sort[1] === "UP") {
return atitle.localeCompare(btitle);
} else if (sort[1] === "DOWN") {
return btitle.localeCompare(atitle);
}
} else if (sort[0] === "LASTCHANGED") {
if (sort[1] === "UP") return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
else if (sort[1] === "DOWN") return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
} else if (sort[0] === "LASTFIN") {
const aLastFinishActivity = a.activity
?.sort(
(aa, bb) =>
Date.parse(bb.customDate ?? bb.updatedAt) -
Date.parse(aa.customDate ?? aa.updatedAt)
)
?.find(
(aa) =>
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
(aa.type === "ADDED_WATCHED" && aa.data?.includes("FINISHED"))
);
const bLastFinishActivity = b.activity
?.sort(
(aa, bb) =>
Date.parse(bb.customDate ?? bb.updatedAt) -
Date.parse(aa.customDate ?? aa.updatedAt)
)
?.find(
(aa) =>
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
(aa.type === "ADDED_WATCHED" && aa.data?.includes("FINISHED"))
);
if (!aLastFinishActivity) return 1;
if (!bLastFinishActivity) return -1;
const alfaDate = aLastFinishActivity.customDate ?? aLastFinishActivity.updatedAt;
const blfaDate = bLastFinishActivity.customDate ?? bLastFinishActivity.updatedAt;
if (sort[1] === "UP") return Date.parse(alfaDate) - Date.parse(blfaDate);
else if (sort[1] === "DOWN") return Date.parse(blfaDate) - Date.parse(alfaDate);
} else if (sort[0] === "RATING") {
if (sort[1] === "UP") return (a.rating ?? 0) - (b.rating ?? 0);
else if (sort[1] === "DOWN") return (b.rating ?? 0) - (a.rating ?? 0);
}
// default DATEADDED DOWN
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
})
.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return 0;
});
// If games type filter enabled, but games disabled on server, make sure we remove it from active filters.
if (!features.games) {
const af = get(activeFilters);
af.type = af.type?.filter((a) => a !== "game");
filters.type = filters.type.filter((f) => f !== "game");
}
// Now apply filters to watch list.
if (filters.status.length > 0 && filters.type.length > 0) {
// If status and type filters applied, combine both.
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
watched = watched.filter(
(w) =>
(filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)) &&
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
);
} else {
watched = watched.filter(
(w) =>
filters.status.includes(w.status?.toLowerCase()) &&
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
);
}
} else if (filters.type.length > 0) {
// Only filter type
watched = watched.filter((w) =>
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
);
} else if (filters.status.length > 0) {
// Only filter status
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
watched = watched.filter(
(w) => filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)
);
} else {
watched = watched.filter((w) => filters.status.includes(w.status?.toLowerCase()));
}
}
} catch (err) {
console.error("filt: Failed to filter/sort current list!", err);
notify({ text: "Failed to filter/sort list!", type: "error", time: 6000 });
}
}
$effect(() => {
if (list && filters.status && filters.type && sort) {
untrack(() => {
filt();
});
}
});
/**
* Checks if content has been watched previously
* by analyzing the watched entrys activity (with
* the latest AI improvements added in of course.)
*/
function contentWatchedPreviously(w: Watched) {
let wp = false;
const relatedActivity = w.activity.filter(
(a) =>
a.type === "ADDED_WATCHED" ||
a.type === "IMPORTED_ADDED_WATCHED" ||
a.type === "IMPORTED_WATCHED" ||
a.type === "STATUS_CHANGED",
);
for (let i = 0; i < relatedActivity.length; i++) {
const ra = relatedActivity[i];
if (ra.type === "IMPORTED_ADDED_WATCHED") {
wp = true;
break;
} else if (
ra.type === "ADDED_WATCHED" ||
ra.type === "IMPORTED_WATCHED"
) {
const data = JSON.parse(ra.data);
if (data?.status == "FINISHED") {
wp = true;
break;
}
} else if (ra.type === "STATUS_CHANGED") {
if (ra.data === "FINISHED") {
wp = true;
break;
}
}
}
return wp;
}
// Monsterous code for filters. Soz.
function filt() {
console.debug("WatchedList: filt()");
try {
// Set watched to list and sort it.
watched = list
.sort((a, b) => {
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
} else if (sort[0] === "ALPHA") {
const atitle = a.content
? a.content.title
: a.game
? a.game.name
: "";
const btitle = b.content
? b.content.title
: b.game
? b.game.name
: "";
if (sort[1] === "UP") {
return atitle.localeCompare(btitle);
} else if (sort[1] === "DOWN") {
return btitle.localeCompare(atitle);
}
} else if (sort[0] === "LASTCHANGED") {
if (sort[1] === "UP")
return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
else if (sort[1] === "DOWN")
return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
} else if (sort[0] === "LASTFIN") {
const aLastFinishActivity = a.activity
?.sort(
(aa, bb) =>
Date.parse(bb.customDate ?? bb.updatedAt) -
Date.parse(aa.customDate ?? aa.updatedAt),
)
?.find(
(aa) =>
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
(aa.type === "ADDED_WATCHED" &&
aa.data?.includes("FINISHED")),
);
const bLastFinishActivity = b.activity
?.sort(
(aa, bb) =>
Date.parse(bb.customDate ?? bb.updatedAt) -
Date.parse(aa.customDate ?? aa.updatedAt),
)
?.find(
(aa) =>
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
(aa.type === "ADDED_WATCHED" &&
aa.data?.includes("FINISHED")),
);
if (!aLastFinishActivity) return 1;
if (!bLastFinishActivity) return -1;
const alfaDate =
aLastFinishActivity.customDate ?? aLastFinishActivity.updatedAt;
const blfaDate =
bLastFinishActivity.customDate ?? bLastFinishActivity.updatedAt;
if (sort[1] === "UP")
return Date.parse(alfaDate) - Date.parse(blfaDate);
else if (sort[1] === "DOWN")
return Date.parse(blfaDate) - Date.parse(alfaDate);
} else if (sort[0] === "RATING") {
if (sort[1] === "UP") return (a.rating ?? 0) - (b.rating ?? 0);
else if (sort[1] === "DOWN")
return (b.rating ?? 0) - (a.rating ?? 0);
}
// default DATEADDED DOWN
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
})
.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return 0;
});
// Now apply filters to watch list.
if (filters.status.length > 0 && filters.type.length > 0) {
// If status and type filters applied, combine both.
if (
settings?.includePreviouslyWatched &&
filters.status.includes("finished")
) {
watched = watched.filter(
(w) =>
(filters.status.includes(w.status?.toLowerCase()) ||
contentWatchedPreviously(w)) &&
filters.type.includes(
w.content ? w.content.type : w.game ? "game" : "",
),
);
} else {
watched = watched.filter(
(w) =>
filters.status.includes(w.status?.toLowerCase()) &&
filters.type.includes(
w.content ? w.content.type : w.game ? "game" : "",
),
);
}
} else if (filters.type.length > 0) {
// Only filter type
watched = watched.filter((w) =>
filters.type.includes(
w.content ? w.content.type : w.game ? "game" : "",
),
);
} else if (filters.status.length > 0) {
// Only filter status
if (
settings?.includePreviouslyWatched &&
filters.status.includes("finished")
) {
watched = watched.filter(
(w) =>
filters.status.includes(w.status?.toLowerCase()) ||
contentWatchedPreviously(w),
);
} else {
watched = watched.filter((w) =>
filters.status.includes(w.status?.toLowerCase()),
);
}
}
} catch (err) {
console.error("filt: Failed to filter/sort current list!", err);
notify({
text: "Failed to filter/sort list!",
type: "error",
time: 6000,
});
}
}
/**
* Callback for when a watched list item is updated through poster,
* this allows us to run the filt() func again so the sorting is
* updated.
*/
function itemUpdated() {
console.debug("itemUpdated");
filt();
}
</script>
<PosterList>
{#if watched?.length > 0}
{#each watched as w (w.id)}
{#if w.game}
<GamePoster
id={w.id}
rating={w.rating}
status={w.status}
media={{
id: w.game.igdbId,
coverId: w.game.coverId,
name: w.game.name,
summary: w.game.summary,
firstReleaseDate: w.game.releaseDate,
poster: w.game.poster
}}
disableInteraction={isPublicList}
extraDetails={{
dateAdded: w.createdAt,
dateModified: w.updatedAt
}}
fluidSize={true}
pinned={w.pinned}
/>
{:else if w.content}
<Poster
id={w.id}
media={{
id: w.content.tmdbId,
poster_path: w.content.poster_path,
title: w.content.title,
overview: w.content.overview,
media_type: w.content.type,
release_date: w.content.release_date,
first_air_date: w.content.first_air_date
}}
rating={w.rating}
status={w.status}
disableInteraction={isPublicList}
extraDetails={{
dateAdded: w.createdAt,
dateModified: w.updatedAt,
lastWatched: getLatestWatchedInTv(w.watchedSeasons, w.watchedEpisodes)
}}
fluidSize={true}
pinned={w.pinned}
/>
{/if}
{/each}
{:else}
<div class="empty-list">
{#if list?.length > 0}
<!-- `watched` (filtered list) is empty, but `list` (unfiltered) isn't,
{#if watched?.length > 0}
{#each watched as w (w.id)}
{#if w.game}
<GamePoster
id={w.id}
rating={w.rating}
status={w.status}
media={{
id: w.game.igdbId,
coverId: w.game.coverId,
name: w.game.name,
summary: w.game.summary,
firstReleaseDate: w.game.releaseDate,
poster: w.game.poster,
}}
disableInteraction={isPublicList}
extraDetails={{
dateAdded: w.createdAt,
dateModified: w.updatedAt,
}}
fluidSize={true}
pinned={w.pinned}
onUpdated={itemUpdated}
/>
{:else if w.content}
<Poster
id={w.id}
media={{
id: w.content.tmdbId,
poster_path: w.content.poster_path,
title: w.content.title,
overview: w.content.overview,
media_type: w.content.type,
release_date: w.content.release_date,
first_air_date: w.content.first_air_date,
}}
rating={w.rating}
status={w.status}
disableInteraction={isPublicList}
extraDetails={{
dateAdded: w.createdAt,
dateModified: w.updatedAt,
lastWatched: getLatestWatchedInTv(
w.watchedSeasons,
w.watchedEpisodes,
),
}}
fluidSize={true}
pinned={w.pinned}
onUpdated={itemUpdated}
/>
{/if}
{/each}
{:else}
<div class="empty-list">
{#if list?.length > 0}
<!-- `watched` (filtered list) is empty, but `list` (unfiltered) isn't,
so we should let the user know why there is nothing to show. -->
<Icon i="filter-circle" wh={80} />
<h2 class="norm">Filters are hiding all results!</h2>
<h4 class="norm">Try changing or removing your active filters.</h4>
<button on:click={() => clearActiveFilters()}>Clear Filters</button>
{:else}
<Icon i="reel" wh={80} />
{#if isPublicList}
<h2 class="norm">This watched list is empty!</h2>
<h4 class="norm">Come back later to see if they have added anything.</h4>
{:else}
<h2 class="norm">Your watched list is empty!</h2>
<h4 class="norm">Try searching for something you would like to add.</h4>
<button on:click={() => goto("/import")}>Import</button>
{/if}
{/if}
</div>
{/if}
<Icon i="filter-circle" wh={80} />
<h2 class="norm">Filters are hiding all results!</h2>
<h4 class="norm">Try changing or removing your active filters.</h4>
<button onclick={() => clearActiveFilters()}>Clear Filters</button>
{:else}
<Icon i="reel" wh={80} />
{#if isPublicList}
<h2 class="norm">This watched list is empty!</h2>
<h4 class="norm">
Come back later to see if they have added anything.
</h4>
{:else}
<h2 class="norm">Your watched list is empty!</h2>
<h4 class="norm">
Try searching for something you would like to add.
</h4>
<button onclick={() => goto("/import")}>Import</button>
{/if}
{/if}
</div>
{/if}
</PosterList>
<style lang="scss">
.empty-list {
display: flex;
flex-flow: column;
gap: 5px;
align-items: center;
.empty-list {
display: flex;
flex-flow: column;
gap: 5px;
align-items: center;
h2 {
margin-top: 10px;
}
h2 {
margin-top: 10px;
}
h4 {
font-weight: normal;
}
h4 {
font-weight: normal;
}
button {
width: max-content;
padding-left: 20px;
padding-right: 20px;
margin-top: 15px;
}
}
button {
width: max-content;
padding-left: 20px;
padding-right: 20px;
margin-top: 15px;
}
}
</style>
+56 -56
View File
@@ -1,65 +1,65 @@
export interface StayInViewOptions {
/**
* If the `node` contains an element (like an arrow for menus),
* that should be shifted over to account for any shifting
* of the `node` itself, then pass a selector for it here.
*/
elToShiftSelector?: string;
/**
* If the `node` contains an element (like an arrow for menus),
* that should be shifted over to account for any shifting
* of the `node` itself, then pass a selector for it here.
*/
elToShiftSelector?: string;
}
export default function stayInView(node: HTMLElement, opts: StayInViewOptions) {
console.debug("stayInView: Initial opts:", opts);
let { elToShiftSelector } = opts;
let viewDeb: ReturnType<typeof setTimeout>;
console.debug("stayInView: Initial opts:", opts);
let { elToShiftSelector } = opts;
let viewDeb: ReturnType<typeof setTimeout>;
/**
* Move element to in view, if it isn't.
*
* Currently only supports moving `node` back into view if oob
* on the left. May need to support other sides and/or resizing
* when `node` still wont fit after shifting it in the future.
*/
const getInView = () => {
const nrect = node.getBoundingClientRect();
const brect = document.body.getBoundingClientRect();
console.debug("stayInView->getInView: Called.", nrect, brect);
if (nrect.x <= brect.x) {
const diff = nrect.x - brect.x + 10;
console.debug(
"stayInView->getInView: Node is out of bounds on the left, shifting forwards to:",
diff
);
node.style.left = `${diff}px`;
if (elToShiftSelector) {
const elToShift = node.querySelector(elToShiftSelector) as HTMLElement;
if (elToShift) {
console.debug("stayInView->getInView: Shifting elToShift.");
const nrectNew = node.getBoundingClientRect();
const arrowDiff = nrectNew.left - nrect.left;
elToShift.style.left = `${elToShift.offsetLeft - arrowDiff}px`;
} else {
console.warn("elToShift not found.", elToShiftSelector);
}
}
}
};
/**
* Move element to in view, if it isn't.
*
* Currently only supports moving `node` back into view if oob
* on the left. May need to support other sides and/or resizing
* when `node` still wont fit after shifting it in the future.
*/
const getInView = () => {
const nrect = node.getBoundingClientRect();
const brect = document.body.getBoundingClientRect();
console.debug("stayInView->getInView: Called.", nrect, brect);
if (nrect.x <= brect.x) {
const diff = nrect.x - brect.x + 10;
console.debug(
"stayInView->getInView: Node is out of bounds on the left, shifting forwards to:",
diff,
);
node.style.left = `${diff}px`;
if (elToShiftSelector) {
const elToShift = node.querySelector(elToShiftSelector) as HTMLElement;
if (elToShift) {
console.debug("stayInView->getInView: Shifting elToShift.");
const nrectNew = node.getBoundingClientRect();
const arrowDiff = nrectNew.left - nrect.left;
elToShift.style.left = `${elToShift.offsetLeft - arrowDiff}px`;
} else {
console.warn("elToShift not found.", elToShiftSelector);
}
}
}
};
const getInViewDeb = () => {
clearTimeout(viewDeb);
viewDeb = setTimeout(getInView, 200);
};
const getInViewDeb = () => {
clearTimeout(viewDeb);
viewDeb = setTimeout(getInView, 200);
};
window.addEventListener("resize", getInViewDeb);
getInView();
window.addEventListener("resize", getInViewDeb);
getInView();
return {
update(opts: StayInViewOptions) {
console.debug("stayInView: Opts updated", opts);
elToShiftSelector = opts.elToShiftSelector;
getInView();
},
destroy() {
window.removeEventListener("resize", getInViewDeb);
}
};
return {
update(opts: StayInViewOptions) {
console.debug("stayInView: Opts updated", opts);
elToShiftSelector = opts.elToShiftSelector;
getInView();
},
destroy() {
window.removeEventListener("resize", getInViewDeb);
},
};
}
+56 -56
View File
@@ -1,66 +1,66 @@
export interface ToolTipOptions {
text: string;
pos?: "left" | "top" | "bot";
text: string;
pos?: "left" | "top" | "bot";
/**
* Only show tooltip if this condition is true.
*/
condition?: boolean;
/**
* Only show tooltip if this condition is true.
*/
condition?: boolean;
}
export default function tooltip(node: HTMLElement, opts: ToolTipOptions) {
let { text, pos = "left", condition = true } = opts;
const tooltip = document.getElementById("tooltip");
let { text, pos = "left", condition = true } = opts;
const tooltip = document.getElementById("tooltip");
const show = () => {
const trimmedText = text?.trim();
if (!condition || !trimmedText) return;
if (tooltip) {
tooltip.innerHTML = trimmedText;
const nrect = node.getBoundingClientRect();
const trect = tooltip.getBoundingClientRect();
nrect.y += window.scrollY; // Add scrollY to node dom rect so tooltip shows correcting when page is scrolled down
if (pos === "left") {
tooltip.style.left = `${nrect.x - trect.width - 10}px`;
tooltip.style.top = `${nrect.y + trect.height / 2 - 19.5}px`;
} else if (pos === "top") {
tooltip.style.left = `${nrect.x - trect.width / 2 + nrect.width / 2}px`;
tooltip.style.top = `${nrect.y - trect.height - 5}px`;
} else if (pos === "bot") {
tooltip.style.left = `${nrect.x - trect.width / 2 + nrect.width / 2}px`;
tooltip.style.top = `${nrect.y + trect.height + 5}px`;
}
tooltip.style.visibility = "visible";
}
};
const show = () => {
const trimmedText = text?.trim();
if (!condition || !trimmedText) return;
if (tooltip) {
tooltip.innerHTML = trimmedText;
const nrect = node.getBoundingClientRect();
const trect = tooltip.getBoundingClientRect();
nrect.y += window.scrollY; // Add scrollY to node dom rect so tooltip shows correcting when page is scrolled down
if (pos === "left") {
tooltip.style.left = `${nrect.x - trect.width - 10}px`;
tooltip.style.top = `${nrect.y + trect.height / 2 - 19.5}px`;
} else if (pos === "top") {
tooltip.style.left = `${nrect.x - trect.width / 2 + nrect.width / 2}px`;
tooltip.style.top = `${nrect.y - trect.height - 5}px`;
} else if (pos === "bot") {
tooltip.style.left = `${nrect.x - trect.width / 2 + nrect.width / 2}px`;
tooltip.style.top = `${nrect.y + trect.height + 5}px`;
}
tooltip.style.visibility = "visible";
}
};
const hide = () => {
if (tooltip) {
tooltip.style.visibility = "hidden";
}
};
const hide = () => {
if (tooltip) {
tooltip.style.visibility = "hidden";
}
};
node.addEventListener("mouseover", show);
node.addEventListener("touchstart", show);
node.addEventListener("mouseout", hide);
node.addEventListener("touchend", hide);
node.addEventListener("click", hide);
node.addEventListener("mouseover", show);
node.addEventListener("touchstart", show);
node.addEventListener("mouseout", hide);
node.addEventListener("touchend", hide);
node.addEventListener("click", hide);
return {
update(opts: ToolTipOptions) {
text = opts.text;
condition = opts.condition ?? true;
},
destroy() {
node.removeEventListener("mouseover", show);
node.removeEventListener("touchstart", show);
node.removeEventListener("mouseout", hide);
node.removeEventListener("touchend", hide);
node.removeEventListener("click", hide);
// Running hide() here fixes scenario where we (eg) hide `node`
// after it is pressed, but tooltip persists and there is no way
// to remove it.
hide();
}
};
return {
update(opts: ToolTipOptions) {
text = opts.text;
condition = opts.condition ?? true;
},
destroy() {
node.removeEventListener("mouseover", show);
node.removeEventListener("touchstart", show);
node.removeEventListener("mouseout", hide);
node.removeEventListener("touchend", hide);
node.removeEventListener("click", hide);
// Running hide() here fixes scenario where we (eg) hide `node`
// after it is pressed, but tooltip persists and there is no way
// to remove it.
hide();
},
};
}
+137 -127
View File
@@ -1,154 +1,164 @@
<script lang="ts">
import type { ContentType, PublicUser, WatchedStatus } from "@/types";
import HorizontalList from "../HorizontalList.svelte";
import Modal from "../Modal.svelte";
import axios from "axios";
import Spinner from "../Spinner.svelte";
import Error from "../Error.svelte";
import Icon from "../Icon.svelte";
import { watchedStatuses } from "../util/helpers";
import type { ContentType, PublicUser, WatchedStatus } from "@/types";
import HorizontalList from "../HorizontalList.svelte";
import Modal from "../Modal.svelte";
import axios from "axios";
import Spinner from "../Spinner.svelte";
import Error from "../Error.svelte";
import Icon from "../Icon.svelte";
import { watchedStatuses } from "../util/helpers";
interface FollowThoughts {
followedUser: PublicUser;
thoughts: string;
status: WatchedStatus;
rating: number;
}
interface FollowThoughts {
followedUser: PublicUser;
thoughts: string;
status: WatchedStatus;
rating: number;
}
export let mediaType: ContentType | "game";
// The tmdbId for movie/tv, igdbId for games.
export let mediaId: number;
interface Props {
mediaType: ContentType | "game";
// The tmdbId for movie/tv, igdbId for games.
mediaId: number;
}
let modalShownFor: FollowThoughts | undefined = undefined;
let { mediaType, mediaId }: Props = $props();
async function getFollowsThoughts() {
return (await axios.get<FollowThoughts[]>(`/follow/thoughts/${mediaType}/${mediaId}`)).data;
}
let modalShownFor: FollowThoughts | undefined = $state(undefined);
async function getFollowsThoughts() {
return (
await axios.get<FollowThoughts[]>(
`/follow/thoughts/${mediaType}/${mediaId}`,
)
).data;
}
</script>
{#await getFollowsThoughts()}
<Spinner />
<Spinner />
{:then fts}
{#if fts?.length > 0}
<HorizontalList title="Followed Thoughts">
{#each fts as ft}
<button
class={["thoughts-card plain", ft.thoughts ? "" : "no-thoughts"].join(" ")}
on:click={() => (modalShownFor = ft)}
>
<div>
<h4 title={ft.followedUser.username}>{ft.followedUser.username}</h4>
{#if ft.status}
<div class="status-icon">
<Icon i={watchedStatuses[ft.status]} wh={22} />
</div>
{/if}
{#if ft.rating}
<span class="rating">
<span>*</span>
{ft.rating}
</span>
{/if}
</div>
<div class="thought">
{ft.thoughts || "No thoughts yet."}
</div>
</button>
{/each}
</HorizontalList>
{/if}
{#if fts?.length > 0}
<HorizontalList title="Followed Thoughts">
{#each fts as ft}
<button
class={["thoughts-card plain", ft.thoughts ? "" : "no-thoughts"].join(
" ",
)}
onclick={() => (modalShownFor = ft)}
>
<div>
<h4 title={ft.followedUser.username}>{ft.followedUser.username}</h4>
{#if ft.status}
<div class="status-icon">
<Icon i={watchedStatuses[ft.status]} wh={22} />
</div>
{/if}
{#if ft.rating}
<span class="rating">
<span>*</span>
{ft.rating}
</span>
{/if}
</div>
<div class="thought">
{ft.thoughts || "No thoughts yet."}
</div>
</button>
{/each}
</HorizontalList>
{/if}
{:catch err}
<Error error={err} pretty="Failed to load followed thoughts!" />
<Error error={err} pretty="Failed to load followed thoughts!" />
{/await}
{#if modalShownFor}
<Modal
title={`${modalShownFor.followedUser.username}'s Thoughts`}
onClose={() => (modalShownFor = undefined)}
>
<span>{modalShownFor.thoughts}</span>
</Modal>
<Modal
title={`${modalShownFor.followedUser.username}'s Thoughts`}
onClose={() => (modalShownFor = undefined)}
>
<span>{modalShownFor.thoughts}</span>
</Modal>
{/if}
<style lang="scss">
.thoughts-card {
display: flex;
flex-flow: column;
padding: 15px 20px;
background-color: $accent-color;
fill: $text-color;
border-radius: 10px;
min-width: 250px;
max-width: 250px;
font-size: 16px;
transition: background-color 200ms ease;
text-align: unset;
overflow: hidden;
cursor: pointer;
.thoughts-card {
display: flex;
flex-flow: column;
padding: 15px 20px;
background-color: $accent-color;
fill: $text-color;
border-radius: 10px;
min-width: 250px;
max-width: 250px;
font-size: 16px;
transition: background-color 200ms ease;
text-align: unset;
overflow: hidden;
cursor: pointer;
& > div {
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
margin-bottom: 3px;
& > div {
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
margin-bottom: 3px;
h4 {
overflow: hidden;
text-overflow: ellipsis;
}
h4 {
overflow: hidden;
text-overflow: ellipsis;
}
.rating {
display: flex;
align-items: start;
justify-content: center;
gap: 5px;
font-size: 18px;
font-weight: bolder;
.rating {
display: flex;
align-items: start;
justify-content: center;
gap: 5px;
font-size: 18px;
font-weight: bolder;
span {
font-family: "Rampart One";
-webkit-text-stroke: 1px $text-color;
font-size: 32px;
line-height: 0.55;
margin-top: 7px;
}
}
span {
font-family: "Rampart One";
-webkit-text-stroke: 1px $text-color;
font-size: 32px;
line-height: 0.55;
margin-top: 7px;
}
}
.status-icon {
margin-left: auto;
margin-right: 2px;
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
}
}
.status-icon {
margin-left: auto;
margin-right: 2px;
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
}
}
.thought {
display: -webkit-box;
-webkit-line-clamp: 9;
-webkit-box-orient: vertical;
hyphens: auto;
overflow: hidden;
}
.thought {
display: -webkit-box;
-webkit-line-clamp: 9;
-webkit-box-orient: vertical;
hyphens: auto;
overflow: hidden;
}
&.no-thoughts {
pointer-events: none;
&.no-thoughts {
pointer-events: none;
.thought {
font-weight: lighter;
}
}
.thought {
font-weight: lighter;
}
}
&:hover {
color: $bg-color;
fill: $bg-color;
background-color: $accent-color-hover;
&:hover {
color: $bg-color;
fill: $bg-color;
background-color: $accent-color-hover;
.rating span {
-webkit-text-stroke: 1px $bg-color;
}
}
}
.rating span {
-webkit-text-stroke: 1px $bg-color;
}
}
}
</style>
+107 -100
View File
@@ -1,123 +1,130 @@
<script lang="ts">
import { onMount } from "svelte";
import Modal from "../Modal.svelte";
import Icon from "../Icon.svelte";
import { notify } from "../util/notify";
import Modal from "../Modal.svelte";
import Icon from "../Icon.svelte";
import { notify } from "../util/notify";
export let contentTitle: string;
export let thoughts: string;
export let onChange: (newThoughts: string) => Promise<boolean>;
interface Props {
contentTitle: string;
thoughts: string;
onChange: (newThoughts: string) => Promise<boolean>;
}
let modalOpen = false;
let textarea: HTMLTextAreaElement | undefined;
$: thoughtsToDisplay = thoughts ? thoughts : `Set thoughts on ${contentTitle}`;
let { contentTitle, thoughts, onChange }: Props = $props();
function resizeTextarea() {
if (!textarea) {
return;
}
textarea.style.height = "";
textarea.style.height = textarea.scrollHeight + "px";
}
let modalOpen = $state(false);
let textarea: HTMLTextAreaElement | undefined = $state();
let thoughtsToDisplay = $derived(
thoughts ? thoughts : `Set thoughts on ${contentTitle}`,
);
$: textarea, resizeTextarea();
function resizeTextarea() {
if (!textarea) {
return;
}
textarea.style.height = "";
textarea.style.height = textarea.scrollHeight + "px";
}
$effect(() => {
if (textarea) resizeTextarea();
});
</script>
<button
class={`plain thoughts${thoughtsToDisplay?.length > 100 ? " long" : ""}${thoughts ? "" : " placeholdered"}`}
on:click={() => {
modalOpen = !modalOpen;
if (modalOpen) {
resizeTextarea();
}
}}
class={`plain thoughts${thoughtsToDisplay?.length > 100 ? " long" : ""}${thoughts ? "" : " placeholdered"}`}
onclick={() => {
modalOpen = !modalOpen;
if (modalOpen) {
resizeTextarea();
}
}}
>
<i><Icon i="pencil" wh={24} /></i>
<p>{thoughtsToDisplay}</p>
<i><Icon i="pencil" wh={24} /></i>
<p>{thoughtsToDisplay}</p>
</button>
{#if modalOpen}
<Modal
title="Your Thoughts"
desc="View or modify your thoughts on {contentTitle}"
onClose={async () => {
if (!textarea) {
notify({
text: "Failed to find the text box! Please copy your changes to avoid losing them and try again!"
});
return;
}
// If thoughts weren't changed or changes saved successfully.
if (thoughts === textarea.value || (await onChange(textarea.value))) {
modalOpen = false;
}
}}
>
<textarea
name="Thoughts"
rows="3"
placeholder={`My thoughts on ${contentTitle}`}
value={thoughts}
bind:this={textarea}
on:input={resizeTextarea}
/>
</Modal>
<Modal
title="Your Thoughts"
desc="View or modify your thoughts on {contentTitle}"
onClose={async () => {
if (!textarea) {
notify({
text: "Failed to find the text box! Please copy your changes to avoid losing them and try again!",
});
return;
}
// If thoughts weren't changed or changes saved successfully.
if (thoughts === textarea.value || (await onChange(textarea.value))) {
modalOpen = false;
}
}}
>
<textarea
name="Thoughts"
rows="3"
placeholder={`My thoughts on ${contentTitle}`}
value={thoughts}
bind:this={textarea}
oninput={resizeTextarea}
></textarea>
</Modal>
{/if}
<style lang="scss">
button.thoughts {
position: relative;
width: 100%;
text-align: start;
padding: 7px 10px;
border: 2px solid $text-color;
border-radius: 5px;
max-height: 100px;
opacity: 0.5;
transition: opacity 150ms ease-in-out;
button.thoughts {
position: relative;
width: 100%;
text-align: start;
padding: 7px 10px;
border: 2px solid $text-color;
border-radius: 5px;
max-height: 100px;
opacity: 0.5;
transition: opacity 150ms ease-in-out;
&:hover {
opacity: 1;
}
&:hover {
opacity: 1;
}
i {
display: flex;
position: absolute;
bottom: 4px;
right: 5px;
opacity: 0;
transform: scale(0.5);
transition:
opacity 150ms ease-in-out,
transform 150ms ease-in-out;
}
i {
display: flex;
position: absolute;
bottom: 4px;
right: 5px;
opacity: 0;
transform: scale(0.5);
transition:
opacity 150ms ease-in-out,
transform 150ms ease-in-out;
}
&:hover i {
transform: scale(1);
opacity: 1;
}
&:hover i {
transform: scale(1);
opacity: 1;
}
&.placeholdered {
padding: 12px 12px;
}
&.placeholdered {
padding: 12px 12px;
}
p {
max-height: 90px;
overflow: hidden;
}
p {
max-height: 90px;
overflow: hidden;
}
&.long p {
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0,
rgba(0, 0, 0, 1) 40%,
rgba(0, 0, 0, 0) 95%,
rgba(0, 0, 0, 0) 0
);
}
}
&.long p {
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0,
rgba(0, 0, 0, 1) 40%,
rgba(0, 0, 0, 0) 95%,
rgba(0, 0, 0, 0) 0
);
}
}
textarea {
border: 0;
}
textarea {
border: 0;
}
</style>
+158 -116
View File
@@ -4,138 +4,180 @@
-->
<script lang="ts">
export let i: string;
export let wh = 24;
interface Props {
i: string;
wh?: number;
}
let { i, wh = 24 }: Props = $props();
</script>
{#if i === "Apple TV Plus"}
<a href="https://tv.apple.com/" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.046 17.091" width={wh} height={wh}>
<path
d="M9.436 2.742A3.857 3.857 0 0 0 10.316 0a3.769 3.769 0 0 0-2.51 1.311 3.622 3.622 0 0 0-.9 2.631 3.138 3.138 0 0 0 2.53-1.2m.82 1.381c-1.4-.081-2.58.8-3.25.8s-1.69-.756-2.79-.736a4.117 4.117 0 0 0-3.5 2.147c-1.5 2.6-.4 6.473 1.06 8.59.71 1.006 1.56 2.205 2.69 2.166s1.48-.7 2.77-.7 1.67.7 2.79.675 1.9-1.008 2.6-2.1a9.317 9.317 0 0 0 1.17-2.42 3.814 3.814 0 0 1-2.27-3.468 3.9 3.9 0 0 1 1.83-3.256 3.991 3.991 0 0 0-3.1-1.7m8.93-2.016V4.96h2.28v1.885h-2.28V13.6c0 1.008.45 1.522 1.45 1.522a7.482 7.482 0 0 0 .82-.06v1.9a7.823 7.823 0 0 1-1.35.1c-2.36 0-3.27-.917-3.27-3.216V6.89h-1.79V5h1.74V2.107Zm10.25 14.853h-2.5L22.736 5h2.49l2.95 9.608h.06L31.186 5h2.44Zm10.98 0h-2.16v-4.9h-4.64V9.9h4.63V5h2.16v4.9h4.64v2.158h-4.63Z"
/>
</svg>
</a>
<a aria-label={i} href="https://tv.apple.com/" target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 45.046 17.091"
width={wh}
height={wh}
>
<path
d="M9.436 2.742A3.857 3.857 0 0 0 10.316 0a3.769 3.769 0 0 0-2.51 1.311 3.622 3.622 0 0 0-.9 2.631 3.138 3.138 0 0 0 2.53-1.2m.82 1.381c-1.4-.081-2.58.8-3.25.8s-1.69-.756-2.79-.736a4.117 4.117 0 0 0-3.5 2.147c-1.5 2.6-.4 6.473 1.06 8.59.71 1.006 1.56 2.205 2.69 2.166s1.48-.7 2.77-.7 1.67.7 2.79.675 1.9-1.008 2.6-2.1a9.317 9.317 0 0 0 1.17-2.42 3.814 3.814 0 0 1-2.27-3.468 3.9 3.9 0 0 1 1.83-3.256 3.991 3.991 0 0 0-3.1-1.7m8.93-2.016V4.96h2.28v1.885h-2.28V13.6c0 1.008.45 1.522 1.45 1.522a7.482 7.482 0 0 0 .82-.06v1.9a7.823 7.823 0 0 1-1.35.1c-2.36 0-3.27-.917-3.27-3.216V6.89h-1.79V5h1.74V2.107Zm10.25 14.853h-2.5L22.736 5h2.49l2.95 9.608h.06L31.186 5h2.44Zm10.98 0h-2.16v-4.9h-4.64V9.9h4.63V5h2.16v4.9h4.64v2.158h-4.63Z"
/>
</svg>
</a>
{:else if i === "Apple TV"}
<a href="https://tv.apple.com/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
<path
d="M20.57 17.735h-1.815l-3.34-9.203h1.633l2.02 5.987c.075.231.273.9.586 2.012l.297-.997.33-1.006 2.094-6.004H24zm-5.344-.066a5.76 5.76 0 0 1-1.55.207c-1.23 0-1.84-.693-1.84-2.087V9.646h-1.063V8.532h1.121V7.081l1.476-.602v2.062h1.707v1.113H13.38v5.805c0 .446.074.75.214.932.14.182.396.264.75.264.207 0 .495-.041.883-.115zm-7.29-5.343c.017 1.764 1.55 2.358 1.567 2.366-.017.042-.248.842-.808 1.658-.487.71-.99 1.418-1.79 1.435-.783.016-1.03-.462-1.93-.462-.89 0-1.17.445-1.913.478-.758.025-1.344-.775-1.838-1.484-.998-1.451-1.765-4.098-.734-5.88.51-.89 1.426-1.451 2.416-1.46.75-.016 1.468.512 1.93.512.461 0 1.327-.627 2.234-.536.38.016 1.452.157 2.136 1.154-.058.033-1.278.743-1.27 2.219M6.468 7.988c.404-.495.685-1.18.61-1.864-.585.025-1.294.388-1.723.883-.38.437-.71 1.138-.619 1.806.652.05 1.328-.338 1.732-.825Z"
/>
</svg>
</a>
<a aria-label={i} href="https://tv.apple.com/" target="_blank">
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
>
<path
d="M20.57 17.735h-1.815l-3.34-9.203h1.633l2.02 5.987c.075.231.273.9.586 2.012l.297-.997.33-1.006 2.094-6.004H24zm-5.344-.066a5.76 5.76 0 0 1-1.55.207c-1.23 0-1.84-.693-1.84-2.087V9.646h-1.063V8.532h1.121V7.081l1.476-.602v2.062h1.707v1.113H13.38v5.805c0 .446.074.75.214.932.14.182.396.264.75.264.207 0 .495-.041.883-.115zm-7.29-5.343c.017 1.764 1.55 2.358 1.567 2.366-.017.042-.248.842-.808 1.658-.487.71-.99 1.418-1.79 1.435-.783.016-1.03-.462-1.93-.462-.89 0-1.17.445-1.913.478-.758.025-1.344-.775-1.838-1.484-.998-1.451-1.765-4.098-.734-5.88.51-.89 1.426-1.451 2.416-1.46.75-.016 1.468.512 1.93.512.461 0 1.327-.627 2.234-.536.38.016 1.452.157 2.136 1.154-.058.033-1.278.743-1.27 2.219M6.468 7.988c.404-.495.685-1.18.61-1.864-.585.025-1.294.388-1.723.883-.38.437-.71 1.138-.619 1.806.652.05 1.328-.338 1.732-.825Z"
/>
</svg>
</a>
{:else if i === "Amazon Prime Video"}
<a href="https://www.amazon.com/gp/video/storefront/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
<path
d="M22.787 15.292c-.336-.43-2.222-.204-3.069-.103-.257.031-.296-.193-.065-.356 1.504-1.056 3.968-.75 4.255-.397.288.357-.076 2.827-1.485 4.007-.217.18-.423.084-.327-.155.317-.792 1.027-2.566.69-2.996m-1.093 1.248c-2.627 1.94-6.437 2.97-9.717 2.97-4.597 0-8.737-1.7-11.87-4.528-.246-.222-.026-.525.27-.353 3.38 1.967 7.559 3.151 11.876 3.151a23.63 23.63 0 0 0 9.06-1.854c.444-.188.816.293.381.614m.482-5.038c-.761 0-1.346-.209-1.755-.626-.409-.418-.613-1.017-.613-1.797 0-.799.209-1.425.627-1.88.418-.454.998-.682 1.741-.682.572 0 1.019.138 1.341.415.323.276.484.645.484 1.105 0 .461-.174.81-.52 1.046-.348.237-.86.355-1.535.355-.35 0-.654-.034-.912-.101.037.411.161.706.373.884.212.178.533.268.963.268.172 0 .34-.011.502-.033a6.208 6.208 0 0 0 .733-.157.304.304 0 0 1 .046-.004c.104 0 .156.07.156.212v.424c0 .098-.013.167-.04.207a.341.341 0 0 1-.162.106 3.954 3.954 0 0 1-1.429.258m-.304-2.893c.314 0 .541-.048.682-.143.142-.095.212-.241.212-.438 0-.387-.23-.58-.69-.58-.59 0-.931.362-1.024 1.087.246.05.52.074.82.074m-9.84 2.755c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.074.249c.325-.215.619-.367.88-.456.26-.09.53-.134.806-.134.553 0 .943.197 1.17.59a3.77 3.77 0 0 1 .885-.452c.276-.092.562-.138.857-.138.43 0 .763.12 1 .36.236.239.354.574.354 1.004v3.253c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.885c-.08 0-.138-.018-.175-.055-.037-.037-.055-.096-.055-.175V8.176c0-.418-.188-.627-.562-.627-.332 0-.667.08-1.005.24v3.345c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.884c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V8.176c0-.418-.187-.627-.562-.627-.344 0-.682.083-1.013.249v3.336c0 .08-.017.138-.051.175-.034.037-.094.055-.18.055zM9.987 5.927c-.234 0-.42-.064-.562-.193-.142-.129-.212-.304-.212-.525 0-.221.07-.397.212-.526.141-.129.328-.193.562-.193.233 0 .42.064.562.193a.676.676 0 0 1 .212.526c0 .22-.07.396-.212.525-.141.129-.329.193-.562.193m-.443 5.437c-.08 0-.138-.019-.175-.055-.037-.037-.055-.096-.055-.176V6.886c0-.086.018-.146.055-.18.037-.034.096-.05.175-.05h.885c.086 0 .146.016.18.05s.05.094.05.18v4.247c0 .08-.017.139-.05.176-.034.036-.094.055-.18.055zm-3.681 0c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.12.497c.245-.27.477-.462.695-.575.219-.114.45-.17.696-.17h.13c.085 0 .147.016.183.05.037.034.056.094.056.18v.773c0 .08-.017.139-.051.176-.034.036-.094.055-.18.055a1.93 1.93 0 0 1-.166-.01 2.968 2.968 0 0 0-.258-.009c-.14 0-.313.02-.516.06-.202.04-.374.091-.515.152v3.097c0 .08-.018.138-.051.175-.034.037-.094.055-.18.055zM.344 13.262c-.08 0-.138-.017-.175-.05-.037-.034-.055-.095-.055-.18V6.886c0-.086.018-.146.055-.18.037-.034.095-.05.175-.05h.664c.14 0 .227.067.258.202l.064.24a2.03 2.03 0 0 1 .668-.424 2.13 2.13 0 0 1 .797-.157c.596 0 1.067.218 1.414.654.348.437.521 1.026.521 1.77 0 .51-.086.955-.258 1.336-.172.38-.405.674-.7.88a1.727 1.727 0 0 1-1.014.308c-.252 0-.491-.04-.719-.12a1.74 1.74 0 0 1-.58-.331v2.018c0 .085-.017.146-.05.18-.034.033-.095.05-.18.05zm2.018-2.81c.344 0 .597-.117.76-.35.163-.234.245-.603.245-1.106 0-.51-.08-.882-.24-1.115-.16-.234-.415-.35-.765-.35-.32 0-.62.083-.903.248v2.424c.27.166.571.249.903.249Z"
/>
</svg>
</a>
<a
aria-label={i}
href="https://www.amazon.com/gp/video/storefront/"
target="_blank"
>
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
>
<path
d="M22.787 15.292c-.336-.43-2.222-.204-3.069-.103-.257.031-.296-.193-.065-.356 1.504-1.056 3.968-.75 4.255-.397.288.357-.076 2.827-1.485 4.007-.217.18-.423.084-.327-.155.317-.792 1.027-2.566.69-2.996m-1.093 1.248c-2.627 1.94-6.437 2.97-9.717 2.97-4.597 0-8.737-1.7-11.87-4.528-.246-.222-.026-.525.27-.353 3.38 1.967 7.559 3.151 11.876 3.151a23.63 23.63 0 0 0 9.06-1.854c.444-.188.816.293.381.614m.482-5.038c-.761 0-1.346-.209-1.755-.626-.409-.418-.613-1.017-.613-1.797 0-.799.209-1.425.627-1.88.418-.454.998-.682 1.741-.682.572 0 1.019.138 1.341.415.323.276.484.645.484 1.105 0 .461-.174.81-.52 1.046-.348.237-.86.355-1.535.355-.35 0-.654-.034-.912-.101.037.411.161.706.373.884.212.178.533.268.963.268.172 0 .34-.011.502-.033a6.208 6.208 0 0 0 .733-.157.304.304 0 0 1 .046-.004c.104 0 .156.07.156.212v.424c0 .098-.013.167-.04.207a.341.341 0 0 1-.162.106 3.954 3.954 0 0 1-1.429.258m-.304-2.893c.314 0 .541-.048.682-.143.142-.095.212-.241.212-.438 0-.387-.23-.58-.69-.58-.59 0-.931.362-1.024 1.087.246.05.52.074.82.074m-9.84 2.755c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.074.249c.325-.215.619-.367.88-.456.26-.09.53-.134.806-.134.553 0 .943.197 1.17.59a3.77 3.77 0 0 1 .885-.452c.276-.092.562-.138.857-.138.43 0 .763.12 1 .36.236.239.354.574.354 1.004v3.253c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.885c-.08 0-.138-.018-.175-.055-.037-.037-.055-.096-.055-.175V8.176c0-.418-.188-.627-.562-.627-.332 0-.667.08-1.005.24v3.345c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.884c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V8.176c0-.418-.187-.627-.562-.627-.344 0-.682.083-1.013.249v3.336c0 .08-.017.138-.051.175-.034.037-.094.055-.18.055zM9.987 5.927c-.234 0-.42-.064-.562-.193-.142-.129-.212-.304-.212-.525 0-.221.07-.397.212-.526.141-.129.328-.193.562-.193.233 0 .42.064.562.193a.676.676 0 0 1 .212.526c0 .22-.07.396-.212.525-.141.129-.329.193-.562.193m-.443 5.437c-.08 0-.138-.019-.175-.055-.037-.037-.055-.096-.055-.176V6.886c0-.086.018-.146.055-.18.037-.034.096-.05.175-.05h.885c.086 0 .146.016.18.05s.05.094.05.18v4.247c0 .08-.017.139-.05.176-.034.036-.094.055-.18.055zm-3.681 0c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.12.497c.245-.27.477-.462.695-.575.219-.114.45-.17.696-.17h.13c.085 0 .147.016.183.05.037.034.056.094.056.18v.773c0 .08-.017.139-.051.176-.034.036-.094.055-.18.055a1.93 1.93 0 0 1-.166-.01 2.968 2.968 0 0 0-.258-.009c-.14 0-.313.02-.516.06-.202.04-.374.091-.515.152v3.097c0 .08-.018.138-.051.175-.034.037-.094.055-.18.055zM.344 13.262c-.08 0-.138-.017-.175-.05-.037-.034-.055-.095-.055-.18V6.886c0-.086.018-.146.055-.18.037-.034.095-.05.175-.05h.664c.14 0 .227.067.258.202l.064.24a2.03 2.03 0 0 1 .668-.424 2.13 2.13 0 0 1 .797-.157c.596 0 1.067.218 1.414.654.348.437.521 1.026.521 1.77 0 .51-.086.955-.258 1.336-.172.38-.405.674-.7.88a1.727 1.727 0 0 1-1.014.308c-.252 0-.491-.04-.719-.12a1.74 1.74 0 0 1-.58-.331v2.018c0 .085-.017.146-.05.18-.034.033-.095.05-.18.05zm2.018-2.81c.344 0 .597-.117.76-.35.163-.234.245-.603.245-1.106 0-.51-.08-.882-.24-1.115-.16-.234-.415-.35-.765-.35-.32 0-.62.083-.903.248v2.424c.27.166.571.249.903.249Z"
/>
</svg>
</a>
{:else if i === "Disney Plus"}
<a href="https://disneyplus.com/" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1041 565" width={wh} height={wh}
><path
d="M736 366c-15 3-52 5-52 5l-5 15s19-2 32 0c0 0 5-1 5 5v11s0 3-5 4l-41 2-6 20s-2 4 3 3 42-8 47-7 11 8 9 14c-2 8-39 32-62 30 0 0-12 1-22-15-9-16 4-45 4-45s-6-13-2-18c0 0 3-2 10-3l9-19s-10 1-16-6c-6-8-7-11-2-13s48-10 78-9c0 0 10-1 19 17 0 0 4 7-3 9Zm-112 72c-4 9-14 19-27 13-12-6-32-46-32-46s-7-15-9-15c0 0-1-3-2 13-1 17 0 49-7 54-6 5-13 3-17-3s-5-20-3-44 8-50 15-58 13-2 15 0c0 0 10 9 26 34l2 5 16 24h2c2 0 1-8 1-8s-3-26-16-71c0 0-2-5 0-11 1-5 6-2 6-2s21 10 30 43c10 33 4 63 0 72Zm-100-85c-2 3-3 8-12 10 0 0-82 5-86 11 0 0-3 3 2 4 4 1 23 4 32 4s42 1 53 15c0 0 7 7 7 23s-3 21-9 27c-7 6-63 31-99-8 0 0-16-18 6-32 0 0 16-10 57 1 0 0 12 5 12 9-1 5-10 10-24 10s-23-7-21-6c1 1-15-8-20-2-5 5-4 9 1 12 13 7 61 5 75-11 0 0 6-7-3-12-8-5-33-8-43-9-9 0-44 0-49-9 0 0-5-6 1-24s46-25 63-27c0 0 48-1 57 8v6ZM388 461c-6 4-19 2-22-2-4-5-5-22-4-49s1-60 7-66c6-5 10 0 12 3 3 4 6 8 7 16l2 54s3 40-2 44Zm12-144c-17 6-28 4-38 0-5 7-7 9-10 10-5 0-9-7-10-10-1-2-3-5-1-12-9-9-10-21-8-28 2-9 18-44 68-48 0 0 24-1 28 11h1s23 1 22 21c0 21-26 47-52 56Zm-46-46c-5 8-5 13-3 16 6-9 16-22 32-33-12 1-22 6-29 17Zm68-14c-15 3-39 24-51 41 18 3 49 2 62-26 0 0 7-18-11-15Zm421 162c-9 16-35 50-70 42-12 28-21 56-27 98 0 0-1 8-8 5-7-2-18-13-20-29-2-20 7-55 25-94-5-9-9-22-6-40 0 0 5-33 38-63 0 0 4-3 7-2 2 1 1 12-1 17l-17 31s-9 17-7 31c18-27 58-81 82-64 9 6 12 19 12 33 0 12-3 25-8 35Zm-7-43s-2-11-12 1c-9 10-25 29-38 54 13-2 27-9 31-13 6-6 21-21 19-42Zm-486 14c-2 24-11 64-77 85-43 13-84 6-107 1 0 9-1 12-3 14s-16 10-24-2c-3-5-5-15-6-24-50-23-74-57-74-58-2-1-13-13-2-28 11-13 47-27 78-32 1-27 5-48 8-57 5-11 11-1 16 6 4 6 7 29 7 48 21-1 33 1 56 5 30 6 50 21 49 38-2 18-17 25-23 25-7 1-17-4-17-4-6-3 0-6 8-9 9-5 7-9 7-9-3-10-43-16-82-16l2 78c27 5 47 4 47 4s100-3 103-66c3-64-99-125-175-145-76-19-118-6-122-4s0 3 0 3 4 0 11 3c7 2 2 6 2 6-13 4-28 2-31-4-2-6 2-11 8-19 5-8 11-8 11-8 93-32 207 26 207 26 107 55 125 118 123 143Zm-282-4c-11 5-3 13-3 13 20 21 44 35 67 43 3-37 3-50 3-68-36 2-57 8-67 12ZM1041 379v13c0 3-2 5-5 5h-63v9c0 19-1 35-3 53 0 3-2 5-5 5h-13a5 5 0 0 1-5-5c2-18 3-34 3-53l-1-9h-62c-3 0-5-2-5-5v-13c0-3 2-6 5-6h62a461 461 0 0 0-8-66l4-2h14c3 0 5 2 5 4 4 22 7 43 8 64h64c3 0 5 3 5 6Z"
/>
<path d="M199 204h1-1Zm0 0h1-1Z" fill="none" />
<defs>
<radialGradient
id="a"
gradientUnits="userSpaceOnUse"
cx="942.5"
cy="279.9"
r="760.1"
fx="942.5"
fy="279.9"
>
<stop offset="0" stop-color="#021192" /><stop offset="0" stop-color="#021096" /><stop
offset=".1"
stop-color="#010cb4"
/>
<stop offset=".1" stop-color="#0008ce" /><stop offset=".1" stop-color="#0006d7" /><stop
offset=".1"
stop-color="#0004e1"
/>
<stop offset=".2" stop-color="#0001fa" /><stop offset=".2" stop-color="#0000fe" /><stop
offset=".2"
stop-color="#0f1eff"
/>
<stop offset="1" stop-color="#0ff" stop-opacity="0" /></radialGradient
>
</defs>
<path
d="M955 274a439 439 0 0 0-772-74c0 2 2 3 3 4l11 4h2c3 0 5-1 6-3A419 419 0 0 1 549 23a414 414 0 0 1 383 254c1 2 3 3 5 3h14c1 0 3 0 4-2v-4Zm-756-70h1-1Zm735 75h1-1Z"
/>
</svg>
</a>
<a aria-label={i} href="https://disneyplus.com/" target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1041 565"
width={wh}
height={wh}
><path
d="M736 366c-15 3-52 5-52 5l-5 15s19-2 32 0c0 0 5-1 5 5v11s0 3-5 4l-41 2-6 20s-2 4 3 3 42-8 47-7 11 8 9 14c-2 8-39 32-62 30 0 0-12 1-22-15-9-16 4-45 4-45s-6-13-2-18c0 0 3-2 10-3l9-19s-10 1-16-6c-6-8-7-11-2-13s48-10 78-9c0 0 10-1 19 17 0 0 4 7-3 9Zm-112 72c-4 9-14 19-27 13-12-6-32-46-32-46s-7-15-9-15c0 0-1-3-2 13-1 17 0 49-7 54-6 5-13 3-17-3s-5-20-3-44 8-50 15-58 13-2 15 0c0 0 10 9 26 34l2 5 16 24h2c2 0 1-8 1-8s-3-26-16-71c0 0-2-5 0-11 1-5 6-2 6-2s21 10 30 43c10 33 4 63 0 72Zm-100-85c-2 3-3 8-12 10 0 0-82 5-86 11 0 0-3 3 2 4 4 1 23 4 32 4s42 1 53 15c0 0 7 7 7 23s-3 21-9 27c-7 6-63 31-99-8 0 0-16-18 6-32 0 0 16-10 57 1 0 0 12 5 12 9-1 5-10 10-24 10s-23-7-21-6c1 1-15-8-20-2-5 5-4 9 1 12 13 7 61 5 75-11 0 0 6-7-3-12-8-5-33-8-43-9-9 0-44 0-49-9 0 0-5-6 1-24s46-25 63-27c0 0 48-1 57 8v6ZM388 461c-6 4-19 2-22-2-4-5-5-22-4-49s1-60 7-66c6-5 10 0 12 3 3 4 6 8 7 16l2 54s3 40-2 44Zm12-144c-17 6-28 4-38 0-5 7-7 9-10 10-5 0-9-7-10-10-1-2-3-5-1-12-9-9-10-21-8-28 2-9 18-44 68-48 0 0 24-1 28 11h1s23 1 22 21c0 21-26 47-52 56Zm-46-46c-5 8-5 13-3 16 6-9 16-22 32-33-12 1-22 6-29 17Zm68-14c-15 3-39 24-51 41 18 3 49 2 62-26 0 0 7-18-11-15Zm421 162c-9 16-35 50-70 42-12 28-21 56-27 98 0 0-1 8-8 5-7-2-18-13-20-29-2-20 7-55 25-94-5-9-9-22-6-40 0 0 5-33 38-63 0 0 4-3 7-2 2 1 1 12-1 17l-17 31s-9 17-7 31c18-27 58-81 82-64 9 6 12 19 12 33 0 12-3 25-8 35Zm-7-43s-2-11-12 1c-9 10-25 29-38 54 13-2 27-9 31-13 6-6 21-21 19-42Zm-486 14c-2 24-11 64-77 85-43 13-84 6-107 1 0 9-1 12-3 14s-16 10-24-2c-3-5-5-15-6-24-50-23-74-57-74-58-2-1-13-13-2-28 11-13 47-27 78-32 1-27 5-48 8-57 5-11 11-1 16 6 4 6 7 29 7 48 21-1 33 1 56 5 30 6 50 21 49 38-2 18-17 25-23 25-7 1-17-4-17-4-6-3 0-6 8-9 9-5 7-9 7-9-3-10-43-16-82-16l2 78c27 5 47 4 47 4s100-3 103-66c3-64-99-125-175-145-76-19-118-6-122-4s0 3 0 3 4 0 11 3c7 2 2 6 2 6-13 4-28 2-31-4-2-6 2-11 8-19 5-8 11-8 11-8 93-32 207 26 207 26 107 55 125 118 123 143Zm-282-4c-11 5-3 13-3 13 20 21 44 35 67 43 3-37 3-50 3-68-36 2-57 8-67 12ZM1041 379v13c0 3-2 5-5 5h-63v9c0 19-1 35-3 53 0 3-2 5-5 5h-13a5 5 0 0 1-5-5c2-18 3-34 3-53l-1-9h-62c-3 0-5-2-5-5v-13c0-3 2-6 5-6h62a461 461 0 0 0-8-66l4-2h14c3 0 5 2 5 4 4 22 7 43 8 64h64c3 0 5 3 5 6Z"
/>
<path d="M199 204h1-1Zm0 0h1-1Z" fill="none" />
<defs>
<radialGradient
id="a"
gradientUnits="userSpaceOnUse"
cx="942.5"
cy="279.9"
r="760.1"
fx="942.5"
fy="279.9"
>
<stop offset="0" stop-color="#021192" /><stop
offset="0"
stop-color="#021096"
/><stop offset=".1" stop-color="#010cb4" />
<stop offset=".1" stop-color="#0008ce" /><stop
offset=".1"
stop-color="#0006d7"
/><stop offset=".1" stop-color="#0004e1" />
<stop offset=".2" stop-color="#0001fa" /><stop
offset=".2"
stop-color="#0000fe"
/><stop offset=".2" stop-color="#0f1eff" />
<stop offset="1" stop-color="#0ff" stop-opacity="0" /></radialGradient
>
</defs>
<path
d="M955 274a439 439 0 0 0-772-74c0 2 2 3 3 4l11 4h2c3 0 5-1 6-3A419 419 0 0 1 549 23a414 414 0 0 1 383 254c1 2 3 3 5 3h14c1 0 3 0 4-2v-4Zm-756-70h1-1Zm735 75h1-1Z"
/>
</svg>
</a>
{:else if i === "Netflix"}
<a href="https://netflix.com/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="30">
<path
d="M5.398 0v.006c3.028 8.556 5.37 15.175 8.348 23.596 2.344.058 4.85.398 4.854.398-2.8-7.924-5.923-16.747-8.487-24zm8.489 0v9.63L18.6 22.951c-.043-7.86-.004-15.913.002-22.95zM5.398 1.05V24c1.873-.225 2.81-.312 4.715-.398v-9.22z"
/>
</svg>
</a>
<a aria-label={i} href="https://netflix.com/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="30">
<path
d="M5.398 0v.006c3.028 8.556 5.37 15.175 8.348 23.596 2.344.058 4.85.398 4.854.398-2.8-7.924-5.923-16.747-8.487-24zm8.489 0v9.63L18.6 22.951c-.043-7.86-.004-15.913.002-22.95zM5.398 1.05V24c1.873-.225 2.81-.312 4.715-.398v-9.22z"
/>
</svg>
</a>
{:else if i === "Sky Go"}
<a href="https://go.sky.com/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
<path
d="M7.387 13.656c0 1.423-.933 2.454-2.823 2.675-1.35.147-3.337-.025-4.294-.148-.025-.147-.074-.343-.074-.49 0-1.252.663-1.522 1.3-1.522.664 0 1.694.123 2.455.123.834 0 1.104-.295 1.104-.565 0-.368-.343-.515-1.006-.638l-1.767-.343C.785 12.453 0 11.423 0 10.343c0-1.325.933-2.454 2.798-2.65 1.398-.148 3.116.024 4.049.122.024.172.049.32.049.491 0 1.252-.663 1.522-1.276 1.522-.491 0-1.227-.099-2.086-.099-.884 0-1.227.246-1.227.54 0 .32.343.442.883.54l1.718.32c1.742.294 2.479 1.3 2.479 2.527m3.092 1.521c0 .761-.295 1.203-1.792 1.203-.196 0-.368-.025-.54-.05V6.22c0-.76.27-1.57 1.767-1.57.196 0 .393.024.565.049zm6.085 3.927c.197.098.59.22 1.105.245.859.025 1.325-.319 1.693-1.08L24 7.913a2.5 2.5 0 0 0-.957-.22c-.589 0-1.399.122-1.914 1.325l-1.497 3.534-2.945-4.81c-.196-.05-.662-.148-1.006-.148-1.03 0-1.62.393-2.233 1.031l-2.871 3.141 2.306 3.632c.418.663.982 1.006 1.89 1.006.589 0 1.104-.147 1.325-.245l-2.773-4.196 1.963-2.086 3.24 5.08Z"
/>
</svg>
</a>
<a aria-label={i} href="https://go.sky.com/" target="_blank">
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
>
<path
d="M7.387 13.656c0 1.423-.933 2.454-2.823 2.675-1.35.147-3.337-.025-4.294-.148-.025-.147-.074-.343-.074-.49 0-1.252.663-1.522 1.3-1.522.664 0 1.694.123 2.455.123.834 0 1.104-.295 1.104-.565 0-.368-.343-.515-1.006-.638l-1.767-.343C.785 12.453 0 11.423 0 10.343c0-1.325.933-2.454 2.798-2.65 1.398-.148 3.116.024 4.049.122.024.172.049.32.049.491 0 1.252-.663 1.522-1.276 1.522-.491 0-1.227-.099-2.086-.099-.884 0-1.227.246-1.227.54 0 .32.343.442.883.54l1.718.32c1.742.294 2.479 1.3 2.479 2.527m3.092 1.521c0 .761-.295 1.203-1.792 1.203-.196 0-.368-.025-.54-.05V6.22c0-.76.27-1.57 1.767-1.57.196 0 .393.024.565.049zm6.085 3.927c.197.098.59.22 1.105.245.859.025 1.325-.319 1.693-1.08L24 7.913a2.5 2.5 0 0 0-.957-.22c-.589 0-1.399.122-1.914 1.325l-1.497 3.534-2.945-4.81c-.196-.05-.662-.148-1.006-.148-1.03 0-1.62.393-2.233 1.031l-2.871 3.141 2.306 3.632c.418.663.982 1.006 1.89 1.006.589 0 1.104-.147 1.325-.245l-2.773-4.196 1.963-2.086 3.24 5.08Z"
/>
</svg>
</a>
{:else if i === "Paramount Plus"}
<a href="https://paramountplus.com" target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
width="100"
viewBox="-161.6 95.4 1000 230.4"
xml:space="preserve"
>
<path
d="M94 154c0-40-42-59-92-59-53 0-102 24-120 61a69 69 0 0 0-8 31 47 47 0 0 0 8 27c7 11 21 19 41 19 25 0 45-14 45-38 0 0 0-6-8-6-6 0-7 4-7 6 1 19-10 34-30 34-22 0-31-19-31-39 0-41 31-68 63-80a128 128 0 0 1 47-8c36 0 66 14 66 51 0 32-26 58-58 60l1-4c7-23 15-48 28-66a71 71 0 0 1 3-5l-2-2a76 76 0 0 0-8 5c-60 47-62 179-142 179a62 62 0 0 1-8 0c-17-3-26-14-26-30l1-10a8 8 0 0 0-7-8h-1c-7 0-10 6-11 14 0 23 17 37 44 39l9 1c62 0 101-48 117-105a138 138 0 0 0 24-5c29-8 62-28 62-62zm596 14h-22l-5 11-9 20h-10l-3 6h11l-13 29c-9 20-24 39-32 39-2 0-3 0-3-2l3-9 10-21c4-9 12-24 12-31s-5-13-14-13c-10 0-19 6-28 15l6-13h-21l-17 38c-7 14-20 36-29 36-2 0-3-1-3-2a16 16 0 0 1 2-5l11-26 18-42h-22l-18 41c-6 13-20 34-29 34a3 3 0 0 1-3-2v-1l3-9 9-21 20-42h-22l-5 10c-3 6-9 14-18 14-4 0-6-2-7-3-1-14-10-22-23-22-19 0-32 11-40 24a123 123 0 0 0-13 29c-8 13-17 23-23 23-2 0-3-1-3-3l3-9 15-31c4-9 6-15 6-22 0-6-4-11-12-11-10 0-22 6-31 16l1-5c0-7-4-11-11-11-10 0-20 6-29 16l6-14h-20l-17 38c-8 18-21 36-29 36-1 0-2-1-2-3l5-15 22-52c2-3-9-6-22-6-15 0-29 7-40 16-7 6-13 9-16 9a2 2 0 0 1-2-1c0-3 8-12 8-19 0-3-2-5-6-5-8 0-18 8-25 16l6-14h-19l-18 38c-8 18-21 37-28 37-2 0-3-1-3-3 0-3 4-12 6-16l22-52c2-3-9-6-23-6-20 0-40 14-50 28a83 83 0 0 0-20 45c0 10 5 17 16 17 13 0 23-10 28-17a36 36 0 0 0-1 4c0 8 3 13 12 13 8 0 19-7 27-17l-7 15h22l21-47c7-16 15-24 17-24a1 1 0 0 1 1 1c0 1-3 6-3 10s2 7 8 7c4 0 10-2 14-5a79 79 0 0 0-18 43c0 10 5 17 16 17 12 0 23-11 29-18a45 45 0 0 0-1 5c0 7 4 13 13 13s16-6 25-17l-7 15h22l21-45c8-20 20-31 26-31a3 3 0 0 1 3 3 20 20 0 0 1-2 7l-29 67h23l21-47c8-20 18-30 25-30 3 0 4 1 4 4a20 20 0 0 1-3 7l-19 42a39 39 0 0 0-4 13c0 7 4 12 14 12 13 0 22-9 33-24v3c1 12 8 22 25 22 20 0 36-14 46-36a78 78 0 0 0 7-25 14 14 0 0 0 7 1 19 19 0 0 0 10-2l-3 7-12 26a41 41 0 0 0-4 15c0 8 4 14 13 14s21-9 30-21a34 34 0 0 0 0 8c0 6 2 13 11 13 10 0 19-8 27-18l-7 16h22l18-41c10-23 20-36 29-36a3 3 0 0 1 4 2v1c0 4-6 14-11 25l-10 22a43 43 0 0 0-4 16c0 7 4 12 12 12 13 0 26-13 33-22a46 46 0 0 0-2 14c0 13 7 19 18 19a34 34 0 0 0 20-6c9-6 15-15 21-23l-4-4c-5 8-11 15-17 19a19 19 0 0 1-11 5c-5 0-8-3-8-9s3-13 7-24l12-27 10-24h15l3-6h-15l14-31zM64 274c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 23-21 38-30 38zm137 0c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 24-21 38-30 38zm244-61c0 12-11 41-23 58-5 7-9 9-13 9-5 0-7-4-6-11 1-10 10-36 23-53 5-8 9-11 14-11 4 0 5 3 5 8zm343-14 20-47h-24l-20 47h-51l-9 19h51l-20 47h24l20-47h51l8-19h-50z"
/>
</svg>
</a>
<a aria-label={i} href="https://paramountplus.com" target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
width="100"
viewBox="-161.6 95.4 1000 230.4"
xml:space="preserve"
>
<path
d="M94 154c0-40-42-59-92-59-53 0-102 24-120 61a69 69 0 0 0-8 31 47 47 0 0 0 8 27c7 11 21 19 41 19 25 0 45-14 45-38 0 0 0-6-8-6-6 0-7 4-7 6 1 19-10 34-30 34-22 0-31-19-31-39 0-41 31-68 63-80a128 128 0 0 1 47-8c36 0 66 14 66 51 0 32-26 58-58 60l1-4c7-23 15-48 28-66a71 71 0 0 1 3-5l-2-2a76 76 0 0 0-8 5c-60 47-62 179-142 179a62 62 0 0 1-8 0c-17-3-26-14-26-30l1-10a8 8 0 0 0-7-8h-1c-7 0-10 6-11 14 0 23 17 37 44 39l9 1c62 0 101-48 117-105a138 138 0 0 0 24-5c29-8 62-28 62-62zm596 14h-22l-5 11-9 20h-10l-3 6h11l-13 29c-9 20-24 39-32 39-2 0-3 0-3-2l3-9 10-21c4-9 12-24 12-31s-5-13-14-13c-10 0-19 6-28 15l6-13h-21l-17 38c-7 14-20 36-29 36-2 0-3-1-3-2a16 16 0 0 1 2-5l11-26 18-42h-22l-18 41c-6 13-20 34-29 34a3 3 0 0 1-3-2v-1l3-9 9-21 20-42h-22l-5 10c-3 6-9 14-18 14-4 0-6-2-7-3-1-14-10-22-23-22-19 0-32 11-40 24a123 123 0 0 0-13 29c-8 13-17 23-23 23-2 0-3-1-3-3l3-9 15-31c4-9 6-15 6-22 0-6-4-11-12-11-10 0-22 6-31 16l1-5c0-7-4-11-11-11-10 0-20 6-29 16l6-14h-20l-17 38c-8 18-21 36-29 36-1 0-2-1-2-3l5-15 22-52c2-3-9-6-22-6-15 0-29 7-40 16-7 6-13 9-16 9a2 2 0 0 1-2-1c0-3 8-12 8-19 0-3-2-5-6-5-8 0-18 8-25 16l6-14h-19l-18 38c-8 18-21 37-28 37-2 0-3-1-3-3 0-3 4-12 6-16l22-52c2-3-9-6-23-6-20 0-40 14-50 28a83 83 0 0 0-20 45c0 10 5 17 16 17 13 0 23-10 28-17a36 36 0 0 0-1 4c0 8 3 13 12 13 8 0 19-7 27-17l-7 15h22l21-47c7-16 15-24 17-24a1 1 0 0 1 1 1c0 1-3 6-3 10s2 7 8 7c4 0 10-2 14-5a79 79 0 0 0-18 43c0 10 5 17 16 17 12 0 23-11 29-18a45 45 0 0 0-1 5c0 7 4 13 13 13s16-6 25-17l-7 15h22l21-45c8-20 20-31 26-31a3 3 0 0 1 3 3 20 20 0 0 1-2 7l-29 67h23l21-47c8-20 18-30 25-30 3 0 4 1 4 4a20 20 0 0 1-3 7l-19 42a39 39 0 0 0-4 13c0 7 4 12 14 12 13 0 22-9 33-24v3c1 12 8 22 25 22 20 0 36-14 46-36a78 78 0 0 0 7-25 14 14 0 0 0 7 1 19 19 0 0 0 10-2l-3 7-12 26a41 41 0 0 0-4 15c0 8 4 14 13 14s21-9 30-21a34 34 0 0 0 0 8c0 6 2 13 11 13 10 0 19-8 27-18l-7 16h22l18-41c10-23 20-36 29-36a3 3 0 0 1 4 2v1c0 4-6 14-11 25l-10 22a43 43 0 0 0-4 16c0 7 4 12 12 12 13 0 26-13 33-22a46 46 0 0 0-2 14c0 13 7 19 18 19a34 34 0 0 0 20-6c9-6 15-15 21-23l-4-4c-5 8-11 15-17 19a19 19 0 0 1-11 5c-5 0-8-3-8-9s3-13 7-24l12-27 10-24h15l3-6h-15l14-31zM64 274c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 23-21 38-30 38zm137 0c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 24-21 38-30 38zm244-61c0 12-11 41-23 58-5 7-9 9-13 9-5 0-7-4-6-11 1-10 10-36 23-53 5-8 9-11 14-11 4 0 5 3 5 8zm343-14 20-47h-24l-20 47h-51l-9 19h51l-20 47h24l20-47h51l8-19h-50z"
/>
</svg>
</a>
{:else if i === "BBC iPlayer"}
<a href="https://www.bbc.co.uk/iplayer" target="_blank">
<svg width={80} height={wh} viewBox="0 0 605 107" xmlns="http://www.w3.org/2000/svg">
<path
d="M.022 34.586h22.003V106.3H.022zM108.188 9.069C100.852 3.025 90.348 0 76.67 0H38.461v106.299h22.598V70.024h15.462c9.118 0 16.848-1.362 23.192-4.088 6.342-2.725 11.174-6.691 14.496-11.894 3.319-5.204 4.98-11.523 4.98-18.956 0-11.299-3.668-19.97-11.001-26.017Zm-17.47 38.877c-3.519 2.728-8.648 4.088-15.387 4.088H61.059V17.989H75.48c6.639 0 11.719 1.39 15.238 4.163 3.518 2.776 5.279 7.088 5.279 12.935 0 5.848-1.761 10.135-5.279 12.859ZM156.058 0h-22.895v106.299h73.295V86.378h-50.4zm127.661 0H259.04c-7.136 16.751-14.198 33.997-21.186 51.737-6.987 17.743-13.852 35.93-20.59 54.562h24.53c2.576-7.731 5.152-15.41 7.731-23.044h43.114c2.576 7.533 5.152 15.216 7.731 23.044h25.126c-6.541-17.641-13.306-35.358-20.294-53.149-6.987-17.789-14.149-35.506-21.483-53.15Zm-27.652 64.225c4.855-13.775 9.862-27.452 15.016-41.033 2.476 6.642 4.98 13.406 7.507 20.294 2.528 6.89 4.981 13.803 7.36 20.739h-29.883Zm106.708-12.121c-4.362-7.631-21.508-44.273-25.571-52.103h-26.018l39.843 71.961V106.3h22.895V71.962L413.917.001h-25.423c-4.163 7.83-21.259 44.372-25.719 52.103Zm84.238 9h44.601v-19.03h-44.601V19.03h50.25V0h-73.145v106.299h75.97V87.27h-53.075zm144.253 23.415c-4.362-7.085-8.772-14.147-13.232-21.186 6.144-2.676 10.876-6.49 14.199-11.447 3.319-4.955 4.98-10.951 4.98-17.989 0-10.902-3.742-19.276-11.224-25.126C578.504 2.924 567.777 0 553.802 0h-38.804v106.299h22.598V67.645h18.287c7.631 12.688 15.015 25.571 22.151 38.654h26.315c-4.363-7.433-8.723-14.692-13.083-21.78Zm-22.747-38.654c-3.668 2.528-9.02 3.791-16.056 3.791h-14.867V17.989h15.016c7.036 0 12.362 1.29 15.982 3.865 3.616 2.579 5.427 6.593 5.427 12.043-.001 5.452-1.836 9.441-5.502 11.968ZM0 0h22.025v18.15H0z"
></path>
</svg>
</a>
<a aria-label={i} href="https://www.bbc.co.uk/iplayer" target="_blank">
<svg
width={80}
height={wh}
viewBox="0 0 605 107"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.022 34.586h22.003V106.3H.022zM108.188 9.069C100.852 3.025 90.348 0 76.67 0H38.461v106.299h22.598V70.024h15.462c9.118 0 16.848-1.362 23.192-4.088 6.342-2.725 11.174-6.691 14.496-11.894 3.319-5.204 4.98-11.523 4.98-18.956 0-11.299-3.668-19.97-11.001-26.017Zm-17.47 38.877c-3.519 2.728-8.648 4.088-15.387 4.088H61.059V17.989H75.48c6.639 0 11.719 1.39 15.238 4.163 3.518 2.776 5.279 7.088 5.279 12.935 0 5.848-1.761 10.135-5.279 12.859ZM156.058 0h-22.895v106.299h73.295V86.378h-50.4zm127.661 0H259.04c-7.136 16.751-14.198 33.997-21.186 51.737-6.987 17.743-13.852 35.93-20.59 54.562h24.53c2.576-7.731 5.152-15.41 7.731-23.044h43.114c2.576 7.533 5.152 15.216 7.731 23.044h25.126c-6.541-17.641-13.306-35.358-20.294-53.149-6.987-17.789-14.149-35.506-21.483-53.15Zm-27.652 64.225c4.855-13.775 9.862-27.452 15.016-41.033 2.476 6.642 4.98 13.406 7.507 20.294 2.528 6.89 4.981 13.803 7.36 20.739h-29.883Zm106.708-12.121c-4.362-7.631-21.508-44.273-25.571-52.103h-26.018l39.843 71.961V106.3h22.895V71.962L413.917.001h-25.423c-4.163 7.83-21.259 44.372-25.719 52.103Zm84.238 9h44.601v-19.03h-44.601V19.03h50.25V0h-73.145v106.299h75.97V87.27h-53.075zm144.253 23.415c-4.362-7.085-8.772-14.147-13.232-21.186 6.144-2.676 10.876-6.49 14.199-11.447 3.319-4.955 4.98-10.951 4.98-17.989 0-10.902-3.742-19.276-11.224-25.126C578.504 2.924 567.777 0 553.802 0h-38.804v106.299h22.598V67.645h18.287c7.631 12.688 15.015 25.571 22.151 38.654h26.315c-4.363-7.433-8.723-14.692-13.083-21.78Zm-22.747-38.654c-3.668 2.528-9.02 3.791-16.056 3.791h-14.867V17.989h15.016c7.036 0 12.362 1.29 15.982 3.865 3.616 2.579 5.427 6.593 5.427 12.043-.001 5.452-1.836 9.441-5.502 11.968ZM0 0h22.025v18.15H0z"
></path>
</svg>
</a>
{:else if i === "Hulu"}
<a href="https://hulu.com" target="_blank">
<svg width={wh} height={wh} viewBox="0 0 64 23" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.0599 6.54678H6.7997C5.65659 6.54678 5.08178 6.85375 5.08178 6.85375V0H0V20.98H5.08178V12.5141C5.08178 11.7654 5.69215 11.1591 6.44385 11.1591H9.40148C10.1544 11.1591 10.763 11.7654 10.763 12.5141V20.98H15.8462V11.8589C15.8462 8.02298 13.2767 6.54678 10.0599 6.54678ZM58.9167 6.54678V15.0115C58.9167 15.7614 58.3078 16.368 57.5556 16.368H54.5982C53.8453 16.368 53.2367 15.7614 53.2367 15.0115V6.54678H48.1526V15.4338C48.1526 19.0662 50.4883 20.98 53.9404 20.98H58.9173V20.9541C62.0966 20.9541 64 18.7023 64 15.4338V6.54678H58.9167ZM30.2441 15.0115C30.2441 15.7614 29.6347 16.368 28.8824 16.368H25.9247C25.1719 16.368 24.563 15.7614 24.563 15.0115V6.54678H19.4797V15.4338C19.4797 19.0662 21.8154 20.98 25.2673 20.98H30.2441V20.9541C33.4237 20.9541 35.3274 18.7023 35.3274 15.4338V6.54678H30.2441V15.0115ZM39.2015 20.98H44.2853V0H39.2015V20.98Z"
></path>
</svg>
</a>
<a aria-label={i} href="https://hulu.com" target="_blank">
<svg
width={wh}
height={wh}
viewBox="0 0 64 23"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.0599 6.54678H6.7997C5.65659 6.54678 5.08178 6.85375 5.08178 6.85375V0H0V20.98H5.08178V12.5141C5.08178 11.7654 5.69215 11.1591 6.44385 11.1591H9.40148C10.1544 11.1591 10.763 11.7654 10.763 12.5141V20.98H15.8462V11.8589C15.8462 8.02298 13.2767 6.54678 10.0599 6.54678ZM58.9167 6.54678V15.0115C58.9167 15.7614 58.3078 16.368 57.5556 16.368H54.5982C53.8453 16.368 53.2367 15.7614 53.2367 15.0115V6.54678H48.1526V15.4338C48.1526 19.0662 50.4883 20.98 53.9404 20.98H58.9173V20.9541C62.0966 20.9541 64 18.7023 64 15.4338V6.54678H58.9167ZM30.2441 15.0115C30.2441 15.7614 29.6347 16.368 28.8824 16.368H25.9247C25.1719 16.368 24.563 15.7614 24.563 15.0115V6.54678H19.4797V15.4338C19.4797 19.0662 21.8154 20.98 25.2673 20.98H30.2441V20.9541C33.4237 20.9541 35.3274 18.7023 35.3274 15.4338V6.54678H30.2441V15.0115ZM39.2015 20.98H44.2853V0H39.2015V20.98Z"
></path>
</svg>
</a>
{:else}
<span>{i}</span>
<span>{i}</span>
{/if}
<style lang="scss">
a {
display: contents;
}
a {
display: contents;
}
svg {
fill: white;
opacity: 0.7;
svg {
fill: white;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
&:hover {
opacity: 1;
}
}
span {
font-size: 14px;
font-weight: bold;
}
span {
font-size: 14px;
font-weight: bold;
}
</style>
+27 -23
View File
@@ -1,32 +1,36 @@
<script lang="ts">
import type { TMDBContentWatchProviders } from "@/types";
import ProviderIcon from "./ProviderIcon.svelte";
import type { TMDBContentWatchProviders } from "@/types";
import ProviderIcon from "./ProviderIcon.svelte";
export let providers: TMDBContentWatchProviders;
interface Props {
providers: TMDBContentWatchProviders;
}
let { providers }: Props = $props();
</script>
{#if providers?.flatrate?.length > 0 || providers?.free?.length > 0}
<div class="streaming-providers">
{#if providers?.flatrate?.length > 0}
{#each providers.flatrate as provider}
<ProviderIcon i={provider.provider_name} wh={40} />
{/each}
{/if}
{#if providers?.free?.length > 0}
{#each providers.free as provider}
<ProviderIcon i={provider.provider_name} wh={40} />
{/each}
{/if}
</div>
<div class="streaming-providers">
{#if providers?.flatrate?.length > 0}
{#each providers.flatrate as provider}
<ProviderIcon i={provider.provider_name} wh={40} />
{/each}
{/if}
{#if providers?.free?.length > 0}
{#each providers.free as provider}
<ProviderIcon i={provider.provider_name} wh={40} />
{/each}
{/if}
</div>
{/if}
<style lang="scss">
.streaming-providers {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 15px;
margin-top: auto;
padding-top: 10px;
}
.streaming-providers {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 15px;
margin-top: auto;
padding-top: 10px;
}
</style>
+19 -17
View File
@@ -1,24 +1,26 @@
<script lang="ts">
import type { ContentType, TMDBMovieSimilar, TMDBShowSimilar } from "@/types";
import HorizontalList from "../HorizontalList.svelte";
import { watchedList } from "@/store";
import { getWatchedDependedProps } from "@/lib/util/helpers";
import Poster from "../poster/Poster.svelte";
import type { ContentType, TMDBMovieSimilar, TMDBShowSimilar } from "@/types";
import HorizontalList from "../HorizontalList.svelte";
import { store } from "@/store.svelte";
import { getWatchedDependedProps } from "@/lib/util/helpers";
import Poster from "../poster/Poster.svelte";
$: wList = $watchedList;
interface Props {
type: ContentType;
similar: TMDBShowSimilar | TMDBMovieSimilar;
}
export let type: ContentType;
export let similar: TMDBShowSimilar | TMDBMovieSimilar;
let { type, similar }: Props = $props();
</script>
{#if similar?.results?.length > 0}
<HorizontalList title="Similar">
{#each similar.results as content}
<Poster
media={{ ...content, media_type: type }}
{...getWatchedDependedProps(content.id, type, wList)}
small={true}
/>
{/each}
</HorizontalList>
<HorizontalList title="Similar">
{#each similar.results as content}
<Poster
media={{ ...content, media_type: type }}
{...getWatchedDependedProps(content.id, type, store.watchedList)}
small={true}
/>
{/each}
</HorizontalList>
{/if}
+64 -55
View File
@@ -1,68 +1,77 @@
<script lang="ts">
export let homepage: string | undefined;
export let title: string;
export let releaseYear: string | number;
export let voteAverage: number;
export let voteCount: number;
interface Props {
homepage: string | undefined;
title: string;
releaseYear: string | number;
voteAverage: number;
voteCount: number;
}
// if voteAvg bigger than 10, it is out of 100, so no need to * by 10
const vote = Math.round(voteAverage > 10 ? voteAverage : voteAverage * 10) / 10;
let { homepage, title, releaseYear, voteAverage, voteCount }: Props =
$props();
// if voteAvg bigger than 10, it is out of 100, so no need to * by 10
const vote =
Math.round(voteAverage > 10 ? voteAverage : voteAverage * 10) / 10;
</script>
<span class="title-container">
<span class="title">
{#if homepage}
<a href={homepage} target="_blank">{title}</a>
{:else}
<span class="t">{title}</span>
{/if}
<span class="year">{releaseYear}</span>
</span>
<span class="rating" title={`TMDB Rating: ${vote} out of 10 (based on ${voteCount} votes)`}>
<span>*</span>
{vote}
</span>
<span class="title">
{#if homepage}
<a href={homepage} target="_blank">{title}</a>
{:else}
<span class="t">{title}</span>
{/if}
<span class="year">{releaseYear}</span>
</span>
<span
class="rating"
title={`TMDB Rating: ${vote} out of 10 (based on ${voteCount} votes)`}
>
<span>*</span>
{vote}
</span>
</span>
<style lang="scss">
.title-container {
display: flex;
gap: 10px;
.title-container {
display: flex;
gap: 10px;
.title {
a,
span.t {
color: white;
text-decoration: none;
font-size: 30px;
font-weight: bold;
padding-right: 3px;
}
.title {
a,
span.t {
color: white;
text-decoration: none;
font-size: 30px;
font-weight: bold;
padding-right: 3px;
}
span.year {
font-size: 20px;
color: rgba($color: #fff, $alpha: 0.7);
}
}
span.year {
font-size: 20px;
color: rgba($color: #fff, $alpha: 0.7);
}
}
.rating {
display: flex;
align-items: start;
justify-content: center;
gap: 5px;
color: green;
margin-left: auto;
font-size: 22px;
color: gold;
font-weight: bolder;
.rating {
display: flex;
align-items: start;
justify-content: center;
gap: 5px;
color: green;
margin-left: auto;
font-size: 22px;
color: gold;
font-weight: bolder;
span {
font-family: "Rampart One";
-webkit-text-stroke: 1px gold;
font-size: 40px;
line-height: 0.7;
margin-top: 7px;
}
}
}
span {
font-family: "Rampart One";
-webkit-text-stroke: 1px gold;
font-size: 40px;
line-height: 0.7;
margin-top: 7px;
}
}
}
</style>
+59 -49
View File
@@ -1,63 +1,73 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount } from "svelte";
export let embed: string;
export let closed: () => void;
interface Props {
embed: string;
closed: () => void;
}
let modalDiv: HTMLDivElement;
let { embed, closed }: Props = $props();
onMount(() => {
// For better experience on keyboard.
// If modal shown by btn click, the btn will still be
// focused, so clicking `esc` wont trigger until tab
// is clicked first... which I count as not intuitive.
if (modalDiv) {
modalDiv.focus();
}
});
let modalDiv: HTMLDivElement = $state();
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
closed();
}
}
onMount(() => {
// For better experience on keyboard.
// If modal shown by btn click, the btn will still be
// focused, so clicking `esc` wont trigger until tab
// is clicked first... which I count as not intuitive.
if (modalDiv) {
modalDiv.focus();
}
});
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
closed();
}
}
</script>
<div
bind:this={modalDiv}
class="modal"
on:click={closed}
on:keydown={handleKeyDown}
role="button"
tabindex="0"
bind:this={modalDiv}
class="modal"
onclick={closed}
onkeydown={handleKeyDown}
role="button"
tabindex="0"
>
{#if embed}
<div class="wrapper">
<iframe title="Video Embed" src={embed} frameborder="0" width="100%" height="100%" />
</div>
{/if}
{#if embed}
<div class="wrapper">
<iframe
title="Video Embed"
src={embed}
frameborder="0"
width="100%"
height="100%"
></iframe>
</div>
{/if}
</div>
<style lang="scss">
.modal {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 0;
left: 0;
width: 100dvw;
height: 100dvh;
background-color: rgba($color: #000000, $alpha: 0.5);
z-index: 99998;
.modal {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 0;
left: 0;
width: 100dvw;
height: 100dvh;
background-color: rgba($color: #000000, $alpha: 0.5);
z-index: 99998;
.wrapper {
width: 100%;
max-width: 800px;
border-radius: 15px;
aspect-ratio: 16 / 9;
overflow: hidden;
margin: 20px;
}
}
.wrapper {
width: 100%;
max-width: 800px;
border-radius: 15px;
aspect-ratio: 16 / 9;
overflow: hidden;
margin: 20px;
}
}
</style>
+93 -77
View File
@@ -1,95 +1,111 @@
<script lang="ts">
import { decode } from "blurhash";
import Icon from "../Icon.svelte";
import { baseURL } from "../util/api";
import { onMount } from "svelte";
import type { Image } from "@/types";
import { run } from "svelte/legacy";
/**
* Users avatar.
*/
export let img: Image | undefined;
export let avatarDropped: ((ev: Event) => void) | undefined = undefined;
import { decode } from "blurhash";
import Icon from "../Icon.svelte";
import { baseURL } from "../util/api";
import { onMount } from "svelte";
import type { Image } from "@/types";
let bhCanvas: HTMLCanvasElement;
let avatarInput: HTMLInputElement;
interface Props {
/**
* Users avatar.
*/
img: Image | undefined;
avatarDropped?: ((ev: Event) => void) | undefined;
}
function avatarLoaded() {
console.log("avatar loaded.. removing canvas");
bhCanvas?.remove();
}
let { img, avatarDropped = undefined }: Props = $props();
$: {
if (img?.path && img?.blurHash && bhCanvas) {
const pixels = decode(img.blurHash, 80, 80);
const ctx = bhCanvas.getContext("2d");
if (ctx) {
const imageData = ctx.createImageData(80, 80);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}
}
}
let bhCanvas: HTMLCanvasElement = $state();
let avatarInput: HTMLInputElement = $state();
onMount(() => {
// Ignore rest if avatarDropped not defined
if (typeof avatarDropped !== "function") return;
function avatarLoaded() {
console.log("avatar loaded.. removing canvas");
bhCanvas?.remove();
}
avatarInput?.addEventListener("input", avatarDropped);
run(() => {
if (img?.path && img?.blurHash && bhCanvas) {
const pixels = decode(img.blurHash, 80, 80);
const ctx = bhCanvas.getContext("2d");
if (ctx) {
const imageData = ctx.createImageData(80, 80);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}
}
});
return () => {
avatarInput?.removeEventListener("input", avatarDropped!);
};
});
onMount(() => {
// Ignore rest if avatarDropped not defined
if (typeof avatarDropped !== "function") return;
avatarInput?.addEventListener("input", avatarDropped);
return () => {
avatarInput?.removeEventListener("input", avatarDropped!);
};
});
</script>
<div class={["img-ctr", typeof avatarDropped === "function" ? "" : "no-click"].join(" ")}>
{#if img?.path}
<canvas bind:this={bhCanvas} />
<img src={`${baseURL}/${img.path}`} alt="" on:load={avatarLoaded} />
{:else}
<Icon i="person" wh="100%" />
{/if}
{#if typeof avatarDropped === "function"}
<input bind:this={avatarInput} type="file" title="" accept=".jpg,.png,.gif,.webp" />
{/if}
<div
class={[
"img-ctr",
typeof avatarDropped === "function" ? "" : "no-click",
].join(" ")}
>
{#if img?.path}
<canvas bind:this={bhCanvas}></canvas>
<img src={`${baseURL}/${img.path}`} alt="" onload={avatarLoaded} />
{:else}
<Icon i="person" wh="100%" />
{/if}
{#if typeof avatarDropped === "function"}
<input
bind:this={avatarInput}
type="file"
title=""
accept=".jpg,.png,.gif,.webp"
/>
{/if}
</div>
<style lang="scss">
.img-ctr {
width: 80px;
min-width: 80px;
height: 80px;
min-height: 80px;
border-radius: 50%;
position: relative;
overflow: hidden;
fill: $text-color;
.img-ctr {
width: 80px;
min-width: 80px;
height: 80px;
min-height: 80px;
border-radius: 50%;
position: relative;
overflow: hidden;
fill: $text-color;
img {
width: 80px;
min-width: 80px;
height: 80px;
min-height: 80px;
object-fit: cover;
}
img {
width: 80px;
min-width: 80px;
height: 80px;
min-height: 80px;
object-fit: cover;
}
canvas {
position: absolute;
left: 0;
}
canvas {
position: absolute;
left: 0;
}
&:not(.no-click):hover {
opacity: 0.8;
}
&:not(.no-click):hover {
opacity: 0.8;
}
input[type="file"] {
opacity: 0;
width: 100%;
height: 100%;
left: 0;
position: absolute;
cursor: pointer;
}
}
input[type="file"] {
opacity: 0;
width: 100%;
height: 100%;
left: 0;
position: absolute;
cursor: pointer;
}
}
</style>
+82 -75
View File
@@ -1,91 +1,98 @@
<script lang="ts">
import { onMount } from "svelte";
import Modal from "../Modal.svelte";
import axios from "axios";
import type { TrustedHeaderAuthLogoutDetailsResponse } from "@/types";
import Spinner from "../Spinner.svelte";
import { clearWatcharrData } from ".";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import Modal from "../Modal.svelte";
import axios from "axios";
import type { TrustedHeaderAuthLogoutDetailsResponse } from "@/types";
import Spinner from "../Spinner.svelte";
import { clearWatcharrData } from ".";
import { goto } from "$app/navigation";
export let onClose: () => void;
interface Props {
onClose: () => void;
}
let loadingBtns = true;
let logoutUrl: string | undefined = undefined;
let { onClose }: Props = $props();
onMount(async () => {
try {
const r = await axios.get<TrustedHeaderAuthLogoutDetailsResponse>(
"/auth/proxy_logout_details"
);
logoutUrl = r?.data?.logoutUrl;
if (!logoutUrl?.toLowerCase()?.startsWith("http")) {
// If no protocol in logoutUrl, set https
logoutUrl = `https://${logoutUrl}`;
}
} catch (err) {
console.error("Failed to get proxy logout details!", err);
}
loadingBtns = false;
});
let loadingBtns = $state(true);
let logoutUrl: string | undefined = $state(undefined);
function logout() {
clearWatcharrData();
goto("/login?noAuto=1");
}
onMount(async () => {
try {
const r = await axios.get<TrustedHeaderAuthLogoutDetailsResponse>(
"/auth/proxy_logout_details",
);
logoutUrl = r?.data?.logoutUrl;
if (!logoutUrl?.toLowerCase()?.startsWith("http")) {
// If no protocol in logoutUrl, set https
logoutUrl = `https://${logoutUrl}`;
}
} catch (err) {
console.error("Failed to get proxy logout details!", err);
}
loadingBtns = false;
});
function proxyLogout() {
if (!logoutUrl) {
console.error("proxyLogout: Not supported without a configured logoutUrl.");
return;
}
clearWatcharrData();
window.location.replace(logoutUrl);
}
function logout() {
clearWatcharrData();
goto("/login?noAuto=1");
}
function proxyLogout() {
if (!logoutUrl) {
console.error(
"proxyLogout: Not supported without a configured logoutUrl.",
);
return;
}
clearWatcharrData();
window.location.replace(logoutUrl);
}
</script>
<Modal
title="Logout"
desc="You logged in via single sign-on. Did you click log out by mistake?"
{onClose}
title="Logout"
desc="You logged in via single sign-on. Did you click log out by mistake?"
{onClose}
>
{#if loadingBtns}
<Spinner />
{:else}
<div>
<p>
{#if logoutUrl}
This is likely what you want: <b
>Fully logout of your single sign-on service and Watcharr</b
>.
{:else}
Single sign-on logout has not been configured on this server. If you are not the server
operator, let them know!
{/if}
</p>
<button disabled={!logoutUrl} on:click={proxyLogout}>Log out of Single Sign-On Service</button
>
<p>
Logging out of Watcharr will clear your local credentials and data, but <b
>you will still be logged in to your single sign-on service</b
>! Do <b>not</b> do this on a public machine and assume you are logged out, this account could
still be accessible.
</p>
<button on:click={logout}>Log out of Watcharr</button>
</div>
{/if}
{#if loadingBtns}
<Spinner />
{:else}
<div>
<p>
{#if logoutUrl}
This is likely what you want: <b
>Fully logout of your single sign-on service and Watcharr</b
>.
{:else}
Single sign-on logout has not been configured on this server. If you
are not the server operator, let them know!
{/if}
</p>
<button disabled={!logoutUrl} onclick={proxyLogout}
>Log out of Single Sign-On Service</button
>
<p>
Logging out of Watcharr will clear your local credentials and data, but <b
>you will still be logged in to your single sign-on service</b
>! Do <b>not</b> do this on a public machine and assume you are logged out,
this account could still be accessible.
</p>
<button onclick={logout}>Log out of Watcharr</button>
</div>
{/if}
</Modal>
<style lang="scss">
div {
display: flex;
flex-flow: column;
div {
display: flex;
flex-flow: column;
p {
margin-bottom: 5px;
}
p {
margin-bottom: 5px;
}
button:first-of-type {
margin-bottom: 15px;
}
}
button:first-of-type {
margin-bottom: 15px;
}
}
</style>
+3 -3
View File
@@ -1,4 +1,4 @@
import { clearAllStores } from "@/store";
import { clearAllStores } from "@/store.svelte";
/**
* Helper to clear local data in client for logout
@@ -9,6 +9,6 @@ import { clearAllStores } from "@/store";
* should not be included here (eg: redirecting to /login).
*/
export function clearWatcharrData() {
localStorage.removeItem("token");
clearAllStores();
localStorage.removeItem("token");
clearAllStores();
}
+67 -82
View File
@@ -1,93 +1,78 @@
<script lang="ts">
import { wlDetailedView } from "@/store";
import type { WLDetailedViewOption } from "@/types";
import { get } from "svelte/store";
import { page } from "$app/stores";
import { store } from "@/store.svelte";
import type { WLDetailedViewOption } from "@/types";
import { page } from "$app/state";
import Menu from "../Menu.svelte";
function detailClicked(d: WLDetailedViewOption) {
let dv = get(wlDetailedView);
if (dv.includes(d)) {
dv = dv.filter((a) => a !== d);
} else {
dv.push(d);
}
wlDetailedView.update((a) => (a = dv));
}
$: dve = $wlDetailedView;
function detailClicked(d: WLDetailedViewOption) {
if (store.wlDetailedView.includes(d)) {
store.wlDetailedView = store.wlDetailedView.filter((a) => a !== d);
} else {
store.wlDetailedView.push(d);
}
}
</script>
<div class={`menu${$page.url?.pathname.startsWith("/search") ? " on-search-page" : ""}`}>
<div class="inner">
<h4 class="norm sm-caps">Shown Details</h4>
<button
class={`plain ${dve?.includes("statusRating") ? "on" : ""}`}
on:click={() => detailClicked("statusRating")}
>
Status & Rating
</button>
<button
class={`plain ${dve?.includes("lastWatched") ? "on" : ""}`}
on:click={() => detailClicked("lastWatched")}
>
Watching Season
</button>
<button
class={`plain ${dve?.includes("dateAdded") ? "on" : ""}`}
on:click={() => detailClicked("dateAdded")}
>
Date Added
</button>
<button
class={`plain ${dve?.includes("dateModified") ? "on" : ""}`}
on:click={() => detailClicked("dateModified")}
>
Date Modified
</button>
</div>
</div>
<Menu
conf={{
width: "200px",
right: "92px",
arrowLeft: page.url?.pathname.startsWith("/search") ? "84px" : "3px",
}}
>
<h4 class="norm sm-caps">Shown Details</h4>
<button
class={`plain ${store.wlDetailedView?.includes("statusRating") ? "on" : ""}`}
onclick={() => detailClicked("statusRating")}
>
Status & Rating
</button>
<button
class={`plain ${store.wlDetailedView?.includes("lastWatched") ? "on" : ""}`}
onclick={() => detailClicked("lastWatched")}
>
Watching Season
</button>
<button
class={`plain ${store.wlDetailedView?.includes("dateAdded") ? "on" : ""}`}
onclick={() => detailClicked("dateAdded")}
>
Date Added
</button>
<button
class={`plain ${store.wlDetailedView?.includes("dateModified") ? "on" : ""}`}
onclick={() => detailClicked("dateModified")}
>
Date Modified
</button>
</Menu>
<style lang="scss">
div.menu {
width: 200px;
right: 92px;
h4 {
margin-bottom: 8px;
&:before {
left: 3px;
}
}
&:not(:first-of-type) {
margin-top: 8px;
}
}
div.menu.on-search-page:before {
left: 86px;
}
button.plain {
text-transform: capitalize;
position: relative;
div.inner {
h4 {
margin-bottom: 8px;
&.on::before {
content: "\2713";
}
&:not(:first-of-type) {
margin-top: 8px;
}
}
& > button {
text-transform: capitalize;
position: relative;
&.on::before {
content: "\2713";
}
&::before {
position: absolute;
top: 4px;
left: 12px;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
}
}
}
&::before {
position: absolute;
top: 4px;
left: 12px;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
}
}
</style>
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
import { store } from "@/store.svelte";
import Menu from "../Menu.svelte";
import { parseTokenPayload, userHasPermission } from "../util/helpers";
import { UserPermission, UserType } from "@/types";
import ProxyUserLogoutModal from "../logout/ProxyUserLogoutModal.svelte";
import { goto } from "$app/navigation";
import { clearWatcharrData } from "../logout";
import { notify } from "../util/notify";
let user = $derived(store.userInfo);
let proxyUserLogoutShown = $state(false);
function logout() {
if (user?.type === UserType.Proxy) {
// Proxy users logout flow is different.
proxyUserLogoutShown = true;
return;
}
clearWatcharrData();
goto("/login");
}
function profile() {
goto("/profile");
}
function serverSettings() {
goto("/server");
}
function userManagement() {
goto("/manage_users");
}
function requestManagement() {
goto("/arr_requests");
}
function shareWatchedList() {
const nid = notify({ type: "loading", text: "Getting link" });
const ud = parseTokenPayload();
console.log(ud);
if (ud?.userId && ud?.username) {
const shareLink = `${window.location.origin}/lists/${ud.userId}/${ud.username}`;
navigator.clipboard
.writeText(shareLink)
.then(() => {
notify({ id: nid, type: "success", text: "Copied share link" });
})
.catch((r) => {
console.error("Failed to copy list share link", r);
notify({
id: nid,
type: "error",
text: `Failed to copy share link:<br/><a href="${shareLink}" target="_blank">${shareLink}</a>`,
time: 20000,
});
});
} else {
notify({ id: nid, type: "error", text: "Failed to get link" });
}
}
</script>
<Menu conf={{ arrowRight: "10px" }}>
{#if user?.username}
<h5 title={user.username}>Hi {user.username}!</h5>
{/if}
<button class="plain" onclick={() => profile()}>Profile</button>
{#if !store.userSettings?.private}
<button class="plain" onclick={() => shareWatchedList()}>Share List</button>
{/if}
{#if user && userHasPermission(user.permissions, UserPermission.PERM_ADMIN)}
<button class="plain" onclick={() => serverSettings()}>Settings</button>
<button class="plain" onclick={() => userManagement()}>Users</button>
{#if store.serverFeatures?.sonarr || store.serverFeatures?.radarr}
<!-- At least one (sonarr/radarr) should be enabled for requests menu item to display. -->
<button class="plain" onclick={() => requestManagement()}>
Requests
</button>
{/if}
{/if}
<button class="plain" onclick={() => logout()}>Logout</button>
{#if proxyUserLogoutShown}
<ProxyUserLogoutModal onClose={() => (proxyUserLogoutShown = false)} />
{/if}
<span>v{__WATCHARR_VERSION__}</span>
</Menu>
+153 -168
View File
@@ -1,187 +1,172 @@
<script lang="ts">
import { activeFilters, clearActiveFilters, serverFeatures } from "@/store";
import type { Filters } from "@/types";
import { get } from "svelte/store";
import Icon from "../Icon.svelte";
import tooltip from "../actions/tooltip";
import { store, clearActiveFilters } from "@/store.svelte";
import type { Filters } from "@/types";
import Icon from "../Icon.svelte";
import tooltip from "../actions/tooltip";
import Menu from "../Menu.svelte";
function filterClicked(type: keyof Filters, f: string) {
const af = get(activeFilters);
if (af[type]?.includes(f)) {
af[type] = af[type]?.filter((a) => a !== f);
} else {
af[type]?.push(f);
}
activeFilters.update((a) => (a = af));
}
$: filter = $activeFilters;
$: features = $serverFeatures;
function filterClicked(type: keyof Filters, f: string) {
if (store.activeFilters[type]?.includes(f)) {
store.activeFilters[type] = store.activeFilters[type]?.filter(
(a) => a !== f,
);
} else {
store.activeFilters[type] = [...store.activeFilters[type], f];
}
store.activeFilters = store.activeFilters;
}
</script>
<div class="menu">
<div class="inner">
<div class="title">
<h4 class="norm sm-caps">type</h4>
{#if filter?.type?.length > 0 || filter?.status?.length > 0}
<button
class="plain"
use:tooltip={{ text: "Clear", pos: "left" }}
on:click={() => clearActiveFilters()}
>
<Icon i="close-circle" wh={18} />
</button>
{/if}
</div>
<div class="type-filter">
<button
class={`${filter.type.includes("tv") ? "active" : ""}`}
on:click={() => filterClicked("type", "tv")}
>
SHOW
</button>
<button
class={`${filter.type.includes("movie") ? "active" : ""}`}
on:click={() => filterClicked("type", "movie")}
>
MOVIE
</button>
{#if features.games}
<button
class={`${filter.type.includes("game") ? "active" : ""}`}
on:click={() => filterClicked("type", "game")}
>
GAME
</button>
{/if}
</div>
<h4 class="norm sm-caps">status</h4>
<button
class={`plain ${filter.status.includes("planned") ? "on" : ""}`}
on:click={() => filterClicked("status", "planned")}
>
planned
</button>
<button
class={`plain ${filter.status.includes("watching") ? "on" : ""}`}
on:click={() => filterClicked("status", "watching")}
>
watching
{#if features.games}
(playing)
{/if}
</button>
<button
class={`plain ${filter.status.includes("finished") ? "on" : ""}`}
on:click={() => filterClicked("status", "finished")}
>
finished
{#if features.games}
(played)
{/if}
</button>
<button
class={`plain ${filter.status.includes("hold") ? "on" : ""}`}
on:click={() => filterClicked("status", "hold")}
>
on hold
</button>
<button
class={`plain ${filter.status.includes("dropped") ? "on" : ""}`}
on:click={() => filterClicked("status", "dropped")}
>
dropped
</button>
</div>
</div>
<Menu conf={{ width: "200px", right: "47px", arrowLeft: "38px" }}>
<div class="title">
<h4 class="norm sm-caps">type</h4>
{#if store.activeFilters?.type?.length > 0 || store.activeFilters?.status?.length > 0}
<button
class="plain"
use:tooltip={{ text: "Clear", pos: "left" }}
onclick={() => clearActiveFilters()}
>
<Icon i="close-circle" wh={18} />
</button>
{/if}
</div>
<div class="type-filter">
<button
class={`${store.activeFilters.type.includes("tv") ? "active" : ""}`}
onclick={() => filterClicked("type", "tv")}
>
SHOW
</button>
<button
class={`${store.activeFilters.type.includes("movie") ? "active" : ""}`}
onclick={() => filterClicked("type", "movie")}
>
MOVIE
</button>
{#if store.serverFeatures?.games}
<button
class={`${store.activeFilters.type.includes("game") ? "active" : ""}`}
onclick={() => filterClicked("type", "game")}
>
GAME
</button>
{/if}
</div>
<h4 class="norm sm-caps">status</h4>
<button
class={`plain ${store.activeFilters.status.includes("planned") ? "on" : ""}`}
onclick={() => filterClicked("status", "planned")}
>
planned
</button>
<button
class={`plain ${store.activeFilters.status.includes("watching") ? "on" : ""}`}
onclick={() => filterClicked("status", "watching")}
>
watching
{#if store.serverFeatures?.games}
(playing)
{/if}
</button>
<button
class={`plain ${store.activeFilters.status.includes("finished") ? "on" : ""}`}
onclick={() => filterClicked("status", "finished")}
>
finished
{#if store.serverFeatures?.games}
(played)
{/if}
</button>
<button
class={`plain ${store.activeFilters.status.includes("hold") ? "on" : ""}`}
onclick={() => filterClicked("status", "hold")}
>
on hold
</button>
<button
class={`plain ${store.activeFilters.status.includes("dropped") ? "on" : ""}`}
onclick={() => filterClicked("status", "dropped")}
>
dropped
</button>
</Menu>
<style lang="scss">
div.menu {
width: 200px;
right: 47px;
h4:not(:first-child) {
margin-top: 8px;
margin-bottom: 8px;
}
&:before {
left: 38px;
}
}
.title {
display: flex;
flex-flow: row;
align-items: center;
margin-bottom: 8px;
gap: 5px;
/* Always height of when clear filters btn is shown so there is no jump */
min-height: 26px;
div.inner {
& > h4 {
margin-top: 8px;
margin-bottom: 8px;
}
button.plain {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
padding: 2px 3px;
border-radius: 8px;
.title {
display: flex;
flex-flow: row;
align-items: center;
margin-bottom: 8px;
gap: 5px;
/* Always height of when clear filters btn is shown so there is no jump */
min-height: 26px;
&.manage-on {
color: #f3555a;
background-color: $text-color;
}
button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
padding: 2px 3px;
border-radius: 8px;
&:first-of-type {
margin-left: auto;
}
}
}
&.manage-on {
color: #f3555a;
background-color: $text-color;
}
button.plain {
text-transform: capitalize;
position: relative;
&:first-of-type {
margin-left: auto;
}
}
}
&.on::before {
content: "\2713";
}
& > button {
text-transform: capitalize;
position: relative;
&::before {
position: absolute;
top: 4px;
left: 7.5px;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
}
}
&.on::before {
content: "\2713";
}
.type-filter {
display: flex;
flex-flow: row;
width: 100%;
&::before {
position: absolute;
top: 4px;
left: 7.5px;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
}
}
button {
border-radius: 0;
padding: 8px 0;
width: 100%;
.type-filter {
display: flex;
flex-flow: row;
width: 100%;
&:first-of-type {
border-radius: 5px 0 0 5px;
}
button {
border-radius: 0;
padding: 8px 0;
width: 100%;
&:not(:first-of-type) {
border-left: unset;
}
&:first-of-type {
border-radius: 5px 0 0 5px;
}
&:not(:first-of-type) {
border-left: unset;
}
&:last-of-type {
border-radius: 0 5px 5px 0;
}
}
}
}
&:last-of-type {
border-radius: 0 5px 5px 0;
}
}
}
</style>
+40 -44
View File
@@ -1,54 +1,50 @@
<script lang="ts">
import { follows } from "@/store";
import { store } from "@/store.svelte";
import Menu from "../Menu.svelte";
export let close: () => {};
interface Props {
close: () => {};
}
$: following = $follows;
let { close }: Props = $props();
</script>
<div class="menu">
<div>
{#if following?.length > 0}
<h4 class="norm sm-caps">following</h4>
<div class="list">
{#each following as f}
<a href="/lists/{f.followedUser.id}/{f.followedUser.username}" on:click={() => close()}>
{f.followedUser.username}
</a>
{/each}
</div>
{:else}
<span style="margin-top: 0;">You are not following anyone.</span>
{/if}
</div>
</div>
<Menu conf={{ width: "180px", arrowRight: "53px" }}>
{#if store.follows?.length > 0}
<h4 class="norm sm-caps">following</h4>
<div class="list">
{#each store.follows as f}
<a
href="/lists/{f.followedUser.id}/{f.followedUser.username}"
onclick={() => close()}
>
{f.followedUser.username}
</a>
{/each}
</div>
{:else}
<span style="margin-top: 0;">You are not following anyone.</span>
{/if}
</Menu>
<style lang="scss">
div {
width: 180px;
h4 {
position: sticky;
top: -10px;
background-color: $bg-color;
}
&:before {
right: 53px;
}
.list {
list-style: none;
display: flex;
flex-flow: column;
width: 100%;
height: 100%;
h4 {
position: sticky;
top: -10px;
background-color: $bg-color;
}
.list {
list-style: none;
display: flex;
flex-flow: column;
width: 100%;
height: 100%;
a {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
a {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
</style>
+80 -96
View File
@@ -1,107 +1,91 @@
<script lang="ts">
import { activeSort } from "@/store";
import { get } from "svelte/store";
import { store } from "@/store.svelte";
import Menu from "../Menu.svelte";
function sortClicked(type: string, modeType: string = "UPDOWN") {
const af = get(activeSort);
let mode: string;
if (modeType === "UPDOWN") {
mode = "UP";
if (af[0] == type) {
if (af[1] === "UP") {
mode = "DOWN";
} else if (af[1] === "DOWN") {
mode = "";
}
}
} else if (modeType === "TOGGLE") {
mode = "ON";
if (af[0] == type) {
if (af[1] === "ON") {
mode = "OFF";
}
}
} else {
console.error("filterClicked() ran without a valid modeType:", modeType);
return;
}
activeSort.update((af) => (af = [type, mode]));
}
$: sort = $activeSort;
function sortClicked(type: string, modeType: string = "UPDOWN") {
let mode: string;
if (modeType === "UPDOWN") {
mode = "UP";
if (store.activeSort[0] == type) {
if (store.activeSort[1] === "UP") {
mode = "DOWN";
} else if (store.activeSort[1] === "DOWN") {
mode = "";
}
}
} else if (modeType === "TOGGLE") {
mode = "ON";
if (store.activeSort[0] == type) {
if (store.activeSort[1] === "ON") {
mode = "OFF";
}
}
} else {
console.error("filterClicked() ran without a valid modeType:", modeType);
return;
}
store.activeSort = [type, mode];
}
</script>
<div class="menu sort-menu">
<div>
<button
class={`plain ${sort[0] == "DATEADDED" ? sort[1].toLowerCase() : ""}`}
on:click={() => sortClicked("DATEADDED")}
>
Date Added
</button>
<button
class={`plain ${sort[0] == "LASTCHANGED" ? sort[1].toLowerCase() : ""}`}
on:click={() => sortClicked("LASTCHANGED")}
>
Last Changed
</button>
<button
class={`plain ${sort[0] == "LASTFIN" ? sort[1].toLowerCase() : ""}`}
on:click={() => sortClicked("LASTFIN")}
>
Last Finished
</button>
<button
class={`plain ${sort[0] == "RATING" ? sort[1].toLowerCase() : ""}`}
on:click={() => sortClicked("RATING")}
>
Rating
</button>
<button
class={`plain ${sort[0] == "ALPHA" ? sort[1].toLowerCase() : ""}`}
on:click={() => sortClicked("ALPHA")}
>
Alphabetical
</button>
</div>
</div>
<Menu conf={{ width: "180px", right: "90px", arrowLeft: "21px" }}>
<button
class={`plain ${store.activeSort[0] == "DATEADDED" ? store.activeSort[1].toLowerCase() : ""}`}
onclick={() => sortClicked("DATEADDED")}
>
Date Added
</button>
<button
class={`plain ${store.activeSort[0] == "LASTCHANGED" ? store.activeSort[1].toLowerCase() : ""}`}
onclick={() => sortClicked("LASTCHANGED")}
>
Last Changed
</button>
<button
class={`plain ${store.activeSort[0] == "LASTFIN" ? store.activeSort[1].toLowerCase() : ""}`}
onclick={() => sortClicked("LASTFIN")}
>
Last Finished
</button>
<button
class={`plain ${store.activeSort[0] == "RATING" ? store.activeSort[1].toLowerCase() : ""}`}
onclick={() => sortClicked("RATING")}
>
Rating
</button>
<button
class={`plain ${store.activeSort[0] == "ALPHA" ? store.activeSort[1].toLowerCase() : ""}`}
onclick={() => sortClicked("ALPHA")}
>
Alphabetical
</button>
</Menu>
<style lang="scss">
div {
width: 180px;
right: 90px;
button {
position: relative;
&:before {
left: 21px;
}
&.down::before {
content: "\2193";
}
& > div {
& > button {
position: relative;
&.up::before {
content: "\2191";
}
&.down::before {
content: "\2193";
}
&.on::before {
content: "\2713";
}
&.up::before {
content: "\2191";
}
&.on::before {
content: "\2713";
}
&::before {
position: absolute;
top: 4px;
left: 12px;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
}
}
}
}
&::before {
position: absolute;
top: 4px;
left: 12px;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
}
}
</style>
+136 -123
View File
@@ -1,148 +1,161 @@
<!-- Extra Details View For Posters -->
<script lang="ts">
import { userSettings, wlDetailedView } from "@/store";
import { RatingSystem, type PosterExtraDetails, type WatchedStatus } from "@/types";
import { getOrdinalSuffix, monthsShort, watchedStatuses } from "../util/helpers";
import Icon from "../Icon.svelte";
import { toShowableRating, toWhichThumb } from "../rating/helpers";
import { page } from "$app/stores";
import { store } from "@/store.svelte";
import {
RatingSystem,
type PosterExtraDetails,
type WatchedStatus,
} from "@/types";
import {
getOrdinalSuffix,
monthsShort,
watchedStatuses,
} from "../util/helpers";
import Icon from "../Icon.svelte";
import { toShowableRating, toWhichThumb } from "../rating/helpers";
import { page } from "$app/state";
export let rating: number | undefined;
export let status: WatchedStatus | undefined;
export let details: PosterExtraDetails | undefined;
interface Props {
rating: number | undefined;
status: WatchedStatus | undefined;
details: PosterExtraDetails | undefined;
}
$: dve = $wlDetailedView;
$: settings = $userSettings;
$: isUsingThumbs = settings && settings.ratingSystem === RatingSystem.Thumbs;
let { rating, status, details }: Props = $props();
function formatDate(e: number) {
if (!e) {
return "Unknown";
}
const d = new Date(e);
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} '${String(
d.getFullYear()
).substring(2, 4)}`;
}
let isUsingThumbs = $derived(
store.userSettings &&
store.userSettings.ratingSystem === RatingSystem.Thumbs,
);
function formatDate(e: number) {
if (!e) {
return "Unknown";
}
const d = new Date(e);
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} '${String(
d.getFullYear(),
).substring(2, 4)}`;
}
</script>
{#if ($page.url?.pathname === "/" || $page.url?.pathname.startsWith("/search")) && details && dve && dve.length > 0}
<div class="extra-details">
<!--
{#if (page.url?.pathname === "/" || page.url?.pathname.startsWith("/search")) && details && store.wlDetailedView && store.wlDetailedView.length > 0}
<div class="extra-details">
<!--
The `if` statements can't be on their own line to look pretty
because that will cause whitespace in the generated markup,
which causes the :empty css tag to not work.
Can be reverted when its possible to trim whitespace in svelte
OR when :empty tag is updated in browsers to new spec and counts whitespace as empty.
-->
<div>
{#if details.dateAdded && dve.includes("dateAdded")}
<span title="Date added to watch list">
<i><Icon i="calendar" /></i>
<span>
{formatDate(Date.parse(details.dateAdded))}
</span>
</span>
{/if}{#if details.dateModified && dve.includes("dateModified")}
<span title="Date last modified">
<i><Icon i="pencil" wh={15} /></i>
<span>
{formatDate(Date.parse(details.dateModified))}
</span>
</span>
{/if}{#if details.lastWatched && dve.includes("lastWatched")}
<span title="Latest season watched">
<i><Icon i="play" wh={15} /></i>
<span>{details.lastWatched}</span>
</span>
{/if}{#if dve.includes("statusRating")}
<span class="status-rating" title="Status and Rating">
{#if !isUsingThumbs}
<i><Icon i="star" /></i>
{/if}
<span class="rating-span">
{#if isUsingThumbs}
{@const r = toWhichThumb(rating)}
{#if r === -1}
<i><Icon i="thumb-down" /></i>
{:else if r === 0}
<span
style="display: flex; transform: translate(2px, 3px); font-size: 40px; font-family: 'Shrikhand';"
>
-
</span>
{:else if r === 1}
<i><Icon i="thumb-up" /></i>
{/if}
{:else}
{toShowableRating(rating)}
{/if}
</span>
{#if status}
<i><Icon i={watchedStatuses[status]} wh={15} /></i>
{/if}
</span>
{/if}
</div>
</div>
<div>
{#if details.dateAdded && store.wlDetailedView.includes("dateAdded")}
<span title="Date added to watch list">
<i><Icon i="calendar" /></i>
<span>
{formatDate(Date.parse(details.dateAdded))}
</span>
</span>
{/if}{#if details.dateModified && store.wlDetailedView.includes("dateModified")}
<span title="Date last modified">
<i><Icon i="pencil" wh={15} /></i>
<span>
{formatDate(Date.parse(details.dateModified))}
</span>
</span>
{/if}{#if details.lastWatched && store.wlDetailedView.includes("lastWatched")}
<span title="Latest season watched">
<i><Icon i="play" wh={15} /></i>
<span>{details.lastWatched}</span>
</span>
{/if}{#if store.wlDetailedView.includes("statusRating")}
<span class="status-rating" title="Status and Rating">
{#if !isUsingThumbs}
<i><Icon i="star" /></i>
{/if}
<span class="rating-span">
{#if isUsingThumbs}
{@const r = toWhichThumb(rating)}
{#if r === -1}
<i><Icon i="thumb-down" /></i>
{:else if r === 0}
<span
style="display: flex; transform: translate(2px, 3px); font-size: 40px; font-family: 'Shrikhand';"
>
-
</span>
{:else if r === 1}
<i><Icon i="thumb-up" /></i>
{/if}
{:else}
{toShowableRating(rating)}
{/if}
</span>
{#if status}
<i><Icon i={watchedStatuses[status]} wh={15} /></i>
{/if}
</span>
{/if}
</div>
</div>
{/if}
<style lang="scss">
.extra-details {
position: absolute;
bottom: 5px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
font-size: 14px;
width: 160px;
color: white;
background-color: $poster-extra-detail-bg-color;
border-radius: 10px;
transition: opacity 100ms ease-out;
pointer-events: none !important;
.extra-details {
position: absolute;
bottom: 5px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
font-size: 14px;
width: 160px;
color: white;
background-color: $poster-extra-detail-bg-color;
border-radius: 10px;
transition: opacity 100ms ease-out;
pointer-events: none !important;
& > div {
padding: 8px 3px;
& > div {
padding: 8px 3px;
&:empty {
padding: 0px;
}
&:empty {
padding: 0px;
}
& > span {
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
height: 15px;
font-weight: bold;
& > span {
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
height: 15px;
font-weight: bold;
&.status-rating {
gap: 10px;
}
&.status-rating {
gap: 10px;
}
&:not(:last-child) {
margin-bottom: 5px;
}
&:not(:last-child) {
margin-bottom: 5px;
}
i {
display: flex;
width: 15px;
fill: white;
}
}
}
i {
display: flex;
width: 15px;
fill: white;
}
}
}
.status-rating i:last-of-type {
margin-left: auto;
}
.status-rating i:last-of-type {
margin-left: auto;
}
.status-rating .rating-span:empty {
/* Can happen when content is not rated and using thumbs system */
display: none;
}
}
.status-rating .rating-span:empty {
/* Can happen when content is not rated and using thumbs system */
display: none;
}
}
</style>
+420 -365
View File
@@ -1,409 +1,464 @@
<script lang="ts">
import type { PosterExtraDetails, Image, WatchedStatus } from "@/types";
import {
addClassToParent,
calculateTransformOrigin,
getOrdinalSuffix,
isTouch,
monthsShort
} from "@/lib/util/helpers";
import { goto } from "$app/navigation";
import { baseURL, removeWatched, updatePlayed } from "../util/api";
import { notify } from "../util/notify";
import { onMount } from "svelte";
import PosterStatus from "./PosterStatus.svelte";
import PosterRating from "./PosterRating.svelte";
import { wlDetailedView } from "@/store";
import { decode } from "blurhash";
import ExtraDetails from "./ExtraDetails.svelte";
import { run } from "svelte/legacy";
export let id: number | undefined = undefined; // Watched list id
export let media: {
id: number;
coverId: string;
firstReleaseDate?: string | number;
name: string;
summary?: string;
poster?: Image;
};
export let rating: number | undefined = undefined;
export let status: WatchedStatus | undefined = undefined;
export let small = false;
export let disableInteraction = false;
export let hideButtons = false;
export let extraDetails: PosterExtraDetails | undefined = undefined;
export let fluidSize = false;
export let pinned = false;
// When provided, default click handlers will instead run this callback.
export let onClick: (() => void) | undefined = undefined;
import type { PosterExtraDetails, Image, WatchedStatus } from "@/types";
import {
addClassToParent,
calculateTransformOrigin,
getOrdinalSuffix,
isTouch,
monthsShort,
mouseOverEl,
} from "@/lib/util/helpers";
import { goto } from "$app/navigation";
import { baseURL, removeWatched, updatePlayed } from "../util/api";
import { notify } from "../util/notify";
import { onMount } from "svelte";
import PosterStatus from "./PosterStatus.svelte";
import PosterRating from "./PosterRating.svelte";
import { decode } from "blurhash";
import ExtraDetails from "./ExtraDetails.svelte";
$: dve = $wlDetailedView;
interface Props {
id?: number | undefined; // Watched list id
media: {
id: number;
coverId: string;
firstReleaseDate?: string | number;
name: string;
summary?: string;
poster?: Image;
};
rating?: number | undefined;
status?: WatchedStatus | undefined;
small?: boolean;
disableInteraction?: boolean;
hideButtons?: boolean;
extraDetails?: PosterExtraDetails | undefined;
fluidSize?: boolean;
pinned?: boolean;
// When provided, default click handlers will instead run this callback.
onClick?: (() => void) | undefined;
/**
* Ran when watched item is updated via poster.
*/
onUpdated?: (() => void) | undefined;
}
// If poster is active (scaled up)
let posterActive = false;
// If mouse in on poster. Added to fix #656.
let mouseOverPoster = false;
let {
id = undefined,
media,
rating = undefined,
status = undefined,
small = false,
disableInteraction = false,
hideButtons = false,
extraDetails = undefined,
fluidSize = false,
pinned = false,
onClick = undefined,
onUpdated = undefined,
}: Props = $props();
let containerEl: HTMLDivElement;
let bhCanvas: HTMLCanvasElement;
// If poster is active (scaled up)
let posterActive = $state(false);
// If mouse in on poster. Added to fix #656.
let mouseOverPoster = $state(false);
const title = `${media.name}`;
const poster = media.poster?.path
? `${baseURL}/${media.poster.path}`
: `https://images.igdb.com/igdb/image/upload/t_cover_big/${media.coverId}.jpg`;
const link = media.id ? `/game/${media.id}` : undefined;
const dateStr = media.firstReleaseDate;
const year = dateStr ? new Date(dateStr).getFullYear() : undefined;
let containerEl: HTMLDivElement | undefined = $state();
let bhCanvas: HTMLCanvasElement | undefined = $state();
function handleStarClick(r: number) {
if (r == rating) return;
updatePlayed(media.id, undefined, r);
}
const title = `${media.name}`;
const poster = media.poster?.path
? `${baseURL}/${media.poster.path}`
: `https://images.igdb.com/igdb/image/upload/t_cover_big/${media.coverId}.jpg`;
const link = media.id ? `/game/${media.id}` : undefined;
const dateStr = media.firstReleaseDate;
const year = dateStr ? new Date(dateStr).getFullYear() : undefined;
function handleStatusClick(type: WatchedStatus | "DELETE") {
if (type === "DELETE") {
if (!id) {
notify({ text: "Content has no watched list id, can't delete.", type: "error" });
return;
}
removeWatched(id);
return;
}
if (type == status) return;
updatePlayed(media.id, type);
}
function handleStarClick(r: number) {
if (r == rating) return;
updatePlayed(media.id, undefined, r).then(() => {
if (typeof onUpdated === "function") {
onUpdated();
runPosterMouseLeaveIfNeeded();
}
});
}
function handleInnerKeyUp(e: KeyboardEvent) {
if (e.key === "Enter" && (e.target as HTMLElement)?.id === "ilikemoviessueme") {
if (typeof onClick !== "undefined") {
onClick();
return;
}
if (link) {
goto(link);
}
}
}
function handleStatusClick(type: WatchedStatus | "DELETE") {
if (type === "DELETE") {
if (!id) {
notify({
text: "Content has no watched list id, can't delete.",
type: "error",
});
return;
}
removeWatched(id);
return;
}
if (type == status) return;
updatePlayed(media.id, type).then(() => {
if (typeof onUpdated === "function") {
onUpdated();
runPosterMouseLeaveIfNeeded();
}
});
}
function formatDate(e: number) {
if (!e) {
return "Unknown";
}
const d = new Date(e);
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} '${String(
d.getFullYear()
).substring(2, 4)}`;
}
function handleInnerKeyUp(e: KeyboardEvent) {
if (
e.key === "Enter" &&
(e.target as HTMLElement)?.id === "ilikemoviessueme"
) {
if (typeof onClick !== "undefined") {
onClick();
return;
}
if (link) {
goto(link);
}
}
}
$: {
if (media.poster?.path && media.poster?.blurHash && bhCanvas) {
const pixels = decode(media.poster.blurHash, 170, 256);
const ctx = bhCanvas.getContext("2d");
if (ctx) {
const imageData = ctx.createImageData(170, 256);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}
}
}
function formatDate(e: number) {
if (!e) {
return "Unknown";
}
const d = new Date(e);
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} '${String(
d.getFullYear(),
).substring(2, 4)}`;
}
onMount(() => {
if (containerEl) {
if (small) {
containerEl.classList.add("small");
}
if (fluidSize) {
containerEl.classList.add("fluid-size");
}
}
});
run(() => {
if (media.poster?.path && media.poster?.blurHash && bhCanvas) {
const pixels = decode(media.poster.blurHash, 170, 256);
const ctx = bhCanvas.getContext("2d");
if (ctx) {
const imageData = ctx.createImageData(170, 256);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}
}
});
onMount(() => {
if (containerEl) {
if (small) {
containerEl.classList.add("small");
}
if (fluidSize) {
containerEl.classList.add("fluid-size");
}
}
});
/**
* Manual way to check if we need to run the mouseleave
* event for the poster containerEl.
*/
function runPosterMouseLeaveIfNeeded() {
// Timeout to give enough time for the element to
// actually move if it needs to.
setTimeout(() => {
if (!mouseOverEl(containerEl)) {
posterOnMouseLeave();
}
}, 100);
}
function posterOnMouseLeave() {
mouseOverPoster = false;
posterActive = false;
const ae = document.activeElement;
if (
ae &&
ae instanceof HTMLElement &&
(ae.parentElement?.id === "ilikemoviessueme" ||
ae.parentElement?.parentElement?.id === "ilikemoviessueme")
) {
// Stops the poster being re-focused after the browser window
// loses focus, then regains it (ex: you middle click the poster,
// go to the opened tab (or lose browser window focus, then when
// you come back the poster is sent `focusin` and stuck activated
// until mouseleave again).
ae.blur();
}
}
</script>
<!-- HACK: disabled this issue for now, it should probably be fixed properly -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li
on:mouseenter={(e) => {
mouseOverPoster = true;
if (!posterActive) calculateTransformOrigin(e);
if (!isTouch()) {
posterActive = true;
}
}}
on:focusin={(e) => {
if (!posterActive) calculateTransformOrigin(e);
if (!isTouch()) {
posterActive = true;
}
}}
on:focusout={() => {
if (!isTouch() && !mouseOverPoster) {
// Only on !isTouch (to match focusin) to avoid breaking a tap and hold on link on mobile.
// and only if mouse isn't still over the poster, fixes focusout on click of rating/status
// poster buttons causing poster to shrink until refocused with click/mouse out & in again.
posterActive = false;
}
}}
on:mouseleave={() => {
mouseOverPoster = false;
posterActive = false;
const ae = document.activeElement;
if (
ae &&
ae instanceof HTMLElement &&
(ae.parentElement?.id === "ilikemoviessueme" ||
ae.parentElement?.parentElement?.id === "ilikemoviessueme")
) {
// Stops the poster being re-focused after the browser window
// loses focus, then regains it (ex: you middle click the poster,
// go to the opened tab (or lose browser window focus, then when
// you come back the poster is sent `focusin` and stuck activated
// until mouseleave again).
ae.blur();
}
}}
on:click={() => (posterActive = true)}
on:keyup={(e) => {
if (e.key === "Tab") {
e.currentTarget.scrollIntoView({ block: "center" });
}
}}
on:keypress={() => console.log("on kpress")}
class={`${posterActive ? "active " : ""}${pinned ? "pinned " : ""}`}
onmouseenter={(e) => {
mouseOverPoster = true;
if (!posterActive) calculateTransformOrigin(e);
if (!isTouch()) {
posterActive = true;
}
}}
onfocusin={(e) => {
if (!posterActive) calculateTransformOrigin(e);
if (!isTouch()) {
posterActive = true;
}
}}
onfocusout={() => {
if (!isTouch() && !mouseOverPoster) {
// Only on !isTouch (to match focusin) to avoid breaking a tap and hold on link on mobile.
// and only if mouse isn't still over the poster, fixes focusout on click of rating/status
// poster buttons causing poster to shrink until refocused with click/mouse out & in again.
posterActive = false;
}
}}
onmouseleave={posterOnMouseLeave}
onclick={() => (posterActive = true)}
onkeyup={(e) => {
if (e.key === "Tab") {
e.currentTarget.scrollIntoView({ block: "center" });
}
}}
onkeypress={() => console.log("on kpress")}
class={`${posterActive ? "active " : ""}${pinned ? "pinned " : ""}`}
>
<div
class={`container${!poster || (!media.coverId && !media.poster?.path) ? " details-shown" : ""}`}
bind:this={containerEl}
>
{#if poster && (media.coverId || media.poster?.path)}
{#if media?.poster?.blurHash}
<canvas width="170" height="256" bind:this={bhCanvas} class="img-loader" />
{:else}
<div class="img-loader"></div>
{/if}
<img
loading="lazy"
src={poster}
alt=""
on:load={(e) => {
addClassToParent(e, "img-loaded");
}}
on:error={(e) => {
addClassToParent(e, "details-shown");
}}
/>
{/if}
{#if id && !posterActive}
<!-- Must be on watched list, and poster not hovered -->
<ExtraDetails details={extraDetails} {status} {rating} />
{/if}
<div
on:click={(e) => {
if (typeof onClick !== "undefined") {
onClick();
// Prevent the link inside this div from being clicked in this case.
e.preventDefault();
return;
}
if (posterActive && link) goto(link);
}}
on:keyup={handleInnerKeyUp}
id="ilikemoviessueme"
class="inner"
role="button"
tabindex="-1"
>
<a data-sveltekit-preload-data="tap" href={link} class="small-scrollbar">
<h2>
{title}
{#if year}
<time>{year}</time>
{/if}
</h2>
<span>{media.summary}</span>
</a>
<div
class={`container${!poster || (!media.coverId && !media.poster?.path) ? " details-shown" : ""}`}
bind:this={containerEl}
>
{#if poster && (media.coverId || media.poster?.path)}
{#if media?.poster?.blurHash}
<canvas width="170" height="256" bind:this={bhCanvas} class="img-loader"
></canvas>
{:else}
<div class="img-loader"></div>
{/if}
<img
loading="lazy"
src={poster}
alt=""
onload={(e) => {
addClassToParent(e, "img-loaded");
}}
onerror={(e) => {
addClassToParent(e, "details-shown");
}}
/>
{/if}
{#if id && !posterActive}
<!-- Must be on watched list, and poster not hovered -->
<ExtraDetails details={extraDetails} {status} {rating} />
{/if}
<div
onclick={(e) => {
if (typeof onClick !== "undefined") {
onClick();
// Prevent the link inside this div from being clicked in this case.
e.preventDefault();
return;
}
if (posterActive && link) goto(link);
}}
onkeyup={handleInnerKeyUp}
id="ilikemoviessueme"
class="inner"
role="button"
tabindex="-1"
>
<a data-sveltekit-preload-data="tap" href={link} class="small-scrollbar">
<h2>
{title}
{#if year}
<time>{year}</time>
{/if}
</h2>
<span>{media.summary}</span>
</a>
{#if !hideButtons}
<div class="buttons">
<PosterRating {rating} {handleStarClick} {disableInteraction} />
<PosterStatus {status} {handleStatusClick} {disableInteraction} isForGame={true} />
</div>
{/if}
</div>
</div>
{#if !hideButtons}
<div class="buttons">
<PosterRating {rating} {handleStarClick} {disableInteraction} />
<PosterStatus
{status}
{handleStatusClick}
{disableInteraction}
isForGame={true}
/>
</div>
{/if}
</div>
</div>
</li>
<style lang="scss">
li.active {
cursor: pointer;
}
li.active {
cursor: pointer;
}
li.pinned:not(.active) .container {
outline: 3px solid gold;
}
li.pinned:not(.active) .container {
outline: 3px solid gold;
}
li {
&:not(.active) {
.container .inner,
.container .inner .buttons {
pointer-events: none !important;
}
}
}
li {
&:not(.active) {
.container .inner,
.container .inner .buttons {
pointer-events: none !important;
}
}
}
.container {
display: flex;
flex-flow: column;
background-color: rgb(48, 45, 45);
overflow: hidden;
flex: 1 1;
border-radius: 5px;
width: 170px;
height: 100%;
min-height: 256.367px;
position: relative;
// aspect-ratio: 2/3;
transition: transform 150ms ease;
.container {
display: flex;
flex-flow: column;
background-color: rgb(48, 45, 45);
overflow: hidden;
flex: 1 1;
border-radius: 5px;
width: 170px;
height: 100%;
min-height: 256.367px;
position: relative;
// aspect-ratio: 2/3;
transition: transform 150ms ease;
&.fluid-size {
height: 100%;
width: 100%;
}
&.fluid-size {
height: 100%;
width: 100%;
}
img {
width: 100%;
height: 100%;
}
img {
width: 100%;
height: 100%;
}
&.img-loaded .img-loader,
&.details-shown .img-loader {
display: none;
}
&.img-loaded .img-loader,
&.details-shown .img-loader {
display: none;
}
.img-loader {
position: absolute;
width: 100%;
height: 100%;
}
.img-loader {
position: absolute;
width: 100%;
height: 100%;
}
div.img-loader {
background-color: gray;
background: linear-gradient(359deg, #5c5c5c, #2c2929, #2c2424);
background-size: 400% 400%;
animation: imgloader 4s ease infinite;
div.img-loader {
background-color: gray;
background: linear-gradient(359deg, #5c5c5c, #2c2929, #2c2424);
background-size: 400% 400%;
animation: imgloader 4s ease infinite;
@keyframes imgloader {
0% {
background-position: 50% 0%;
}
50% {
background-position: 50% 100%;
}
100% {
background-position: 50% 0%;
}
}
}
@keyframes imgloader {
0% {
background-position: 50% 0%;
}
50% {
background-position: 50% 100%;
}
100% {
background-position: 50% 0%;
}
}
}
canvas.img-loader {
animation: cimgloader 4s ease infinite;
canvas.img-loader {
animation: cimgloader 4s ease infinite;
@keyframes cimgloader {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
}
@keyframes cimgloader {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
}
.inner {
position: absolute;
opacity: 0;
display: flex;
flex-flow: column;
top: 0;
height: 100%;
width: 100%;
padding: 10px;
background-color: transparent;
transition: opacity 150ms cubic-bezier(0.19, 1, 0.22, 1);
.inner {
position: absolute;
opacity: 0;
display: flex;
flex-flow: column;
top: 0;
height: 100%;
width: 100%;
padding: 10px;
background-color: transparent;
transition: opacity 150ms cubic-bezier(0.19, 1, 0.22, 1);
& > a {
height: 100%;
overflow: auto;
}
& > a {
height: 100%;
overflow: auto;
}
h2 {
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
color: white;
word-wrap: break-word;
h2 {
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
color: white;
word-wrap: break-word;
a {
color: white;
}
time {
font-size: 14px;
font-weight: 400;
color: rgba(255, 255, 255, 0.7);
}
}
time {
font-size: 14px;
font-weight: 400;
color: rgba(255, 255, 255, 0.7);
}
}
span {
color: white;
margin: 5px 0 5px 0;
font-size: 9px;
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
hyphens: auto;
overflow: hidden;
}
span {
color: white;
margin: 5px 0 5px 0;
font-size: 9px;
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
hyphens: auto;
overflow: hidden;
}
.buttons {
display: flex;
flex-flow: row;
margin-top: auto;
gap: 10px;
height: 35px;
}
}
.buttons {
display: flex;
flex-flow: row;
margin-top: auto;
gap: 10px;
height: 35px;
}
}
&.small .inner span {
font-size: 11px;
}
&.small .inner span {
font-size: 11px;
}
.active & {
transform: scale(1.3);
z-index: 99;
}
.active & {
transform: scale(1.3);
z-index: 99;
}
.active &.small {
transform: scale(1.1);
}
.active &.small {
transform: scale(1.1);
}
.active &,
&:global(.details-shown) {
img {
filter: blur(4px) grayscale(80%);
// This makes the background very dark,
// but atleast the text is visible.. may want to change later.
mix-blend-mode: multiply;
}
.active &,
&:global(.details-shown) {
img {
filter: blur(4px) grayscale(80%);
// This makes the background very dark,
// but atleast the text is visible.. may want to change later.
mix-blend-mode: multiply;
}
.inner {
color: white;
opacity: 1;
}
}
}
.inner {
color: white;
opacity: 1;
}
}
}
</style>
+160 -143
View File
@@ -1,167 +1,184 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { addClassToParent, calculateTransformOrigin } from "@/lib/util/helpers";
import { goto } from "$app/navigation";
import {
addClassToParent,
calculateTransformOrigin,
} from "@/lib/util/helpers";
export let id: number | undefined;
export let name: string | undefined;
export let path: string | undefined;
export let role: string | undefined = undefined;
export let zoomOnHover: boolean = true;
interface Props {
id: number | undefined;
name: string | undefined;
path: string | undefined;
role?: string | undefined;
zoomOnHover?: boolean;
}
const poster = path ? `https://image.tmdb.org/t/p/w300_and_h450_bestv2${path}` : undefined;
const link = id ? `/person/${id}` : undefined;
let {
id,
name,
path,
role = undefined,
zoomOnHover = true,
}: Props = $props();
const poster = path
? `https://image.tmdb.org/t/p/w300_and_h450_bestv2${path}`
: undefined;
const link = id ? `/person/${id}` : undefined;
</script>
<!-- Quick fix to ignore error, should be fixed -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li
on:mouseenter={(e) => calculateTransformOrigin(e)}
on:focusin={(e) => calculateTransformOrigin(e)}
on:click={() => {
if (link) goto(link);
}}
on:keypress={() => console.log("on kpress")}
onmouseenter={(e) => calculateTransformOrigin(e)}
onfocusin={(e) => calculateTransformOrigin(e)}
onclick={() => {
if (link) goto(link);
}}
onkeypress={() => console.log("on kpress")}
>
<div class={`container${!poster ? " details-shown" : ""}${!zoomOnHover ? " no-zoom" : ""}`}>
{#if poster}
<div class="img-loader" />
<img
loading="lazy"
src={poster}
alt=""
on:load={(e) => {
addClassToParent(e, "img-loaded");
}}
on:error={(e) => {
console.log("on err");
addClassToParent(e, "details-shown");
}}
/>
{/if}
<div class="inner">
<h2>
{#if link}
<a data-sveltekit-preload-data="tap" href={link}>
{name}
</a>
{:else}
{name}
{/if}
</h2>
{#if role}
<h3>{role}</h3>
{/if}
</div>
</div>
<div
class={`container${!poster ? " details-shown" : ""}${!zoomOnHover ? " no-zoom" : ""}`}
>
{#if poster}
<div class="img-loader"></div>
<img
loading="lazy"
src={poster}
alt=""
onload={(e) => {
addClassToParent(e, "img-loaded");
}}
onerror={(e) => {
console.log("on err");
addClassToParent(e, "details-shown");
}}
/>
{/if}
<div class="inner">
<h2>
{#if link}
<a data-sveltekit-preload-data="tap" href={link}>
{name}
</a>
{:else}
{name}
{/if}
</h2>
{#if role}
<h3>{role}</h3>
{/if}
</div>
</div>
</li>
<style lang="scss">
.container {
display: flex;
flex-flow: column;
background-color: rgb(48, 45, 45);
overflow: hidden;
flex: 1 1;
border-radius: 5px;
width: 170px;
height: 100%;
min-height: 256.367px;
position: relative;
transition:
transform 150ms ease,
outline 50ms ease;
cursor: pointer;
.container {
display: flex;
flex-flow: column;
background-color: rgb(48, 45, 45);
overflow: hidden;
flex: 1 1;
border-radius: 5px;
width: 170px;
height: 100%;
min-height: 256.367px;
position: relative;
transition:
transform 150ms ease,
outline 50ms ease;
cursor: pointer;
img {
width: 100%;
height: 100%;
}
img {
width: 100%;
height: 100%;
}
&.img-loaded .img-loader,
&.details-shown .img-loader {
display: none;
}
&.img-loaded .img-loader,
&.details-shown .img-loader {
display: none;
}
.img-loader {
position: absolute;
width: 100%;
height: 100%;
background-color: gray;
background: linear-gradient(359deg, #5c5c5c, #2c2929, #2c2424);
background-size: 400% 400%;
animation: imgloader 4s ease infinite;
.img-loader {
position: absolute;
width: 100%;
height: 100%;
background-color: gray;
background: linear-gradient(359deg, #5c5c5c, #2c2929, #2c2424);
background-size: 400% 400%;
animation: imgloader 4s ease infinite;
@keyframes imgloader {
0% {
background-position: 50% 0%;
}
50% {
background-position: 50% 100%;
}
100% {
background-position: 50% 0%;
}
}
}
@keyframes imgloader {
0% {
background-position: 50% 0%;
}
50% {
background-position: 50% 100%;
}
100% {
background-position: 50% 0%;
}
}
}
.inner {
position: absolute;
display: flex;
flex-flow: column;
top: 0;
height: 100%;
width: 100%;
padding: 10px;
background-color: transparent;
.inner {
position: absolute;
display: flex;
flex-flow: column;
top: 0;
height: 100%;
width: 100%;
padding: 10px;
background-color: transparent;
h2,
h3 {
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
color: white;
word-wrap: break-word;
h2,
h3 {
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
color: white;
word-wrap: break-word;
a {
color: white;
}
}
a {
color: white;
}
}
h2 {
margin-top: auto;
text-shadow: 1px 1px 3px black;
}
h2 {
margin-top: auto;
text-shadow: 1px 1px 3px black;
}
h3 {
font-size: 12px;
text-shadow: 0px 1px 1px black;
}
}
h3 {
font-size: 12px;
text-shadow: 0px 1px 1px black;
}
}
&:not(.no-zoom) {
&:hover,
&:has(:focus-visible) {
transform: scale(1.3);
z-index: 99;
}
}
&:not(.no-zoom) {
&:hover,
&:has(:global(:focus-visible)) {
transform: scale(1.3);
z-index: 99;
}
}
&:not(:not(.no-zoom)) {
&:hover,
&:has(:focus-visible) {
outline: 3px solid $text-color;
}
}
&:not(:not(.no-zoom)) {
&:hover,
&:has(:global(:focus-visible)) {
outline: 3px solid $text-color;
}
}
&:hover,
&:has(:focus-visible),
&:global(.details-shown) {
.inner {
color: white;
}
}
}
&:hover,
&:has(:global(:focus-visible)),
&:global(.details-shown) {
.inner {
color: white;
}
}
}
</style>
+390 -331
View File
@@ -1,370 +1,429 @@
<script lang="ts">
import type { PosterExtraDetails, MediaType, WatchedStatus } from "@/types";
import { addClassToParent, calculateTransformOrigin, isTouch } from "@/lib/util/helpers";
import { goto } from "$app/navigation";
import { baseURL, removeWatched, updateWatched } from "../util/api";
import { notify } from "../util/notify";
import { onMount } from "svelte";
import PosterStatus from "./PosterStatus.svelte";
import PosterRating from "./PosterRating.svelte";
import ExtraDetails from "./ExtraDetails.svelte";
import type { PosterExtraDetails, MediaType, WatchedStatus } from "@/types";
import {
addClassToParent,
calculateTransformOrigin,
isTouch,
mouseOverEl,
} from "@/lib/util/helpers";
import { goto } from "$app/navigation";
import { baseURL, removeWatched, updateWatched } from "../util/api";
import { notify } from "../util/notify";
import { onMount } from "svelte";
import PosterStatus from "./PosterStatus.svelte";
import PosterRating from "./PosterRating.svelte";
import ExtraDetails from "./ExtraDetails.svelte";
export let id: number | undefined = undefined; // Watched list id
export let media: {
poster_path?: string;
title?: string;
name?: string;
overview?: string;
id: number; // tmdb id
media_type: MediaType;
release_date?: string;
first_air_date?: string;
};
export let rating: number | undefined = undefined;
export let status: WatchedStatus | undefined = undefined;
export let small = false;
export let disableInteraction = false;
export let hideButtons = false;
export let extraDetails: PosterExtraDetails | undefined = undefined;
export let fluidSize = false;
export let pinned = false;
/**
* If the poster should be hidden if not on users watched list (no `id`).
* Doing it this way so we can quickly hide posters with css and avoid
* triggering the #each block again where we create poster lists,
* which makes this functionality more performant (because we don't have
* support for virtual lists yet, we are re-creating all posters in places).
* Notably 'On my list' feature (eg on person page).
*/
export let hideIfNotOnList = false;
// When provided, default click handlers will instead run this callback.
export let onClick: (() => void) | undefined = undefined;
interface Props {
id?: number | undefined; // Watched list id
media: {
poster_path?: string;
title?: string;
name?: string;
overview?: string;
id: number; // tmdb id
media_type: MediaType;
release_date?: string;
first_air_date?: string;
};
rating?: number | undefined;
status?: WatchedStatus | undefined;
small?: boolean;
disableInteraction?: boolean;
hideButtons?: boolean;
extraDetails?: PosterExtraDetails | undefined;
fluidSize?: boolean;
pinned?: boolean;
/**
* If the poster should be hidden if not on users watched list (no `id`).
* Doing it this way so we can quickly hide posters with css and avoid
* triggering the #each block again where we create poster lists,
* which makes this functionality more performant (because we don't have
* support for virtual lists yet, we are re-creating all posters in places).
* Notably 'On my list' feature (eg on person page).
*/
hideIfNotOnList?: boolean;
// When provided, default click handlers will instead run this callback.
onClick?: (() => void) | undefined;
/**
* Ran when watched item is updated via poster.
*/
onUpdated?: (() => void) | undefined;
}
// If poster is active (scaled up)
let posterActive = false;
// If mouse in on poster. Added to fix #656.
let mouseOverPoster = false;
let {
id = undefined,
media,
rating = undefined,
status = undefined,
small = false,
disableInteraction = false,
hideButtons = false,
extraDetails = undefined,
fluidSize = false,
pinned = false,
hideIfNotOnList = false,
onClick = undefined,
onUpdated = undefined,
}: Props = $props();
let containerEl: HTMLDivElement;
// If poster is active (scaled up)
let posterActive = $state(false);
// If mouse in on poster. Added to fix #656.
let mouseOverPoster = $state(false);
$: title = media.title || media.name;
// For now, if the content is on watched list, we can assume we have a local
// cached image. Could be improved, since we could have a cached image for
// show not on someone elses watched list.
$: poster = id
? `${baseURL}/img${media.poster_path}`
: `https://image.tmdb.org/t/p/w500${media.poster_path}`;
$: link = media.id ? `/${media.media_type}/${media.id}` : undefined;
$: dateStr = media.release_date || media.first_air_date;
$: year = dateStr ? new Date(dateStr).getFullYear() : undefined;
let containerEl: HTMLDivElement | undefined = $state();
function handleStarClick(r: number) {
if (r == rating) return;
updateWatched(media.id, media.media_type, undefined, r);
}
let title = $derived(media.title || media.name);
// For now, if the content is on watched list, we can assume we have a local
// cached image. Could be improved, since we could have a cached image for
// show not on someone elses watched list.
let poster = $derived(
id
? `${baseURL}/img${media.poster_path}`
: `https://image.tmdb.org/t/p/w500${media.poster_path}`,
);
let link = $derived(
media.id ? `/${media.media_type}/${media.id}` : undefined,
);
let dateStr = $derived(media.release_date || media.first_air_date);
let year = $derived(dateStr ? new Date(dateStr).getFullYear() : undefined);
function handleStatusClick(type: WatchedStatus | "DELETE") {
if (type === "DELETE") {
if (!id) {
notify({ text: "Content has no watched list id, can't delete.", type: "error" });
return;
}
removeWatched(id);
return;
}
if (type == status) return;
updateWatched(media.id, media.media_type, type);
}
function handleStarClick(r: number) {
if (r == rating) return;
updateWatched(media.id, media.media_type, undefined, r).then(() => {
if (typeof onUpdated === "function") {
onUpdated();
runPosterMouseLeaveIfNeeded();
}
});
}
function handleInnerKeyUp(e: KeyboardEvent) {
if (e.key === "Enter" && (e.target as HTMLElement)?.id === "ilikemoviessueme") {
if (typeof onClick !== "undefined") {
onClick();
return;
}
if (link) {
goto(link);
}
}
}
function handleStatusClick(type: WatchedStatus | "DELETE") {
if (type === "DELETE") {
if (!id) {
notify({
text: "Content has no watched list id, can't delete.",
type: "error",
});
return;
}
removeWatched(id);
return;
}
if (type == status) return;
updateWatched(media.id, media.media_type, type).then(() => {
if (typeof onUpdated === "function") {
onUpdated();
runPosterMouseLeaveIfNeeded();
}
});
}
onMount(() => {
if (containerEl) {
if (small) {
containerEl.classList.add("small");
}
if (fluidSize) {
containerEl.classList.add("fluid-size");
}
}
});
function handleInnerKeyUp(e: KeyboardEvent) {
if (
e.key === "Enter" &&
(e.target as HTMLElement)?.id === "ilikemoviessueme"
) {
if (typeof onClick !== "undefined") {
onClick();
return;
}
if (link) {
goto(link);
}
}
}
onMount(() => {
if (containerEl) {
if (small) {
containerEl.classList.add("small");
}
if (fluidSize) {
containerEl.classList.add("fluid-size");
}
}
});
/**
* Manual way to check if we need to run the mouseleave
* event for the poster containerEl.
*/
function runPosterMouseLeaveIfNeeded() {
// Timeout to give enough time for the element to
// actually move if it needs to.
setTimeout(() => {
if (!mouseOverEl(containerEl)) {
posterOnMouseLeave();
}
}, 100);
}
function posterOnMouseLeave() {
mouseOverPoster = false;
posterActive = false;
const ae = document.activeElement;
if (
ae &&
ae instanceof HTMLElement &&
(ae.parentElement?.id === "ilikemoviessueme" ||
ae.parentElement?.parentElement?.id === "ilikemoviessueme")
) {
// Stops the poster being re-focused after the browser window
// loses focus, then regains it (ex: you middle click the poster,
// go to the opened tab (or lose browser window focus, then when
// you come back the poster is sent `focusin` and stuck activated
// until mouseleave again).
ae.blur();
}
}
</script>
<!-- HACK: disabled this issue for now, it should probably be fixed properly -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li
on:mouseenter={(e) => {
mouseOverPoster = true;
if (!posterActive) calculateTransformOrigin(e);
if (!isTouch()) {
posterActive = true;
}
}}
on:focusin={(e) => {
if (!posterActive) calculateTransformOrigin(e);
if (!isTouch()) {
posterActive = true;
}
}}
on:focusout={() => {
if (!isTouch() && !mouseOverPoster) {
// Only on !isTouch (to match focusin) to avoid breaking a tap and hold on link on mobile.
// and only if mouse isn't still over the poster, fixes focusout on click of rating/status
// poster buttons causing poster to shrink until refocused with click/mouse out & in again.
posterActive = false;
}
}}
on:mouseleave={() => {
mouseOverPoster = false;
posterActive = false;
const ae = document.activeElement;
if (
ae &&
ae instanceof HTMLElement &&
(ae.parentElement?.id === "ilikemoviessueme" ||
ae.parentElement?.parentElement?.id === "ilikemoviessueme")
) {
// Stops the poster being re-focused after the browser window
// loses focus, then regains it (ex: you middle click the poster,
// go to the opened tab (or lose browser window focus, then when
// you come back the poster is sent `focusin` and stuck activated
// until mouseleave again).
ae.blur();
}
}}
on:click={() => (posterActive = true)}
on:keyup={(e) => {
if (e.key === "Tab") {
e.currentTarget.scrollIntoView({ block: "center" });
}
}}
on:keypress={() => console.log("on kpress")}
class={`${posterActive ? "active " : ""}${pinned ? "pinned " : ""}${hideIfNotOnList && !id ? "hidden " : ""}`}
onmouseenter={(e) => {
mouseOverPoster = true;
if (!posterActive) calculateTransformOrigin(e);
if (!isTouch()) {
posterActive = true;
}
}}
onfocusin={(e) => {
if (!posterActive) calculateTransformOrigin(e);
if (!isTouch()) {
posterActive = true;
}
}}
onfocusout={() => {
if (!isTouch() && !mouseOverPoster) {
// Only on !isTouch (to match focusin) to avoid breaking a tap and hold on link on mobile.
// and only if mouse isn't still over the poster, fixes focusout on click of rating/status
// poster buttons causing poster to shrink until refocused with click/mouse out & in again.
posterActive = false;
}
}}
onmouseleave={posterOnMouseLeave}
onclick={() => (posterActive = true)}
onkeyup={(e) => {
if (e.key === "Tab") {
e.currentTarget.scrollIntoView({ block: "center" });
}
}}
onkeypress={() => console.log("on kpress")}
class={`${posterActive ? "active " : ""}${pinned ? "pinned " : ""}${hideIfNotOnList && !id ? "hidden " : ""}`}
>
<div
class={`container${!poster || !media.poster_path ? " details-shown" : ""}`}
bind:this={containerEl}
>
{#if poster && media.poster_path}
<div class="img-loader" />
<img
loading="lazy"
src={poster}
alt=""
on:load={(e) => {
addClassToParent(e, "img-loaded");
}}
on:error={(e) => {
addClassToParent(e, "details-shown");
}}
/>
{/if}
{#if id && !posterActive}
<!-- Must be on watched list, and poster not hovered -->
<ExtraDetails details={extraDetails} {status} {rating} />
{/if}
<div
on:click={(e) => {
if (typeof onClick !== "undefined") {
onClick();
// Prevent the link inside this div from being clicked in this case.
e.preventDefault();
return;
}
if (posterActive && link) goto(link);
}}
on:keyup={handleInnerKeyUp}
id="ilikemoviessueme"
class="inner"
role="button"
tabindex="-1"
>
<a data-sveltekit-preload-data="tap" href={link} class="small-scrollbar">
<h2>
{title}
{#if year}
<time>{year}</time>
{/if}
</h2>
<span>{media.overview}</span>
</a>
<div
class={`container${!poster || !media.poster_path ? " details-shown" : ""}`}
bind:this={containerEl}
>
{#if poster && media.poster_path}
<div class="img-loader"></div>
<img
loading="lazy"
src={poster}
alt=""
onload={(e) => {
addClassToParent(e, "img-loaded");
}}
onerror={(e) => {
addClassToParent(e, "details-shown");
}}
/>
{/if}
{#if id && !posterActive}
<!-- Must be on watched list, and poster not hovered -->
<ExtraDetails details={extraDetails} {status} {rating} />
{/if}
<div
onclick={(e) => {
if (typeof onClick !== "undefined") {
onClick();
// Prevent the link inside this div from being clicked in this case.
e.preventDefault();
return;
}
if (posterActive && link) goto(link);
}}
onkeyup={handleInnerKeyUp}
id="ilikemoviessueme"
class="inner"
role="button"
tabindex="-1"
>
<a data-sveltekit-preload-data="tap" href={link} class="small-scrollbar">
<h2>
{title}
{#if year}
<time>{year}</time>
{/if}
</h2>
<span>{media.overview}</span>
</a>
{#if !hideButtons}
<div class="buttons">
<PosterRating {rating} {handleStarClick} {disableInteraction} />
<PosterStatus {status} {handleStatusClick} {disableInteraction} />
</div>
{/if}
</div>
</div>
{#if !hideButtons}
<div class="buttons">
<PosterRating {rating} {handleStarClick} {disableInteraction} />
<PosterStatus {status} {handleStatusClick} {disableInteraction} />
</div>
{/if}
</div>
</div>
</li>
<style lang="scss">
li.hidden {
display: none;
}
li.hidden {
display: none;
}
li.active {
cursor: pointer;
}
li.active {
cursor: pointer;
}
li.pinned:not(.active) .container {
outline: 3px solid gold;
}
li.pinned:not(.active) .container {
outline: 3px solid gold;
}
li {
&:not(.active) {
.container .inner,
.container .inner .buttons {
pointer-events: none !important;
}
}
}
li {
&:not(.active) {
.container .inner,
.container .inner .buttons {
pointer-events: none !important;
}
}
}
.container {
display: flex;
flex-flow: column;
background-color: rgb(48, 45, 45);
overflow: hidden;
flex: 1 1;
border-radius: 5px;
min-width: 170px;
width: 170px;
position: relative;
aspect-ratio: 170000/256367;
transition: transform 150ms ease;
.container {
display: flex;
flex-flow: column;
background-color: rgb(48, 45, 45);
overflow: hidden;
flex: 1 1;
border-radius: 5px;
min-width: 170px;
width: 170px;
position: relative;
aspect-ratio: 170000/256367;
transition: transform 150ms ease;
&.fluid-size {
height: 100%;
width: 100%;
}
&.fluid-size {
height: 100%;
width: 100%;
}
img {
width: 100%;
height: 100%;
}
img {
width: 100%;
height: 100%;
}
&.img-loaded .img-loader,
&.details-shown .img-loader {
display: none;
}
&.img-loaded .img-loader,
&.details-shown .img-loader {
display: none;
}
.img-loader {
position: absolute;
width: 100%;
height: 100%;
background-color: gray;
background: linear-gradient(359deg, #5c5c5c, #2c2929, #2c2424);
background-size: 400% 400%;
animation: imgloader 4s ease infinite;
.img-loader {
position: absolute;
width: 100%;
height: 100%;
background-color: gray;
background: linear-gradient(359deg, #5c5c5c, #2c2929, #2c2424);
background-size: 400% 400%;
animation: imgloader 4s ease infinite;
@keyframes imgloader {
0% {
background-position: 50% 0%;
}
50% {
background-position: 50% 100%;
}
100% {
background-position: 50% 0%;
}
}
}
@keyframes imgloader {
0% {
background-position: 50% 0%;
}
50% {
background-position: 50% 100%;
}
100% {
background-position: 50% 0%;
}
}
}
.inner {
position: absolute;
opacity: 0;
display: flex;
flex-flow: column;
top: 0;
height: 100%;
width: 100%;
padding: 10px;
background-color: transparent;
transition: opacity 150ms cubic-bezier(0.19, 1, 0.22, 1);
.inner {
position: absolute;
opacity: 0;
display: flex;
flex-flow: column;
top: 0;
height: 100%;
width: 100%;
padding: 10px;
background-color: transparent;
transition: opacity 150ms cubic-bezier(0.19, 1, 0.22, 1);
& > a {
height: 100%;
overflow: auto;
}
& > a {
height: 100%;
overflow: auto;
}
h2 {
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
color: white;
word-wrap: break-word;
h2 {
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
color: white;
word-wrap: break-word;
a {
color: white;
}
time {
font-size: 14px;
font-weight: 400;
color: rgba(255, 255, 255, 0.7);
}
}
time {
font-size: 14px;
font-weight: 400;
color: rgba(255, 255, 255, 0.7);
}
}
span {
color: white;
margin: 5px 0 5px 0;
font-size: 9px;
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
hyphens: auto;
overflow: hidden;
}
span {
color: white;
margin: 5px 0 5px 0;
font-size: 9px;
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
hyphens: auto;
overflow: hidden;
}
.buttons {
display: flex;
flex-flow: row;
margin-top: auto;
gap: 10px;
height: 35px;
}
}
.buttons {
display: flex;
flex-flow: row;
margin-top: auto;
gap: 10px;
height: 35px;
}
}
&.small .inner span {
font-size: 11px;
}
&.small .inner span {
font-size: 11px;
}
.active & {
transform: scale(1.3);
z-index: 99;
}
.active & {
transform: scale(1.3);
z-index: 99;
}
.active &.small {
transform: scale(1.1);
}
.active &.small {
transform: scale(1.1);
}
.active &,
&:global(.details-shown) {
img {
filter: blur(4px) grayscale(80%);
// This makes the background very dark,
// but atleast the text is visible.. may want to change later.
mix-blend-mode: multiply;
}
.active &,
&:global(.details-shown) {
img {
filter: blur(4px) grayscale(80%);
// This makes the background very dark,
// but atleast the text is visible.. may want to change later.
mix-blend-mode: multiply;
}
.inner {
color: white;
opacity: 1;
}
}
}
.inner {
color: white;
opacity: 1;
}
}
}
</style>
+55 -50
View File
@@ -1,66 +1,71 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount } from "svelte";
export let type: "wrapped" | "vertical" = "wrapped";
interface Props {
type?: "wrapped" | "vertical";
children?: import("svelte").Snippet;
}
let ulEl: HTMLUListElement;
let { type = "wrapped", children }: Props = $props();
onMount(() => {
if (ulEl) {
ulEl.classList.add(type);
}
});
let ulEl: HTMLUListElement = $state();
onMount(() => {
if (ulEl) {
ulEl.classList.add(type);
}
});
</script>
<div>
<ul bind:this={ulEl}>
<slot />
</ul>
<ul bind:this={ulEl}>
{@render children?.()}
</ul>
</div>
<style lang="scss">
div {
display: flex;
justify-content: center;
}
div {
display: flex;
justify-content: center;
}
ul {
display: flex;
flex-flow: row;
justify-content: center;
gap: 10px;
list-style: none;
flex-wrap: wrap;
margin: 20px 10px;
max-width: 1200px;
ul {
display: flex;
flex-flow: row;
justify-content: center;
gap: 10px;
list-style: none;
flex-wrap: wrap;
margin: 20px 10px;
max-width: 1200px;
&:global(.vertical) {
flex-wrap: nowrap;
justify-content: unset;
overflow-x: auto;
padding: 15px 8px;
margin: 5px 0;
}
&:global(.vertical) {
flex-wrap: nowrap;
justify-content: unset;
overflow-x: auto;
padding: 15px 8px;
margin: 5px 0;
}
&:global(.wrapped li) {
@media screen and (max-width: 390px) {
flex: 0 48.5%;
}
@media screen and (max-width: 352px) {
flex: 0 48%;
}
@media screen and (width <= 349px) {
flex: unset;
}
}
&:global(.wrapped li) {
@media screen and (max-width: 390px) {
flex: 0 48.5%;
}
@media screen and (max-width: 352px) {
flex: 0 48%;
}
@media screen and (width <= 349px) {
flex: unset;
}
}
&:global(.wrapped .container) {
min-width: 150px !important;
&:global(.wrapped .container) {
min-width: 150px !important;
@media screen and (width <= 349px), screen and (width > 390px) {
width: 170px !important;
min-height: 256.367px !important;
}
}
}
@media screen and (width <= 349px), screen and (width > 390px) {
width: 170px !important;
min-height: 256.367px !important;
}
}
}
</style>
+266 -224
View File
@@ -1,256 +1,298 @@
<script lang="ts">
import { userSettings } from "@/store";
import tooltip from "../actions/tooltip";
import { RatingStep, RatingSystem } from "@/types";
import Icon from "../Icon.svelte";
import { toShowableRating, toWhichThumb } from "../rating/helpers";
import { store } from "@/store.svelte";
import tooltip from "../actions/tooltip";
import { RatingStep, RatingSystem } from "@/types";
import Icon from "../Icon.svelte";
import { toShowableRating, toWhichThumb } from "../rating/helpers";
export let rating: number | undefined = undefined;
export let handleStarClick: (rating: number) => void;
export let disableInteraction: boolean = false;
/**
* When not minimal, we will use user settings to
* display ratings as they want.
*/
export let minimal = false;
export let direction: "top" | "bot" = "top";
export let btnTooltip: string = "";
export let hideStarWhenRated = false;
interface Props {
rating?: number | undefined;
handleStarClick: (rating: number) => void;
disableInteraction?: boolean;
/**
* When not minimal, we will use user settings to
* display ratings as they want.
*/
minimal?: boolean;
direction?: "top" | "bot";
btnTooltip?: string;
hideStarWhenRated?: boolean;
}
let ratingsShown = false;
let {
rating = undefined,
handleStarClick,
disableInteraction = false,
minimal = false,
direction = "top",
btnTooltip = "",
hideStarWhenRated = false,
}: Props = $props();
$: settings = $userSettings;
$: isUsingThumbs = settings && settings.ratingSystem === RatingSystem.Thumbs;
let ratingsShown = $state(false);
// let settings = $derived($userSettings);
let isUsingThumbs = $derived(
store.userSettings &&
store.userSettings.ratingSystem === RatingSystem.Thumbs,
);
</script>
<button
class={[
"rating",
minimal ? (!rating ? "minimal" : "minimal-space") : "",
disableInteraction ? "interaction-disabled" : "",
minimal ? "is-minimal" : ""
].join(" ")}
on:click={(ev) => {
ev.stopPropagation();
ratingsShown = !ratingsShown;
}}
on:mouseleave={(ev) => {
ratingsShown = false;
}}
use:tooltip={{ text: btnTooltip, pos: "top", condition: !!btnTooltip && !ratingsShown }}
class={[
"rating",
minimal ? (!rating ? "minimal" : "minimal-space") : "",
disableInteraction ? "interaction-disabled" : "",
minimal ? "is-minimal" : "",
].join(" ")}
onclick={(ev) => {
ev.stopPropagation();
ratingsShown = !ratingsShown;
}}
onmouseleave={(ev) => {
ratingsShown = false;
}}
use:tooltip={{
text: btnTooltip,
pos: "top",
condition: !!btnTooltip && !ratingsShown,
}}
>
{#if !isUsingThumbs || (isUsingThumbs && minimal && !rating)}
<span class="star" style={hideStarWhenRated && rating ? "display: none" : ""}>*</span>
{/if}
<span class={[!rating && disableInteraction ? "unrated-text" : "", "rating-text"].join(" ")}>
{#if rating}
{#if isUsingThumbs}
{@const r = toWhichThumb(rating)}
{#if r === -1}
<Icon i="thumb-down" />
{:else if r === 0}
<span
style="display: flex; transform: translate(2px, -7px); font-size: 40px; font-family: 'Shrikhand';"
>
-
</span>
{:else if r === 1}
<Icon i="thumb-up" />
{/if}
{:else}
{toShowableRating(rating)}
{/if}
{:else if minimal}
{""}
{:else if disableInteraction}
Unrated
{:else}
Rate
{/if}
</span>
{#if !isUsingThumbs || (isUsingThumbs && minimal && !rating)}
<span
class="star"
style={hideStarWhenRated && rating ? "display: none" : ""}>*</span
>
{/if}
<span
class={[
!rating && disableInteraction ? "unrated-text" : "",
"rating-text",
].join(" ")}
>
{#if rating}
{#if isUsingThumbs}
{@const r = toWhichThumb(rating)}
{#if r === -1}
<Icon i="thumb-down" />
{:else if r === 0}
<span
style="display: flex; transform: translate(2px, -7px); font-size: 40px; font-family: 'Shrikhand';"
>
-
</span>
{:else if r === 1}
<Icon i="thumb-up" />
{/if}
{:else}
{toShowableRating(rating)}
{/if}
{:else if minimal}
{""}
{:else if disableInteraction}
Unrated
{:else}
Rate
{/if}
</span>
<div
class={[
ratingsShown ? "shown" : "",
"small-scrollbar",
direction,
isUsingThumbs ? "is-using-thumbs" : "",
minimal ? "is-minimal" : ""
].join(" ")}
>
{#if isUsingThumbs}
<button
on:click={() => handleStarClick(1)}
class="plain{rating && rating > 0 && rating < 5 ? ' active' : ''}"
style="display: flex; justify-content: center;"
>
<i style="display: flex; width: 35px;"><Icon i="thumb-down" /></i>
</button>
<button
on:click={() => handleStarClick(5)}
class="plain{rating && rating > 4 && rating < 9 ? ' active' : ''}"
style="display: flex; justify-content: center;"
>
<span
style="display: flex; transform: translate(0px, -2px); font-size: 40px; height: 40px; font-family: 'Shrikhand';"
>
-
</span>
</button>
<button
on:click={() => handleStarClick(9)}
class="plain{rating && rating > 8 ? ' active' : ''}"
style="display: flex; justify-content: center;"
>
<i style="display: flex; width: 35px;"><Icon i="thumb-up" /></i>
</button>
{:else}
{@const stars =
settings?.ratingSystem == RatingSystem.OutOf5
? [5, 4, 3, 2, 1]
: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]}
{#each stars as v}
<button
class="plain{rating === v ? ' active' : ''}"
on:click={(ev) => {
ev.stopPropagation();
handleStarClick(settings?.ratingSystem === RatingSystem.OutOf5 ? v * 2 : v);
ratingsShown = false;
}}
>
{#if settings?.ratingSystem === RatingSystem.OutOf100}
{v * 10}
{:else if settings?.ratingSystem === RatingSystem.OutOf5}
{v}
{:else}
{v}
{/if}
</button>
{/each}
{/if}
</div>
<div
class={[
ratingsShown ? "shown" : "",
"small-scrollbar",
direction,
isUsingThumbs ? "is-using-thumbs" : "",
minimal ? "is-minimal" : "",
].join(" ")}
>
{#if isUsingThumbs}
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
onclick={() => handleStarClick(1)}
class="plain{rating && rating > 0 && rating < 5 ? ' active' : ''}"
style="display: flex; justify-content: center;"
>
<i style="display: flex; width: 35px;"><Icon i="thumb-down" /></i>
</button>
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
onclick={() => handleStarClick(5)}
class="plain{rating && rating > 4 && rating < 9 ? ' active' : ''}"
style="display: flex; justify-content: center;"
>
<span
style="display: flex; transform: translate(0px, -2px); font-size: 40px; height: 40px; font-family: 'Shrikhand';"
>
-
</span>
</button>
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
onclick={() => handleStarClick(9)}
class="plain{rating && rating > 8 ? ' active' : ''}"
style="display: flex; justify-content: center;"
>
<i style="display: flex; width: 35px;"><Icon i="thumb-up" /></i>
</button>
{:else}
{@const stars =
store.userSettings?.ratingSystem == RatingSystem.OutOf5
? [5, 4, 3, 2, 1]
: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]}
{#each stars as v}
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
class="plain{rating === v ? ' active' : ''}"
onclick={(ev) => {
ev.stopPropagation();
handleStarClick(
store.userSettings?.ratingSystem === RatingSystem.OutOf5
? v * 2
: v,
);
ratingsShown = false;
}}
>
{#if store.userSettings?.ratingSystem === RatingSystem.OutOf100}
{v * 10}
{:else if store.userSettings?.ratingSystem === RatingSystem.OutOf5}
{v}
{:else}
{v}
{/if}
</button>
{/each}
{/if}
</div>
</button>
<style lang="scss">
button {
padding: 3px;
position: relative;
font-family: "Rampart One";
width: 100%;
height: 100%;
button.rating {
padding: 3px;
position: relative;
font-family: "Rampart One";
width: 100%;
height: 100%;
&.is-minimal {
padding: 3px 8px;
}
&.is-minimal {
padding: 3px 8px;
}
&.interaction-disabled {
pointer-events: none;
cursor: default;
background-color: transparent;
border: unset;
fill: white;
color: white;
&.interaction-disabled {
pointer-events: none;
cursor: default;
background-color: transparent;
border: unset;
fill: white;
color: white;
span {
color: white !important;
}
span {
color: white !important;
}
.unrated-text {
display: flex;
align-items: center;
font-size: 15px !important;
}
}
.unrated-text {
display: flex;
align-items: center;
font-size: 15px !important;
}
}
&.minimal span:first-child {
letter-spacing: unset;
}
&.minimal span:first-child {
letter-spacing: unset;
}
&.minimal-space span:first-child {
letter-spacing: 5px;
}
&.minimal-space span:first-child {
letter-spacing: 5px;
}
span {
&.star {
color: $text-color;
font-size: 39px;
letter-spacing: 10px;
line-height: 52px;
height: 42px;
}
span {
&.star {
color: $text-color;
font-size: 39px;
letter-spacing: 8px;
line-height: 52px;
height: 42px;
}
&.rating-text {
color: $text-color;
font-size: 22px;
height: 35px; // quick fix to make the rating num look centered - text-stroke makes it look not centered
&.rating-text {
color: $text-color;
font-size: 22px;
height: 35px; // quick fix to make the rating num look centered - text-stroke makes it look not centered
& :global(svg) {
height: 100%;
padding: 5px;
}
}
}
& :global(svg) {
height: 100%;
padding: 5px;
}
}
}
&:hover span,
&:focus-visible span {
color: $poster-rating-color;
fill: $poster-rating-color;
}
&:hover span,
&:focus-visible span {
color: $poster-rating-color;
fill: $poster-rating-color;
}
div {
display: flex;
flex-flow: column;
position: absolute;
width: 100%;
height: 200px;
background-color: $bg-color;
top: calc(-100% - 170px);
list-style: none;
border-radius: 4px 4px 0 0;
overflow: auto;
scrollbar-width: thin;
z-index: 40;
box-shadow: 0px 0px 1px #000;
div {
display: flex;
flex-flow: column;
position: absolute;
width: 100%;
height: 200px;
background-color: $bg-color;
top: calc(-100% - 170px);
list-style: none;
border-radius: 4px 4px 0 0;
overflow: auto;
scrollbar-width: thin;
z-index: 40;
box-shadow: 0px 0px 1px #000;
&:not(.shown) {
display: none;
}
&:not(.shown) {
display: none;
}
&.bot {
top: calc(100% + 2px);
border-radius: 0 0 4px 4px;
}
&.bot {
top: calc(100% + 2px);
border-radius: 0 0 4px 4px;
}
button {
width: 100%;
color: $text-color;
fill: $text-color;
-webkit-text-stroke: 0.5px $text-color;
font-size: 20px;
button.plain {
width: 100%;
color: $text-color;
fill: $text-color;
-webkit-text-stroke: 0.5px $text-color;
font-size: 20px;
font-family: "Rampart One";
& :global(svg) {
width: 100%;
padding: 0 4.5px;
}
& :global(svg) {
width: 100%;
padding: 0 4.5px;
}
&:hover,
&:focus-visible {
background-color: rgb(100, 100, 100, 0.25);
}
}
&:hover,
&:focus-visible {
background-color: rgb(100, 100, 100, 0.25);
}
}
&.is-using-thumbs {
height: 150px;
&.is-using-thumbs {
height: 150px;
&:not(.is-minimal) {
top: calc(-100% - 120px);
}
&:not(.is-minimal) {
top: calc(-100% - 120px);
}
button span {
/* Overriding color so dash for thumbs ratings stays text-color */
color: $text-color;
}
}
}
}
button.plain {
min-height: 40px;
overflow: hidden;
span {
/* Overriding color so dash for thumbs ratings stays text-color */
color: $text-color;
font-family: "Rampart One";
}
}
}
}
}
</style>
+146 -127
View File
@@ -1,150 +1,169 @@
<script lang="ts">
import type { WatchedStatus } from "@/types";
import Icon from "../Icon.svelte";
import tooltip from "../actions/tooltip";
import { toUnderstandableStatus, watchedStatuses } from "../util/helpers";
import type { WatchedStatus } from "@/types";
import Icon from "../Icon.svelte";
import tooltip from "../actions/tooltip";
import { toUnderstandableStatus, watchedStatuses } from "../util/helpers";
export let status: WatchedStatus | undefined = undefined;
export let handleStatusClick: (status: WatchedStatus | "DELETE") => void;
export let direction: "top" | "bot" = "top";
export let width = "40%";
export let small = false;
export let btnTooltip: string = "";
export let disableInteraction: boolean = false;
export let isForGame = false;
interface Props {
status?: WatchedStatus | undefined;
handleStatusClick: (status: WatchedStatus | "DELETE") => void;
direction?: "top" | "bot";
width?: string;
small?: boolean;
btnTooltip?: string;
disableInteraction?: boolean;
isForGame?: boolean;
}
let statusesShown = false;
let {
status = undefined,
handleStatusClick,
direction = "top",
width = "40%",
small = false,
btnTooltip = "",
disableInteraction = false,
isForGame = false,
}: Props = $props();
let statusesShown = $state(false);
</script>
<button
class={["status", disableInteraction ? "interaction-disabled" : ""].join(" ")}
style={`width: ${width};`}
on:click={(ev) => {
ev.stopPropagation();
statusesShown = !statusesShown;
}}
on:mouseleave={(ev) => {
statusesShown = false;
}}
use:tooltip={{ text: btnTooltip, pos: "top", condition: !!btnTooltip && !statusesShown }}
class={["status", disableInteraction ? "interaction-disabled" : ""].join(" ")}
style={`width: ${width};`}
onclick={(ev) => {
ev.stopPropagation();
statusesShown = !statusesShown;
}}
onmouseleave={(ev) => {
statusesShown = false;
}}
use:tooltip={{
text: btnTooltip,
pos: "top",
condition: !!btnTooltip && !statusesShown,
}}
>
{#if status}
<Icon i={watchedStatuses[status]} />
{:else}
<span class={["no-icon", small ? "small" : ""].join(" ")}>+</span>
{/if}
<div
class={[
statusesShown ? "shown" : "",
"small-scrollbar",
status ? "has-status" : "",
direction
].join(" ")}
>
{#each Object.entries(watchedStatuses) as [statusName, icon]}
<button
class="plain{status && status !== statusName ? ' not-active' : ''}"
on:click={() => handleStatusClick(statusName)}
use:tooltip={{
text: toUnderstandableStatus(statusName, isForGame)
}}
>
<Icon i={icon} />
</button>
{/each}
{#if status}
<button
class="plain not-active"
on:click={() => handleStatusClick("DELETE")}
use:tooltip={{ text: "Delete" }}
>
<Icon i="trash" />
</button>
{/if}
</div>
{#if status}
<Icon i={watchedStatuses[status]} />
{:else}
<span class={["no-icon", small ? "small" : ""].join(" ")}>+</span>
{/if}
<div
class={[
statusesShown ? "shown" : "",
"small-scrollbar",
status ? "has-status" : "",
direction,
].join(" ")}
>
{#each Object.entries(watchedStatuses) as [statusName, icon]}
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
class="plain{status && status !== statusName ? ' not-active' : ''}"
onclick={() => handleStatusClick(statusName)}
use:tooltip={{
text: toUnderstandableStatus(statusName, isForGame),
}}
>
<Icon i={icon} />
</button>
{/each}
{#if status}
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
class="plain not-active"
onclick={() => handleStatusClick("DELETE")}
use:tooltip={{ text: "Delete" }}
>
<Icon i="trash" />
</button>
{/if}
</div>
</button>
<style lang="scss">
button {
padding: 3px;
position: relative;
font-family: "Rampart One";
height: 100%;
button.status {
padding: 3px;
position: relative;
font-family: "Rampart One";
height: 100%;
&.interaction-disabled {
pointer-events: none;
cursor: default;
background-color: transparent;
border: unset;
fill: white;
color: white;
&.interaction-disabled {
pointer-events: none;
cursor: default;
background-color: transparent;
border: unset;
fill: white;
color: white;
span {
color: white !important;
}
}
span {
color: white !important;
}
}
.no-icon {
color: $text-color;
font-size: 30px;
height: 52px;
.no-icon {
color: $text-color;
font-size: 30px;
height: 52px;
&.small {
height: 30px;
line-height: 22px;
}
}
&.small {
height: 30px;
line-height: 22px;
}
}
&:hover .no-icon,
&:focus-visible .no-icon {
color: $bg-color;
fill: $bg-color;
}
&:hover .no-icon,
&:focus-visible .no-icon {
color: $bg-color;
fill: $bg-color;
}
div {
display: flex;
flex-flow: column;
position: absolute;
width: 100%;
height: 200px;
background-color: $bg-color;
top: calc(-100% - 170px);
list-style: none;
border-radius: 4px 4px 0 0;
overflow: auto;
scrollbar-width: thin;
z-index: 40;
box-shadow: 0px 0px 1px #000;
div {
display: flex;
flex-flow: column;
position: absolute;
width: 100%;
height: 200px;
background-color: $bg-color;
top: calc(-100% - 170px);
list-style: none;
border-radius: 4px 4px 0 0;
overflow: auto;
scrollbar-width: thin;
z-index: 40;
box-shadow: 0px 0px 1px #000;
&:not(.shown) {
display: none;
}
&:not(.shown) {
display: none;
}
&.bot {
top: calc(100% + 2px);
border-radius: 0 0 4px 4px;
}
&.bot {
top: calc(100% + 2px);
border-radius: 0 0 4px 4px;
}
button {
width: 100%;
color: $text-color;
fill: $text-color;
-webkit-text-stroke: 0.5px $text-color;
button {
width: 100%;
color: $text-color;
fill: $text-color;
-webkit-text-stroke: 0.5px $text-color;
& :global(svg) {
width: 100%;
padding: 0 2px;
}
& :global(svg) {
width: 100%;
padding: 0 2px;
}
&:hover,
&:focus-visible {
background-color: rgb(100, 100, 100, 0.5);
}
}
&:hover,
&:focus-visible {
background-color: rgb(100, 100, 100, 0.5);
}
}
&.has-status :global(svg) {
padding: 0 4.5px;
}
}
}
&.has-status :global(svg) {
padding: 0 4.5px;
}
}
}
</style>
+21 -19
View File
@@ -1,30 +1,32 @@
<script lang="ts">
import { userSettings } from "@/store";
import { RatingSystem } from "@/types";
import StarRating from "./StarRating.svelte";
import ThumbRating from "./ThumbRating.svelte";
import { store } from "@/store.svelte";
import { RatingSystem } from "@/types";
import StarRating from "./StarRating.svelte";
import ThumbRating from "./ThumbRating.svelte";
$: settings = $userSettings;
interface Props {
rating: number | undefined;
onChange: (newRating: number) => Promise<boolean>;
}
export let rating: number | undefined;
export let onChange: (newRating: number) => Promise<boolean>;
let { rating, onChange }: Props = $props();
</script>
<div class="wrap">
{#if settings?.ratingSystem === RatingSystem.Thumbs}
<ThumbRating {rating} {onChange} />
{:else}
<!-- All other systems work with the stars -->
<StarRating {rating} {onChange} />
{/if}
{#if store.userSettings?.ratingSystem === RatingSystem.Thumbs}
<ThumbRating {rating} {onChange} />
{:else}
<!-- All other systems work with the stars -->
<StarRating {rating} {onChange} />
{/if}
</div>
<style lang="scss">
.wrap {
width: 377px;
.wrap {
width: 377px;
@media screen and (max-width: 420px) {
width: 100%;
}
}
@media screen and (max-width: 420px) {
width: 100%;
}
}
</style>
+142 -114
View File
@@ -1,130 +1,158 @@
<script lang="ts">
import { userSettings } from "@/store";
import Setting from "../settings/Setting.svelte";
import { RatingStep, RatingSystem } from "@/types";
import { updateUserSetting } from "../util/api";
import { store } from "@/store.svelte";
import Setting from "../settings/Setting.svelte";
import { RatingStep, RatingSystem } from "@/types";
import { updateUserSetting } from "../util/api";
$: settings = $userSettings;
function update(v: RatingSystem) {
if (!store.userSettings) {
console.error("No settings.");
return;
}
updateUserSetting("ratingSystem", v, () => {
store.userSettings!.ratingSystem = v;
});
}
function update(v: RatingSystem) {
if (!settings) {
console.error("No settings.");
return;
}
updateUserSetting("ratingSystem", v, () => {
settings.ratingSystem = v;
});
}
function updateStep(v: number) {
if (!settings) {
console.error("No settings.");
return;
}
updateUserSetting("ratingStep", v, () => {
settings.ratingStep = v;
});
}
function updateStep(v: number) {
if (!store.userSettings) {
console.error("No settings.");
return;
}
updateUserSetting("ratingStep", v, () => {
store.userSettings!.ratingStep = v;
});
}
</script>
<Setting title="Rating System" desc="How would you like to rate content?">
<div class="rat-wrap">
<button
class={["plain", settings?.ratingSystem === RatingSystem.OutOf5 ? "active" : ""].join(" ")}
on:click={() => update(RatingSystem.OutOf5)}
>
0-5
</button>
<button
class={[
"plain",
settings?.ratingSystem === RatingSystem.OutOf10 || !settings?.ratingSystem ? "active" : ""
].join(" ")}
on:click={() => update(RatingSystem.OutOf10)}
>
0-10
</button>
<button
class={["plain", settings?.ratingSystem === RatingSystem.OutOf100 ? "active" : ""].join(" ")}
on:click={() => update(RatingSystem.OutOf100)}
>
0-100
</button>
<button
class={["plain", settings?.ratingSystem === RatingSystem.Thumbs ? "active" : ""].join(" ")}
on:click={() => update(RatingSystem.Thumbs)}
>
Thumbs
</button>
</div>
<div class="rat-wrap">
<button
class={[
"plain",
store.userSettings?.ratingSystem === RatingSystem.OutOf5
? "active"
: "",
].join(" ")}
onclick={() => update(RatingSystem.OutOf5)}
>
0-5
</button>
<button
class={[
"plain",
store.userSettings?.ratingSystem === RatingSystem.OutOf10 ||
!store.userSettings?.ratingSystem
? "active"
: "",
].join(" ")}
onclick={() => update(RatingSystem.OutOf10)}
>
0-10
</button>
<button
class={[
"plain",
store.userSettings?.ratingSystem === RatingSystem.OutOf100
? "active"
: "",
].join(" ")}
onclick={() => update(RatingSystem.OutOf100)}
>
0-100
</button>
<button
class={[
"plain",
store.userSettings?.ratingSystem === RatingSystem.Thumbs
? "active"
: "",
].join(" ")}
onclick={() => update(RatingSystem.Thumbs)}
>
Thumbs
</button>
</div>
</Setting>
{#if settings?.ratingSystem === RatingSystem.OutOf10 || settings?.ratingSystem === RatingSystem.OutOf5 || !settings?.ratingSystem}
<Setting title="Rating Step" desc="How would you like to increment through the stars?">
<div class="rat-wrap">
<button
class={["plain", settings?.ratingStep === RatingStep.Point1 ? "active" : ""].join(" ")}
on:click={() => updateStep(RatingStep.Point1)}
>
0.1
</button>
<button
class={["plain", settings?.ratingStep === RatingStep.Point5 ? "active" : ""].join(" ")}
on:click={() => updateStep(RatingStep.Point5)}
>
0.5
</button>
<button
class={[
"plain",
settings?.ratingStep === RatingStep.One || !settings?.ratingStep ? "active" : ""
].join(" ")}
on:click={() => updateStep(RatingStep.One)}
>
1
</button>
</div>
</Setting>
{#if store.userSettings?.ratingSystem === RatingSystem.OutOf10 || store.userSettings?.ratingSystem === RatingSystem.OutOf5 || !store.userSettings?.ratingSystem}
<Setting
title="Rating Step"
desc="How would you like to increment through the stars?"
>
<div class="rat-wrap">
<button
class={[
"plain",
store.userSettings?.ratingStep === RatingStep.Point1 ? "active" : "",
].join(" ")}
onclick={() => updateStep(RatingStep.Point1)}
>
0.1
</button>
<button
class={[
"plain",
store.userSettings?.ratingStep === RatingStep.Point5 ? "active" : "",
].join(" ")}
onclick={() => updateStep(RatingStep.Point5)}
>
0.5
</button>
<button
class={[
"plain",
store.userSettings?.ratingStep === RatingStep.One ||
!store.userSettings?.ratingStep
? "active"
: "",
].join(" ")}
onclick={() => updateStep(RatingStep.One)}
>
1
</button>
</div>
</Setting>
{/if}
<style lang="scss">
.rat-wrap {
display: flex;
flex-flow: row;
/* gap: 5px; */
border-radius: 10px;
overflow: auto;
.rat-wrap {
display: flex;
flex-flow: row;
/* gap: 5px; */
border-radius: 10px;
overflow: auto;
button {
display: flex;
flex-flow: row;
align-items: center;
justify-content: center;
gap: 3px;
width: 100%;
padding: 15px 8px;
color: $text-color;
background-color: $accent-color;
/* font-family: "Shrikhand", sans-serif; */
font-size: 16px;
transition:
color 150ms ease-in-out,
background-color 150ms ease-in-out;
button {
display: flex;
flex-flow: row;
align-items: center;
justify-content: center;
gap: 3px;
width: 100%;
padding: 15px 8px;
color: $text-color;
background-color: $accent-color;
/* font-family: "Shrikhand", sans-serif; */
font-size: 16px;
transition:
color 150ms ease-in-out,
background-color 150ms ease-in-out;
&:hover,
&.active {
color: $bg-color;
background-color: $accent-color-hover;
font-weight: bold;
}
&:hover,
&.active {
color: $bg-color;
background-color: $accent-color-hover;
font-weight: bold;
}
&:not(:last-of-type) {
/* border-right: 1px solid $placeholder-color; */
}
&:not(:last-of-type) {
/* border-right: 1px solid $placeholder-color; */
}
:global(svg) {
width: 24px;
}
}
}
:global(svg) {
width: 24px;
}
}
}
</style>
+442 -409
View File
@@ -1,259 +1,276 @@
<!-- To be used from Rating component -->
<script lang="ts">
import { userSettings } from "@/store";
import { RatingStep, RatingSystem } from "@/types";
import { onMount } from "svelte";
import { run } from "svelte/legacy";
$: settings = $userSettings;
import { store } from "@/store.svelte";
import { RatingStep, RatingSystem } from "@/types";
import { onMount } from "svelte";
export let rating: number | undefined;
export let onChange: (newRating: number) => Promise<boolean>;
interface Props {
rating: number | undefined;
onChange: (newRating: number) => Promise<boolean>;
}
let hoveredRating: number | undefined;
let shownRating: number | undefined;
let shownPerc: number | undefined;
let ratingContainer: HTMLDivElement;
let ratingWrapEl: HTMLDivElement;
let ratingText: HTMLSpanElement;
let highlightContainer: HTMLDivElement;
let normalContainer: HTMLDivElement;
/**
* Percentage star step.
*/
let starStep = 10;
$: stars =
settings?.ratingSystem == RatingSystem.OutOf5
? [5, 4, 3, 2, 1]
: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
let { rating, onChange }: Props = $props();
const ratingDesc = [
"Apalling",
"Horrible",
"Very Bad",
"Bad",
"Average",
"Fine",
"Good",
"Very Good",
"Great",
"Masterpiece"
];
let hoveredRating: number | undefined = $state();
let shownRating: number | undefined = $state();
let shownPerc: number | undefined = $state();
let ratingContainer: HTMLDivElement | undefined = $state();
let ratingWrapEl: HTMLDivElement | undefined = $state();
let ratingText: HTMLSpanElement | undefined = $state();
let highlightContainer: HTMLDivElement | undefined = $state();
let normalContainer: HTMLDivElement | undefined = $state();
/**
* Percentage star step.
*/
let starStep = $state(10);
async function saveSelectedRating() {
if (!shownPerc) {
console.warn("saveSelectedRating: Rating not set, ignoring call.");
return;
}
// Rating needs to always scale to out of 10 before saving,
// scaling down the shown percentage will work.
const r = Math.round(shownPerc) / 10;
console.log("saveSelectedRating:", shownPerc, "=", r);
if (r === rating) {
console.warn("saveSelectedRating: Rating not changed, ignoring call.");
return;
}
return await onChange(r);
}
const ratingDesc = [
"Apalling",
"Horrible",
"Very Bad",
"Bad",
"Average",
"Fine",
"Good",
"Very Good",
"Great",
"Masterpiece",
];
$: {
if (hoveredRating !== undefined && hoveredRating > 0) {
console.debug("showRatingCaller: We have a hoveredRating.");
shownRating = hoveredRating;
showRating(
Math.round(
(hoveredRating * 100) / (settings?.ratingSystem === RatingSystem.OutOf5 ? 5 : 10)
)
);
} else if (rating !== undefined) {
console.debug("showRatingCaller: We have a rating.");
shownRating = rating;
showRating(Math.round((rating * 100) / 10));
} else {
console.debug("showRatingCaller: We have nothing.");
shownRating = undefined;
showRating(0);
}
}
async function saveSelectedRating() {
if (!shownPerc) {
console.warn("saveSelectedRating: Rating not set, ignoring call.");
return;
}
// Rating needs to always scale to out of 10 before saving,
// scaling down the shown percentage will work.
const r = Math.round(shownPerc) / 10;
console.log("saveSelectedRating:", shownPerc, "=", r);
if (r === rating) {
console.warn("saveSelectedRating: Rating not changed, ignoring call.");
return;
}
return await onChange(r);
}
function setHoveredRatingFromPerc(perc: number) {
let hovR = perc;
if (settings?.ratingSystem === RatingSystem.OutOf5) {
hovR = hovR / 20;
} else {
hovR = hovR / 10;
}
hoveredRating = Math.max(Math.min(hovR, stars[0]), 0);
}
function setHoveredRatingFromPerc(perc: number) {
let hovR = perc;
if (store.userSettings?.ratingSystem === RatingSystem.OutOf5) {
hovR = hovR / 20;
} else {
hovR = hovR / 10;
}
hoveredRating = Math.max(Math.min(hovR, stars[0]), 0);
}
/**
* Show RatingText over hovered star.
*/
function moveRatingText() {
try {
if (!hoveredRating) {
handleRatingHoverEnd();
return;
}
// Get star number we are putting text above
let r: number;
if (settings?.ratingSystem === RatingSystem.OutOf5) {
r = Math.ceil(hoveredRating);
} else {
r = Math.ceil(hoveredRating);
}
const start = ratingContainer?.getBoundingClientRect()?.x;
const starl = ratingWrapEl?.getBoundingClientRect()?.left;
const bodyRect = document.body.getBoundingClientRect();
const oneStarWidth = bodyRect.width <= 420 ? 33.5 : 37.5;
const offset = (r - 1) * oneStarWidth;
let prospectLeft = starl + offset - start + 11.5;
const bodyXX = bodyRect.width - 120;
if (bodyXX < prospectLeft) {
// Move text back if going off right
prospectLeft = prospectLeft - (prospectLeft - bodyXX);
} else if (bodyRect.x + 20 > prospectLeft && starl < 60) {
// This should stop the first star text from going off left
prospectLeft = prospectLeft + 30;
}
ratingText.style.left = `${prospectLeft}px`;
} catch (err) {
console.error("moveRatingText: Failed!", err);
}
}
/**
* Show RatingText over hovered star.
*/
function moveRatingText() {
try {
if (!hoveredRating) {
handleRatingHoverEnd();
return;
}
if (!ratingContainer || !ratingWrapEl) {
console.error("moveRatingText: Required elements not found!");
handleRatingHoverEnd();
return;
}
// Get star number we are putting text above
let r: number;
if (store.userSettings?.ratingSystem === RatingSystem.OutOf5) {
r = Math.ceil(hoveredRating);
} else {
r = Math.ceil(hoveredRating);
}
const start = ratingContainer?.getBoundingClientRect()?.x;
const starl = ratingWrapEl?.getBoundingClientRect()?.left;
const bodyRect = document.body.getBoundingClientRect();
const oneStarWidth = bodyRect.width <= 420 ? 33.5 : 37.5;
const offset = (r - 1) * oneStarWidth;
let prospectLeft = starl + offset - start + 11.5;
const bodyXX = bodyRect.width - 120;
if (bodyXX < prospectLeft) {
// Move text back if going off right
prospectLeft = prospectLeft - (prospectLeft - bodyXX);
} else if (bodyRect.x + 20 > prospectLeft && starl < 60) {
// This should stop the first star text from going off left
prospectLeft = prospectLeft + 30;
}
if (ratingText) ratingText.style.left = `${prospectLeft}px`;
} catch (err) {
console.error("moveRatingText: Failed!", err);
}
}
function handleRatingHoverEnd() {
console.debug("handleRatingHoverEnd");
hoveredRating = undefined;
ratingText.style.left = "50%";
}
function handleRatingHoverEnd() {
console.debug("handleRatingHoverEnd");
hoveredRating = undefined;
if (ratingText) {
ratingText.style.left = "50%";
}
}
function handleMouseOver(
ev: (TouchEvent | MouseEvent) & {
currentTarget: EventTarget & HTMLDivElement;
}
) {
const rect = ev.currentTarget.getBoundingClientRect();
const x = (ev instanceof MouseEvent ? ev.clientX : ev.touches[0].clientX) - rect.left; // rel to start of container
const perc = Math.ceil(Math.round((x * 100) / rect.width) / starStep) * starStep;
setHoveredRatingFromPerc(perc);
moveRatingText();
}
function handleMouseOver(
ev: (TouchEvent | MouseEvent) & {
currentTarget: EventTarget & HTMLDivElement;
},
) {
const rect = ev.currentTarget.getBoundingClientRect();
const x =
(ev instanceof MouseEvent ? ev.clientX : ev.touches[0].clientX) -
rect.left; // rel to start of container
const perc =
Math.ceil(Math.round((x * 100) / rect.width) / starStep) * starStep;
setHoveredRatingFromPerc(perc);
moveRatingText();
}
function handleKeyDown(
ev: KeyboardEvent & {
currentTarget: EventTarget & HTMLDivElement;
}
) {
console.log("handleKeyDown:", ev);
if (ev.code === "ArrowRight") {
console.debug("handleKeyDown: Increasing selected rating.");
setHoveredRatingFromPerc((shownPerc ?? 0) + starStep);
} else if (ev.code === "ArrowLeft") {
console.debug("handleKeyDown: Decreasing selected rating.");
setHoveredRatingFromPerc(shownPerc ? shownPerc - starStep : 0);
} else if (ev.key === "Enter") {
console.debug("handleKeyDown: Enter pressed.. saving rating.");
saveSelectedRating();
}
}
function handleKeyDown(
ev: KeyboardEvent & {
currentTarget: EventTarget & HTMLDivElement;
},
) {
console.log("handleKeyDown:", ev);
if (ev.code === "ArrowRight") {
console.debug("handleKeyDown: Increasing selected rating.");
setHoveredRatingFromPerc((shownPerc ?? 0) + starStep);
} else if (ev.code === "ArrowLeft") {
console.debug("handleKeyDown: Decreasing selected rating.");
setHoveredRatingFromPerc(shownPerc ? shownPerc - starStep : 0);
} else if (ev.key === "Enter") {
console.debug("handleKeyDown: Enter pressed.. saving rating.");
saveSelectedRating();
}
}
function showRating(perc: number) {
try {
if (!highlightContainer || !normalContainer) {
console.warn("showRating: Containers not defined yet.");
return;
}
// console.debug("showRating: perc", perc, starStep);
perc = Math.max(Math.min(Math.round(perc / starStep) * starStep, 100), 0);
// console.debug("showRating: perc2", perc);
if (perc > 1) {
let percToHighlight = perc;
let percToHide = 100 - perc;
if (starStep == 5) {
// On step of 5, it looks nicer when we take highlight back one percent,
// otherwise more than half the star looks highlighted... ew. This is only visual.
percToHighlight--;
percToHide++;
}
highlightContainer.style.display = "flex";
highlightContainer.style.width = `${percToHighlight}%`;
// We shrink this container too because of what seems to be a bug
// where the text-stroke draws on the upper layer too, instead
// of being hidden by the highlighted stars overlay.
normalContainer.style.width = `${percToHide}%`;
} else {
highlightContainer.style.display = "none";
highlightContainer.style.width = "0";
normalContainer.style.width = "100%";
}
shownPerc = perc;
} catch (err) {
console.error("showRating: Failed!", err);
}
}
function showRating(perc: number) {
try {
if (!highlightContainer || !normalContainer) {
console.warn("showRating: Containers not defined yet.");
return;
}
// console.debug("showRating: perc", perc, starStep);
perc = Math.max(Math.min(Math.round(perc / starStep) * starStep, 100), 0);
// console.debug("showRating: perc2", perc);
if (perc > 1) {
let percToHighlight = perc;
let percToHide = 100 - perc;
if (starStep == 5) {
// On step of 5, it looks nicer when we take highlight back one percent,
// otherwise more than half the star looks highlighted... ew. This is only visual.
percToHighlight--;
percToHide++;
}
highlightContainer.style.display = "flex";
highlightContainer.style.width = `${percToHighlight}%`;
// We shrink this container too because of what seems to be a bug
// where the text-stroke draws on the upper layer too, instead
// of being hidden by the highlighted stars overlay.
normalContainer.style.width = `${percToHide}%`;
} else {
highlightContainer.style.display = "none";
highlightContainer.style.width = "0";
normalContainer.style.width = "100%";
}
shownPerc = perc;
} catch (err) {
console.error("showRating: Failed!", err);
}
}
$: {
// console.log("block", starStep, settings?.ratingStep, settings.ratingSystem);
try {
if (settings) {
if (typeof settings.ratingSystem === "number") {
// Set default star step for system.
switch (settings.ratingSystem) {
case RatingSystem.OutOf100:
starStep = 1;
break;
onMount(() => {
if (rating) showRating(Math.round((rating * 100) / 10));
});
let stars = $derived(
store.userSettings?.ratingSystem == RatingSystem.OutOf5
? [5, 4, 3, 2, 1]
: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1],
);
run(() => {
if (hoveredRating !== undefined && hoveredRating > 0) {
console.debug("showRatingCaller: We have a hoveredRating.");
shownRating = hoveredRating;
showRating(
Math.round(
(hoveredRating * 100) /
(store.userSettings?.ratingSystem === RatingSystem.OutOf5 ? 5 : 10),
),
);
} else if (rating !== undefined) {
console.debug("showRatingCaller: We have a rating.");
shownRating = rating;
showRating(Math.round((rating * 100) / 10));
} else {
console.debug("showRatingCaller: We have nothing.");
shownRating = undefined;
showRating(0);
}
});
run(() => {
// console.log("block", starStep, settings?.ratingStep, settings.ratingSystem);
try {
if (store.userSettings) {
if (typeof store.userSettings.ratingSystem === "number") {
// Set default star step for system.
switch (store.userSettings.ratingSystem) {
case RatingSystem.OutOf100:
starStep = 1;
break;
case RatingSystem.OutOf5:
starStep = 20;
break;
case RatingSystem.OutOf5:
starStep = 20;
break;
case RatingSystem.OutOf10:
default:
starStep = 10;
break;
}
}
// Override default with user set step if supported by this system.
if (typeof settings.ratingStep === "number") {
if (
settings.ratingSystem === RatingSystem.OutOf5 ||
settings.ratingSystem === RatingSystem.OutOf10 ||
!settings.ratingSystem
) {
if (
settings.ratingStep === RatingStep.Point1 ||
settings.ratingStep === RatingStep.Point5 ||
settings.ratingStep === RatingStep.One
) {
// Turn enum value into an actual step value.
const actualRatingStep =
settings.ratingStep === RatingStep.Point1
? 0.1
: settings.ratingStep === RatingStep.Point5
? 0.5
: 1;
console.log("actualRatingStep", actualRatingStep);
starStep =
settings.ratingSystem === RatingSystem.OutOf5
? actualRatingStep * 20
: actualRatingStep * 10;
console.debug("Set starStep from setting:", starStep);
} else {
starStep = settings.ratingSystem === RatingSystem.OutOf5 ? 20 : 10;
console.debug("Set starStep using default:", starStep);
}
}
}
}
} catch (err) {
console.error("Failed to set startStep from settings:", err);
}
}
onMount(() => {
if (rating) showRating(Math.round((rating * 100) / 10));
});
case RatingSystem.OutOf10:
default:
starStep = 10;
break;
}
}
// Override default with user set step if supported by this system.
if (typeof store.userSettings.ratingStep === "number") {
if (
store.userSettings.ratingSystem === RatingSystem.OutOf5 ||
store.userSettings.ratingSystem === RatingSystem.OutOf10 ||
!store.userSettings.ratingSystem
) {
if (
store.userSettings.ratingStep === RatingStep.Point1 ||
store.userSettings.ratingStep === RatingStep.Point5 ||
store.userSettings.ratingStep === RatingStep.One
) {
// Turn enum value into an actual step value.
const actualRatingStep =
store.userSettings.ratingStep === RatingStep.Point1
? 0.1
: store.userSettings.ratingStep === RatingStep.Point5
? 0.5
: 1;
console.log("actualRatingStep", actualRatingStep);
starStep =
store.userSettings.ratingSystem === RatingSystem.OutOf5
? actualRatingStep * 20
: actualRatingStep * 10;
console.debug("Set starStep from setting:", starStep);
} else {
starStep =
store.userSettings.ratingSystem === RatingSystem.OutOf5
? 20
: 10;
console.debug("Set starStep using default:", starStep);
}
}
}
}
} catch (err) {
console.error("Failed to set startStep from settings:", err);
}
});
</script>
<!-- step: {starStep}
@@ -262,190 +279,206 @@ hoveredRating: {hoveredRating}<br />
shownPerc: {shownPerc}<br /> -->
<div class="rating-container" bind:this={ratingContainer}>
<span bind:this={ratingText}>
{#if hoveredRating}
{#if settings?.ratingSystem === RatingSystem.OutOf5 && shownPerc}
{ratingDesc[Math.ceil(shownPerc / 10) - 1]}
{:else}
{ratingDesc[Math.ceil(hoveredRating) - 1]}
{/if}
{#if settings?.ratingSystem === RatingSystem.OutOf100}
({shownPerc})
{:else}
({hoveredRating})
{/if}
{:else if typeof rating === "number" && rating > 0}
{ratingDesc[Math.ceil(rating) - 1]}
{#if shownPerc}
{#if settings?.ratingSystem === RatingSystem.OutOf100}
({shownPerc})
{:else if settings?.ratingSystem === RatingSystem.OutOf5}
({shownPerc / 20})
{:else}
({shownPerc / 10})
{/if}
{/if}
{:else}
Select Your Rating
{/if}
</span>
<div
class="rating-wrap"
bind:this={ratingWrapEl}
on:pointermove={(ev) => handleMouseOver(ev)}
on:touchmove={(ev) => handleMouseOver(ev)}
on:touchstart={(ev) => {
// Prevent `click` event from triggering.
// Mainly for firefox mobile, these events dont work the same
// there (as chromium mobile), so forcing touch devices to use
// this flow (touchstart -> touchend) for clicks too will
// workaround that difference.
ev.preventDefault();
handleMouseOver(ev);
}}
on:mouseleave={(ev) => {
if (!ev.relatedTarget) {
// When not focused on the browser, and then clicking a star directly
// without first focusing the browser, this event can be triggered,
// which causes hoveredRating to reset. This check seems to fix that.
console.debug("rating-wrap: mouseleave event triggered, but me think mistake.. ignoring.");
return;
}
console.debug("rating-wrap: mouseleave");
handleRatingHoverEnd();
}}
on:touchend={async () => {
console.debug("rating-wrap: touchend");
await saveSelectedRating();
handleRatingHoverEnd();
}}
on:blur={() => {
console.debug("rating-wrap: blur");
handleRatingHoverEnd();
}}
on:click={() => {
saveSelectedRating();
}}
on:keydown={(ev) => handleKeyDown(ev)}
role="button"
tabindex="0"
>
<!-- The unlit stars. -->
<div bind:this={normalContainer} class="rating the-normal-one" tabindex="-1">
{#each stars as _}
<button class="plain" tabindex="-1">*</button>
{/each}
</div>
<!-- Overlays on stars above to show them as highlighted. -->
<div bind:this={highlightContainer} class="rating the-highlight-one" tabindex="-1">
{#each stars as _}
<button class="plain lit" tabindex="-1">*</button>
{/each}
</div>
<!-- Hidden stars, just to keep correct layout since the two above are abolute. -->
<div class="rating the-hidden-one-for-layout-reasons" tabindex="-1">
{#each stars as _}
<button class="plain" style="opacity: 0; pointer-events: none;" tabindex="-1">*</button>
{/each}
</div>
</div>
<span class="keyboard-tip">Left/Right Arrows to change rating, Enter to save.</span>
<span bind:this={ratingText}>
{#if hoveredRating}
{#if store.userSettings?.ratingSystem === RatingSystem.OutOf5 && shownPerc}
{ratingDesc[Math.ceil(shownPerc / 10) - 1]}
{:else}
{ratingDesc[Math.ceil(hoveredRating) - 1]}
{/if}
{#if store.userSettings?.ratingSystem === RatingSystem.OutOf100}
({shownPerc})
{:else}
({hoveredRating})
{/if}
{:else if typeof rating === "number" && rating > 0}
{ratingDesc[Math.ceil(rating) - 1]}
{#if shownPerc}
{#if store.userSettings?.ratingSystem === RatingSystem.OutOf100}
({shownPerc})
{:else if store.userSettings?.ratingSystem === RatingSystem.OutOf5}
({shownPerc / 20})
{:else}
({shownPerc / 10})
{/if}
{/if}
{:else}
Select Your Rating
{/if}
</span>
<div
class="rating-wrap"
bind:this={ratingWrapEl}
onpointermove={(ev) => handleMouseOver(ev)}
ontouchmove={(ev) => handleMouseOver(ev)}
ontouchstart={(ev) => {
// Prevent `click` event from triggering.
// Mainly for firefox mobile, these events dont work the same
// there (as chromium mobile), so forcing touch devices to use
// this flow (touchstart -> touchend) for clicks too will
// workaround that difference.
ev.preventDefault();
handleMouseOver(ev);
}}
onmouseleave={(ev) => {
if (!ev.relatedTarget) {
// When not focused on the browser, and then clicking a star directly
// without first focusing the browser, this event can be triggered,
// which causes hoveredRating to reset. This check seems to fix that.
console.debug(
"rating-wrap: mouseleave event triggered, but me think mistake.. ignoring.",
);
return;
}
console.debug("rating-wrap: mouseleave");
handleRatingHoverEnd();
}}
ontouchend={async () => {
console.debug("rating-wrap: touchend");
await saveSelectedRating();
handleRatingHoverEnd();
}}
onblur={() => {
console.debug("rating-wrap: blur");
handleRatingHoverEnd();
}}
onclick={() => {
saveSelectedRating();
}}
onkeydown={(ev) => handleKeyDown(ev)}
role="button"
tabindex="0"
>
<!-- The unlit stars. -->
<div
bind:this={normalContainer}
class="rating the-normal-one"
tabindex="-1"
>
{#each stars as _}
<button class="plain" tabindex="-1">*</button>
{/each}
</div>
<!-- Overlays on stars above to show them as highlighted. -->
<div
bind:this={highlightContainer}
class="rating the-highlight-one"
tabindex="-1"
>
{#each stars as _}
<button class="plain lit" tabindex="-1">*</button>
{/each}
</div>
<!-- Hidden stars, just to keep correct layout since the two above are abolute. -->
<div class="rating the-hidden-one-for-layout-reasons" tabindex="-1">
{#each stars as _}
<button
class="plain"
style="opacity: 0; pointer-events: none;"
tabindex="-1">*</button
>
{/each}
</div>
</div>
<span class="keyboard-tip"
>Left/Right Arrows to change rating, Enter to save.</span
>
</div>
<style lang="scss">
.rating-container {
display: flex;
flex-flow: column;
overflow: visible;
.rating-container {
display: flex;
flex-flow: column;
overflow: visible;
& > span {
position: relative;
transition:
left 100ms ease-in,
transform 100ms ease-in;
max-width: max-content;
left: 50%;
transform: translateX(-50%);
}
}
& > span {
position: relative;
transition:
left 100ms ease-in,
transform 100ms ease-in;
max-width: max-content;
left: 50%;
transform: translateX(-50%);
}
}
.rating-wrap {
position: relative;
user-select: none;
cursor: pointer;
width: max-content;
margin-left: auto;
margin-right: auto;
/* For chromium on mobile, looks a lil gross */
-webkit-tap-highlight-color: transparent;
.rating-wrap {
position: relative;
user-select: none;
cursor: pointer;
width: max-content;
margin-left: auto;
margin-right: auto;
/* For chromium on mobile, looks a lil gross */
-webkit-tap-highlight-color: transparent;
&:focus-visible {
+ .keyboard-tip {
display: unset;
}
}
}
&:focus-visible {
+ .keyboard-tip {
display: unset;
}
}
}
.keyboard-tip {
// For when the rating-wrap is accessed via keyboard.
font-size: 12px;
margin-top: 5px;
margin-bottom: 5px;
display: none;
}
.keyboard-tip {
// For when the rating-wrap is accessed via keyboard.
font-size: 12px;
margin-top: 5px;
margin-bottom: 5px;
display: none;
}
.rating {
display: flex;
align-items: center;
color: $text-color;
overflow: hidden;
margin: 10px 0 10px 0;
padding: 1px;
.rating {
display: flex;
align-items: center;
color: $text-color;
overflow: hidden;
margin: 10px 0 10px 0;
padding: 1px;
&.the-highlight-one {
width: 0%;
display: none;
overflow: hidden;
-webkit-text-stroke: 1.5px gold;
pointer-events: none;
}
&.the-highlight-one {
width: 0%;
display: none;
overflow: hidden;
-webkit-text-stroke: 1.5px gold;
pointer-events: none;
}
&:not(.the-highlight-one) {
flex-flow: row-reverse;
justify-content: center;
}
&:not(.the-highlight-one) {
flex-flow: row-reverse;
justify-content: center;
}
&:not(.the-hidden-one-for-layout-reasons) {
position: absolute;
left: 0;
top: 0;
}
&:not(.the-hidden-one-for-layout-reasons) {
position: absolute;
left: 0;
top: 0;
}
&.the-normal-one {
justify-content: unset;
white-space: nowrap;
left: unset;
right: 0;
-webkit-text-stroke: 1.5px $text-color;
pointer-events: none;
}
&.the-normal-one {
justify-content: unset;
white-space: nowrap;
left: unset;
right: 0;
-webkit-text-stroke: 1.5px $text-color;
pointer-events: none;
}
button {
font-size: 55px;
font-family: "Rampart One";
letter-spacing: 10px;
line-height: 52px;
height: 38px;
button {
font-size: 55px;
font-family: "Rampart One";
letter-spacing: 10px;
line-height: 52px;
height: 38px;
&:global(.lit),
&:global(.lit ~ button) {
color: gold;
-webkit-text-stroke: 1.5px gold;
}
&:global(.lit),
&:global(.lit ~ button) {
color: gold;
-webkit-text-stroke: 1.5px gold;
}
@media screen and (max-width: 420px) {
letter-spacing: 6px;
}
}
}
@media screen and (max-width: 420px) {
letter-spacing: 6px;
}
}
}
</style>
+52 -48
View File
@@ -1,63 +1,67 @@
<!-- To be used from Rating component -->
<script lang="ts">
import Icon from "../Icon.svelte";
import tooltip from "../actions/tooltip";
import Icon from "../Icon.svelte";
import tooltip from "../actions/tooltip";
export let rating: number | undefined;
export let onChange: (newRating: number) => Promise<boolean>;
interface Props {
rating: number | undefined;
onChange: (newRating: number) => Promise<boolean>;
}
$: r = rating ? Math.round(rating) : 0;
let { rating, onChange }: Props = $props();
let r = $derived(rating ? Math.round(rating) : 0);
</script>
<div class="thumbs-ctr">
<button
use:tooltip={{ text: "Disliked", pos: "top" }}
on:click={() => onChange(1)}
class={r && r > 0 && r < 5 ? "active" : ""}
>
<Icon i="thumb-down" />
</button>
<button
use:tooltip={{ text: "Mediocre", pos: "top" }}
on:click={() => onChange(5)}
class={r && r > 4 && r < 8 ? "active" : ""}
>
<span>-</span>
</button>
<button
use:tooltip={{ text: "Liked", pos: "top" }}
on:click={() => onChange(9)}
class={r && r > 7 ? "active" : ""}
>
<Icon i="thumb-up" />
</button>
<button
use:tooltip={{ text: "Disliked", pos: "top" }}
onclick={() => onChange(1)}
class={r && r > 0 && r < 5 ? "active" : ""}
>
<Icon i="thumb-down" />
</button>
<button
use:tooltip={{ text: "Mediocre", pos: "top" }}
onclick={() => onChange(5)}
class={r && r > 4 && r < 8 ? "active" : ""}
>
<span>-</span>
</button>
<button
use:tooltip={{ text: "Liked", pos: "top" }}
onclick={() => onChange(9)}
class={r && r > 7 ? "active" : ""}
>
<Icon i="thumb-up" />
</button>
</div>
<style lang="scss">
.thumbs-ctr {
display: flex;
flex-flow: row;
gap: 10px;
.thumbs-ctr {
display: flex;
flex-flow: row;
gap: 10px;
button {
display: flex;
align-items: center;
justify-content: center;
height: 60px;
button {
display: flex;
align-items: center;
justify-content: center;
height: 60px;
&:nth-child(2) span {
/* Center the dash (-) for the 'mediocre' rating button */
transform: translateY(5px);
font-size: 50px;
font-family: "Shrikhand";
}
&:nth-child(2) span {
/* Center the dash (-) for the 'mediocre' rating button */
transform: translateY(5px);
font-size: 50px;
font-family: "Shrikhand";
}
&.active {
color: gold;
fill: gold;
background-color: $text-color;
}
}
}
&.active {
color: gold;
fill: gold;
background-color: $text-color;
}
}
}
</style>
+43 -41
View File
@@ -1,4 +1,4 @@
import { userSettings } from "@/store";
import { store } from "@/store.svelte";
import { RatingStep, RatingSystem } from "@/types";
import { get } from "svelte/store";
@@ -9,47 +9,49 @@ import { get } from "svelte/store";
* Only used for star ratings, not thumbs.
*/
export function toShowableRating(r?: number) {
if (!r) {
return 0;
}
const settings = get(userSettings);
if (!settings || (!settings.ratingSystem && !settings.ratingStep)) {
return Math.round(r);
}
if (settings.ratingSystem === RatingSystem.OutOf100) {
return r * 10;
}
if (settings.ratingSystem === RatingSystem.OutOf5) {
if (settings.ratingStep === RatingStep.Point5) {
return Math.ceil((r / 2) * 2) / 2;
}
if (settings.ratingStep === RatingStep.Point1) {
return Math.round((r / 2) * 10) / 10;
}
return Math.round(r / 2);
}
if (settings.ratingSystem === RatingSystem.OutOf10) {
if (settings.ratingStep === RatingStep.Point5) {
return Math.ceil(r * 2) / 2;
}
if (settings.ratingStep === RatingStep.Point1) {
return r;
}
return Math.round(r);
}
return Math.round(r);
if (!r) {
return 0;
}
if (
!store.userSettings ||
(!store.userSettings.ratingSystem && !store.userSettings.ratingStep)
) {
return Math.round(r);
}
if (store.userSettings.ratingSystem === RatingSystem.OutOf100) {
return r * 10;
}
if (store.userSettings.ratingSystem === RatingSystem.OutOf5) {
if (store.userSettings.ratingStep === RatingStep.Point5) {
return Math.ceil((r / 2) * 2) / 2;
}
if (store.userSettings.ratingStep === RatingStep.Point1) {
return Math.round((r / 2) * 10) / 10;
}
return Math.round(r / 2);
}
if (store.userSettings.ratingSystem === RatingSystem.OutOf10) {
if (store.userSettings.ratingStep === RatingStep.Point5) {
return Math.ceil(r * 2) / 2;
}
if (store.userSettings.ratingStep === RatingStep.Point1) {
return r;
}
return Math.round(r);
}
return Math.round(r);
}
export function toWhichThumb(r?: number) {
if (!r) {
return;
}
const rr = Math.round(r);
if (rr > 0 && rr <= 4) {
return -1;
} else if (r >= 4 && r <= 7) {
return 0;
} else if (r >= 8) {
return 1;
}
if (!r) {
return;
}
const rr = Math.round(r);
if (rr > 0 && rr <= 4) {
return -1;
} else if (r >= 4 && r <= 7) {
return 0;
} else if (r >= 8) {
return 1;
}
}
+219 -195
View File
@@ -1,219 +1,243 @@
<script lang="ts">
import type {
ArrDetailsResponse,
ArrInfoResponse,
ArrRequestResponse,
ArrRequestStatus,
ContentType
} from "@/types";
import axios from "axios";
import { onMount } from "svelte";
import Icon from "../Icon.svelte";
import tooltip from "../actions/tooltip";
import { msToAmountsOfTime } from "../util/helpers";
import { notify } from "../util/notify";
import type {
ArrDetailsResponse,
ArrInfoResponse,
ArrRequestResponse,
ArrRequestStatus,
ContentType,
} from "@/types";
import axios from "axios";
import { onMount } from "svelte";
import Icon from "../Icon.svelte";
import tooltip from "../actions/tooltip";
import { msToAmountsOfTime } from "../util/helpers";
import { notify } from "../util/notify";
export let type: ContentType;
export let tmdbId: number;
export let openRequestModal: () => void;
interface Props {
type: ContentType;
tmdbId: number;
openRequestModal: () => void;
}
let existingRequest: ArrRequestResponse | undefined;
let info: ArrInfoResponse | undefined;
// The status, extra added string types are set in this file.
let status: ArrDetailsResponse | "available" | "requested" | ArrRequestStatus | undefined;
let estimatedCompletionIn: string | undefined;
let { type, tmdbId, openRequestModal }: Props = $props();
async function getInfo() {
try {
if (!existingRequest) {
console.warn("getInfo called before existingRequest exists.");
return;
}
if (
existingRequest.status !== "APPROVED" &&
existingRequest.status !== "AUTO_APPROVED" &&
existingRequest.status !== "FOUND"
) {
console.debug("getInfo: Request is not in an approved state.. not continuing.");
return;
}
const resp = await axios.get<ArrInfoResponse>(
`/arr/${type === "movie" ? "rad" : "son"}/info/${existingRequest.id}`
);
if (resp?.data) {
info = resp.data;
if (info.hasFile) {
status = "available";
} else {
getStatus();
}
}
} catch (err: any) {
if (err?.response?.status === 404 && err?.response?.data?.error === "request deleted") {
return;
}
console.error("ArrRequestButton: getInfo failed!", err);
notify({
text: `Failed when getting info from ${type === "movie" ? "Radarr" : "Sonarr"}`,
type: "error"
});
}
}
let existingRequest: ArrRequestResponse | undefined;
let info: ArrInfoResponse | undefined;
// The status, extra added string types are set in this file.
let status:
| ArrDetailsResponse
| "available"
| "requested"
| ArrRequestStatus
| undefined = $state();
let estimatedCompletionIn: string | undefined = $state();
async function getStatus(ev: MouseEvent | undefined = undefined) {
try {
if (!existingRequest) {
console.warn("getStatus called before existingRequest exists.");
return;
}
const statusResp = await axios.get<ArrDetailsResponse>(
`/arr/${type === "movie" ? "rad" : "son"}/status/${existingRequest.serverName}/${existingRequest.arrId}`
);
if (statusResp.status === 204) {
status = "requested";
} else if (statusResp?.data) {
status = statusResp.data;
const estMs =
new Date(status.estimatedCompletionTime).getTime() - new Date(Date.now()).getTime();
const est = msToAmountsOfTime(estMs);
if (est.days > 0) {
estimatedCompletionIn = `${est.days} Day${est.days > 1 ? "s" : ""}`;
} else if (est.hours > 0) {
estimatedCompletionIn = `${est.hours} Hour${est.hours > 1 ? "s" : ""}`;
} else if (est.minutes > 0) {
estimatedCompletionIn = `${est.minutes} Minute${est.minutes > 1 ? "s" : ""}`;
} else if (est.seconds > 0) {
estimatedCompletionIn = `${est.seconds} Second${est.seconds > 1 ? "s" : ""}`;
} else {
estimatedCompletionIn = undefined;
}
}
if (ev?.target) {
setTimeout(() => {
(ev?.target as HTMLButtonElement)?.blur();
}, 250);
}
} catch (err) {
console.error("ArrRequestButton: getStatus failed!", err);
notify({
text: `Failed to fetch content status from ${type === "movie" ? "Radarr" : "Sonarr"}`,
type: "error"
});
}
}
async function getInfo() {
try {
if (!existingRequest) {
console.warn("getInfo called before existingRequest exists.");
return;
}
if (
existingRequest.status !== "APPROVED" &&
existingRequest.status !== "AUTO_APPROVED" &&
existingRequest.status !== "FOUND"
) {
console.debug(
"getInfo: Request is not in an approved state.. not continuing.",
);
return;
}
const resp = await axios.get<ArrInfoResponse>(
`/arr/${type === "movie" ? "rad" : "son"}/info/${existingRequest.id}`,
);
if (resp?.data) {
info = resp.data;
if (info.hasFile) {
status = "available";
} else {
getStatus();
}
}
} catch (err: any) {
if (
err?.response?.status === 404 &&
err?.response?.data?.error === "request deleted"
) {
return;
}
console.error("ArrRequestButton: getInfo failed!", err);
notify({
text: `Failed when getting info from ${type === "movie" ? "Radarr" : "Sonarr"}`,
type: "error",
});
}
}
async function lookForExisting() {
try {
const existingRequestResp = await axios.get<ArrRequestResponse>(
`/arr/${type === "movie" ? "rad" : "son"}/request/${tmdbId}`
);
if (existingRequestResp?.data && existingRequestResp?.data?.arrId) {
existingRequest = existingRequestResp?.data;
getInfo();
} else if (existingRequestResp?.data) {
// If no arrId, use status in request (pending, denied, etc)
console.log("No arrId in request resp.. using request status for btn status if set.");
if (existingRequestResp?.data?.status) {
status = existingRequestResp?.data?.status;
}
}
} catch (err) {
console.error("ArrRequestButton: lookForExisting failed!", err);
notify({
text: `Failed when looking for an existing request for this content`,
type: "error"
});
}
}
async function getStatus(ev: MouseEvent | undefined = undefined) {
try {
if (!existingRequest) {
console.warn("getStatus called before existingRequest exists.");
return;
}
const statusResp = await axios.get<ArrDetailsResponse>(
`/arr/${type === "movie" ? "rad" : "son"}/status/${existingRequest.serverName}/${existingRequest.arrId}`,
);
if (statusResp.status === 204) {
status = "requested";
} else if (statusResp?.data) {
status = statusResp.data;
const estMs =
new Date(status.estimatedCompletionTime).getTime() -
new Date(Date.now()).getTime();
const est = msToAmountsOfTime(estMs);
if (est.days > 0) {
estimatedCompletionIn = `${est.days} Day${est.days > 1 ? "s" : ""}`;
} else if (est.hours > 0) {
estimatedCompletionIn = `${est.hours} Hour${est.hours > 1 ? "s" : ""}`;
} else if (est.minutes > 0) {
estimatedCompletionIn = `${est.minutes} Minute${est.minutes > 1 ? "s" : ""}`;
} else if (est.seconds > 0) {
estimatedCompletionIn = `${est.seconds} Second${est.seconds > 1 ? "s" : ""}`;
} else {
estimatedCompletionIn = undefined;
}
}
if (ev?.target) {
setTimeout(() => {
(ev?.target as HTMLButtonElement)?.blur();
}, 250);
}
} catch (err) {
console.error("ArrRequestButton: getStatus failed!", err);
notify({
text: `Failed to fetch content status from ${type === "movie" ? "Radarr" : "Sonarr"}`,
type: "error",
});
}
}
// Used by Request modals to set existing request data in this component from their request response.
export function setExistingRequest(r: ArrRequestResponse) {
if (existingRequest) {
console.error(
"ArrRequestButton: setExistingRequest: Existing request has already been defined! Not continuing."
);
return;
}
if (!r) {
console.error("ArrRequestButton: setExistingRequest: No response passed in.", r);
return;
}
console.debug("ArrRequestButton: setExistingRequest: Running..", r);
existingRequest = r;
if (existingRequest?.status) {
status = existingRequest?.status;
}
getInfo();
}
async function lookForExisting() {
try {
const existingRequestResp = await axios.get<ArrRequestResponse>(
`/arr/${type === "movie" ? "rad" : "son"}/request/${tmdbId}`,
);
if (existingRequestResp?.data && existingRequestResp?.data?.arrId) {
existingRequest = existingRequestResp?.data;
getInfo();
} else if (existingRequestResp?.data) {
// If no arrId, use status in request (pending, denied, etc)
console.log(
"No arrId in request resp.. using request status for btn status if set.",
);
if (existingRequestResp?.data?.status) {
status = existingRequestResp?.data?.status;
}
}
} catch (err) {
console.error("ArrRequestButton: lookForExisting failed!", err);
notify({
text: `Failed when looking for an existing request for this content`,
type: "error",
});
}
}
onMount(() => {
lookForExisting();
});
// Used by Request modals to set existing request data in this component from their request response.
export function setExistingRequest(r: ArrRequestResponse) {
if (existingRequest) {
console.error(
"ArrRequestButton: setExistingRequest: Existing request has already been defined! Not continuing.",
);
return;
}
if (!r) {
console.error(
"ArrRequestButton: setExistingRequest: No response passed in.",
r,
);
return;
}
console.debug("ArrRequestButton: setExistingRequest: Running..", r);
existingRequest = r;
if (existingRequest?.status) {
status = existingRequest?.status;
}
getInfo();
}
onMount(() => {
lookForExisting();
});
</script>
{#if typeof status === "object" || status === "requested"}
<button
on:click={getStatus}
use:tooltip={{
text:
status === "requested"
? "Waiting for approval or download to start"
: status.status === "downloading"
? estimatedCompletionIn
? `Done in ${estimatedCompletionIn}`
: "Estimation Unavailable"
: status.status === "paused"
? "Download has been paused"
: "",
pos: "bot"
}}
>
<div><Icon i="refresh" /></div>
<span>
{#if status === "requested"}
Requested
{:else}
{status.status}
{status.status === "downloading" ? (status?.progress ? `(${status?.progress}%)` : "") : ""}
{/if}
</span>
</button>
<button
onclick={getStatus}
use:tooltip={{
text:
status === "requested"
? "Waiting for approval or download to start"
: status.status === "downloading"
? estimatedCompletionIn
? `Done in ${estimatedCompletionIn}`
: "Estimation Unavailable"
: status.status === "paused"
? "Download has been paused"
: "",
pos: "bot",
}}
>
<div><Icon i="refresh" /></div>
<span>
{#if status === "requested"}
Requested
{:else}
{status.status}
{status.status === "downloading"
? status?.progress
? `(${status?.progress}%)`
: ""
: ""}
{/if}
</span>
</button>
{:else if status === "available" || status === "FOUND"}
<button disabled>Available</button>
<button disabled>Available</button>
{:else if status === "PENDING"}
<button disabled>Pending</button>
<button disabled>Pending</button>
{:else if status === "APPROVED"}
<button disabled>Approved</button>
<button disabled>Approved</button>
{:else if status === "AUTO_APPROVED"}
<button disabled>Auto Approved</button>
<button disabled>Auto Approved</button>
{:else if status === "DENIED"}
<button disabled>Denied</button>
<button disabled>Denied</button>
{:else}
<button on:click={openRequestModal}>Request</button>
<button onclick={openRequestModal}>Request</button>
{/if}
<style lang="scss">
button {
max-width: fit-content;
text-transform: capitalize;
position: relative;
button {
max-width: fit-content;
text-transform: capitalize;
position: relative;
& > div {
display: none;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
& > div {
display: none;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
&:hover:not(:focus) {
& > div {
display: block;
}
&:hover:not(:focus) {
& > div {
display: block;
}
& > span {
visibility: hidden;
}
}
}
& > span {
visibility: hidden;
}
}
}
</style>
+192 -174
View File
@@ -1,195 +1,213 @@
<script lang="ts">
import axios from "axios";
import Modal from "../Modal.svelte";
import type {
ArrRequestResponse,
RadarrSettings,
RadarrTestResponse,
TMDBMovieDetails
} from "@/types";
import { notify } from "../util/notify";
import DropDown from "../DropDown.svelte";
import Setting from "../settings/Setting.svelte";
import Spinner from "../Spinner.svelte";
import { run } from "svelte/legacy";
export let content: TMDBMovieDetails;
export let onClose: (r: ArrRequestResponse | undefined) => void;
import axios from "axios";
import Modal from "../Modal.svelte";
import type {
ArrRequestResponse,
RadarrSettings,
RadarrTestResponse,
TMDBMovieDetails,
} from "@/types";
import { notify } from "../util/notify";
import DropDown from "../DropDown.svelte";
import Setting from "../settings/Setting.svelte";
import Spinner from "../Spinner.svelte";
export let approveMode = false;
export let originalRequest: ArrRequestResponse | undefined = undefined;
interface Props {
content: TMDBMovieDetails;
onClose: (r: ArrRequestResponse | undefined) => void;
approveMode?: boolean;
originalRequest?: ArrRequestResponse | undefined;
}
let servarrs: RadarrSettings[];
let selectedServarrIndex: number;
let inputsDisabled = true;
let selectedServerCfg: RadarrTestResponse | undefined;
let addRequestRunning = false;
let {
content,
onClose,
approveMode = false,
originalRequest = undefined,
}: Props = $props();
async function getServers() {
try {
inputsDisabled = true;
const r = await axios.get("/arr/rad");
if (r.data?.length > 0) {
servarrs = r.data;
selectedServarrIndex = 0;
} else {
notify({ text: "No servers found", type: "error" });
}
inputsDisabled = false;
processOriginalRequest();
} catch (err) {
console.error("Failed to get servers!", err);
notify({ text: "Failed to load servers", type: "error" });
}
}
let servarrs: RadarrSettings[] = $state();
let selectedServarrIndex: number = $state();
let inputsDisabled = true;
let selectedServerCfg: RadarrTestResponse | undefined = $state();
let addRequestRunning = $state(false);
async function getConfig(name: string) {
try {
inputsDisabled = true;
const r = await axios.get<RadarrTestResponse>(`/arr/rad/config/${name}`);
selectedServerCfg = r.data;
inputsDisabled = false;
} catch (err) {
console.error("Failed to get config!", err);
notify({ text: "Failed to load config", type: "error" });
}
}
async function getServers() {
try {
inputsDisabled = true;
const r = await axios.get("/arr/rad");
if (r.data?.length > 0) {
servarrs = r.data;
selectedServarrIndex = 0;
} else {
notify({ text: "No servers found", type: "error" });
}
inputsDisabled = false;
processOriginalRequest();
} catch (err) {
console.error("Failed to get servers!", err);
notify({ text: "Failed to load servers", type: "error" });
}
}
async function request() {
let nid;
try {
if (!servarrs || !servarrs[selectedServarrIndex]) {
notify({ text: "Must select a server", type: "error" });
return;
}
if (!selectedServerCfg) {
notify({ text: "No selected server config found", type: "error" });
return;
}
addRequestRunning = true;
nid = notify({ text: "Requesting", type: "loading" });
const server = servarrs[selectedServarrIndex];
const rootFolder = selectedServerCfg.rootFolders?.find((f) => f.id === server.rootFolder);
if (!rootFolder) {
console.error(
"movie request.. no root folder found with id:",
server.rootFolder,
"rf:",
rootFolder
);
notify({ id: nid, text: "No Root Folder Found", type: "error" });
return;
}
const resp = await axios.post<ArrRequestResponse>(
`/arr/rad/request${approveMode && originalRequest ? `/approve/${originalRequest.id}` : ""}`,
{
serverName: server.name,
title: content.title,
year: new Date(content.release_date)?.getFullYear(),
tmdbId: content.id,
qualityProfile: server.qualityProfile,
rootFolder: rootFolder.path
}
);
addRequestRunning = false;
if (resp.data) {
notify({ id: nid, text: "Request complete", type: "success" });
onClose(resp.data);
}
} catch (err) {
console.error("content request failed!", err);
addRequestRunning = false;
notify({ id: nid, text: "Request failed!", type: "error" });
}
}
async function getConfig(name: string) {
try {
inputsDisabled = true;
const r = await axios.get<RadarrTestResponse>(`/arr/rad/config/${name}`);
selectedServerCfg = r.data;
inputsDisabled = false;
} catch (err) {
console.error("Failed to get config!", err);
notify({ text: "Failed to load config", type: "error" });
}
}
function processOriginalRequest() {
if (!originalRequest) {
return;
}
try {
if (originalRequest.requestJson) {
const ogr = JSON.parse(originalRequest.requestJson);
if (!ogr) {
console.info("processOriginalRequest: No json.", ogr);
return;
}
if (ogr?.serverName) {
console.debug("processOriginalRequest: restoring server name:", ogr?.serverName);
const idx = servarrs?.findIndex((s) => s.name === ogr?.serverName);
if (idx !== -1) {
selectedServarrIndex = idx;
}
}
} else {
notify({
type: "error",
text: "Full original request could not be restored. You may continue, but prefilled settings may not be true to the original request.",
time: 10000
});
}
} catch (err) {
console.error("processOriginalRequest: Failed!", err);
notify({ text: "Failed when processing original request!", type: "error" });
}
}
async function request() {
let nid;
try {
if (!servarrs || !servarrs[selectedServarrIndex]) {
notify({ text: "Must select a server", type: "error" });
return;
}
if (!selectedServerCfg) {
notify({ text: "No selected server config found", type: "error" });
return;
}
addRequestRunning = true;
nid = notify({ text: "Requesting", type: "loading" });
const server = servarrs[selectedServarrIndex];
const rootFolder = selectedServerCfg.rootFolders?.find(
(f) => f.id === server.rootFolder,
);
if (!rootFolder) {
console.error(
"movie request.. no root folder found with id:",
server.rootFolder,
"rf:",
rootFolder,
);
notify({ id: nid, text: "No Root Folder Found", type: "error" });
return;
}
const resp = await axios.post<ArrRequestResponse>(
`/arr/rad/request${approveMode && originalRequest ? `/approve/${originalRequest.id}` : ""}`,
{
serverName: server.name,
title: content.title,
year: new Date(content.release_date)?.getFullYear(),
tmdbId: content.id,
qualityProfile: server.qualityProfile,
rootFolder: rootFolder.path,
},
);
addRequestRunning = false;
if (resp.data) {
notify({ id: nid, text: "Request complete", type: "success" });
onClose(resp.data);
}
} catch (err) {
console.error("content request failed!", err);
addRequestRunning = false;
notify({ id: nid, text: "Request failed!", type: "error" });
}
}
$: {
if (typeof selectedServarrIndex !== "undefined" && servarrs?.length > 0) {
const s = servarrs[selectedServarrIndex];
if (!s) {
selectedServerCfg = undefined;
} else {
getConfig(s.name);
}
}
}
function processOriginalRequest() {
if (!originalRequest) {
return;
}
try {
if (originalRequest.requestJson) {
const ogr = JSON.parse(originalRequest.requestJson);
if (!ogr) {
console.info("processOriginalRequest: No json.", ogr);
return;
}
if (ogr?.serverName) {
console.debug(
"processOriginalRequest: restoring server name:",
ogr?.serverName,
);
const idx = servarrs?.findIndex((s) => s.name === ogr?.serverName);
if (idx !== -1) {
selectedServarrIndex = idx;
}
}
} else {
notify({
type: "error",
text: "Full original request could not be restored. You may continue, but prefilled settings may not be true to the original request.",
time: 10000,
});
}
} catch (err) {
console.error("processOriginalRequest: Failed!", err);
notify({
text: "Failed when processing original request!",
type: "error",
});
}
}
getServers();
run(() => {
if (typeof selectedServarrIndex !== "undefined" && servarrs?.length > 0) {
const s = servarrs[selectedServarrIndex];
if (!s) {
selectedServerCfg = undefined;
} else {
getConfig(s.name);
}
}
});
getServers();
</script>
<Modal
title={approveMode ? "Approve Request" : "Request"}
desc={content.title}
onClose={() => onClose(undefined)}
title={approveMode ? "Approve Request" : "Request"}
desc={content.title}
onClose={() => onClose(undefined)}
>
<div class="req-ctr">
{#if servarrs}
{@const server = servarrs[selectedServarrIndex]}
<div class="req-ctr">
{#if servarrs}
{@const server = servarrs[selectedServarrIndex]}
{#if servarrs?.length > 1}
<Setting title="Select the server to use">
<DropDown
placeholder="Select a server"
bind:active={selectedServarrIndex}
options={servarrs?.length > 0
? servarrs.map((s, i) => {
return { id: i, value: s.name };
})
: []}
/>
</Setting>
{/if}
{#if servarrs?.length > 1}
<Setting title="Select the server to use">
<DropDown
placeholder="Select a server"
bind:active={selectedServarrIndex}
options={servarrs?.length > 0
? servarrs.map((s, i) => {
return { id: i, value: s.name };
})
: []}
/>
</Setting>
{/if}
<button on:click={request} disabled={addRequestRunning}>
{approveMode ? "Approve" : "Request"}
</button>
{:else}
<Spinner />
{/if}
</div>
<button onclick={request} disabled={addRequestRunning}>
{approveMode ? "Approve" : "Request"}
</button>
{:else}
<Spinner />
{/if}
</div>
</Modal>
<style lang="scss">
.req-ctr {
display: flex;
flex-flow: column;
gap: 10px;
height: 100%;
.req-ctr {
display: flex;
flex-flow: column;
gap: 10px;
height: 100%;
button {
margin-top: auto;
margin-left: auto;
width: max-content;
}
}
button {
margin-top: auto;
margin-left: auto;
width: max-content;
}
}
</style>
+238 -216
View File
@@ -1,240 +1,262 @@
<script lang="ts">
import axios from "axios";
import Modal from "../Modal.svelte";
import type {
ArrRequestResponse,
ListBoxItem,
SonarrSettings,
SonarrTestResponse,
TMDBShowDetails
} from "@/types";
import { notify } from "../util/notify";
import DropDown from "../DropDown.svelte";
import Setting from "../settings/Setting.svelte";
import Spinner from "../Spinner.svelte";
import ListBox from "../ListBox.svelte";
import { run } from "svelte/legacy";
const animeKeywordId = 210024;
import axios from "axios";
import Modal from "../Modal.svelte";
import type {
ArrRequestResponse,
ListBoxItem,
SonarrSettings,
SonarrTestResponse,
TMDBShowDetails,
} from "@/types";
import { notify } from "../util/notify";
import DropDown from "../DropDown.svelte";
import Setting from "../settings/Setting.svelte";
import Spinner from "../Spinner.svelte";
import ListBox from "../ListBox.svelte";
export let content: TMDBShowDetails;
export let onClose: (r: ArrRequestResponse | undefined) => void;
const animeKeywordId = 210024;
export let approveMode = false;
export let originalRequest: ArrRequestResponse | undefined = undefined;
interface Props {
content: TMDBShowDetails;
onClose: (r: ArrRequestResponse | undefined) => void;
approveMode?: boolean;
originalRequest?: ArrRequestResponse | undefined;
}
let servarrs: SonarrSettings[];
let selectedServarrIndex: number;
let inputsDisabled = true;
let selectedServerCfg: SonarrTestResponse | undefined;
let seasonItems: ListBoxItem[] = content.seasons.map((s) => {
return {
id: s.season_number,
value: false,
displayValue: s.name
};
});
let addRequestRunning = false;
let {
content,
onClose,
approveMode = false,
originalRequest = undefined,
}: Props = $props();
async function getServers() {
try {
inputsDisabled = true;
const r = await axios.get("/arr/son");
if (r.data?.length > 0) {
servarrs = r.data;
selectedServarrIndex = 0;
} else {
notify({ text: "No servers found", type: "error" });
}
inputsDisabled = false;
processOriginalRequest();
} catch (err) {
console.error("Failed to get servers!", err);
notify({ text: "Failed to load servers", type: "error" });
}
}
let servarrs: SonarrSettings[] = $state();
let selectedServarrIndex: number = $state();
let inputsDisabled = true;
let selectedServerCfg: SonarrTestResponse | undefined = $state();
let seasonItems: ListBoxItem[] = $state(
content.seasons.map((s) => {
return {
id: s.season_number,
value: false,
displayValue: s.name,
};
}),
);
let addRequestRunning = $state(false);
async function getConfig(name: string) {
try {
inputsDisabled = true;
const r = await axios.get<SonarrTestResponse>(`/arr/son/config/${name}`);
selectedServerCfg = r.data;
inputsDisabled = false;
} catch (err) {
console.error("Failed to get config!", err);
notify({ text: "Failed to load config", type: "error" });
}
}
async function getServers() {
try {
inputsDisabled = true;
const r = await axios.get("/arr/son");
if (r.data?.length > 0) {
servarrs = r.data;
selectedServarrIndex = 0;
} else {
notify({ text: "No servers found", type: "error" });
}
inputsDisabled = false;
processOriginalRequest();
} catch (err) {
console.error("Failed to get servers!", err);
notify({ text: "Failed to load servers", type: "error" });
}
}
async function request() {
let nid;
try {
if (!servarrs || !servarrs[selectedServarrIndex]) {
notify({ text: "Must select a server", type: "error" });
return;
}
if (!selectedServerCfg) {
notify({ text: "No selected server config found", type: "error" });
return;
}
addRequestRunning = true;
nid = notify({ text: "Requesting", type: "loading" });
const server = servarrs[selectedServarrIndex];
const rootFolder = selectedServerCfg.rootFolders?.find((f) => f.id === server.rootFolder);
if (!rootFolder) {
console.error(
"show request.. no root folder found with id:",
server.rootFolder,
"rf:",
rootFolder
);
notify({ id: nid, text: "No Root Folder Found", type: "error" });
return;
}
const resp = await axios.post(
`/arr/son/request${approveMode && originalRequest ? `/approve/${originalRequest.id}` : ""}`,
{
serverName: server.name,
title: content.name,
year: new Date(content.first_air_date)?.getFullYear(),
tmdbId: content.id,
tvdbId: content.external_ids.tvdb_id,
seriesType: content.keywords.results?.find((k) => k.id == animeKeywordId)
? "anime"
: "standard",
qualityProfile: server.qualityProfile,
rootFolder: rootFolder.path,
languageProfile: server.languageProfile,
seasons: seasonItems.map((s) => {
return {
seasonNumber: s.id,
monitored: s.value
};
})
}
);
addRequestRunning = false;
if (resp?.data) {
notify({ id: nid, text: "Request complete", type: "success" });
onClose(resp.data);
}
} catch (err) {
console.error("content request failed!", err);
addRequestRunning = false;
notify({ id: nid, text: "Request failed!", type: "error" });
}
}
async function getConfig(name: string) {
try {
inputsDisabled = true;
const r = await axios.get<SonarrTestResponse>(`/arr/son/config/${name}`);
selectedServerCfg = r.data;
inputsDisabled = false;
} catch (err) {
console.error("Failed to get config!", err);
notify({ text: "Failed to load config", type: "error" });
}
}
function processOriginalRequest() {
if (!originalRequest) {
return;
}
try {
if (originalRequest.requestJson) {
const ogr = JSON.parse(originalRequest.requestJson);
if (!ogr) {
console.info("processOriginalRequest: No json.", ogr);
return;
}
if (ogr?.seasons?.length > 0) {
console.debug("processOriginalRequest: Found seasons.. restoring.");
for (let i = 0; i < ogr.seasons.length; i++) {
const s = ogr.seasons[i];
// Default is not monitored, so no point going through the whole rigmarole to 'restore' the default value.
if (!s.monitored) {
continue;
}
const sItem = seasonItems?.find((si) => si.id === s.seasonNumber);
if (sItem) {
sItem.value = s.monitored;
}
}
}
if (ogr?.serverName) {
console.debug("processOriginalRequest: restoring server name:", ogr?.serverName);
const idx = servarrs?.findIndex((s) => s.name === ogr?.serverName);
if (idx !== -1) {
selectedServarrIndex = idx;
}
}
} else {
notify({
type: "error",
text: "Full original request could not be restored. You may continue, but prefilled settings may not be true to the original request.",
time: 10000
});
}
} catch (err) {
console.error("processOriginalRequest: Failed!", err);
notify({ text: "Failed when processing original request!", type: "error" });
}
}
async function request() {
let nid;
try {
if (!servarrs || !servarrs[selectedServarrIndex]) {
notify({ text: "Must select a server", type: "error" });
return;
}
if (!selectedServerCfg) {
notify({ text: "No selected server config found", type: "error" });
return;
}
addRequestRunning = true;
nid = notify({ text: "Requesting", type: "loading" });
const server = servarrs[selectedServarrIndex];
const rootFolder = selectedServerCfg.rootFolders?.find(
(f) => f.id === server.rootFolder,
);
if (!rootFolder) {
console.error(
"show request.. no root folder found with id:",
server.rootFolder,
"rf:",
rootFolder,
);
notify({ id: nid, text: "No Root Folder Found", type: "error" });
return;
}
const resp = await axios.post(
`/arr/son/request${approveMode && originalRequest ? `/approve/${originalRequest.id}` : ""}`,
{
serverName: server.name,
title: content.name,
year: new Date(content.first_air_date)?.getFullYear(),
tmdbId: content.id,
tvdbId: content.external_ids.tvdb_id,
seriesType: content.keywords.results?.find(
(k) => k.id == animeKeywordId,
)
? "anime"
: "standard",
qualityProfile: server.qualityProfile,
rootFolder: rootFolder.path,
languageProfile: server.languageProfile,
seasons: seasonItems.map((s) => {
return {
seasonNumber: s.id,
monitored: s.value,
};
}),
},
);
addRequestRunning = false;
if (resp?.data) {
notify({ id: nid, text: "Request complete", type: "success" });
onClose(resp.data);
}
} catch (err) {
console.error("content request failed!", err);
addRequestRunning = false;
notify({ id: nid, text: "Request failed!", type: "error" });
}
}
$: {
if (typeof selectedServarrIndex !== "undefined" && servarrs?.length > 0) {
const s = servarrs[selectedServarrIndex];
if (!s) {
selectedServerCfg = undefined;
} else {
getConfig(s.name);
}
}
}
function processOriginalRequest() {
if (!originalRequest) {
return;
}
try {
if (originalRequest.requestJson) {
const ogr = JSON.parse(originalRequest.requestJson);
if (!ogr) {
console.info("processOriginalRequest: No json.", ogr);
return;
}
if (ogr?.seasons?.length > 0) {
console.debug("processOriginalRequest: Found seasons.. restoring.");
for (let i = 0; i < ogr.seasons.length; i++) {
const s = ogr.seasons[i];
// Default is not monitored, so no point going through the whole rigmarole to 'restore' the default value.
if (!s.monitored) {
continue;
}
const sItem = seasonItems?.find((si) => si.id === s.seasonNumber);
if (sItem) {
sItem.value = s.monitored;
}
}
}
if (ogr?.serverName) {
console.debug(
"processOriginalRequest: restoring server name:",
ogr?.serverName,
);
const idx = servarrs?.findIndex((s) => s.name === ogr?.serverName);
if (idx !== -1) {
selectedServarrIndex = idx;
}
}
} else {
notify({
type: "error",
text: "Full original request could not be restored. You may continue, but prefilled settings may not be true to the original request.",
time: 10000,
});
}
} catch (err) {
console.error("processOriginalRequest: Failed!", err);
notify({
text: "Failed when processing original request!",
type: "error",
});
}
}
getServers();
run(() => {
if (typeof selectedServarrIndex !== "undefined" && servarrs?.length > 0) {
const s = servarrs[selectedServarrIndex];
if (!s) {
selectedServerCfg = undefined;
} else {
getConfig(s.name);
}
}
});
getServers();
</script>
<Modal
title={approveMode ? "Approve Request" : "Request"}
desc={content.name}
onClose={() => onClose(undefined)}
title={approveMode ? "Approve Request" : "Request"}
desc={content.name}
onClose={() => onClose(undefined)}
>
<div class="req-ctr">
{#if servarrs}
{@const server = servarrs[selectedServarrIndex]}
<div class="req-ctr">
{#if servarrs}
{@const server = servarrs[selectedServarrIndex]}
<div class="seasons-list">
<ListBox bind:options={seasonItems} allCheckBox="All Seasons" />
</div>
<div class="seasons-list">
<ListBox bind:options={seasonItems} allCheckBox="All Seasons" />
</div>
{#if servarrs?.length > 1}
<Setting title="Select the server to use">
<DropDown
placeholder="Select a server"
bind:active={selectedServarrIndex}
options={servarrs?.length > 0
? servarrs.map((s, i) => {
return { id: i, value: s.name };
})
: []}
/>
</Setting>
{/if}
{#if servarrs?.length > 1}
<Setting title="Select the server to use">
<DropDown
placeholder="Select a server"
bind:active={selectedServarrIndex}
options={servarrs?.length > 0
? servarrs.map((s, i) => {
return { id: i, value: s.name };
})
: []}
/>
</Setting>
{/if}
<button on:click={request} disabled={addRequestRunning}>
{approveMode ? "Approve" : "Request"}
</button>
{:else}
<Spinner />
{/if}
</div>
<button onclick={request} disabled={addRequestRunning}>
{approveMode ? "Approve" : "Request"}
</button>
{:else}
<Spinner />
{/if}
</div>
</Modal>
<style lang="scss">
.req-ctr {
display: flex;
flex-flow: column;
gap: 10px;
height: 100%;
.req-ctr {
display: flex;
flex-flow: column;
gap: 10px;
height: 100%;
.seasons-list {
max-height: 500px;
overflow: auto;
}
.seasons-list {
max-height: 500px;
overflow: auto;
}
button {
margin-top: auto;
margin-left: auto;
width: max-content;
}
}
button {
margin-top: auto;
margin-left: auto;
width: max-content;
}
}
</style>
+69 -58
View File
@@ -1,73 +1,84 @@
<script lang="ts">
export let title: string = "";
export let desc: string = "";
export let row: boolean = false;
export let tag: string | undefined = undefined;
interface Props {
title?: string;
desc?: string;
row?: boolean;
tag?: string | undefined;
children?: import("svelte").Snippet;
}
let {
title = "",
desc = "",
row = false,
tag = undefined,
children,
}: Props = $props();
</script>
<div class={[row ? "row" : "", "setting-ctr"].join(" ")}>
{#if row}
<div>
<h4 class="norm">
{title}
{#if tag}
<span class="tag">{tag}</span>
{/if}
</h4>
<h5 class="norm">{desc}</h5>
</div>
{:else}
<h4 class="norm">
{title}
{#if tag}
<span class="tag">{tag}</span>
{/if}
</h4>
<h5 class="norm">{desc}</h5>
{/if}
{#if row}
<div>
<h4 class="norm">
{title}
{#if tag}
<span class="tag">{tag}</span>
{/if}
</h4>
<h5 class="norm">{desc}</h5>
</div>
{:else}
<h4 class="norm">
{title}
{#if tag}
<span class="tag">{tag}</span>
{/if}
</h4>
<h5 class="norm">{desc}</h5>
{/if}
<slot />
{@render children?.()}
</div>
<style lang="scss">
h4 {
font-size: 16px;
display: flex;
flex-flow: row;
align-items: center;
gap: 5px;
h4 {
font-size: 16px;
display: flex;
flex-flow: row;
align-items: center;
gap: 5px;
& > .tag {
font-size: 10px;
background-color: $accent-color;
padding: 3px 5px;
border-radius: 5px;
}
}
& > .tag {
font-size: 10px;
background-color: $accent-color;
padding: 3px 5px;
border-radius: 5px;
}
}
h5 {
font-weight: normal;
font-size: 13.28px;
}
h5 {
font-weight: normal;
font-size: 13.28px;
}
.setting-ctr {
margin: 0 15px;
.setting-ctr {
margin: 0 15px;
&.row {
display: flex;
flex-flow: row;
gap: 10px;
align-items: center;
&.row {
display: flex;
flex-flow: row;
gap: 10px;
align-items: center;
& > div:first-of-type {
margin-right: auto;
}
}
& > div:first-of-type {
margin-right: auto;
}
}
&:not(.row) {
h5 {
margin-bottom: 5px;
}
}
}
&:not(.row) {
h5 {
margin-bottom: 5px;
}
}
}
</style>
+45 -41
View File
@@ -1,54 +1,58 @@
<script lang="ts">
import Icon from "../Icon.svelte";
import type { Icon as IconT } from "@/types";
import Icon from "../Icon.svelte";
import type { Icon as IconT } from "@/types";
export let title: string;
export let desc: string;
export let icon: IconT = "arrow";
export let onClick: () => void;
interface Props {
title: string;
desc: string;
icon?: IconT;
onClick: () => void;
}
let { title, desc, icon = "arrow", onClick }: Props = $props();
</script>
<button class="plain" on:click={onClick}>
<div>
<h4 class="norm">{title}</h4>
<h5 class="norm">{desc}</h5>
</div>
<Icon i={icon} facing="right" />
<button class="plain" onclick={onClick}>
<div>
<h4 class="norm">{title}</h4>
<h5 class="norm">{desc}</h5>
</div>
<Icon i={icon} facing="right" />
</button>
<style lang="scss">
h4 {
font-size: 16px;
}
h4 {
font-size: 16px;
}
h5 {
font-weight: normal;
font-size: 13.28px;
}
h5 {
font-weight: normal;
font-size: 13.28px;
}
button {
display: flex;
flex-flow: row;
align-items: center;
width: 100%;
text-align: left;
border: 2px dashed $text-color;
border-radius: 10px;
padding: 15px;
transition: all 200ms ease;
margin-top: 10px;
button.plain {
display: flex;
flex-flow: row;
align-items: center;
width: 100%;
text-align: left;
border: 2px dashed $text-color;
border-radius: 10px;
padding: 15px;
transition: all 200ms ease;
margin-top: 10px;
& > div {
margin-right: auto;
}
& > div {
margin-right: auto;
}
&:hover {
border-style: solid;
}
&:hover {
border-style: solid;
}
:global(svg) {
width: 24px;
min-width: 24px;
}
}
:global(svg) {
width: 24px;
min-width: 24px;
}
}
</style>
+15 -7
View File
@@ -1,12 +1,20 @@
<script lang="ts">
interface Props {
children?: import("svelte").Snippet;
}
let { children }: Props = $props();
</script>
<div class="settings">
<slot />
{@render children?.()}
</div>
<style lang="scss">
.settings {
display: flex;
flex-flow: column;
gap: 15px;
width: 100%;
}
.settings {
display: flex;
flex-flow: column;
gap: 15px;
width: 100%;
}
</style>
+46 -36
View File
@@ -1,48 +1,58 @@
<script lang="ts">
import tooltip from "../actions/tooltip";
import tooltip from "../actions/tooltip";
export let name: string;
export let value: string | number;
export let large = false;
export let href: string | undefined = undefined;
export let disc: string | undefined = undefined;
interface Props {
name: string;
value: string | number;
large?: boolean;
href?: string | undefined;
disc?: string | undefined;
}
let {
name,
value,
large = false,
href = undefined,
disc = undefined,
}: Props = $props();
</script>
<a {href}>
{#if disc}
<div class="disclaimer" use:tooltip={{ text: disc, pos: "top" }}>*</div>
{/if}
<span class={large ? "large" : ""}>{value}</span>
<span>{name}</span>
{#if disc}
<div class="disclaimer" use:tooltip={{ text: disc, pos: "top" }}>*</div>
{/if}
<span class={large ? "large" : ""}>{value}</span>
<span>{name}</span>
</a>
<style lang="scss">
a {
position: relative;
display: flex;
flex-flow: column;
flex-grow: 1;
flex: 1 1 115px;
padding: 20px 15px;
background-color: $accent-color;
border-radius: 8px;
a {
position: relative;
display: flex;
flex-flow: column;
flex-grow: 1;
flex: 1 1 115px;
padding: 20px 15px;
background-color: $accent-color;
border-radius: 8px;
> span:first-of-type {
font-weight: bold;
font-size: 20px;
margin-top: auto;
> span:first-of-type {
font-weight: bold;
font-size: 20px;
margin-top: auto;
&.large {
font-size: 32px;
}
}
&.large {
font-size: 32px;
}
}
.disclaimer {
position: absolute;
top: 5px;
right: 8px;
font-weight: bold;
font-size: 20px;
}
}
.disclaimer {
position: absolute;
top: 5px;
right: 8px;
font-weight: bold;
font-size: 20px;
}
}
</style>
+16 -8
View File
@@ -1,13 +1,21 @@
<script lang="ts">
interface Props {
children?: import("svelte").Snippet;
}
let { children }: Props = $props();
</script>
<div>
<slot />
{@render children?.()}
</div>
<style lang="scss">
div {
display: flex;
flex-flow: row;
gap: 12px;
margin-top: 15px;
flex-wrap: wrap;
}
div {
display: flex;
flex-flow: row;
gap: 12px;
margin-top: 15px;
flex-wrap: wrap;
}
</style>
+54 -44
View File
@@ -1,58 +1,68 @@
<script lang="ts">
import type { Watched } from "@/types";
import tooltip from "../actions/tooltip";
import Icon from "../Icon.svelte";
import TagMenu from "./TagMenu.svelte";
import { tagWatched, untagWatched } from "./api";
import { onMount } from "svelte";
import type { Watched } from "@/types";
import tooltip from "../actions/tooltip";
import Icon from "../Icon.svelte";
import TagMenu from "./TagMenu.svelte";
import { tagWatched, untagWatched } from "./api";
import { onMount } from "svelte";
export let watchedItem: Watched;
interface Props {
watchedItem: Watched;
}
let menuOpen = false;
let { watchedItem }: Props = $props();
onMount(() => {
const onScroll = () => {
menuOpen = false;
};
let menuOpen = $state(false);
window.addEventListener("scroll", onScroll);
onMount(() => {
const onScroll = () => {
menuOpen = false;
};
return () => {
window.removeEventListener("scroll", onScroll);
};
});
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
});
</script>
<div>
<button
on:click={() => (menuOpen = !menuOpen)}
use:tooltip={{
text: `Add to a Tag`,
pos: "bot"
}}
>
<Icon i={"tag"} wh={19} />
</button>
<button
onclick={() => (menuOpen = !menuOpen)}
use:tooltip={{
text: `Add to a Tag`,
pos: "bot",
}}
>
<Icon i={"tag"} wh={19} />
</button>
{#if menuOpen}
<TagMenu
titleText="Add To Tag"
classes="from-add-to-tag-btn"
selectedTags={watchedItem.tags}
onTagClick={(tag, remove) => {
console.debug("Tag: Adding content to tag. Remove?:", remove);
if (remove) {
untagWatched(watchedItem.id, tag);
} else {
tagWatched(watchedItem.id, tag);
}
}}
/>
{/if}
{#if menuOpen}
<TagMenu
titleText="Add To Tag"
selectedTags={watchedItem.tags}
onTagClick={(tag, remove) => {
console.debug("Tag: Adding content to tag. Remove?:", remove);
if (remove) {
untagWatched(watchedItem.id, tag);
} else {
tagWatched(watchedItem.id, tag);
}
}}
menuConfig={{
top: "50px",
right: "-78px",
arrowLeft: "87px",
/* The place where this button will be is always dark, so white works for both themes */
arrowColor: "white",
}}
/>
{/if}
</div>
<style lang="scss">
div {
position: relative;
}
div {
position: relative;
}
</style>
+154 -137
View File
@@ -1,154 +1,171 @@
<script lang="ts">
import { tags } from "@/store";
import Modal from "../Modal.svelte";
import Setting from "../settings/Setting.svelte";
import SettingsList from "../settings/SettingsList.svelte";
import ColorSelector from "../ColorSelector.svelte";
import { notify } from "../util/notify";
import axios from "axios";
import type { Tag, TagAddRequest } from "@/types";
import { get } from "svelte/store";
import { onMount } from "svelte";
import { store } from "@/store.svelte";
import Modal from "../Modal.svelte";
import Setting from "../settings/Setting.svelte";
import SettingsList from "../settings/SettingsList.svelte";
import ColorSelector from "../ColorSelector.svelte";
import { notify } from "../util/notify";
import axios from "axios";
import type { Tag, TagAddRequest } from "@/types";
import { get } from "svelte/store";
import { onMount } from "svelte";
export let onClose: () => void;
// Passing an existing tag will enable 'Edit Tag' mode.
export let existingTag: Tag | undefined = undefined;
interface Props {
onClose: () => void;
// Passing an existing tag will enable 'Edit Tag' mode.
existingTag?: Tag | undefined;
}
const colorPresets = [
["#36BA98", "#000"],
["#FF4191", "#231731"],
["#173B45", "#FFE0C9"],
["#6EACDA", "#021526"],
["#180161", "#FF9E73"],
["#FFBC0A", "#2F0A39"],
["#C1FF9B", "#321673"],
["#F5FFC6", "#5126B5"],
["#6D213C", "#FAFF70"],
["#F7C4A5", "#2f0082"],
["#F7EFE5", "#63338c"]
];
let { onClose, existingTag = undefined }: Props = $props();
function getRandomColorPreset() {
return colorPresets[Math.floor(Math.random() * colorPresets.length)];
}
const colorPresets = [
["#36BA98", "#000"],
["#FF4191", "#231731"],
["#173B45", "#FFE0C9"],
["#6EACDA", "#021526"],
["#180161", "#FF9E73"],
["#FFBC0A", "#2F0A39"],
["#C1FF9B", "#321673"],
["#F5FFC6", "#5126B5"],
["#6D213C", "#FAFF70"],
["#F7C4A5", "#2f0082"],
["#F7EFE5", "#63338c"],
];
const defaultPreset = getRandomColorPreset();
let textColor = defaultPreset[1];
let bgColor = defaultPreset[0];
let tagName = "";
let error = "";
let submitDisabled = false;
let modalTitle = "Create A Tag";
let modalDesc = "Create a new tag";
let submitBtnText = "Create Tag";
function getRandomColorPreset() {
return colorPresets[Math.floor(Math.random() * colorPresets.length)];
}
async function addTag() {
console.debug("addTag:", tagName, textColor, bgColor);
if (!tagName) {
error = "Tag must have a name!";
return;
}
const nid = notify({ text: "Creating Tag", type: "loading" });
try {
const resp = await axios.post<Tag>("/tag", {
name: tagName,
color: textColor,
bgColor
} as TagAddRequest);
console.log("addTag: Tag was created", resp.data);
const _tags = get(tags);
_tags.push(resp.data);
tags.update((t) => t);
notify({ id: nid, text: "Tag Created!", type: "success" });
onClose();
} catch (err) {
console.error("addTag: Failed!", err);
notify({ id: nid, text: "Failed!", type: "error", time: 1 });
error = "Failed!";
}
}
const defaultPreset = getRandomColorPreset();
let textColor = $state(defaultPreset[1]);
let bgColor = $state(defaultPreset[0]);
let tagName = $state("");
let error = $state("");
let submitDisabled = $state(false);
let modalTitle = $state("Create A Tag");
let modalDesc = $state("Create a new tag");
let submitBtnText = $state("Create Tag");
async function updateTag() {
console.debug("updateTag:", existingTag, tagName, textColor, bgColor);
if (!tagName) {
error = "Tag must have a name!";
return;
}
const nid = notify({ text: "Modifying Tag", type: "loading" });
try {
const resp = await axios.put<Tag>(`/tag/${existingTag!.id}`, {
name: tagName,
color: textColor,
bgColor
} as TagAddRequest);
console.log("updateTag: Tag was edited", resp.data);
existingTag!.name = tagName;
existingTag!.color = textColor;
existingTag!.bgColor = bgColor;
// Doesn't update `updatedAt`... may need to in the future if we need to sort by it, etc
tags.update((t) => t);
notify({ id: nid, text: "Tag Modified!", type: "success" });
onClose();
} catch (err) {
console.error("updateTag: Failed!", err);
notify({ id: nid, text: "Failed!", type: "error", time: 1 });
error = "Failed!";
}
}
async function addTag() {
console.debug("addTag:", tagName, textColor, bgColor);
if (!tagName) {
error = "Tag must have a name!";
return;
}
const nid = notify({ text: "Creating Tag", type: "loading" });
try {
const resp = await axios.post<Tag>("/tag", {
name: tagName,
color: textColor,
bgColor,
} as TagAddRequest);
console.log("addTag: Tag was created", resp.data);
// const _tags = get(tags);
store.tags.push(resp.data);
// tags.update((t) => t);
notify({ id: nid, text: "Tag Created!", type: "success" });
onClose();
} catch (err) {
console.error("addTag: Failed!", err);
notify({ id: nid, text: "Failed!", type: "error", time: 1 });
error = "Failed!";
}
}
async function submitClicked() {
submitDisabled = true;
try {
if (existingTag) {
await updateTag();
} else {
await addTag();
}
} catch (err) {
console.log("CreateTagModal: Submit failed!", err);
}
submitDisabled = false;
}
async function updateTag() {
console.debug("updateTag:", existingTag, tagName, textColor, bgColor);
if (!tagName) {
error = "Tag must have a name!";
return;
}
const nid = notify({ text: "Modifying Tag", type: "loading" });
try {
const resp = await axios.put<Tag>(`/tag/${existingTag!.id}`, {
name: tagName,
color: textColor,
bgColor,
} as TagAddRequest);
console.log("updateTag: Tag was edited", resp.data);
existingTag!.name = tagName;
existingTag!.color = textColor;
existingTag!.bgColor = bgColor;
// Doesn't update `updatedAt`... may need to in the future if we need to sort by it, etc
// tags.update((t) => t);
notify({ id: nid, text: "Tag Modified!", type: "success" });
onClose();
} catch (err) {
console.error("updateTag: Failed!", err);
notify({ id: nid, text: "Failed!", type: "error", time: 1 });
error = "Failed!";
}
}
onMount(() => {
if (existingTag) {
console.log("CreateTagModal: Entering edit mode for tag:", existingTag);
modalTitle = "Edit Tag";
modalDesc = "Edit an existing tag";
submitBtnText = "Edit Tag";
tagName = existingTag.name;
textColor = existingTag.color;
bgColor = existingTag.bgColor;
}
});
async function submitClicked() {
submitDisabled = true;
try {
if (existingTag) {
await updateTag();
} else {
await addTag();
}
} catch (err) {
console.log("CreateTagModal: Submit failed!", err);
}
submitDisabled = false;
}
onMount(() => {
if (existingTag) {
console.log("CreateTagModal: Entering edit mode for tag:", existingTag);
modalTitle = "Edit Tag";
modalDesc = "Edit an existing tag";
submitBtnText = "Edit Tag";
tagName = existingTag.name;
textColor = existingTag.color;
bgColor = existingTag.bgColor;
}
});
</script>
<div class="wrap">
<Modal title={modalTitle} desc={modalDesc} maxWidth="500px" {onClose} {error}>
<SettingsList>
<Setting title="Name" desc="What should we call this tag?">
<input type="text" name="name" placeholder="Name" bind:value={tagName} />
</Setting>
<Setting title="Text Color" desc="Color for your tags text." row>
<ColorSelector bind:value={textColor} style="max-width: 150px;" />
</Setting>
<Setting title="Background Color" desc="Color for your tags background." row>
<ColorSelector bind:value={bgColor} style="max-width: 150px;" />
</Setting>
<button class="add-tag-btn" on:click={() => submitClicked()} disabled={submitDisabled}>
{submitBtnText}
</button>
</SettingsList>
</Modal>
<Modal title={modalTitle} desc={modalDesc} maxWidth="500px" {onClose} {error}>
<SettingsList>
<Setting title="Name" desc="What should we call this tag?">
<input
type="text"
name="name"
placeholder="Name"
bind:value={tagName}
/>
</Setting>
<Setting title="Text Color" desc="Color for your tags text." row>
<ColorSelector bind:value={textColor} style="max-width: 150px;" />
</Setting>
<Setting
title="Background Color"
desc="Color for your tags background."
row
>
<ColorSelector bind:value={bgColor} style="max-width: 150px;" />
</Setting>
<button
class="add-tag-btn"
onclick={() => submitClicked()}
disabled={submitDisabled}
>
{submitBtnText}
</button>
</SettingsList>
</Modal>
</div>
<style lang="scss">
.add-tag-btn {
width: max-content;
margin-left: auto;
}
.add-tag-btn {
width: max-content;
margin-left: auto;
}
.wrap {
color: $text-color;
}
.wrap {
color: $text-color;
}
</style>
+97 -87
View File
@@ -1,102 +1,112 @@
<script lang="ts">
import { tags, watchedList } from "@/store";
import Modal from "../Modal.svelte";
import Setting from "../settings/Setting.svelte";
import SettingsList from "../settings/SettingsList.svelte";
import ColorSelector from "../ColorSelector.svelte";
import { notify } from "../util/notify";
import axios from "axios";
import type { Tag as TagT, TagAddRequest } from "@/types";
import { get } from "svelte/store";
import Tag from "./Tag.svelte";
import { onMount } from "svelte";
import { store } from "@/store.svelte";
import Modal from "../Modal.svelte";
import Setting from "../settings/Setting.svelte";
import SettingsList from "../settings/SettingsList.svelte";
import ColorSelector from "../ColorSelector.svelte";
import { notify } from "../util/notify";
import axios from "axios";
import type { Tag as TagT, TagAddRequest } from "@/types";
import { get } from "svelte/store";
import Tag from "./Tag.svelte";
import { onMount } from "svelte";
export let tag: TagT;
export let onClose: () => void;
interface Props {
tag: TagT;
onClose: () => void;
}
let error = "";
let deleteDisabled = false;
let { tag, onClose }: Props = $props();
async function deleteTag() {
console.debug("deleteTag:", tag);
if (!tag || !tag.id) {
error = "Tag doesn't have an id!";
return;
}
deleteDisabled = true;
const nid = notify({ text: "Deleting Tag", type: "loading" });
try {
const resp = await axios.delete(`/tag/${tag.id}`);
console.log("deleteTag: Tag was deleted", resp.data);
// 1. Remove tag from store.
const _tags = get(tags);
tags.update((t) => t);
const newList = _tags.filter((t) => t.id !== tag.id);
tags.update(() => newList);
// 2. Remove tag from all watched entries in store.
try {
const wList = get(watchedList);
for (let i = 0; i < wList.length; i++) {
const wi = wList[i];
if (wi.tags && wi.tags.length > 0) {
wi.tags = wi.tags.filter((t) => t.id !== tag.id);
}
}
watchedList.update(() => wList);
} catch (err) {
console.error("deleteTag: Failed to remove tags from watched entries:", err);
}
notify({ id: nid, text: "Tag Deleted!", type: "success" });
onClose();
} catch (err) {
console.error("deleteTag: Failed!", err);
notify({ id: nid, text: "Failed!", type: "error", time: 1 });
error = "Failed To Delete!";
}
deleteDisabled = false;
}
let error = $state("");
let deleteDisabled = $state(false);
onMount(() => {
// Sort of prevent accidental clickage, wait 3s after opening modal before enabling delete btn.
deleteDisabled = true;
setTimeout(() => {
deleteDisabled = false;
}, 3000);
});
async function deleteTag() {
console.debug("deleteTag:", tag);
if (!tag || !tag.id) {
error = "Tag doesn't have an id!";
return;
}
deleteDisabled = true;
const nid = notify({ text: "Deleting Tag", type: "loading" });
try {
const resp = await axios.delete(`/tag/${tag.id}`);
console.log("deleteTag: Tag was deleted", resp.data);
// 1. Remove tag from store.
// const _tags = get(tags);
// store.tags.update((t) => t);
store.tags = store.tags.filter((t) => t.id !== tag.id);
// tags.update(() => newList);
// 2. Remove tag from all watched entries in store.
try {
for (let i = 0; i < store.watchedList.length; i++) {
const wi = store.watchedList[i];
if (wi.tags && wi.tags.length > 0) {
wi.tags = wi.tags.filter((t) => t.id !== tag.id);
}
}
// watchedList.update(() => wList);
} catch (err) {
console.error(
"deleteTag: Failed to remove tags from watched entries:",
err,
);
}
notify({ id: nid, text: "Tag Deleted!", type: "success" });
onClose();
} catch (err) {
console.error("deleteTag: Failed!", err);
notify({ id: nid, text: "Failed!", type: "error", time: 1 });
error = "Failed To Delete!";
}
deleteDisabled = false;
}
onMount(() => {
// Sort of prevent accidental clickage, wait 3s after opening modal before enabling delete btn.
deleteDisabled = true;
setTimeout(() => {
deleteDisabled = false;
}, 3000);
});
</script>
<Modal
title="Permanently Delete Tag"
desc="Are you sure you want to delete this tag?"
maxWidth="500px"
{onClose}
{error}
title="Permanently Delete Tag"
desc="Are you sure you want to delete this tag?"
maxWidth="500px"
{onClose}
{error}
>
<div class="inner">
<Tag {tag} />
<button class="delete-tag-btn" on:click={() => deleteTag()} disabled={deleteDisabled}>
Yes, fully delete (unrecoverable)
</button>
</div>
<div class="inner">
<Tag {tag} />
<button
class="delete-tag-btn"
onclick={() => deleteTag()}
disabled={deleteDisabled}
>
Yes, fully delete (unrecoverable)
</button>
</div>
</Modal>
<style lang="scss">
.delete-tag-btn {
width: max-content;
margin-left: auto;
.delete-tag-btn {
width: max-content;
margin-left: auto;
&:hover {
color: $error;
}
}
&:hover {
color: $error;
}
}
.inner {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
max-width: 100%;
width: 100%;
}
.inner {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
max-width: 100%;
width: 100%;
}
</style>
+41 -35
View File
@@ -1,48 +1,54 @@
<script lang="ts">
import type { Tag } from "@/types";
import { run } from "svelte/legacy";
export let tag: Tag;
export let onClick: () => void | undefined = undefined!;
import type { Tag } from "@/types";
let tagBtn: HTMLButtonElement;
interface Props {
tag: Tag;
onClick?: () => void | undefined;
}
$: {
if (tagBtn) {
if (tag.color) {
tagBtn.style.color = tag.color;
}
if (tag.bgColor) {
tagBtn.style.background = tag.bgColor;
}
}
}
let { tag, onClick = undefined! }: Props = $props();
let tagBtn: HTMLButtonElement = $state();
run(() => {
if (tagBtn) {
if (tag.color) {
tagBtn.style.color = tag.color;
}
if (tag.bgColor) {
tagBtn.style.background = tag.bgColor;
}
}
});
</script>
<button
bind:this={tagBtn}
class={`plain`}
on:click={() => {
if (typeof onClick === "function") {
onClick();
}
}}
bind:this={tagBtn}
class={`plain`}
onclick={() => {
if (typeof onClick === "function") {
onClick();
}
}}
>
{tag.name}
{tag.name}
</button>
<style lang="scss">
button {
text-transform: capitalize;
position: relative;
width: max-content;
border-radius: 8px;
padding: 5px 8px;
text-wrap: wrap;
word-break: break-word;
transition: opacity 150ms ease-in-out;
button {
text-transform: capitalize;
position: relative;
width: max-content;
border-radius: 8px;
padding: 5px 8px;
text-wrap: wrap;
word-break: break-word;
transition: opacity 150ms ease-in-out;
&:hover {
opacity: 0.8;
}
}
&:hover {
opacity: 0.8;
}
}
</style>
+130 -136
View File
@@ -1,160 +1,154 @@
<script lang="ts">
import { tags } from "@/store";
import Icon from "../Icon.svelte";
import CreateTagModal from "./CreateTagModal.svelte";
import type { Tag as TagT } from "@/types";
import Tag from "./Tag.svelte";
import DeleteTagModal from "./DeleteTagModal.svelte";
import stayInView from "../actions/stayInView";
import { store } from "@/store.svelte";
import Icon from "../Icon.svelte";
import CreateTagModal from "./CreateTagModal.svelte";
import type { Tag as TagT } from "@/types";
import Tag from "./Tag.svelte";
import DeleteTagModal from "./DeleteTagModal.svelte";
import Menu, { type MenuConfig } from "../Menu.svelte";
export let titleText: string | undefined = undefined;
export let classes: string | undefined = undefined;
export let onTagClick: (tag: TagT, remove: boolean) => void | undefined = undefined!;
export let selectedTags: TagT[] | undefined = undefined;
/**
* When `showManageBtn` is true, a manage icon will appear at top
* of menu for the user to click. When toggled on, clicking a tag
* will trigger a deletion instead of `onTagClick()`.
*/
export let showManageBtn = false;
interface Props {
titleText?: string | undefined;
onTagClick?: (tag: TagT, remove: boolean) => void | undefined;
selectedTags?: TagT[] | undefined;
/**
* When `showManageBtn` is true, a manage icon will appear at top
* of menu for the user to click. When toggled on, clicking a tag
* will trigger a deletion instead of `onTagClick()`.
*/
showManageBtn?: boolean;
menuConfig?: MenuConfig;
}
$: allTags = $tags;
const defaultMenuConfig = {
width: "200px",
right: "47px",
arrowLeft: "78px",
};
let tagModalOpen = false;
let inManageMode = false;
let tagToDelete: TagT | undefined = undefined;
let {
titleText = undefined,
onTagClick = undefined!,
selectedTags = undefined,
showManageBtn = false,
menuConfig = {},
}: Props = $props();
function deleteTag(t: TagT) {
// This will show the DeleteTagModal (look below).
tagToDelete = t;
}
let allTags = $derived(store.tags);
let tagModalOpen = $state(false);
let inManageMode = $state(false);
let tagToDelete: TagT | undefined = $state(undefined);
function deleteTag(t: TagT) {
// This will show the DeleteTagModal (look below).
tagToDelete = t;
}
</script>
<div class={[`menu`, classes].join(" ")} use:stayInView={{ elToShiftSelector: "& > .arrow" }}>
<i class="arrow"></i>
<div class="inner">
<div class="title">
<h4 class="norm sm-caps">{titleText ? titleText : "my tags"}</h4>
{#if showManageBtn}
<button
class={["plain", inManageMode ? "manage-on" : ""].join(" ")}
on:click={() => (inManageMode = !inManageMode)}
>
<Icon i="trash" wh={18} />
</button>
{/if}
<button class="plain" on:click={() => (tagModalOpen = !tagModalOpen)}>
<Icon i="add" wh={22} />
</button>
</div>
{#if allTags && allTags.length > 0}
{#if inManageMode}
<strong style="font-size: 12px; margin-bottom: 10px;">Click a tag to delete it.</strong>
{/if}
<div class="list">
{#each allTags as t}
{@const isSelected = selectedTags
? selectedTags.find((tag) => tag.id === t.id)
? true
: false
: false}
<div>
<Tag
tag={t}
onClick={() => (inManageMode ? deleteTag(t) : onTagClick(t, isSelected))}
/>
{#if isSelected}
<Icon i="check" wh={18} />
{/if}
</div>
{/each}
</div>
{:else}
<span style="margin-top: 0;">You have no tags yet!</span>
{/if}
</div>
</div>
<Menu conf={Object.assign(defaultMenuConfig, menuConfig)}>
<div class="title">
<h4 class="norm sm-caps">{titleText ? titleText : "my tags"}</h4>
{#if showManageBtn}
<button
class={["plain", inManageMode ? "manage-on" : ""].join(" ")}
onclick={() => (inManageMode = !inManageMode)}
>
<Icon i="trash" wh={18} />
</button>
{/if}
<button class="plain" onclick={() => (tagModalOpen = !tagModalOpen)}>
<Icon i="add" wh={22} />
</button>
</div>
{#if allTags && allTags.length > 0}
{#if inManageMode}
<strong style="font-size: 12px; margin-bottom: 10px;"
>Click a tag to delete it.</strong
>
{/if}
<div class="list">
{#each allTags as t}
{@const isSelected = selectedTags
? selectedTags.find((tag) => tag.id === t.id)
? true
: false
: false}
<div>
<Tag
tag={t}
onClick={() =>
inManageMode ? deleteTag(t) : onTagClick(t, isSelected)}
/>
{#if isSelected}
<Icon i="check" wh={18} />
{/if}
</div>
{/each}
</div>
{:else}
<span style="margin-top: 0;">You have no tags yet!</span>
{/if}
</Menu>
{#if tagModalOpen}
<CreateTagModal onClose={() => (tagModalOpen = false)} />
<CreateTagModal onClose={() => (tagModalOpen = false)} />
{/if}
{#if tagToDelete}
<DeleteTagModal tag={tagToDelete} onClose={() => (tagToDelete = undefined)} />
<DeleteTagModal tag={tagToDelete} onClose={() => (tagToDelete = undefined)} />
{/if}
<style lang="scss">
div.menu {
width: 200px;
right: 47px;
h4 {
color: $text-color;
.arrow {
left: 78px;
}
&:not(:first-of-type) {
margin-top: 8px;
}
}
&.from-add-to-tag-btn {
top: 50px;
right: -78px;
.title {
display: flex;
flex-flow: row;
align-items: center;
margin-bottom: 8px;
gap: 5px;
.arrow {
left: 87px;
/* The place where this button will be is always dark, so white works for both themes */
border-bottom-color: white;
}
}
}
button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
padding: 2px 3px;
border-radius: 8px;
div.inner {
h4 {
color: $text-color;
&.manage-on {
color: #f3555a;
background-color: $text-color;
}
&:not(:first-of-type) {
margin-top: 8px;
}
}
&:first-of-type {
margin-left: auto;
}
}
}
.title {
display: flex;
flex-flow: row;
align-items: center;
margin-bottom: 8px;
gap: 5px;
.list {
display: flex;
flex-flow: column;
gap: 5px;
button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
padding: 2px 3px;
border-radius: 8px;
& > div {
display: flex;
align-items: center;
gap: 5px;
color: $text-color;
&.manage-on {
color: #f3555a;
background-color: $text-color;
}
&:first-of-type {
margin-left: auto;
}
}
}
.list {
display: flex;
flex-flow: column;
gap: 5px;
& > div {
display: flex;
align-items: center;
gap: 5px;
color: $text-color;
:global(svg) {
min-width: 18px;
}
}
}
}
:global(svg) {
min-width: 18px;
}
}
}
</style>
+66 -54
View File
@@ -1,61 +1,73 @@
import { watchedList } from "@/store";
import { store } from "@/store.svelte";
import axios from "axios";
import { get } from "svelte/store";
import { notify } from "../util/notify";
import type { Tag } from "@/types";
export async function tagWatched(watchedId: number, tag: Tag): Promise<boolean> {
// If item is already in watched store, run update request instead
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === watchedId);
const nid = notify({ text: `Tagging`, type: "loading" });
if (!wEntry) {
notify({ id: nid, text: "Failed To Tag! Watched entry not found.", type: "error" });
return false;
}
return await axios
.post(`/watched/${watchedId}/tag/${tag.id}`)
.then((resp) => {
console.log("tagWatched: Status:", resp.status);
if (!wEntry.tags) {
wEntry.tags = [tag];
} else {
wEntry.tags.push(tag);
}
watchedList.update(() => wList);
notify({ id: nid, text: `Tagged!`, type: "success" });
return true;
})
.catch((err) => {
console.error("tagWatched: Request failed!", err);
notify({ id: nid, text: "Failed To Tag!", type: "error" });
return false;
});
export async function tagWatched(
watchedId: number,
tag: Tag,
): Promise<boolean> {
// If item is already in watched store, run update request instead
const wEntry = store.watchedList.find((w) => w.id === watchedId);
const nid = notify({ text: `Tagging`, type: "loading" });
if (!wEntry) {
notify({
id: nid,
text: "Failed To Tag! Watched entry not found.",
type: "error",
});
return false;
}
return await axios
.post(`/watched/${watchedId}/tag/${tag.id}`)
.then((resp) => {
console.log("tagWatched: Status:", resp.status);
if (!wEntry.tags) {
wEntry.tags = [tag];
} else {
wEntry.tags.push(tag);
}
// watchedList.update(() => wList);
notify({ id: nid, text: `Tagged!`, type: "success" });
return true;
})
.catch((err) => {
console.error("tagWatched: Request failed!", err);
notify({ id: nid, text: "Failed To Tag!", type: "error" });
return false;
});
}
export async function untagWatched(watchedId: number, tag: Tag): Promise<boolean> {
// If item is already in watched store, run update request instead
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === watchedId);
const nid = notify({ text: `Untagging`, type: "loading" });
if (!wEntry) {
notify({ id: nid, text: "Failed To Untag! Watched entry not found.", type: "error" });
return false;
}
return await axios
.delete(`/watched/${watchedId}/tag/${tag.id}`)
.then((resp) => {
console.log("untagWatched: Status:", resp.status);
if (wEntry.tags) {
wEntry.tags = wEntry.tags.filter((t) => t.id !== tag.id);
}
watchedList.update(() => wList);
notify({ id: nid, text: `Untagged!`, type: "success" });
return true;
})
.catch((err) => {
console.error("untagWatched: Request failed!", err);
notify({ id: nid, text: "Failed To Untag!", type: "error" });
return false;
});
export async function untagWatched(
watchedId: number,
tag: Tag,
): Promise<boolean> {
// If item is already in watched store, run update request instead
// const wList = get(watchedList);
const wEntry = store.watchedList.find((w) => w.id === watchedId);
const nid = notify({ text: `Untagging`, type: "loading" });
if (!wEntry) {
notify({
id: nid,
text: "Failed To Untag! Watched entry not found.",
type: "error",
});
return false;
}
return await axios
.delete(`/watched/${watchedId}/tag/${tag.id}`)
.then((resp) => {
console.log("untagWatched: Status:", resp.status);
if (wEntry.tags) {
wEntry.tags = wEntry.tags.filter((t) => t.id !== tag.id);
}
// watchedList.update(() => wList);
notify({ id: nid, text: `Untagged!`, type: "success" });
return true;
})
.catch((err) => {
console.error("untagWatched: Request failed!", err);
notify({ id: nid, text: "Failed To Untag!", type: "error" });
return false;
});
}
+21 -17
View File
@@ -1,27 +1,31 @@
<script lang="ts">
import { UserType } from "@/types";
import Icon from "../Icon.svelte";
import { UserType } from "@/types";
import Icon from "../Icon.svelte";
export let type: UserType;
interface Props {
type: UserType;
}
let { type }: Props = $props();
</script>
{#if !type}
<span
style="font-family: 'Rampart One'; font-weight: bold; font-size: 21px; line-height: 20px; user-select: none;"
>
W
</span>
<span
style="font-family: 'Rampart One'; font-weight: bold; font-size: 21px; line-height: 20px; user-select: none;"
>
W
</span>
{:else if type == UserType.Jellyfin}
<Icon i="jellyfin" wh={20} />
<Icon i="jellyfin" wh={20} />
{:else if type == UserType.Plex}
<Icon i="plex" wh={20} />
<Icon i="plex" wh={20} />
{:else if type == UserType.Proxy}
<Icon i="lock-closed" wh={20} />
<Icon i="lock-closed" wh={20} />
{:else}
<!-- Unknown type -->
<span
style="font-family: 'Rampart One'; font-weight: bold; font-size: 21px; line-height: 20px; user-select: none;"
>
?
</span>
<!-- Unknown type -->
<span
style="font-family: 'Rampart One'; font-weight: bold; font-size: 21px; line-height: 20px; user-select: none;"
>
?
</span>
{/if}
+288 -290
View File
@@ -1,30 +1,29 @@
import { follows, serverFeatures, userInfo, userSettings, watchedList } from "@/store";
import { store } from "@/store.svelte";
import {
UserType,
type JellyfinFoundContent,
type MediaType,
type Watched,
type WatchedAddRequest,
type WatchedStatus,
type WatchedUpdateRequest,
type WatchedUpdateResponse,
type UserSettings,
type Follow,
type PlayedAddRequest,
type ActivityUpdateRequest
UserType,
type JellyfinFoundContent,
type MediaType,
type Watched,
type WatchedAddRequest,
type WatchedStatus,
type WatchedUpdateRequest,
type WatchedUpdateResponse,
type UserSettings,
type Follow,
type PlayedAddRequest,
type ActivityUpdateRequest,
} from "@/types";
import axios from "axios";
import { get } from "svelte/store";
import { notify, unNotify } from "./notify";
import { browser } from "$app/environment";
const { MODE } = import.meta.env;
export const baseURL =
MODE === "development"
? browser
? `${location.protocol}//${location.hostname}:3080/api`
: "http://127.0.0.1:3080/api"
: "/api";
MODE === "development"
? browser
? `${location.protocol}//${location.hostname}:3080/api`
: "http://127.0.0.1:3080/api"
: "/api";
console.log("api: baseURL constructed:", baseURL);
/**
@@ -32,50 +31,57 @@ console.log("api: baseURL constructed:", baseURL);
* @returns Was success?
*/
async function _updateWatched(
wEntry: Watched,
status?: WatchedStatus,
rating?: number,
thoughts?: string,
pinned?: boolean
wEntry: Watched,
status?: WatchedStatus,
rating?: number,
thoughts?: string,
pinned?: boolean,
): Promise<boolean> {
if (!status && !rating && typeof thoughts === "undefined" && typeof pinned === "undefined") {
console.warn("_updateWatched: Nothing was provided, so nothing can be updated!!!!");
return false;
}
const nid = notify({ text: `Saving`, type: "loading" });
const obj = {} as WatchedUpdateRequest;
if (status) obj.status = status;
if (rating) obj.rating = rating;
if (typeof thoughts !== "undefined") obj.thoughts = thoughts;
if (thoughts === "") obj.removeThoughts = true;
if (typeof pinned !== "undefined") obj.pinned = pinned;
return await axios
.put<WatchedUpdateResponse>(`/watched/${wEntry.id}`, obj)
.then((resp) => {
if (status) wEntry.status = status;
if (rating) wEntry.rating = rating;
if (typeof thoughts !== "undefined") wEntry.thoughts = thoughts;
if (typeof pinned !== "undefined") wEntry.pinned = pinned;
if (resp?.data?.newActivity && resp?.data?.newActivity?.id) {
if (wEntry.activity?.length > 0) {
wEntry.activity.push(resp.data.newActivity);
} else {
wEntry.activity = [resp.data.newActivity];
}
// We want to update the updatedAt field too (so
// change is reflected when filtering modified at)
// We can piggy back from this data for now.
wEntry.updatedAt = resp.data.newActivity.createdAt;
}
watchedList.update((w) => w);
notify({ id: nid, text: `Saved!`, type: "success" });
return true;
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Update!", type: "error" });
return false;
});
if (
!status &&
!rating &&
typeof thoughts === "undefined" &&
typeof pinned === "undefined"
) {
console.warn(
"_updateWatched: Nothing was provided, so nothing can be updated!!!!",
);
return false;
}
const nid = notify({ text: `Saving`, type: "loading" });
const obj = {} as WatchedUpdateRequest;
if (status) obj.status = status;
if (rating) obj.rating = rating;
if (typeof thoughts !== "undefined") obj.thoughts = thoughts;
if (thoughts === "") obj.removeThoughts = true;
if (typeof pinned !== "undefined") obj.pinned = pinned;
return await axios
.put<WatchedUpdateResponse>(`/watched/${wEntry.id}`, obj)
.then((resp) => {
if (status) wEntry.status = status;
if (rating) wEntry.rating = rating;
if (typeof thoughts !== "undefined") wEntry.thoughts = thoughts;
if (typeof pinned !== "undefined") wEntry.pinned = pinned;
if (resp?.data?.newActivity && resp?.data?.newActivity?.id) {
if (wEntry.activity?.length > 0) {
wEntry.activity.push(resp.data.newActivity);
} else {
wEntry.activity = [resp.data.newActivity];
}
// We want to update the updatedAt field too (so
// change is reflected when filtering modified at)
// We can piggy back from this data for now.
wEntry.updatedAt = resp.data.newActivity.createdAt;
}
// watchedList.update((w) => w);
notify({ id: nid, text: `Saved!`, type: "success" });
return true;
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Update!", type: "error" });
return false;
});
}
/**
@@ -87,42 +93,40 @@ async function _updateWatched(
* @returns Was success?
*/
export async function updateWatched(
contentId: number,
contentType: MediaType,
status?: WatchedStatus,
rating?: number,
thoughts?: string,
pinned?: boolean
contentId: number,
contentType: MediaType,
status?: WatchedStatus,
rating?: number,
thoughts?: string,
pinned?: boolean,
): Promise<boolean> {
// If item is already in watched store, run update request instead
const wList = get(watchedList);
const wEntry = wList.find(
(w) => w.content?.tmdbId === contentId && w.content?.type === contentType
);
if (wEntry?.id) {
return await _updateWatched(wEntry, status, rating, thoughts, pinned);
}
// Add new watched item
const nid = notify({ text: `Adding`, type: "loading" });
return await axios
.post("/watched", {
contentId,
contentType,
rating,
status
} as WatchedAddRequest)
.then((resp) => {
console.log("Added watched:", resp.data);
wList.push(resp.data as Watched);
watchedList.update(() => wList);
notify({ id: nid, text: `Added!`, type: "success" });
return true;
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Add!", type: "error" });
return false;
});
// If item is already in watched store, run update request instead
const wEntry = store.watchedList.find(
(w) => w.content?.tmdbId === contentId && w.content?.type === contentType,
);
if (wEntry?.id) {
return await _updateWatched(wEntry, status, rating, thoughts, pinned);
}
// Add new watched item
const nid = notify({ text: `Adding`, type: "loading" });
return await axios
.post("/watched", {
contentId,
contentType,
rating,
status,
} as WatchedAddRequest)
.then((resp) => {
console.log("Added watched:", resp.data);
store.watchedList.push(resp.data as Watched);
notify({ id: nid, text: `Added!`, type: "success" });
return true;
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Add!", type: "error" });
return false;
});
}
/**
@@ -130,236 +134,230 @@ export async function updateWatched(
* @param id Watched Entry ID
*/
export function removeWatched(id: number) {
const nid = notify({ text: `Removing`, type: "loading" });
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === id);
if (!wEntry) {
console.log("Watched entry does not exist!");
notify({ text: "Item Doesn't Exist On Watched List!", type: "error" });
return;
}
axios
.delete(`/watched/${id}`)
.then((resp) => {
console.log("Removed watched:", resp.data);
const newList = wList.filter((w) => w.id !== id);
watchedList.update(() => newList);
notify({ id: nid, text: "Removed!", type: "error" });
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Remove!", type: "error" });
});
const nid = notify({ text: `Removing`, type: "loading" });
const wEntry = store.watchedList.find((w) => w.id === id);
if (!wEntry) {
console.log("Watched entry does not exist!");
notify({ text: "Item Doesn't Exist On Watched List!", type: "error" });
return;
}
axios
.delete(`/watched/${id}`)
.then((resp) => {
console.log("Removed watched:", resp.data);
store.watchedList = store.watchedList.filter((w) => w.id !== id);
notify({ id: nid, text: "Removed!", type: "error" });
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Remove!", type: "error" });
});
}
export async function updatePlayed(
igdbId: number,
status?: WatchedStatus,
rating?: number,
thoughts?: string,
pinned?: boolean
igdbId: number,
status?: WatchedStatus,
rating?: number,
thoughts?: string,
pinned?: boolean,
): Promise<boolean> {
// If item is already in watched store, run update request instead
const wList = get(watchedList);
const wEntry = wList.find((w) => w.game?.igdbId === igdbId);
if (wEntry?.id) {
return await _updateWatched(wEntry, status, rating, thoughts, pinned);
}
// Add new played item
const nid = notify({ text: `Adding`, type: "loading" });
return await axios
.post("/game/played", {
igdbId,
rating,
status
} as PlayedAddRequest)
.then((resp) => {
console.log("Added watched(played) game:", resp.data);
wList.push(resp.data as Watched);
watchedList.update(() => wList);
notify({ id: nid, text: `Added!`, type: "success" });
return true;
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Add!", type: "error" });
return false;
});
// If item is already in watched store, run update request instead
const wEntry = store.watchedList.find((w) => w.game?.igdbId === igdbId);
if (wEntry?.id) {
return await _updateWatched(wEntry, status, rating, thoughts, pinned);
}
// Add new played item
const nid = notify({ text: `Adding`, type: "loading" });
return await axios
.post("/game/played", {
igdbId,
rating,
status,
} as PlayedAddRequest)
.then((resp) => {
console.log("Added watched(played) game:", resp.data);
store.watchedList.push(resp.data as Watched);
notify({ id: nid, text: `Added!`, type: "success" });
return true;
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Add!", type: "error" });
return false;
});
}
export function updateActivity(watchedId: number, activityId: number, date: Date) {
const nid = notify({ text: "Updating", type: "loading" });
console.debug("updateActivity called", watchedId, activityId, date);
try {
axios
.put("/activity/" + activityId, {
customDate: date.toISOString()
} as ActivityUpdateRequest)
.then((resp) => {
console.log("Updated activity timestamp:", resp.status);
const wList = get(watchedList);
const activity = wList
.find((w) => w.id === watchedId)
?.activity.find((a) => a.id === activityId);
if (activity) {
activity.customDate = date.toISOString();
watchedList.update(() => wList);
}
notify({ id: nid, text: "Updated!", type: "success" });
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed to Update!", type: "error" });
});
} catch (err) {
console.error("updateActivity failed!", err);
notify({ id: nid, text: "Failed!", type: "error" });
}
export function updateActivity(
watchedId: number,
activityId: number,
date: Date,
) {
const nid = notify({ text: "Updating", type: "loading" });
console.debug("updateActivity called", watchedId, activityId, date);
try {
axios
.put("/activity/" + activityId, {
customDate: date.toISOString(),
} as ActivityUpdateRequest)
.then((resp) => {
console.log("Updated activity timestamp:", resp.status);
const activity = store.watchedList
.find((w) => w.id === watchedId)
?.activity.find((a) => a.id === activityId);
if (activity) {
activity.customDate = date.toISOString();
}
notify({ id: nid, text: "Updated!", type: "success" });
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed to Update!", type: "error" });
});
} catch (err) {
console.error("updateActivity failed!", err);
notify({ id: nid, text: "Failed!", type: "error" });
}
}
export function removeActivity(watchedId: number, activityId: number) {
const nid = notify({ text: "Deleting", type: "loading" });
axios
.delete("/activity/" + activityId)
.then((resp) => {
const wList = get(watchedList);
const wListItem = wList.find((w) => w.id === watchedId);
if (wListItem) {
wListItem.activity = wListItem.activity.filter((i) => i.id !== activityId);
watchedList.update(() => wList);
}
notify({ id: nid, text: "Deleted!", type: "success" });
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed to Delete!", type: "error" });
});
const nid = notify({ text: "Deleting", type: "loading" });
axios
.delete("/activity/" + activityId)
.then((resp) => {
const wListItem = store.watchedList.find((w) => w.id === watchedId);
if (wListItem) {
wListItem.activity = wListItem.activity.filter(
(i) => i.id !== activityId,
);
}
notify({ id: nid, text: "Deleted!", type: "success" });
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed to Delete!", type: "error" });
});
}
export async function contentExistsOnJellyfin(
type: MediaType,
name: string,
tmdbId: number
type: MediaType,
name: string,
tmdbId: number,
): Promise<JellyfinFoundContent | undefined> {
try {
const user = get(userInfo);
if (Number(user?.type) == UserType.Jellyfin) {
const resp = await axios.get(`/jellyfin/${type}/${name}/${tmdbId}`);
console.log("contentExistsOnJellyfin response:", resp.data);
return resp.data as JellyfinFoundContent;
}
} catch (err) {
console.error(err);
// notify({ text: "Failed To Remove!", type: "error" });
}
try {
if (Number(store.userInfo?.type) == UserType.Jellyfin) {
const resp = await axios.get(`/jellyfin/${type}/${name}/${tmdbId}`);
console.log("contentExistsOnJellyfin response:", resp.data);
return resp.data as JellyfinFoundContent;
}
} catch (err) {
console.error(err);
// notify({ text: "Failed To Remove!", type: "error" });
}
}
export function updateUserSetting<K extends keyof UserSettings>(
name: K,
value: UserSettings[K],
done?: () => void
name: K,
value: UserSettings[K],
done?: () => void,
) {
console.log("Updating user setting", name, "to", value);
const uSettings = get(userSettings);
if (!uSettings) {
console.log("updateUserSetting: userSettings not set..");
return;
}
const originalValue = uSettings[name];
const nid = notify({ type: "loading", text: "Updating" });
axios
.post("/user/update", { [name]: value })
.then((r) => {
if (r.status === 200) {
uSettings[name] = value;
userSettings.update((u) => (u = uSettings));
notify({ id: nid, type: "success", text: "Updated" });
if (typeof done !== "undefined") done();
}
})
.catch((err) => {
console.error("Failed to update user setting", err);
notify({ id: nid, type: "error", text: "Couldn't Update" });
uSettings[name] = originalValue;
userSettings.update((u) => (u = uSettings));
if (typeof done !== "undefined") done();
});
console.log("Updating user setting", name, "to", value);
if (!store.userSettings) {
console.log("updateUserSetting: userSettings not set..");
return;
}
const originalValue = store.userSettings[name];
const nid = notify({ type: "loading", text: "Updating" });
axios
.post("/user/update", { [name]: value })
.then((r) => {
if (r.status === 200) {
if (store.userSettings) store.userSettings[name] = value;
notify({ id: nid, type: "success", text: "Updated" });
if (typeof done !== "undefined") done();
}
})
.catch((err) => {
console.error("Failed to update user setting", err);
notify({ id: nid, type: "error", text: "Couldn't Update" });
if (store.userSettings) store.userSettings[name] = originalValue;
if (typeof done !== "undefined") done();
});
}
export function changeUserPassword(
oldPassword: string,
newPassword: string,
done?: (errMsg?: string) => void
oldPassword: string,
newPassword: string,
done?: (errMsg?: string) => void,
) {
const nid = notify({ type: "loading", text: "Changing Password" });
axios
.post("/auth/change_password", { oldPassword, newPassword })
.then((r) => {
if (r.status === 200) {
notify({ id: nid, type: "success", text: "Password Changed" });
if (typeof done !== "undefined") done();
}
})
.catch((err) => {
const errMsg = err?.response?.data?.error
? err.response.data.error
: "Couldn't Change Password";
console.error("Change Password Form - Failed to change password on the server", err);
unNotify(nid);
if (typeof done !== "undefined") done(errMsg);
});
const nid = notify({ type: "loading", text: "Changing Password" });
axios
.post("/auth/change_password", { oldPassword, newPassword })
.then((r) => {
if (r.status === 200) {
notify({ id: nid, type: "success", text: "Password Changed" });
if (typeof done !== "undefined") done();
}
})
.catch((err) => {
const errMsg = err?.response?.data?.error
? err.response.data.error
: "Couldn't Change Password";
console.error(
"Change Password Form - Failed to change password on the server",
err,
);
unNotify(nid);
if (typeof done !== "undefined") done(errMsg);
});
}
/**
* Update serverFeatues store with fresh data.
*/
export async function getServerFeatures() {
try {
const f = await axios.get("/features");
if (f?.data) {
serverFeatures.update((sf) => (sf = f.data));
}
} catch (err) {
console.error("getServerFeatures failed!", err);
}
try {
const f = await axios.get("/features");
if (f?.data) {
store.serverFeatures = f.data;
}
} catch (err) {
console.error("getServerFeatures failed!", err);
}
}
export async function followUser(id: number) {
const nid = notify({ text: `Following`, type: "loading" });
axios
.post(`/follow/${id}`)
.then((resp) => {
console.log("Followed:", resp.data);
const f = get(follows);
f.push(resp.data as Follow);
follows.update(() => f);
notify({ id: nid, text: `Followed!`, type: "success" });
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Follow!", type: "error" });
});
const nid = notify({ text: `Following`, type: "loading" });
axios
.post(`/follow/${id}`)
.then((resp) => {
console.log("Followed:", resp.data);
store.follows.push(resp.data as Follow);
notify({ id: nid, text: `Followed!`, type: "success" });
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Follow!", type: "error" });
});
}
export async function unfollowUser(id: number) {
const nid = notify({ text: `Unfollowing`, type: "loading" });
axios
.delete(`/follow/${id}`)
.then((resp) => {
console.log("Unfollowed:", resp.data);
const f = get(follows);
follows.update(() => f.filter((fo) => fo.followedUser.id != id));
notify({ id: nid, text: `Unfollowed!`, type: "success" });
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Unfollow!", type: "error" });
});
const nid = notify({ text: `Unfollowing`, type: "loading" });
axios
.delete(`/follow/${id}`)
.then((resp) => {
console.log("Unfollowed:", resp.data);
store.follows = store.follows.filter((fo) => fo.followedUser.id != id);
notify({ id: nid, text: `Unfollowed!`, type: "success" });
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Unfollow!", type: "error" });
});
}
/**
* For use with routes that don't require authentication (eg login/register)
*/
export const noAuthAxios = axios.create({
baseURL: baseURL
baseURL: baseURL,
});
+290 -252
View File
@@ -1,167 +1,179 @@
import { appTheme } from "@/store";
import { store } from "@/store.svelte";
import {
UserPermission,
type Icon,
type MediaType,
type TMDBContentCreditsCrew,
type Theme,
type TokenClaims,
type Watched,
type WatchedStatus,
type WatchedEpisode,
type WatchedSeason
UserPermission,
type Icon,
type MediaType,
type TMDBContentCreditsCrew,
type Theme,
type TokenClaims,
type Watched,
type WatchedStatus,
type WatchedEpisode,
type WatchedSeason,
} from "@/types";
export const watchedStatuses: {
[key in WatchedStatus]: Icon;
[key in WatchedStatus]: Icon;
} = {
PLANNED: "calendar",
WATCHING: "clock",
FINISHED: "check",
HOLD: "pause",
DROPPED: "thumb-down"
PLANNED: "calendar",
WATCHING: "clock",
FINISHED: "check",
HOLD: "pause",
DROPPED: "thumb-down",
};
export const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
export const monthsShort = [
"Jan",
"Feb",
"March",
"Apr",
"May",
"June",
"July",
"Aug",
"Sept",
"Oct",
"Nov",
"Dec"
"Jan",
"Feb",
"March",
"Apr",
"May",
"June",
"July",
"Aug",
"Sept",
"Oct",
"Nov",
"Dec",
];
export function isTouch() {
return "ontouchstart" in window;
return "ontouchstart" in window;
}
// Not passing wList from #each loop caused it not to have reactivity.
// Passing it through must allow it to recognize it as a dependency?
export function getWatchedDependedProps(wid: number, wtype: MediaType, list: Watched[]) {
const wel = list.find((wl) => wl.content?.tmdbId === wid && wl.content?.type === wtype);
if (!wel) return {};
console.log(wid, wtype, wel?.content?.title, wel?.status, wel?.rating);
return {
id: wel.id,
status: wel.status,
rating: wel.rating,
extraDetails: {
dateAdded: wel.createdAt,
dateModified: wel.updatedAt,
lastWatched: getLatestWatchedInTv(wel.watchedSeasons, wel.watchedEpisodes)
}
};
export function getWatchedDependedProps(
wid: number,
wtype: MediaType,
list: Watched[],
) {
const wel = list.find(
(wl) => wl.content?.tmdbId === wid && wl.content?.type === wtype,
);
if (!wel) return {};
console.log(wid, wtype, wel?.content?.title, wel?.status, wel?.rating);
return {
id: wel.id,
status: wel.status,
rating: wel.rating,
extraDetails: {
dateAdded: wel.createdAt,
dateModified: wel.updatedAt,
lastWatched: getLatestWatchedInTv(
wel.watchedSeasons,
wel.watchedEpisodes,
),
},
};
}
export function getPlayedDependedProps(wid: number, list: Watched[]) {
const wel = list.find((wl) => wl.game?.igdbId === wid);
if (!wel) return {};
return {
id: wel.id,
status: wel.status,
rating: wel.rating,
extraDetails: {
dateAdded: wel.createdAt,
dateModified: wel.updatedAt
}
};
const wel = list.find((wl) => wl.game?.igdbId === wid);
if (!wel) return {};
return {
id: wel.id,
status: wel.status,
rating: wel.rating,
extraDetails: {
dateAdded: wel.createdAt,
dateModified: wel.updatedAt,
},
};
}
// Get biggest season watching or biggest season watched.
// This could probably be simpler but -_-
export function getLatestWatchedInTv(
ws: WatchedSeason[] | undefined,
we: WatchedEpisode[] | undefined
ws: WatchedSeason[] | undefined,
we: WatchedEpisode[] | undefined,
): string {
if ((!ws || ws.length <= 0) && (!we || we.length <= 0)) {
return "";
}
if ((!ws || ws.length <= 0) && (!we || we.length <= 0)) {
return "";
}
let biggestSeasonWatched = -1;
let biggestSeasonWatching = -1;
if (ws && ws.length > 0) {
for (let i = 0; i < ws.length; i++) {
const s = ws[i];
if (s.status === "WATCHING") {
if (s.seasonNumber > biggestSeasonWatching) {
biggestSeasonWatching = s.seasonNumber;
}
} else if (s.status === "FINISHED") {
if (s.seasonNumber > biggestSeasonWatched) {
biggestSeasonWatched = s.seasonNumber;
}
}
}
}
const season = biggestSeasonWatching >= 0 ? biggestSeasonWatching : biggestSeasonWatched;
let biggestSeasonWatched = -1;
let biggestSeasonWatching = -1;
if (ws && ws.length > 0) {
for (let i = 0; i < ws.length; i++) {
const s = ws[i];
if (s.status === "WATCHING") {
if (s.seasonNumber > biggestSeasonWatching) {
biggestSeasonWatching = s.seasonNumber;
}
} else if (s.status === "FINISHED") {
if (s.seasonNumber > biggestSeasonWatched) {
biggestSeasonWatched = s.seasonNumber;
}
}
}
}
const season =
biggestSeasonWatching >= 0 ? biggestSeasonWatching : biggestSeasonWatched;
// Look for biggest watched/watching episode in season if any.
// Does same thing as above.
let episode: WatchedEpisode | undefined;
if (we && we.length > 0) {
let biggestEpisodeWatched: WatchedEpisode | undefined;
let biggestEpisodeWatching: WatchedEpisode | undefined;
for (let i = 0; i < we.length; i++) {
const s = we[i];
if (season >= 0 && s.seasonNumber !== season) continue;
if (s.status === "WATCHING") {
if (!biggestEpisodeWatching) {
biggestEpisodeWatching = s;
}
if (
s.episodeNumber > biggestEpisodeWatching.episodeNumber ||
s.seasonNumber > biggestEpisodeWatching.seasonNumber
) {
biggestEpisodeWatching = s;
}
} else if (s.status === "FINISHED") {
if (!biggestEpisodeWatched) {
biggestEpisodeWatched = s;
}
if (
s.episodeNumber > biggestEpisodeWatched.episodeNumber ||
s.seasonNumber > biggestEpisodeWatched.seasonNumber
) {
biggestEpisodeWatched = s;
}
}
}
if (biggestEpisodeWatched || biggestEpisodeWatching) {
episode =
biggestEpisodeWatching !== undefined ? biggestEpisodeWatching : biggestEpisodeWatched;
}
}
// Look for biggest watched/watching episode in season if any.
// Does same thing as above.
let episode: WatchedEpisode | undefined;
if (we && we.length > 0) {
let biggestEpisodeWatched: WatchedEpisode | undefined;
let biggestEpisodeWatching: WatchedEpisode | undefined;
for (let i = 0; i < we.length; i++) {
const s = we[i];
if (season >= 0 && s.seasonNumber !== season) continue;
if (s.status === "WATCHING") {
if (!biggestEpisodeWatching) {
biggestEpisodeWatching = s;
}
if (
s.episodeNumber > biggestEpisodeWatching.episodeNumber ||
s.seasonNumber > biggestEpisodeWatching.seasonNumber
) {
biggestEpisodeWatching = s;
}
} else if (s.status === "FINISHED") {
if (!biggestEpisodeWatched) {
biggestEpisodeWatched = s;
}
if (
s.episodeNumber > biggestEpisodeWatched.episodeNumber ||
s.seasonNumber > biggestEpisodeWatched.seasonNumber
) {
biggestEpisodeWatched = s;
}
}
}
if (biggestEpisodeWatched || biggestEpisodeWatching) {
episode =
biggestEpisodeWatching !== undefined
? biggestEpisodeWatching
: biggestEpisodeWatched;
}
}
if (season >= 0 && episode) {
return seasonAndEpToReadable(season, episode.episodeNumber);
} else if (season >= 0) {
return `Season ${season}`;
} else if (episode) {
return seasonAndEpToReadable(episode.seasonNumber, episode.episodeNumber);
} else {
return "";
}
if (season >= 0 && episode) {
return seasonAndEpToReadable(season, episode.episodeNumber);
} else if (season >= 0) {
return `Season ${season}`;
} else if (episode) {
return seasonAndEpToReadable(episode.seasonNumber, episode.episodeNumber);
} else {
return "";
}
}
/**
@@ -183,17 +195,17 @@ export function getLatestWatchedInTv(
* @param isForGame If this status is being displayed for a game or not.
*/
export function toUnderstandableStatus(s: WatchedStatus, isForGame: boolean) {
if (isForGame) {
if (s === "FINISHED") {
return "played";
} else if (s === "WATCHING") {
return "playing";
}
}
if (s === "HOLD") {
return "on hold";
}
return s?.toLowerCase();
if (isForGame) {
if (s === "FINISHED") {
return "played";
} else if (s === "WATCHING") {
return "playing";
}
}
if (s === "HOLD") {
return "on hold";
}
return s?.toLowerCase();
}
/**
@@ -202,12 +214,12 @@ export function toUnderstandableStatus(s: WatchedStatus, isForGame: boolean) {
* @param c Class to add to parent.
*/
export function addClassToParent(
e: Event & {
currentTarget: EventTarget & Element;
},
c: string
e: Event & {
currentTarget: EventTarget & Element;
},
c: string,
) {
(e.currentTarget?.parentNode as HTMLDivElement)?.classList.add(c);
(e.currentTarget?.parentNode as HTMLDivElement)?.classList.add(c);
}
/**
@@ -216,9 +228,13 @@ export function addClassToParent(
* @returns Top Crew
*/
export function getTopCrew(crew: TMDBContentCreditsCrew[]) {
return crew.filter(
(c) => c.job === "Director" || c.job === "Writer" || c.job === "Characters" || c.job === "Story"
);
return crew.filter(
(c) =>
c.job === "Director" ||
c.job === "Writer" ||
c.job === "Characters" ||
c.job === "Story",
);
}
/**
@@ -228,35 +244,35 @@ export function getTopCrew(crew: TMDBContentCreditsCrew[]) {
* @param e
*/
export function calculateTransformOrigin(
e: Event & {
currentTarget: EventTarget & HTMLLIElement;
}
e: Event & {
currentTarget: EventTarget & HTMLLIElement;
},
) {
const magicNumber = 26;
const ctr = e.currentTarget.querySelector(".container") as HTMLElement;
const pb = ctr.getBoundingClientRect();
const sx = pb.x;
const sw = pb.width;
const wb = document.body.getBoundingClientRect();
const magicNumber = 26;
const ctr = e.currentTarget.querySelector(".container") as HTMLElement;
const pb = ctr.getBoundingClientRect();
const sx = pb.x;
const sw = pb.width;
const wb = document.body.getBoundingClientRect();
if (ctr) {
ctr.style.transformOrigin = "unset";
const origins = [];
// Overflow on right
if (sx + sw + magicNumber > wb.x + wb.width) {
origins.push("right");
}
// Overflow on left
if (sx - magicNumber < wb.x) {
origins.push("left");
}
// Overflow on bottom
const ppb = e.currentTarget.getBoundingClientRect();
if (ppb.bottom + magicNumber > window.innerHeight) {
origins.push("bottom");
}
ctr.style.transformOrigin = `${origins.join(" ")}`;
}
if (ctr) {
ctr.style.transformOrigin = "unset";
const origins = [];
// Overflow on right
if (sx + sw + magicNumber > wb.x + wb.width) {
origins.push("right");
}
// Overflow on left
if (sx - magicNumber < wb.x) {
origins.push("left");
}
// Overflow on bottom
const ppb = e.currentTarget.getBoundingClientRect();
if (ppb.bottom + magicNumber > window.innerHeight) {
origins.push("bottom");
}
ctr.style.transformOrigin = `${origins.join(" ")}`;
}
}
/**
@@ -264,91 +280,103 @@ export function calculateTransformOrigin(
* @param i Day number.
*/
export function getOrdinalSuffix(i: number) {
const j = i % 10,
k = i % 100;
if (j == 1 && k != 11) {
return "st";
}
if (j == 2 && k != 12) {
return "nd";
}
if (j == 3 && k != 13) {
return "rd";
}
return "th";
const j = i % 10,
k = i % 100;
if (j == 1 && k != 11) {
return "st";
}
if (j == 2 && k != 12) {
return "nd";
}
if (j == 3 && k != 13) {
return "rd";
}
return "th";
}
/**
* Toggle site wide theme.
* @param theme The theme to switch to.
* @param updateStore Should the store be updated to new theme?
* **If set to `false`, state should be manually updated.**
*/
export function toggleTheme(theme: Theme) {
if (theme === "dark") {
document.documentElement.classList.add("theme-dark");
appTheme.update((t) => (t = "dark"));
} else {
document.documentElement.classList.remove("theme-dark");
appTheme.update((t) => (t = "light"));
}
export function toggleTheme(theme: Theme, updateStore = true) {
if (theme === "dark") {
document.documentElement.classList.add("theme-dark");
if (updateStore) {
store.appTheme = "dark";
}
} else {
document.documentElement.classList.remove("theme-dark");
if (updateStore) {
store.appTheme = "light";
}
}
}
export function parseTokenPayload(): TokenClaims | undefined {
try {
const token = localStorage.getItem("token");
if (!token) return;
return JSON.parse(atob(token.split(".")[1])) as TokenClaims;
} catch (err) {
return;
}
try {
const token = localStorage.getItem("token");
if (!token) return;
return JSON.parse(atob(token.split(".")[1])) as TokenClaims;
} catch (err) {
return;
}
}
export async function sleep(ms: number) {
return new Promise<void>((r) =>
setTimeout(() => {
r();
}, ms)
);
return new Promise<void>((r) =>
setTimeout(() => {
r();
}, ms),
);
}
export function userHasPermission(perms: UserPermission, reqPerm: UserPermission): boolean {
// Admins have permission for everything.
if (perms & UserPermission.PERM_ADMIN) {
return true;
}
return (perms & reqPerm) == reqPerm;
export function userHasPermission(
perms: UserPermission,
reqPerm: UserPermission,
): boolean {
// Admins have permission for everything.
if (perms & UserPermission.PERM_ADMIN) {
return true;
}
return (perms & reqPerm) == reqPerm;
}
/**
* Takes season and episode number to make a quickly understandable
* string in this format S1E1.
*/
export function seasonAndEpToReadable(season: number | undefined, episode: number | undefined) {
return `S${typeof season === "number" ? String(season) : "(unknown)"}E${typeof episode === "number" ? String(episode) : "(unknown)"}`;
export function seasonAndEpToReadable(
season: number | undefined,
episode: number | undefined,
) {
return `S${typeof season === "number" ? String(season) : "(unknown)"}E${typeof episode === "number" ? String(episode) : "(unknown)"}`;
}
export function msToAmountsOfTime(ms: number) {
const seconds = Math.floor((ms / 1000) % 60);
const minutes = Math.floor((ms / (1000 * 60)) % 60);
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24);
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
return { days, hours, minutes, seconds };
const seconds = Math.floor((ms / 1000) % 60);
const minutes = Math.floor((ms / (1000 * 60)) % 60);
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24);
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
return { days, hours, minutes, seconds };
}
/** I'm no warden of time, this is as relative as it's getting. */
export function toRelativeDate(d: Date): string {
if (!d) {
return "Unknown";
}
const dn = new Date(Date.now());
if (d.getFullYear() === dn.getFullYear()) {
if (d.getMonth() === dn.getMonth()) {
if (d.getDate() === dn.getDate()) {
return "Today";
}
}
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]}`;
}
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} ${d.getFullYear()}`;
if (!d) {
return "Unknown";
}
const dn = new Date(Date.now());
if (d.getFullYear() === dn.getFullYear()) {
if (d.getMonth() === dn.getMonth()) {
if (d.getDate() === dn.getDate()) {
return "Today";
}
}
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]}`;
}
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} ${d.getFullYear()}`;
}
/**
@@ -356,18 +384,28 @@ export function toRelativeDate(d: Date): string {
* @param s Seconds.
*/
export function toRelativeTime(s: number) {
const totalMinutes = Math.floor(s / 60);
const hours = Math.floor(totalMinutes / 60);
const mins = totalMinutes % 60;
const secs = Math.floor(s % 60);
if (hours > 0) {
return `${hours} hours`;
}
if (mins > 0) {
return `${mins} minutes`;
}
if (secs > 0) {
return `${secs} seconds`;
}
return "now";
const totalMinutes = Math.floor(s / 60);
const hours = Math.floor(totalMinutes / 60);
const mins = totalMinutes % 60;
const secs = Math.floor(s % 60);
if (hours > 0) {
return `${hours} hours`;
}
if (mins > 0) {
return `${mins} minutes`;
}
if (secs > 0) {
return `${secs} seconds`;
}
return "now";
}
/**
* Tests whether the mouse is hovering over `el`.
*/
export function mouseOverEl(el?: HTMLElement) {
if (!el) {
return false;
}
return el.matches(":hover");
}
+42 -44
View File
@@ -1,55 +1,53 @@
import { notifications } from "@/store";
import { get } from "svelte/store";
import { store } from "@/store.svelte";
export interface Notification {
/**
* Notification ID.
* Used to reference an exiting notification.
*/
id?: number;
/**
* Notification ID.
* Used to reference an exiting notification.
*/
id?: number;
/**
* Text shown in popup;
*/
text: string;
/**
* Text shown in popup;
*/
text: string;
/**
* Type of notification, controls the style.
* Loading notifs never hide, so after request completion
* or failure, the notif must be updated or `unNotify`ed.
*/
type?: "error" | "success" | "loading";
/**
* Type of notification, controls the style.
* Loading notifs never hide, so after request completion
* or failure, the notif must be updated or `unNotify`ed.
*/
type?: "error" | "success" | "loading";
/**
* How long in milliseconds the popup will stay shown for.
*/
time?: number;
/**
* How long in milliseconds the popup will stay shown for.
*/
time?: number;
}
export function notify(n: Notification) {
const notifs = get(notifications);
if (n.id) {
const notif = notifs.find((not) => not.id === n.id);
if (notif) {
notif.type = n.type;
notif.text = n.text;
notifications.update(() => notifs);
} else {
console.error("Can't update notif that doesnt exist", n);
}
} else {
n.id = Math.random();
notifs.push({ ...n });
notifications.update(() => notifs);
}
if (n.type !== "loading" && n.time !== Infinity) {
setTimeout(() => unNotify(n.id!), n.time ?? 2500);
}
return n.id;
if (n.id) {
const notif = store.notifications.find((not) => not.id === n.id);
if (notif) {
notif.type = n.type;
notif.text = n.text;
} else {
console.error("Can't update notif that doesnt exist", n);
}
} else {
n.id = Math.random();
store.notifications.push({ ...n });
}
if (n.type !== "loading" && n.time !== Infinity) {
setTimeout(() => unNotify(n.id!), n.time ?? 2500);
}
return n.id;
}
export function unNotify(id: number) {
const ns = get(notifications);
const dn = ns.filter((e) => e.id !== id);
notifications.update(() => dn);
export function unNotify(id?: number) {
if (!id) {
console.warn("unNotify: Tried removing a notification without an id.");
return;
}
store.notifications = store.notifications.filter((e) => e.id !== id);
}
+108 -95
View File
@@ -1,120 +1,133 @@
import { noAuthAxios } from "./api";
interface Plex {
win: Window;
headers: Record<string, string>;
clientId: string;
win: Window;
headers: Record<string, string>;
clientId: string;
}
interface PlexPin {
id: number;
code: string;
id: number;
code: string;
}
//
function uuidv4() {
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c: any) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
);
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c: any) =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
).toString(16),
);
}
export function preparePlexAuth(): Plex {
// No point making a store for this.
let clientId = localStorage.getItem("plex-cid");
if (!clientId) {
if (crypto.randomUUID) {
console.log("preparePlexAuth: Using randomUUID");
clientId = crypto.randomUUID();
} else {
// Use this if randomUUID is unavailable (ex in unsecure context, this use case isn't as big a deelio)
console.log("preparePlexAuth: Not using randomUUID");
clientId = uuidv4();
}
localStorage.setItem("plex-cid", clientId);
}
// No point making a store for this.
let clientId = localStorage.getItem("plex-cid");
if (!clientId) {
if (crypto.randomUUID) {
console.log("preparePlexAuth: Using randomUUID");
clientId = crypto.randomUUID();
} else {
// Use this if randomUUID is unavailable (ex in unsecure context, this use case isn't as big a deelio)
console.log("preparePlexAuth: Not using randomUUID");
clientId = uuidv4();
}
localStorage.setItem("plex-cid", clientId);
}
const w = 600;
const h = 800;
// Fixes dual-screen position Most browsers Firefox
const dualScreenLeft = window.screenLeft != undefined ? window.screenLeft : window.screenX;
const dualScreenTop = window.screenTop != undefined ? window.screenTop : window.screenY;
const width = window.innerWidth
? window.innerWidth
: document.documentElement.clientWidth
? document.documentElement.clientWidth
: screen.width;
const height = window.innerHeight
? window.innerHeight
: document.documentElement.clientHeight
? document.documentElement.clientHeight
: screen.height;
const left = width / 2 - w / 2 + dualScreenLeft;
const top = height / 2 - h / 2 + dualScreenTop;
const w = 600;
const h = 800;
// Fixes dual-screen position Most browsers Firefox
const dualScreenLeft =
window.screenLeft != undefined ? window.screenLeft : window.screenX;
const dualScreenTop =
window.screenTop != undefined ? window.screenTop : window.screenY;
const width = window.innerWidth
? window.innerWidth
: document.documentElement.clientWidth
? document.documentElement.clientWidth
: screen.width;
const height = window.innerHeight
? window.innerHeight
: document.documentElement.clientHeight
? document.documentElement.clientHeight
: screen.height;
const left = width / 2 - w / 2 + dualScreenLeft;
const top = height / 2 - h / 2 + dualScreenTop;
const win = window.open(
"",
"Continue to Watcharr With Plex",
`scrollbars=yes, width=${w}, height=${h}, top=${top}, left=${left}`
);
if (win) {
win.focus();
return {
win,
// Don't really want to give all the possible headers,
// trying to minimize it to what gets it working.
headers: {
"X-Plex-Product": "Watcharr",
"X-Plex-Client-Identifier": clientId,
"X-Plex-Version": "Plex OAuth",
"X-Plex-Model": "Plex OAuth",
"X-Plex-Platform": "Firefox",
"X-Plex-Platform-Version": "123.0",
"X-Plex-Device": "Windows",
"X-Plex-Device-Name": "Watcharr"
},
clientId
};
}
throw new Error("Failed to prepare popup!");
const win = window.open(
"",
"Continue to Watcharr With Plex",
`scrollbars=yes, width=${w}, height=${h}, top=${top}, left=${left}`,
);
if (win) {
win.focus();
return {
win,
// Don't really want to give all the possible headers,
// trying to minimize it to what gets it working.
headers: {
"X-Plex-Product": "Watcharr",
"X-Plex-Client-Identifier": clientId,
"X-Plex-Version": "Plex OAuth",
"X-Plex-Model": "Plex OAuth",
"X-Plex-Platform": "Firefox",
"X-Plex-Platform-Version": "123.0",
"X-Plex-Device": "Windows",
"X-Plex-Device-Name": "Watcharr",
},
clientId,
};
}
throw new Error("Failed to prepare popup!");
}
export async function doPlexLogin({ win, headers, clientId }: Plex): Promise<PlexPin> {
const pin = await getPlexPin(headers);
win.location.href = `https://app.plex.tv/auth/#!?clientID=${clientId}&code=${pin.code}&context=Watcharr&context[device][device]=${headers["X-Plex-Device"]}&context[device][deviceName]=${headers["X-Plex-Device-Name"]}&context[device][platform]=${headers["X-Plex-Platform"]}&context[device][platformVersion]=${headers["X-Plex-Platform-Version"]}&context[device][product]=${headers["X-Plex-Product"]}`;
return pin;
export async function doPlexLogin({
win,
headers,
clientId,
}: Plex): Promise<PlexPin> {
const pin = await getPlexPin(headers);
win.location.href = `https://app.plex.tv/auth/#!?clientID=${clientId}&code=${pin.code}&context=Watcharr&context[device][device]=${headers["X-Plex-Device"]}&context[device][deviceName]=${headers["X-Plex-Device-Name"]}&context[device][platform]=${headers["X-Plex-Platform"]}&context[device][platformVersion]=${headers["X-Plex-Platform-Version"]}&context[device][product]=${headers["X-Plex-Product"]}`;
return pin;
}
export async function plexPinPoll(
pin: PlexPin,
{ win, headers }: Plex,
done: (err?: Error, token?: string) => void
pin: PlexPin,
{ win, headers }: Plex,
done: (err?: Error, token?: string) => void,
) {
const doPoll = async () => {
try {
console.debug("plexPinPoll");
const r = await noAuthAxios.get(`https://plex.tv/api/v2/pins/${pin.id}`, {
headers: { ...headers, code: pin.code }
});
if (r.data?.authToken) {
done(undefined, r.data.authToken);
win.close();
} else if (win?.closed) {
done(new Error("Plex popup closed before login completed"));
} else {
setTimeout(doPoll, 1000);
}
} catch (err) {
console.error("plexPinPoll: Failed!", err);
done(new Error("Pin poll failed"));
}
};
setTimeout(doPoll, 1000);
const doPoll = async () => {
try {
console.debug("plexPinPoll");
const r = await noAuthAxios.get(`https://plex.tv/api/v2/pins/${pin.id}`, {
headers: { ...headers, code: pin.code },
});
if (r.data?.authToken) {
done(undefined, r.data.authToken);
win.close();
} else if (win?.closed) {
done(new Error("Plex popup closed before login completed"));
} else {
setTimeout(doPoll, 1000);
}
} catch (err) {
console.error("plexPinPoll: Failed!", err);
done(new Error("Pin poll failed"));
}
};
setTimeout(doPoll, 1000);
}
async function getPlexPin(headers: Record<string, string>): Promise<PlexPin> {
const r = await noAuthAxios.post(`https://plex.tv/api/v2/pins?strong=true`, undefined, {
headers: headers
});
console.debug("getPlexPin:", r.data);
return { id: r.data.id, code: r.data.code };
const r = await noAuthAxios.post(
`https://plex.tv/api/v2/pins?strong=true`,
undefined,
{
headers: headers,
},
);
console.debug("getPlexPin:", r.data);
return { id: r.data.id, code: r.data.code };
}
+283 -346
View File
@@ -1,441 +1,378 @@
@import "./vars.scss";
@font-face {
font-family: "Shrikhand";
src:
local("Shrikhand Regular"),
local("Shrikhand-Regular"),
url("/Shrikhand-Regular.woff2") format("woff2");
font-weight: normal;
font-style: italic;
font-display: swap;
font-family: "Shrikhand";
src:
local("Shrikhand Regular"),
local("Shrikhand-Regular"),
url("/Shrikhand-Regular.woff2") format("woff2");
font-weight: normal;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Rampart One";
src:
local("Rampart One Regular"),
local("RampartOne-Regular"),
url("/RampartOne-Regular.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
font-family: "Rampart One";
src:
local("Rampart One Regular"),
local("RampartOne-Regular"),
url("/RampartOne-Regular.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
:global(*) {
padding: 0;
margin: 0;
box-sizing: border-box;
padding: 0;
margin: 0;
box-sizing: border-box;
}
:global(body) {
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
background-color: $bg-color;
color: $text-color;
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
background-color: $bg-color;
color: $text-color;
}
:global(h1:not(.norm), h2:not(.norm), h3:not(.norm), h4:not(.norm), h5:not(.norm)) {
font-family:
"Shrikhand",
system-ui,
-apple-system,
BlinkMacSystemFont;
:global(
h1:not(.norm),
h2:not(.norm),
h3:not(.norm),
h4:not(.norm),
h5:not(.norm)
) {
font-family:
"Shrikhand",
system-ui,
-apple-system,
BlinkMacSystemFont;
}
:global(.sm-caps) {
font-variant: small-caps;
font-variant: small-caps;
}
:global(a) {
text-decoration: none;
color: $text-color;
text-decoration: none;
color: $text-color;
}
:global(input, textarea) {
padding: 7px 10px;
border: 2px solid black;
border-radius: 5px;
width: 100%;
padding: 7px 10px;
border: 2px solid black;
border-radius: 5px;
width: 100%;
}
:global(input.invalid) {
border: 3px solid $error;
border: 3px solid $error;
}
:global(textarea) {
padding: 5px;
max-width: 100%;
color: $text-color;
background-color: transparent;
border-color: $text-color;
padding: 5px;
max-width: 100%;
color: $text-color;
background-color: transparent;
border-color: $text-color;
}
:global(button) {
cursor: pointer;
cursor: pointer;
}
:global(button:not(.plain)),
:global(a.btn) {
display: flex;
align-items: center;
justify-content: center;
padding: 7px 10px;
border: 2px solid $text-color;
border-radius: 5px;
background-color: $bg-color;
color: $text-color;
fill: $text-color;
font-weight: bold;
width: 100%;
transition:
background-color 100ms ease,
opacity 100ms ease;
display: flex;
align-items: center;
justify-content: center;
padding: 7px 10px;
border: 2px solid $text-color;
border-radius: 5px;
background-color: $bg-color;
color: $text-color;
fill: $text-color;
font-weight: bold;
width: 100%;
transition:
background-color 100ms ease,
opacity 100ms ease;
& :global(svg) {
height: 100%;
width: auto;
}
& :global(svg) {
height: 100%;
width: auto;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:not(:disabled) {
&:hover,
&:focus-visible,
&.active {
background-color: $text-color;
color: $bg-color;
fill: $bg-color;
opacity: 1;
}
}
&:not(:disabled) {
&:hover,
&:focus-visible,
&.active {
background-color: $text-color;
color: $bg-color;
fill: $bg-color;
opacity: 1;
}
}
}
:global(button.secondary) {
border: 2px solid transparent;
border: 2px solid transparent;
&:hover,
&:focus-visible {
background-color: white;
color: black;
border: 2px solid black;
}
&:hover,
&:focus-visible {
background-color: white;
color: black;
border: 2px solid black;
}
}
:global(button.danger) {
border: 2px solid $error;
color: $error;
border: 2px solid $error;
color: $error;
&:not(:disabled) {
&:hover,
&:focus-visible {
background-color: $error;
color: white;
}
}
&:not(:disabled) {
&:hover,
&:focus-visible {
background-color: $error;
color: white;
}
}
}
:global(button.plain),
:global(a.plain-btn) {
background-color: transparent;
color: $text-color;
border: 0;
background-color: transparent;
color: $text-color;
border: 0;
}
:global(button.not-active) {
opacity: 0.5;
opacity: 0.5;
}
:global(#tooltip) {
visibility: hidden;
position: absolute;
padding: 8px 5px;
background-color: black;
color: white;
border: 1px solid white;
border-radius: 6px;
text-transform: capitalize;
z-index: 99999;
transition:
top 100ms ease,
left 100ms ease;
pointer-events: none;
visibility: hidden;
position: absolute;
padding: 8px 5px;
background-color: black;
color: white;
border: 1px solid white;
border-radius: 6px;
text-transform: capitalize;
z-index: 99999;
transition:
top 100ms ease,
left 100ms ease;
pointer-events: none;
}
:global {
#notifications {
display: flex;
flex-flow: column;
gap: 10px;
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
z-index: 99999;
#notifications {
display: flex;
flex-flow: column;
gap: 10px;
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
z-index: 99999;
.notif {
display: flex;
flex-flow: row;
align-items: center;
min-width: 200px;
color: black;
background-color: white;
border-radius: 8px;
border: 1px solid rgba($color: #000000, $alpha: 0.2);
box-shadow: 0 4px 10px rgba($color: #000000, $alpha: 0.2);
animation: comein 250ms ease forwards;
position: relative;
.notif {
display: flex;
flex-flow: row;
align-items: center;
min-width: 200px;
color: black;
background-color: white;
border-radius: 8px;
border: 1px solid rgba($color: #000000, $alpha: 0.2);
box-shadow: 0 4px 10px rgba($color: #000000, $alpha: 0.2);
animation: comein 250ms ease forwards;
position: relative;
&.loading {
padding-left: 10px;
}
&.loading {
padding-left: 10px;
}
@keyframes comein {
from {
opacity: 0;
}
@keyframes comein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
to {
opacity: 1;
}
}
&.error {
color: white;
background-color: $error;
border: 1px solid $error;
&.error {
color: white;
background-color: $error;
border: 1px solid $error;
span {
border-color: rgba($color: white, $alpha: 0.5);
}
}
span {
border-color: rgba($color: white, $alpha: 0.5);
}
}
&.success {
color: white;
background-color: $success;
border: 1px solid $success;
&.success {
color: white;
background-color: $success;
border: 1px solid $success;
span {
border-color: rgba($color: white, $alpha: 0.5);
}
}
span {
border-color: rgba($color: white, $alpha: 0.5);
}
}
span {
width: 100%;
height: 100%;
padding-right: 12px;
border-right: 1px solid rgba($color: black, $alpha: 0.2);
padding: 10px 12px;
padding-left: 9px;
span {
width: 100%;
height: 100%;
padding-right: 12px;
border-right: 1px solid rgba($color: black, $alpha: 0.2);
padding: 10px 12px;
padding-left: 9px;
a {
color: white;
text-decoration: underline;
}
}
a {
color: white;
text-decoration: underline;
}
}
button {
display: flex;
align-items: center;
margin: 8px;
width: 22px;
height: 100%;
color: inherit;
}
}
}
button {
display: flex;
align-items: center;
margin: 8px;
width: 22px;
height: 100%;
color: inherit;
}
}
}
.small-scrollbar {
@supports selector(::-webkit-scrollbar) {
&::-webkit-scrollbar {
width: 4.5px;
}
.small-scrollbar {
@supports selector(::-webkit-scrollbar) {
&::-webkit-scrollbar {
width: 4.5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 20px;
border: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 20px;
border: transparent;
}
// The scrollbar-width property will always override
// -webkit-scrollbar, but since the scrollbars in
// chromium look ugly af, we will try to unset scrollbar-width
// in these cases and use our manual styling instead to try
// mimicking beatiful firefox.
scrollbar-width: auto !important;
}
}
}
:global(div.menu) {
display: flex;
flex-flow: column;
position: absolute;
right: 10px;
top: 55px;
width: 125px;
border: 3px solid $text-color;
border-radius: 10px;
background-color: $bg-color;
list-style: none;
z-index: 50;
// The lil arrow, adjust its pos under each specific menu.
&:not(:has(> .arrow)):before,
:global(.arrow) {
// Works by showing arrow in ::before pseudoelement,
// or if more control is needed, applies to any .arrow
// element.
content: "";
position: absolute;
bottom: 100%;
width: 0;
border-top: 10px solid transparent;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid $text-color;
}
& > div {
display: flex;
flex-flow: column;
padding: 10px;
width: 100%;
max-height: calc(100dvh - 65px);
overflow: auto;
h5 {
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
}
button,
a {
font-size: 14px;
padding: 8px 16px;
text-align: center;
cursor: pointer;
transition: background-color 200ms ease;
&:hover,
&:focus-visible {
background-color: $text-color;
color: $bg-color;
}
}
span {
margin-top: 8px;
font-size: 11px;
color: gray;
text-align: center;
}
}
// The scrollbar-width property will always override
// -webkit-scrollbar, but since the scrollbars in
// chromium look ugly af, we will try to unset scrollbar-width
// in these cases and use our manual styling instead to try
// mimicking beatiful firefox.
scrollbar-width: auto !important;
}
}
}
:global(table) {
margin-top: 20px;
width: 100%;
border-spacing: 0px;
border: 1px solid $accent-color;
border-radius: 10px;
font-size: 16px;
margin-top: 20px;
width: 100%;
border-spacing: 0px;
border: 1px solid $accent-color;
border-radius: 10px;
font-size: 16px;
th {
padding: 12px 15px;
text-align: left;
transition: padding 100ms ease;
th {
padding: 12px 15px;
text-align: left;
transition: padding 100ms ease;
&:first-of-type {
border-top-left-radius: 10px;
}
&:first-of-type {
border-top-left-radius: 10px;
}
&:last-of-type {
border-top-right-radius: 10px;
}
&:last-of-type {
border-top-right-radius: 10px;
}
&.loading-col {
width: 28px;
padding: 0;
}
}
&.loading-col {
width: 28px;
padding: 0;
}
}
tr {
th {
background-color: $accent-color;
}
tr {
th {
background-color: $accent-color;
}
&:last-child {
td:first-of-type {
border-bottom-left-radius: 10px;
}
&:last-child {
td:first-of-type {
border-bottom-left-radius: 10px;
}
td:last-of-type {
border-bottom-right-radius: 10px;
}
}
td:last-of-type {
border-bottom-right-radius: 10px;
}
}
&:nth-child(odd) td {
background-color: $accent-color;
}
}
&:nth-child(odd) td {
background-color: $accent-color;
}
}
td {
padding: 5px;
td {
padding: 5px;
&.icon-cell {
padding-right: 3px;
&.icon-cell {
padding-right: 3px;
& > div {
display: flex;
padding-left: 4px;
}
}
& > div {
display: flex;
padding-left: 4px;
}
}
input {
background: transparent;
color: $text-color;
border: 0;
font-size: 16px;
padding: 0;
padding: 7px 10px;
transition: padding 100ms ease;
input {
background: transparent;
color: $text-color;
border: 0;
font-size: 16px;
padding: 0;
padding: 7px 10px;
transition: padding 100ms ease;
&[type="number"] {
appearance: textfield;
&[type="number"] {
appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
button {
&.delete {
display: flex;
justify-content: center;
color: $placeholder-color;
button {
&.delete {
display: flex;
justify-content: center;
color: $placeholder-color;
&:hover {
color: $error;
}
}
}
}
&:hover {
color: $error;
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+30 -30
View File
@@ -9,39 +9,39 @@ import { notify } from "@/lib/util/notify";
import { clearWatcharrData } from "@/lib/logout";
axios.interceptors.request.use(
(config) => {
if (!config.baseURL) {
config.baseURL = baseURL;
(config) => {
if (!config.baseURL) {
config.baseURL = baseURL;
// Only want to set auth header if requesting to our backend.
const token = localStorage.getItem("token");
// Don't require token check if going to auth route (login/register)
if (!token && !config.url?.includes("/auth")) {
console.error("No token, going to login. Endpoint:", config.url);
goto("/login?again=1");
throw new axios.Cancel("No auth token found");
}
config.headers.set("Authorization", token);
}
// Only want to set auth header if requesting to our backend.
const token = localStorage.getItem("token");
// Don't require token check if going to auth route (login/register)
if (!token && !config.url?.includes("/auth")) {
console.error("No token, going to login. Endpoint:", config.url);
goto("/login?again=1");
throw new axios.Cancel("No auth token found");
}
config.headers.set("Authorization", token);
}
return config;
},
(error) => {
return Promise.reject(error);
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
axios.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response?.status === 401) {
console.error("Recieved 401 response, going to login.");
notify({ text: "Request Authorization Failed!", type: "error" });
clearWatcharrData();
goto("/login?again=1");
}
return Promise.reject(error);
}
(response) => {
return response;
},
(error) => {
if (error.response?.status === 401) {
console.error("Recieved 401 response, going to login.");
notify({ text: "Request Authorization Failed!", type: "error" });
clearWatcharrData();
goto("/login?again=1");
}
return Promise.reject(error);
},
);

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