mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-04-24 16:53:37 +00:00
Compare commits
11 Commits
b1f1fa8780
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f105d05338 | ||
| c3eefb3ec6 | |||
| 444e633f50 | |||
| 013117507e | |||
| 604cada126 | |||
| 1e9077a819 | |||
| 1dbd22d42e | |||
| c8262a3bda | |||
| cb90281e5e | |||
| 0cd2a73a8b | |||
| c309016299 |
@@ -29,6 +29,6 @@
|
||||
"prettier": "^3.8.1",
|
||||
"sass": "1.54.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^6.0.3"
|
||||
"vite": "^6.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
70
scripts/convert-svg-to-vue.mjs
Normal file
70
scripts/convert-svg-to-vue.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Usage: node convert-svg-to-svelte.js [inputDir] [outputDir]
|
||||
* Defaults: svgs src/icons
|
||||
*/
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const INPUT_DIR = process.argv[2] || "svgs";
|
||||
const OUTPUT_DIR = process.argv[3] || "src/icons";
|
||||
|
||||
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
function processSvg(svgContent) {
|
||||
// Strip XML/DOCTYPE
|
||||
let out = svgContent
|
||||
.replace(/<\?xml[\s\S]*?\?>\s*/i, "")
|
||||
.replace(/<!DOCTYPE[\s\S]*?>\s*/i, "");
|
||||
|
||||
// Remove ALL comments
|
||||
out = out.replace(/<!--[\s\S]*?-->\s*/g, "");
|
||||
|
||||
// Remove <g id="icomoon-ignore"></g> with any whitespace between tags
|
||||
out = out.replace(/<g\s+id=(["'])icomoon-ignore\1\s*>\s*<\/g>\s*/gi, "");
|
||||
|
||||
// Ensure only width="100%" height="100%" on the <svg> tag
|
||||
out = out.replace(/<svg\b[^>]*>/i, match => {
|
||||
let tag = match
|
||||
.replace(/\s+(width|height)\s*=\s*"[^"]*"/gi, "")
|
||||
.replace(/\s+(width|height)\s*=\s*'[^']*'/gi, "");
|
||||
return tag.replace(/>$/, ' width="100%" height="100%">');
|
||||
});
|
||||
|
||||
// Prepend the single license comment
|
||||
out =
|
||||
"<!-- generated by icomoon.io - licensed Lindua icon -->\n" +
|
||||
out.replace(/^\s+/, "");
|
||||
|
||||
// Wrap with <template> tags
|
||||
out = "<template>\n" + out + "\n</template>";
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function convertSvgs(inputDir = INPUT_DIR, outputDir = OUTPUT_DIR) {
|
||||
if (!fs.existsSync(inputDir)) {
|
||||
console.warn(`Input directory not found: ${inputDir}`);
|
||||
return;
|
||||
}
|
||||
const files = fs
|
||||
.readdirSync(inputDir)
|
||||
.filter(f => f.toLowerCase().endsWith(".svg"));
|
||||
files.forEach(file => {
|
||||
const src = path.join(inputDir, file);
|
||||
const baseName = file.replace(/\.svg$/i, "");
|
||||
// Convert kebab-case to PascalCase (e.g., clipboard-text -> ClipboardText)
|
||||
const pascalCase = baseName
|
||||
.split("-")
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join("");
|
||||
const destFileName = `Icon${pascalCase}.vue`;
|
||||
const dest = path.join(outputDir, destFileName);
|
||||
const svgContent = fs.readFileSync(src, "utf8");
|
||||
const processed = processSvg(svgContent);
|
||||
fs.writeFileSync(dest, processed, "utf8");
|
||||
console.log(`Converted: ${file} -> ${path.basename(dest)}`);
|
||||
});
|
||||
}
|
||||
|
||||
convertSvgs();
|
||||
63
scripts/icon-converter.js
Normal file
63
scripts/icon-converter.js
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Usage: node convert-svg-to-svelte.js [inputDir] [outputDir]
|
||||
* Defaults: ./svgs ./svelte
|
||||
*/
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const INPUT_DIR = process.argv[2] || "../svgs";
|
||||
const OUTPUT_DIR = process.argv[3] || "../src/icons";
|
||||
|
||||
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
function processSvg(svgContent) {
|
||||
// Strip XML/DOCTYPE
|
||||
let out = svgContent
|
||||
.replace(/<\?xml[\s\S]*?\?>\s*/i, "")
|
||||
.replace(/<!DOCTYPE[\s\S]*?>\s*/i, "");
|
||||
|
||||
// Remove ALL comments
|
||||
out = out.replace(/<!--[\s\S]*?-->\s*/g, "");
|
||||
|
||||
// Remove <g id="icomoon-ignore"></g> with any whitespace between tags
|
||||
out = out.replace(/<g\s+id=(["'])icomoon-ignore\1\s*>\s*<\/g>\s*/gi, "");
|
||||
|
||||
// Ensure only width="100%" height="100%" on the <svg> tag
|
||||
out = out.replace(/<svg\b[^>]*>/i, match => {
|
||||
let tag = match
|
||||
.replace(/\s+(width|height)\s*=\s*"[^"]*"/gi, "")
|
||||
.replace(/\s+(width|height)\s*=\s*'[^']*'/gi, "");
|
||||
return tag.replace(/>$/, ' width="100%" height="100%">');
|
||||
});
|
||||
|
||||
// Prepend the single license comment
|
||||
out =
|
||||
"<!-- generated by icomoon.io - licensed Lindua icon -->\n" +
|
||||
out.replace(/^\s+/, "");
|
||||
|
||||
// Wrap with <template> tags
|
||||
out = "<template>\n" + out + "\n</template>";
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function convertSvgs(inputDir = INPUT_DIR, outputDir = OUTPUT_DIR) {
|
||||
if (!fs.existsSync(inputDir)) {
|
||||
console.warn(`Input directory not found: ${inputDir}`);
|
||||
return;
|
||||
}
|
||||
const files = fs
|
||||
.readdirSync(inputDir)
|
||||
.filter(f => f.toLowerCase().endsWith(".svg"));
|
||||
files.forEach(file => {
|
||||
const src = path.join(inputDir, file);
|
||||
const dest = path.join(outputDir, file.replace(/\.svg$/i, ".vue"));
|
||||
const svgContent = fs.readFileSync(src, "utf8");
|
||||
const processed = processSvg(svgContent);
|
||||
fs.writeFileSync(dest, processed, "utf8");
|
||||
console.log(`Converted: ${file} -> ${path.basename(dest)}`);
|
||||
});
|
||||
}
|
||||
|
||||
convertSvgs();
|
||||
@@ -13,7 +13,8 @@
|
||||
<!-- Popup that will show above existing rendered content -->
|
||||
<popup />
|
||||
|
||||
<darkmode-toggle />
|
||||
<!-- Command Palette for quick navigation -->
|
||||
<command-palette />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,7 +23,7 @@
|
||||
import NavigationHeader from "@/components/header/NavigationHeader.vue";
|
||||
import NavigationIcons from "@/components/header/NavigationIcons.vue";
|
||||
import Popup from "@/components/Popup.vue";
|
||||
import DarkmodeToggle from "@/components/ui/DarkmodeToggle.vue";
|
||||
import CommandPalette from "@/components/ui/CommandPalette.vue";
|
||||
|
||||
const router = useRouter();
|
||||
</script>
|
||||
@@ -49,11 +50,12 @@
|
||||
|
||||
.navigation-icons-gutter {
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
height: calc(100vh - var(--header-size));
|
||||
margin: 0;
|
||||
top: var(--header-size);
|
||||
width: var(--header-size);
|
||||
background-color: var(--background-color-secondary);
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -61,7 +63,6 @@
|
||||
grid-column: 2 / 3;
|
||||
width: calc(100% - var(--header-size));
|
||||
grid-row: 2;
|
||||
z-index: 5;
|
||||
|
||||
@include mobile {
|
||||
grid-column: 1 / 3;
|
||||
|
||||
46
src/api.ts
46
src/api.ts
@@ -135,6 +135,17 @@ const getTmdbMovieListByName = async (
|
||||
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console
|
||||
};
|
||||
|
||||
const getTmdbMovieDiscoverByName = async (
|
||||
name: string,
|
||||
page = 1
|
||||
): Promise<IList> => {
|
||||
const url = new URL(`/api/v2/movie/discover/${name}`, API_HOSTNAME);
|
||||
url.searchParams.append("page", page.toString());
|
||||
|
||||
return fetch(url.href).then(resp => resp.json());
|
||||
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console
|
||||
};
|
||||
|
||||
// Fetches requested items.
|
||||
const getRequests = async (page = 1) => {
|
||||
const url = new URL("/api/v2/request", API_HOSTNAME);
|
||||
@@ -262,17 +273,22 @@ const getRequestStatus = async (
|
||||
.catch(err => Promise.reject(err));
|
||||
};
|
||||
|
||||
/*
|
||||
const watchLink = async (title, year) => {
|
||||
const watchLink = async (title: string, year: string) => {
|
||||
const url = new URL("/api/v1/plex/watch-link", API_HOSTNAME);
|
||||
url.searchParams.append("title", title);
|
||||
url.searchParams.append("year", year);
|
||||
|
||||
return fetch(url.href)
|
||||
const options: RequestInit = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include"
|
||||
};
|
||||
|
||||
return fetch(url.href, options)
|
||||
.then(resp => resp.json())
|
||||
.then(response => response.link);
|
||||
};
|
||||
|
||||
/*
|
||||
const movieImages = id => {
|
||||
const url = new URL(`v2/movie/${id}/images`, API_HOSTNAME);
|
||||
|
||||
@@ -373,9 +389,9 @@ const updateSettings = async (settings: any) => {
|
||||
|
||||
// - - - Authenticate with plex - - -
|
||||
|
||||
const linkPlexAccount = async (username: string, password: string) => {
|
||||
const linkPlexAccount = async (authToken: string) => {
|
||||
const url = new URL("/api/v1/user/link_plex", API_HOSTNAME);
|
||||
const body = { username, password };
|
||||
const body = { authToken };
|
||||
|
||||
const options: RequestInit = {
|
||||
method: "POST",
|
||||
@@ -387,7 +403,7 @@ const linkPlexAccount = async (username: string, password: string) => {
|
||||
return fetch(url.href, options)
|
||||
.then(resp => resp.json())
|
||||
.catch(error => {
|
||||
console.error(`api error linking plex account: ${username}`); // eslint-disable-line no-console
|
||||
console.error("api error linking plex account"); // eslint-disable-line no-console
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
@@ -408,6 +424,20 @@ const unlinkPlexAccount = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const plexRecentlyAddedInLibrary = async (id: number) => {
|
||||
const url = new URL(`/api/v2/plex/recently_added/${id}`, API_HOSTNAME);
|
||||
const options: RequestInit = {
|
||||
credentials: "include"
|
||||
};
|
||||
|
||||
return fetch(url.href, options)
|
||||
.then(resp => resp.json())
|
||||
.catch(error => {
|
||||
console.error(`api error fetch plex recently added`); // eslint-disable-line no-console
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
// - - - User graphs - - -
|
||||
|
||||
const fetchGraphData = async (
|
||||
@@ -538,6 +568,7 @@ const elasticSearchMoviesAndShows = async (query: string, count = 22) => {
|
||||
};
|
||||
|
||||
export {
|
||||
API_HOSTNAME,
|
||||
getMovie,
|
||||
getShow,
|
||||
getPerson,
|
||||
@@ -545,6 +576,7 @@ export {
|
||||
getShowCredits,
|
||||
getPersonCredits,
|
||||
getTmdbMovieListByName,
|
||||
getTmdbMovieDiscoverByName,
|
||||
searchTmdb,
|
||||
getUserRequests,
|
||||
getRequests,
|
||||
@@ -554,12 +586,14 @@ export {
|
||||
getRequestStatus,
|
||||
linkPlexAccount,
|
||||
unlinkPlexAccount,
|
||||
plexRecentlyAddedInLibrary,
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
fetchGraphData,
|
||||
watchLink,
|
||||
getEmoji,
|
||||
elasticSearchMoviesAndShows
|
||||
};
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
<template>
|
||||
<li class="card">
|
||||
<a @click="openCastItem" @keydown.enter="openCastItem">
|
||||
<img :src="pictureUrl" alt="Movie or person poster image" />
|
||||
<p class="name">{{ creditItem.name || creditItem.title }}</p>
|
||||
<p class="meta">{{ creditItem.character || creditItem.year }}</p>
|
||||
<li class="cast-card">
|
||||
<a
|
||||
class="cast-card__link"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="ariaLabel"
|
||||
@click="openCastItem"
|
||||
@keydown.enter="openCastItem"
|
||||
>
|
||||
<div class="cast-card__image-wrapper">
|
||||
<img
|
||||
class="cast-card__image"
|
||||
:src="pictureUrl"
|
||||
:alt="imageAltText"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="cast-card__content">
|
||||
<p class="cast-card__name">{{ creditItem.name || creditItem.title }}</p>
|
||||
<p v-if="metaText" class="cast-card__meta">{{ metaText }}</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
@@ -33,85 +49,139 @@
|
||||
return "/assets/no-image_small.svg";
|
||||
});
|
||||
|
||||
const metaText = computed(() => {
|
||||
if ("character" in props.creditItem && props.creditItem.character) {
|
||||
return props.creditItem.character;
|
||||
}
|
||||
if ("job" in props.creditItem && props.creditItem.job) {
|
||||
return props.creditItem.job;
|
||||
}
|
||||
if ("year" in props.creditItem && props.creditItem.year) {
|
||||
return props.creditItem.year;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const imageAltText = computed(() => {
|
||||
const name = props.creditItem.name || (props.creditItem as any).title || "";
|
||||
if ("character" in props.creditItem) {
|
||||
return `${name} as ${props.creditItem.character}`;
|
||||
}
|
||||
if ("job" in props.creditItem) {
|
||||
return `${name}, ${props.creditItem.job}`;
|
||||
}
|
||||
return name ? `Poster for ${name}` : "No image available";
|
||||
});
|
||||
|
||||
const ariaLabel = computed(() => {
|
||||
const name = props.creditItem.name || (props.creditItem as any).title || "";
|
||||
if ("character" in props.creditItem && props.creditItem.character) {
|
||||
return `View ${name}, played ${props.creditItem.character}`;
|
||||
}
|
||||
if ("job" in props.creditItem && props.creditItem.job) {
|
||||
return `View ${name}, ${props.creditItem.job}`;
|
||||
}
|
||||
return `View ${name}`;
|
||||
});
|
||||
|
||||
function openCastItem() {
|
||||
store.dispatch("popup/open", { ...props.creditItem });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
li a p:first-of-type {
|
||||
padding-top: 10px;
|
||||
}
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
|
||||
li.card p {
|
||||
font-size: 1em;
|
||||
padding: 0 10px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-height: calc(10px + ((16px * var(--line-height)) * 3));
|
||||
}
|
||||
|
||||
li.card {
|
||||
margin: 10px;
|
||||
margin-right: 4px;
|
||||
padding-bottom: 10px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
min-width: 140px;
|
||||
width: 140px;
|
||||
background-color: var(--background-color-secondary);
|
||||
color: var(--text-color);
|
||||
|
||||
transition: all 0.3s ease;
|
||||
transform: scale(0.97) translateZ(0);
|
||||
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
.cast-card {
|
||||
list-style: none;
|
||||
margin: 0 10px 10px 0;
|
||||
width: 150px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
transform: scale(1.03);
|
||||
.cast-card__link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background-color: var(
|
||||
--highlight-secondary,
|
||||
var(--background-color-secondary)
|
||||
);
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.character {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color-70);
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
// margin-top: auto;
|
||||
max-height: calc((0.9em * var(--line-height)) * 1);
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 210px;
|
||||
background-color: var(--background-color);
|
||||
object-fit: cover;
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--highlight-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.cast-card__image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--background-color) 0%,
|
||||
var(--background-color-secondary) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.cast-card__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cast-card__content {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.cast-card__name {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
color: var(--highlight-bg, var(--text-color));
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cast-card__meta {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
color: var(--highlight-bg, var(--text-color-70));
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
||||
277
src/components/DiscoverMinimal.vue
Normal file
277
src/components/DiscoverMinimal.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<section class="discover-minimal">
|
||||
<div class="discover-minimal-header">
|
||||
<div class="header-content">
|
||||
<h2 class="discover-title">Explore Collections</h2>
|
||||
<p class="discover-description">
|
||||
Curated selections organized by genre, mood, and decade
|
||||
</p>
|
||||
</div>
|
||||
<router-link to="/discover" class="view-all-link">
|
||||
<span class="desktop-only">View All Categories →</span>
|
||||
<span class="mobile-only">View All →</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<DiscoverShowcase @select="navigateToDiscover" />
|
||||
|
||||
<div class="featured-collections-wrapper">
|
||||
<div class="featured-collections-header">
|
||||
<div class="header-decorator"></div>
|
||||
<h3 class="featured-title">Featured Picks</h3>
|
||||
<div class="header-decorator"></div>
|
||||
</div>
|
||||
<div class="featured-collections">
|
||||
<ResultsSection
|
||||
v-for="list in featuredLists"
|
||||
:key="list.id"
|
||||
:api-function="list.apiFunction"
|
||||
:title="list.title"
|
||||
:short-list="true"
|
||||
section-type="discover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import ResultsSection from "@/components/ResultsSection.vue";
|
||||
import DiscoverShowcase from "@/components/DiscoverShowcase.vue";
|
||||
import { getTmdbMovieDiscoverByName } from "../api";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const featuredLists = [
|
||||
{
|
||||
id: "feel_good",
|
||||
title: "Feel Good",
|
||||
apiFunction: () => getTmdbMovieDiscoverByName("feel_good")
|
||||
},
|
||||
{
|
||||
id: "2000s_classics",
|
||||
title: "2000s Classics",
|
||||
apiFunction: () => getTmdbMovieDiscoverByName("2000s_classics")
|
||||
},
|
||||
{
|
||||
id: "horror_hits",
|
||||
title: "Horror Hits",
|
||||
apiFunction: () => getTmdbMovieDiscoverByName("horror_hits")
|
||||
}
|
||||
];
|
||||
|
||||
function navigateToDiscover(categoryId?: string) {
|
||||
router.push(`/discover${categoryId ? `?category=${categoryId}` : ""}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.discover-minimal {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.01) 0%,
|
||||
rgba(255, 255, 255, 0.03) 50%,
|
||||
rgba(255, 255, 255, 0.01) 100%
|
||||
);
|
||||
padding: 3rem 0;
|
||||
position: relative;
|
||||
margin: 2rem 0;
|
||||
width: 100%;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 90%;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.2) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 90%;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.15) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
padding: 1rem 0 0.5rem;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.discover-minimal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem 2rem;
|
||||
gap: 1rem;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem 0.6rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
|
||||
@include mobile {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.discover-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
letter-spacing: -0.5px;
|
||||
|
||||
@include mobile {
|
||||
font-size: 1.75rem;
|
||||
margin: 0 0 0.15rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.discover-description {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: $text-color-70;
|
||||
font-weight: 300;
|
||||
|
||||
@include mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.view-all-link {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 25px;
|
||||
color: $text-color-70;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
@include mobile {
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: var(--text-color);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.featured-collections-wrapper {
|
||||
padding-top: 2rem;
|
||||
position: relative;
|
||||
|
||||
@include mobile {
|
||||
margin-top: 0;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.featured-collections-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
|
||||
@include mobile {
|
||||
padding: 0 1rem 0.4rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.header-decorator {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.3) 50%,
|
||||
rgba(255, 255, 255, 0.3) 100%
|
||||
);
|
||||
|
||||
&:last-child {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.3) 0%,
|
||||
rgba(255, 255, 255, 0.3) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.featured-title {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.9rem;
|
||||
color: $text-color-70;
|
||||
|
||||
@include mobile {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.featured-collections {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 20px;
|
||||
max-width: calc(100% - 4rem);
|
||||
margin: 0 auto;
|
||||
|
||||
@include mobile {
|
||||
border-radius: 12px;
|
||||
padding: 0.25rem 0;
|
||||
max-width: calc(100% - 2rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
360
src/components/DiscoverShowcase.vue
Normal file
360
src/components/DiscoverShowcase.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<div class="category-showcase">
|
||||
<div class="categories-grid">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="category-card"
|
||||
:class="[
|
||||
`category-${category.id}`,
|
||||
{ active: activeCategory === category.id }
|
||||
]"
|
||||
@click="$emit('select', category.id)"
|
||||
>
|
||||
<component :is="category.icon" class="category-icon" />
|
||||
<div class="category-info">
|
||||
<h3 class="category-name">{{ category.label }}</h3>
|
||||
<p class="category-count">
|
||||
<span class="desktop-only">{{ category.count }} collections</span>
|
||||
<span class="mobile-only">{{ category.count }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="category-arrow">→</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import IconPopular from "@/icons/IconPopular.vue";
|
||||
import IconSpotlights from "@/icons/IconSpotlights.vue";
|
||||
import IconTheater from "@/icons/IconTheater.vue";
|
||||
import IconCalendar from "@/icons/IconCalendar.vue";
|
||||
import IconStar from "@/icons/IconStar.vue";
|
||||
|
||||
interface Props {
|
||||
activeCategory?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
activeCategory: ""
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
select: [categoryId: string];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const categories = [
|
||||
{ id: "popular", label: "Popular", icon: IconPopular, count: 5 },
|
||||
{ id: "genres", label: "Genres", icon: IconSpotlights, count: 13 },
|
||||
{ id: "moods", label: "Moods & Themes", icon: IconTheater, count: 7 },
|
||||
{ id: "decades", label: "By Decade", icon: IconCalendar, count: 4 },
|
||||
{ id: "special", label: "Special Collections", icon: IconStar, count: 11 }
|
||||
];
|
||||
|
||||
function navigateToDiscover(categoryId?: string) {
|
||||
router.push(`/discover${categoryId ? `?category=${categoryId}` : ""}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.category-showcase {
|
||||
padding: 1.5rem;
|
||||
padding-top: 0;
|
||||
|
||||
@include mobile {
|
||||
padding: 0 1rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
justify-content: center;
|
||||
|
||||
@include mobile {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
.category-card {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@include mobile {
|
||||
padding: 0.45rem 0.7rem;
|
||||
gap: 0.4rem;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
&.category-popular {
|
||||
background: rgba(255, 80, 80, 0.15);
|
||||
border-color: rgba(255, 80, 80, 0.3);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(255, 120, 120, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
&.category-genres {
|
||||
background: rgba(80, 140, 255, 0.15);
|
||||
border-color: rgba(80, 140, 255, 0.3);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(120, 170, 255, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
&.category-moods {
|
||||
background: rgba(160, 80, 255, 0.15);
|
||||
border-color: rgba(160, 80, 255, 0.3);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(180, 120, 255, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
&.category-decades {
|
||||
background: rgba(80, 200, 200, 0.15);
|
||||
border-color: rgba(80, 200, 200, 0.3);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(100, 220, 220, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
&.category-special {
|
||||
background: rgba(255, 180, 80, 0.15);
|
||||
border-color: rgba(255, 180, 80, 0.3);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(255, 200, 120, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
transform: translateY(-3px) scale(1.03);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
transform: rotate(5deg) scale(1.15);
|
||||
}
|
||||
|
||||
.category-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
&.category-popular {
|
||||
background: rgba(255, 80, 80, 0.3);
|
||||
border-color: rgba(255, 80, 80, 0.6);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(255, 160, 160, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.category-genres {
|
||||
background: rgba(80, 140, 255, 0.3);
|
||||
border-color: rgba(80, 140, 255, 0.6);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(160, 210, 255, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.category-moods {
|
||||
background: rgba(160, 80, 255, 0.3);
|
||||
border-color: rgba(160, 80, 255, 0.6);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(220, 160, 255, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.category-decades {
|
||||
background: rgba(80, 200, 200, 0.3);
|
||||
border-color: rgba(80, 200, 200, 0.6);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(140, 255, 255, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.category-special {
|
||||
background: rgba(255, 180, 80, 0.3);
|
||||
border-color: rgba(255, 180, 80, 0.6);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(255, 230, 160, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.15) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px) scale(1.03);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&.category-popular {
|
||||
background: rgba(255, 80, 80, 0.25);
|
||||
border-color: rgba(255, 80, 80, 0.5);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(255, 140, 140, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.category-genres {
|
||||
background: rgba(80, 140, 255, 0.25);
|
||||
border-color: rgba(80, 140, 255, 0.5);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(140, 190, 255, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.category-moods {
|
||||
background: rgba(160, 80, 255, 0.25);
|
||||
border-color: rgba(160, 80, 255, 0.5);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(200, 140, 255, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.category-decades {
|
||||
background: rgba(80, 200, 200, 0.25);
|
||||
border-color: rgba(80, 200, 200, 0.5);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(120, 240, 240, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.category-special {
|
||||
background: rgba(255, 180, 80, 0.25);
|
||||
border-color: rgba(255, 180, 80, 0.5);
|
||||
|
||||
.category-icon {
|
||||
fill: rgba(255, 220, 140, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
transform: rotate(5deg) scale(1.15);
|
||||
}
|
||||
|
||||
.category-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: var(--text-color);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
flex-shrink: 0;
|
||||
|
||||
@include mobile {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
line-height: 1;
|
||||
|
||||
@include mobile {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.category-name {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
|
||||
@include mobile {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.category-count {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-weight: 500;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
@include mobile {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.category-arrow {
|
||||
font-size: 1.1rem;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
margin-left: 0.25rem;
|
||||
|
||||
@include mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<canvas ref="graphCanvas"></canvas>
|
||||
<div class="graph-wrapper">
|
||||
<canvas ref="graphCanvas"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ref, onMounted, watch, onBeforeUnmount } from "vue";
|
||||
import {
|
||||
Chart,
|
||||
LineElement,
|
||||
@@ -16,12 +18,14 @@
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip,
|
||||
Filler,
|
||||
ChartType
|
||||
} from "chart.js";
|
||||
import type { BarOptions, ChartOptions } from "chart.js";
|
||||
|
||||
import type { Ref } from "vue";
|
||||
import { convertSecondsToHumanReadable } from "../utils";
|
||||
import { GraphValueTypes } from "../interfaces/IGraph";
|
||||
import { GraphTypes, GraphValueTypes } from "../interfaces/IGraph";
|
||||
import type { IGraphDataset, IGraphData } from "../interfaces/IGraph";
|
||||
|
||||
Chart.register(
|
||||
@@ -34,7 +38,8 @@
|
||||
CategoryScale,
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
Filler
|
||||
);
|
||||
|
||||
interface Props {
|
||||
@@ -42,129 +47,188 @@
|
||||
data: IGraphData;
|
||||
type: ChartType;
|
||||
stacked: boolean;
|
||||
|
||||
datasetDescriptionSuffix: string;
|
||||
tooltipDescriptionSuffix: string;
|
||||
graphValueType?: GraphValueTypes;
|
||||
}
|
||||
|
||||
Chart.defaults.elements.point.radius = 0;
|
||||
Chart.defaults.elements.point.hitRadius = 10;
|
||||
// Chart.defaults.elements.point.pointHoverRadius = 10;
|
||||
Chart.defaults.elements.point.hoverBorderWidth = 4;
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const graphCanvas: Ref<HTMLCanvasElement> = ref(null);
|
||||
let graphInstance = null;
|
||||
|
||||
/* eslint-disable no-use-before-define */
|
||||
onMounted(() => generateGraph());
|
||||
watch(() => props.data, generateGraph);
|
||||
/* eslint-enable no-use-before-define */
|
||||
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
|
||||
let graphInstance: Chart | null = null;
|
||||
|
||||
const graphTemplates = [
|
||||
{
|
||||
backgroundColor: "rgba(54, 162, 235, 0.2)",
|
||||
borderColor: "rgba(54, 162, 235, 1)",
|
||||
borderWidth: 1,
|
||||
tension: 0.4
|
||||
borderColor: "#6366F1",
|
||||
backgroundColor: "rgba(99,102,241,0.12)"
|
||||
},
|
||||
{
|
||||
backgroundColor: "rgba(255, 159, 64, 0.2)",
|
||||
borderColor: "rgba(255, 159, 64, 1)",
|
||||
borderWidth: 1,
|
||||
tension: 0.4
|
||||
borderColor: "#F59E0B",
|
||||
backgroundColor: "rgba(245,158,11,0.12)"
|
||||
},
|
||||
{
|
||||
backgroundColor: "rgba(255, 99, 132, 0.2)",
|
||||
borderColor: "rgba(255, 99, 132, 1)",
|
||||
borderWidth: 1,
|
||||
tension: 0.4
|
||||
borderColor: "#10B981",
|
||||
backgroundColor: "rgba(16,185,129,0.12)"
|
||||
}
|
||||
];
|
||||
// const gridColor = getComputedStyle(document.documentElement).getPropertyValue(
|
||||
// "--text-color-5"
|
||||
// );
|
||||
|
||||
function hydrateGraphLineOptions(dataset: IGraphDataset, index: number) {
|
||||
onMounted(() => generateGraph());
|
||||
watch(() => props.data, generateGraph, { deep: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (graphInstance) graphInstance.destroy();
|
||||
});
|
||||
|
||||
function removeEmptyDataset(dataset: IGraphDataset) {
|
||||
return dataset;
|
||||
return !dataset.data.every(point => point === 0);
|
||||
}
|
||||
|
||||
function hydrateDataset(dataset: IGraphDataset, index: number) {
|
||||
const base = graphTemplates[index % graphTemplates.length];
|
||||
|
||||
if (props.type === "bar") {
|
||||
return {
|
||||
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
|
||||
data: dataset.data,
|
||||
backgroundColor: base.borderColor,
|
||||
inflateAmount: 0,
|
||||
borderRadius: {
|
||||
topLeft: 8,
|
||||
topRight: 8,
|
||||
bottomLeft: 8,
|
||||
bottomRight: 8
|
||||
},
|
||||
|
||||
borderSkipped: false,
|
||||
borderWidth: 2,
|
||||
borderColor: "transparent",
|
||||
|
||||
// Slight spacing between categories
|
||||
barPercentage: 0.8,
|
||||
categoryPercentage: 0.9
|
||||
} as BarOptions;
|
||||
}
|
||||
|
||||
// Line chart — subtle, minimal points
|
||||
return {
|
||||
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
|
||||
data: dataset.data,
|
||||
...graphTemplates[index]
|
||||
borderColor: base.borderColor,
|
||||
backgroundColor: base.backgroundColor,
|
||||
borderWidth: 2,
|
||||
tension: 0.35,
|
||||
fill: true,
|
||||
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5,
|
||||
pointHitRadius: 12,
|
||||
pointBackgroundColor: base.borderColor,
|
||||
pointBorderColor: base.borderColor,
|
||||
pointBorderWidth: 0
|
||||
};
|
||||
}
|
||||
|
||||
function removeEmptyDataset(dataset: IGraphDataset) {
|
||||
/* eslint-disable-next-line no-unneeded-ternary */
|
||||
return dataset.data.every(point => point === 0) ? false : true;
|
||||
}
|
||||
|
||||
function generateGraph() {
|
||||
if (!graphCanvas.value) return;
|
||||
|
||||
const datasets = props.data.series
|
||||
.filter(removeEmptyDataset)
|
||||
.map(hydrateGraphLineOptions);
|
||||
.map(hydrateDataset);
|
||||
|
||||
const graphOptions = {
|
||||
const chartData = {
|
||||
labels: props.data.labels,
|
||||
datasets
|
||||
};
|
||||
|
||||
const options: ChartOptions = {
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
layout: {
|
||||
padding: { top: 8 }
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
// title: (tooltipItem, data) => `Watch date: ${tooltipItem[0].label}`,
|
||||
label: tooltipItem => {
|
||||
const context = tooltipItem.dataset.label.split(" ")[0];
|
||||
const text = `${context} ${props.tooltipDescriptionSuffix}`;
|
||||
legend: {
|
||||
display: true
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: "#111827",
|
||||
bodyColor: "#e5e7eb",
|
||||
padding: 12,
|
||||
cornerRadius: 8,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: (tooltipItem: any) => {
|
||||
const context = tooltipItem.dataset.label.split(" ")[0];
|
||||
|
||||
let type = GraphTypes.Plays;
|
||||
let value = tooltipItem.raw;
|
||||
if (props.graphValueType === GraphValueTypes.Time) {
|
||||
if (props.graphValueType === String(GraphTypes.Duration)) {
|
||||
value = convertSecondsToHumanReadable(value);
|
||||
type = GraphTypes.Duration;
|
||||
}
|
||||
|
||||
return ` ${text}: ${value}`;
|
||||
const text = `${context} ${type}`;
|
||||
return `${text}: ${value}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
scales: {
|
||||
xAxes: {
|
||||
x: {
|
||||
stacked: props.stacked,
|
||||
gridLines: {
|
||||
display: false
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: "#9CA3AF",
|
||||
font: { size: 11 }
|
||||
}
|
||||
},
|
||||
yAxes: {
|
||||
|
||||
y: {
|
||||
stacked: props.stacked,
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "rgba(0,0,0,0.04)",
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
callback: value => {
|
||||
if (props.graphValueType === GraphValueTypes.Time) {
|
||||
color: "#9CA3AF",
|
||||
font: { size: 11 },
|
||||
padding: 8,
|
||||
callback: (value: number) => {
|
||||
if (props.graphValueType === String(GraphTypes.Duration)) {
|
||||
return convertSecondsToHumanReadable(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = {
|
||||
labels: props.data.labels.toString().split(","),
|
||||
datasets
|
||||
};
|
||||
|
||||
if (graphInstance) {
|
||||
graphInstance.clear();
|
||||
graphInstance.data = chartData;
|
||||
graphInstance.update("none");
|
||||
graphInstance.update();
|
||||
return;
|
||||
}
|
||||
|
||||
graphInstance = new Chart(graphCanvas.value, {
|
||||
type: props.type,
|
||||
data: chartData,
|
||||
options: graphOptions
|
||||
options
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<style scoped lang="scss">
|
||||
.graph-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,12 +31,22 @@
|
||||
info?: string | Array<string>;
|
||||
link?: string;
|
||||
shortList?: boolean;
|
||||
sectionType?: "list" | "discover";
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sectionType: "list"
|
||||
});
|
||||
|
||||
const urlify = computed(() => {
|
||||
return `/list/${props.title.toLowerCase().replace(" ", "_")}`;
|
||||
const normalizedTitle = props.title
|
||||
.toLowerCase()
|
||||
.replace(/'s\b/g, "") // Remove possessive 's
|
||||
.replace(/[^\w\d\s-]/g, "") // Remove special characters (keep word chars, dashes, digits, spaces)
|
||||
.replace(/\s+/g, "_") // Replace spaces with underscores
|
||||
.replace(/-/g, "_") // Replace dash with underscore
|
||||
.replace(/_+/g, "_"); // Replace multiple underscores with single underscore
|
||||
return `/${props.sectionType}/${normalizedTitle}`;
|
||||
});
|
||||
|
||||
const prettify = computed(() => {
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
<template>
|
||||
<div v-if="isOpen" class="movie-popup" @click="close" @keydown.enter="close">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="popupContainer"
|
||||
class="movie-popup"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
@click="close"
|
||||
@keydown.enter="close"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div class="movie-popup__box" @click.stop>
|
||||
<person v-if="type === 'person'" :id="id" type="person" />
|
||||
<movie v-else :id="id" :type="type"></movie>
|
||||
<button class="movie-popup__close" @click="close"></button>
|
||||
<button class="movie-popup__close" @click="close" tabindex="0"></button>
|
||||
</div>
|
||||
<i class="loader"></i>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import Movie from "@/components/popup/Movie.vue";
|
||||
import Person from "@/components/popup/Person.vue";
|
||||
@@ -26,6 +36,8 @@
|
||||
const isOpen: Ref<boolean> = ref();
|
||||
const id: Ref<string> = ref();
|
||||
const type: Ref<MediaTypes> = ref();
|
||||
const popupContainer = ref<HTMLElement | null>(null);
|
||||
let previouslyFocusedElement: HTMLElement | null = null;
|
||||
|
||||
const unsubscribe = store.subscribe((mutation, state) => {
|
||||
if (!mutation.type.includes("popup")) return;
|
||||
@@ -76,6 +88,75 @@
|
||||
close();
|
||||
}
|
||||
|
||||
function getFocusableElements(): HTMLElement[] {
|
||||
if (!popupContainer.value) return [];
|
||||
|
||||
const focusableSelectors = [
|
||||
"button:not([disabled])",
|
||||
"a[href]",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])'
|
||||
].join(", ");
|
||||
|
||||
return Array.from(
|
||||
popupContainer.value.querySelectorAll(focusableSelectors)
|
||||
) as HTMLElement[];
|
||||
}
|
||||
|
||||
function trapFocus(event: KeyboardEvent) {
|
||||
if (event.key !== "Tab") return;
|
||||
|
||||
const focusableElements = getFocusableElements();
|
||||
if (focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Shift + Tab
|
||||
if (document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab
|
||||
if (document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
trapFocus(event);
|
||||
}
|
||||
|
||||
function setInitialFocus() {
|
||||
nextTick(() => {
|
||||
// Focus the popup container itself instead of a specific element
|
||||
// This allows tab to start fresh without any element being focused
|
||||
if (popupContainer.value) {
|
||||
popupContainer.value.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(isOpen, newValue => {
|
||||
if (newValue) {
|
||||
// Store the previously focused element
|
||||
previouslyFocusedElement = document.activeElement as HTMLElement;
|
||||
// Set focus to popup
|
||||
setInitialFocus();
|
||||
} else {
|
||||
// Restore focus to previously focused element
|
||||
if (previouslyFocusedElement) {
|
||||
previouslyFocusedElement.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("keyup", checkEventForEscapeKey);
|
||||
|
||||
onMounted(() => {
|
||||
@@ -104,6 +185,10 @@
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow: auto;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&__box {
|
||||
max-width: 768px;
|
||||
position: relative;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div ref="resultSection" class="resultSection">
|
||||
<page-header v-bind="{ title, info, shortList }" />
|
||||
<page-header
|
||||
v-bind="{ title, info, shortList, sectionType: props.sectionType }"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="!loadedPages.includes(1) && loading == false"
|
||||
@@ -40,9 +42,12 @@
|
||||
title: string;
|
||||
apiFunction: (page: number) => Promise<IList>;
|
||||
shortList?: boolean;
|
||||
sectionType?: "list" | "discover";
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sectionType: "list"
|
||||
});
|
||||
|
||||
const results: Ref<ListResults> = ref([]);
|
||||
const page: Ref<number> = ref(1);
|
||||
|
||||
86
src/components/activity/StatsOverview.vue
Normal file
86
src/components/activity/StatsOverview.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div v-if="watchStats" class="stats-overview">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ watchStats.totalPlays }}</div>
|
||||
<div class="stat-label">Total Plays</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ watchStats.totalHours }}h</div>
|
||||
<div class="stat-label">Watch Time</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ watchStats.moviePlays }}</div>
|
||||
<div class="stat-label">Movies watched</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ watchStats.episodePlays }}</div>
|
||||
<div class="stat-label">Episodes watched</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WatchStats } from "../../composables/useTautulliStats";
|
||||
|
||||
interface Props {
|
||||
watchStats: WatchStats | null;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@include mobile-only {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--background-ui);
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@include mobile-only {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--highlight-color);
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-color-60);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 300;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
101
src/components/activity/WatchHistory.vue
Normal file
101
src/components/activity/WatchHistory.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div v-if="topContent.length > 0" class="watch-history">
|
||||
<h3 class="section-title">Last Watched</h3>
|
||||
<div class="top-content-list">
|
||||
<div
|
||||
v-for="(item, index) in topContent"
|
||||
:key="index"
|
||||
class="top-content-item"
|
||||
>
|
||||
<div class="content-rank">{{ index + 1 }}</div>
|
||||
<div class="content-details">
|
||||
<div class="content-title">{{ item.title }}</div>
|
||||
<div class="content-meta">
|
||||
{{ item.type }} • {{ item.plays }} plays • {{ item.duration }}min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface TopContentItem {
|
||||
title: string;
|
||||
type: string;
|
||||
plays: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
topContent: TopContentItem[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.watch-history {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.top-content-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
|
||||
@include mobile-only {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.top-content-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--background-ui);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--text-color-50);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--text-color);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.content-rank {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--highlight-color);
|
||||
min-width: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.content-meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-60);
|
||||
}
|
||||
</style>
|
||||
@@ -95,7 +95,8 @@
|
||||
}
|
||||
|
||||
function parseElasticResponse(elasticResponse: IAutocompleteSearchResults) {
|
||||
const data = elasticResponse.hits.hits;
|
||||
const { hits } = elasticResponse.hits;
|
||||
const data = hits.length > 0 ? hits : (searchResults.value ?? []);
|
||||
|
||||
const results: Array<IAutocompleteResult> = [];
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
const signinNavigationIcon: INavigationIcon = {
|
||||
title: "Signin",
|
||||
route: "/signin",
|
||||
route: "/login",
|
||||
icon: IconProfileLock
|
||||
};
|
||||
|
||||
|
||||
@@ -15,13 +15,14 @@
|
||||
import { ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import NavigationIcon from "@/components/header/NavigationIcon.vue";
|
||||
import IconInbox from "@/icons/IconInbox.vue";
|
||||
import IconMailboxFull from "@/icons/IconMailboxFull.vue";
|
||||
import IconNowPlaying from "@/icons/IconNowPlaying.vue";
|
||||
import IconPopular from "@/icons/IconPopular.vue";
|
||||
import IconUpcoming from "@/icons/IconUpcoming.vue";
|
||||
import IconSettings from "@/icons/IconSettings.vue";
|
||||
import IconActivity from "@/icons/IconActivity.vue";
|
||||
import IconBinoculars from "@/icons/IconBinoculars.vue";
|
||||
import IconHelm from "@/icons/IconHelm.vue";
|
||||
import IconDiscover from "@/icons/IconDiscover.vue";
|
||||
import type INavigationIcon from "../../interfaces/INavigationIcon";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -30,13 +31,18 @@
|
||||
{
|
||||
title: "Requests",
|
||||
route: "/list/requests",
|
||||
icon: IconInbox
|
||||
icon: IconMailboxFull
|
||||
},
|
||||
{
|
||||
title: "Now Playing",
|
||||
route: "/list/now_playing",
|
||||
icon: IconNowPlaying
|
||||
},
|
||||
{
|
||||
title: "Discover",
|
||||
route: "/discover",
|
||||
icon: IconDiscover
|
||||
},
|
||||
{
|
||||
title: "Popular",
|
||||
route: "/list/popular",
|
||||
@@ -58,7 +64,7 @@
|
||||
title: "Torrents",
|
||||
route: "/torrents",
|
||||
requiresAuth: true,
|
||||
icon: IconBinoculars
|
||||
icon: IconHelm
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
|
||||
143
src/components/plex/PlexAuthButton.vue
Normal file
143
src/components/plex/PlexAuthButton.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="plex-connect">
|
||||
<div class="info-box">
|
||||
<IconInfo class="info-icon" />
|
||||
<p>
|
||||
Sign in to your Plex account to get information about recently added
|
||||
movies and to see your watch history
|
||||
</p>
|
||||
</div>
|
||||
<div class="signin-container">
|
||||
<button @click="handleAuth" :disabled="loading" class="plex-signin-btn">
|
||||
{{ loading ? "Connecting..." : "Sign in with Plex" }}
|
||||
|
||||
<IconPlex v-if="!loading" class="plex-icon" />
|
||||
</button>
|
||||
<p class="popup-note">A popup window will open for authentication</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePlexAuth } from "@/composables/usePlexAuth";
|
||||
import IconInfo from "@/icons/IconInfo.vue";
|
||||
import IconPlex from "@/icons/IconPlex.vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
authSuccess: [token: string];
|
||||
authError: [message: string];
|
||||
}>();
|
||||
|
||||
const { loading, openAuthPopup } = usePlexAuth();
|
||||
|
||||
function handleAuth() {
|
||||
openAuthPopup(
|
||||
token => emit("authSuccess", token),
|
||||
error => emit("authError", error)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
padding: 0.65rem;
|
||||
background-color: var(--background-ui);
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 0.85rem;
|
||||
border-left: 3px solid var(--highlight-color);
|
||||
|
||||
@include mobile-only {
|
||||
padding: 0.6rem;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: var(--highlight-color);
|
||||
flex-shrink: 0;
|
||||
|
||||
@include mobile-only {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.signin-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.plex-signin-btn {
|
||||
padding: 1rem 1.75rem;
|
||||
background-color: #c87818;
|
||||
color: $white;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
align-self: flex-start;
|
||||
box-shadow: 0 4px 12px rgba(200, 120, 24, 0.25);
|
||||
letter-spacing: -0.01em;
|
||||
|
||||
@include mobile-only {
|
||||
width: 100%;
|
||||
padding: 0.9rem 1.4rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: #b36a15;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(200, 120, 24, 0.4);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 4px 12px rgba(200, 120, 24, 0.3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.plex-icon {
|
||||
flex-shrink: 0;
|
||||
--size: 24px;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-note {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
230
src/components/plex/PlexLibraryItem.vue
Normal file
230
src/components/plex/PlexLibraryItem.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<a
|
||||
v-if="item.plexUrl"
|
||||
:href="item.plexUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="plex-library-item"
|
||||
>
|
||||
<figure :class="`item-poster ${item.type}`">
|
||||
<img
|
||||
v-if="item.poster"
|
||||
:src="item.poster"
|
||||
:alt="item.title"
|
||||
class="poster-image"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="poster-fallback">
|
||||
<component :is="fallbackIconComponent" />
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<div class="item-details">
|
||||
<p class="item-title">{{ item.title }}</p>
|
||||
<div class="item-meta">
|
||||
<span v-if="item.year" class="item-year">{{ item.year }}</span>
|
||||
<span v-if="item.rating" class="item-rating">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon
|
||||
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
|
||||
/>
|
||||
</svg>
|
||||
{{ item.rating }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showExtras" class="item-extras">
|
||||
<span v-if="item.artist">{{ item.artist }}</span>
|
||||
<span v-if="item.episodes">{{ item.episodes }} episodes</span>
|
||||
<span v-if="item.tracks">{{ item.tracks }} tracks</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div v-else class="plex-library-item plex-library-item--no-link">
|
||||
<figure class="item-poster">
|
||||
<img
|
||||
v-if="item.poster"
|
||||
:src="item.poster"
|
||||
:alt="item.title"
|
||||
class="poster-image"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="poster-fallback">
|
||||
<component :is="fallbackIconComponent" />
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<div class="item-details">
|
||||
<p class="item-title">{{ item.title }}</p>
|
||||
<div class="item-meta">
|
||||
<span v-if="item.year" class="item-year">{{ item.year }}</span>
|
||||
<span v-if="item.rating" class="item-rating">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon
|
||||
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
|
||||
/>
|
||||
</svg>
|
||||
{{ item.rating }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showExtras" class="item-extras">
|
||||
<span v-if="item.artist">{{ item.artist }}</span>
|
||||
<span v-if="item.episodes">{{ item.episodes }} episodes</span>
|
||||
<span v-if="item.tracks">{{ item.tracks }} tracks</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import IconMovie from "@/icons/IconMovie.vue";
|
||||
import IconShow from "@/icons/IconShow.vue";
|
||||
import IconMusic from "@/icons/IconMusic.vue";
|
||||
|
||||
interface LibraryItem {
|
||||
title: string;
|
||||
poster?: string;
|
||||
fallbackIcon?: string;
|
||||
year?: number;
|
||||
rating?: number;
|
||||
artist?: string;
|
||||
episodes?: number;
|
||||
tracks?: number;
|
||||
plexUrl?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
item: LibraryItem;
|
||||
showExtras?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const fallbackIconComponent = computed(() => {
|
||||
if (props.item.fallbackIcon === "🎬") return IconMovie;
|
||||
if (props.item.fallbackIcon === "📺") return IconShow;
|
||||
if (props.item.fallbackIcon === "🎵") return IconMusic;
|
||||
return IconMovie; // Default fallback
|
||||
});
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const target = event.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style style="scss" scoped>
|
||||
.plex-library-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.plex-library-item:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.plex-library-item--no-link {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.plex-library-item--no-link:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.item-poster {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #333;
|
||||
margin: 0;
|
||||
|
||||
&.music {
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
}
|
||||
|
||||
.poster-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.poster-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #333 0%, #222 100%);
|
||||
padding: 20%;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.item-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.item-year {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.item-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.item-extras {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.item-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
382
src/components/plex/PlexLibraryModal.vue
Normal file
382
src/components/plex/PlexLibraryModal.vue
Normal file
@@ -0,0 +1,382 @@
|
||||
<template>
|
||||
<div class="modal-overlay library-modal-overlay" @click="emit('close')">
|
||||
<div class="library-modal-content" @click.stop>
|
||||
<div class="library-modal-header">
|
||||
<div class="library-header-title">
|
||||
<div class="library-icon-large">
|
||||
<component :is="libraryIconComponent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3>{{ getLibraryTitle(libraryType) }}</h3>
|
||||
<p class="library-subtitle">{{ details.total }} items</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-btn" @click="emit('close')">
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="library-modal-body">
|
||||
<!-- Stats Overview -->
|
||||
<div class="library-stats-overview">
|
||||
<div class="overview-stat">
|
||||
<span class="overview-label">Total Items</span>
|
||||
<span class="overview-value">{{
|
||||
formatNumber(details.total)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="overview-stat" v-if="libraryType === 'tv shows'">
|
||||
<span class="overview-label">Seasons</span>
|
||||
<span class="overview-value">{{
|
||||
formatNumber(details?.childCount)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="overview-stat" v-if="libraryType === 'tv shows'">
|
||||
<span class="overview-label">Episodes</span>
|
||||
<span class="overview-value">{{
|
||||
formatNumber(details?.leafCount)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="overview-stat" v-if="libraryType === 'music'">
|
||||
<span class="overview-label">Tracks</span>
|
||||
<span class="overview-value">{{ details?.totalTracks }}</span>
|
||||
</div>
|
||||
<div class="overview-stat">
|
||||
<span class="overview-label">Duration</span>
|
||||
<span class="overview-value">{{
|
||||
convertSecondsToHumanReadable(details?.duration / 1000)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recently Added -->
|
||||
<div class="library-section">
|
||||
<h4 class="section-title">Recently Added</h4>
|
||||
<div class="recent-items-grid">
|
||||
<PlexLibraryItem
|
||||
v-for="(item, index) in recentlyAdded"
|
||||
:key="index"
|
||||
:item="item"
|
||||
:show-extras="
|
||||
libraryType === 'music' || libraryType === 'tv shows'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Genres -->
|
||||
<div class="library-section">
|
||||
<h4 class="section-title">Top Genres</h4>
|
||||
<div class="genre-list">
|
||||
<div
|
||||
v-for="(genre, index) in details.genres"
|
||||
:key="index"
|
||||
class="genre-item"
|
||||
>
|
||||
<span class="genre-name">{{ genre.name }}</span>
|
||||
<div class="genre-bar-container">
|
||||
<div
|
||||
class="genre-bar"
|
||||
:style="{
|
||||
width: (genre.count / details.total) * 100 + '%'
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="genre-count">{{ genre.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from "vue";
|
||||
import IconClose from "@/icons/IconClose.vue";
|
||||
import IconMovie from "@/icons/IconMovie.vue";
|
||||
import IconShow from "@/icons/IconShow.vue";
|
||||
import IconMusic from "@/icons/IconMusic.vue";
|
||||
import PlexLibraryItem from "@/components/plex/PlexLibraryItem.vue";
|
||||
import { getLibraryTitle } from "@/utils/plexHelpers";
|
||||
import { plexRecentlyAddedInLibrary } from "@/api";
|
||||
import { processLibraryItem } from "@/utils/plexHelpers";
|
||||
import { formatNumber, convertSecondsToHumanReadable } from "@/utils";
|
||||
import { usePlexAuth } from "@/composables/usePlexAuth";
|
||||
|
||||
const { getPlexAuthCookie } = usePlexAuth();
|
||||
const authToken = getPlexAuthCookie();
|
||||
|
||||
interface LibraryDetails {
|
||||
id: number;
|
||||
title: string;
|
||||
total: number;
|
||||
childCount?: number;
|
||||
leafCount?: number;
|
||||
duration: number;
|
||||
genres: Array<{
|
||||
name: string;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
libraryType: string;
|
||||
details: LibraryDetails;
|
||||
serverUrl: string;
|
||||
serverMachineId: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
let recentlyAdded = ref([]);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
|
||||
const libraryIconComponent = computed(() => {
|
||||
if (props.libraryType === "movies") return IconMovie;
|
||||
if (props.libraryType === "tv shows") return IconShow;
|
||||
if (props.libraryType === "music") return IconMusic;
|
||||
return IconMovie;
|
||||
});
|
||||
|
||||
function fetchRecentlyAdded() {
|
||||
plexRecentlyAddedInLibrary(props.details.id).then(added => {
|
||||
recentlyAdded.value = added?.MediaContainer?.Metadata.map(el =>
|
||||
processLibraryItem(
|
||||
el,
|
||||
props.libraryType,
|
||||
authToken,
|
||||
props.serverUrl,
|
||||
props.serverMachineId
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function checkEventForEscapeKey(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
emit("close");
|
||||
}
|
||||
|
||||
window.addEventListener("keyup", checkEventForEscapeKey);
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecentlyAdded();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keyup", checkEventForEscapeKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/media-queries.scss";
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
|
||||
@include mobile {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.library-modal-content {
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
|
||||
@include mobile {
|
||||
max-height: 100vh;
|
||||
border-radius: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.library-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.library-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.library-icon-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--highlight-color);
|
||||
}
|
||||
}
|
||||
|
||||
.library-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.library-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
--size: 2.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
height: var(--size);
|
||||
width: var(--size);
|
||||
border-radius: 6px;
|
||||
fill: white;
|
||||
transition: all 0.2s;
|
||||
|
||||
@include mobile {
|
||||
margin: auto 0;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.library-modal-body {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.library-stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.overview-stat {
|
||||
background: #252525;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.overview-label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.overview-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.library-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recent-items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.genre-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.genre-item {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr 60px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.genre-name {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.genre-bar-container {
|
||||
height: 8px;
|
||||
background: #333;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.genre-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #e5a00d 0%, #ffbf3f 100%);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.genre-count {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.library-stats-overview {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.recent-items-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.genre-item {
|
||||
grid-template-columns: 100px 1fr 50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
219
src/components/plex/PlexLibraryStats.vue
Normal file
219
src/components/plex/PlexLibraryStats.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="library-stats">
|
||||
<div
|
||||
v-for="stat in displayStats"
|
||||
:key="stat.key"
|
||||
class="stat-card"
|
||||
:class="{
|
||||
disabled: stat.value === undefined || stat.value === 0 || loading,
|
||||
unclickable: !!!stat.clickable
|
||||
}"
|
||||
@click="
|
||||
stat.clickable &&
|
||||
stat.value?.total > 0 &&
|
||||
!loading &&
|
||||
handleClick(stat.key)
|
||||
"
|
||||
>
|
||||
<div class="stat-icon">
|
||||
<component :is="stat.icon" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value" v-if="!loading">
|
||||
{{ formatNumber(stat.value?.total) }}
|
||||
</div>
|
||||
<div class="stat-value loading-dots" v-else>...</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { formatNumber } from "@/utils";
|
||||
import IconMovie from "@/icons/IconMovie.vue";
|
||||
import IconShow from "@/icons/IconShow.vue";
|
||||
import IconMusic from "@/icons/IconMusic.vue";
|
||||
import IconClock from "@/icons/IconClock.vue";
|
||||
|
||||
interface LibraryStat {
|
||||
id: number;
|
||||
title: string;
|
||||
total: number;
|
||||
childCount?: number;
|
||||
leafCount?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
movies: LibraryStat;
|
||||
shows: LibraryStat;
|
||||
music: LibraryStat;
|
||||
watchtime: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
openLibrary: [type: string];
|
||||
}>();
|
||||
|
||||
const displayStats = computed(() => [
|
||||
{
|
||||
key: "movies",
|
||||
icon: IconMovie,
|
||||
value: props.movies,
|
||||
label: "Movies",
|
||||
clickable: true
|
||||
},
|
||||
{
|
||||
key: "tv shows",
|
||||
icon: IconShow,
|
||||
value: props.shows,
|
||||
label: "TV Shows",
|
||||
clickable: true
|
||||
},
|
||||
{
|
||||
key: "music",
|
||||
icon: IconMusic,
|
||||
value: props.music,
|
||||
label: "Albums",
|
||||
clickable: true
|
||||
},
|
||||
{
|
||||
key: "watchtime",
|
||||
icon: IconClock,
|
||||
value: props.watchtime,
|
||||
label: "Hours Watched",
|
||||
clickable: false
|
||||
}
|
||||
]);
|
||||
|
||||
function handleClick(type: string) {
|
||||
emit("openLibrary", type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.library-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.85rem;
|
||||
|
||||
@include tablet-only {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@include mobile-only {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--background-ui);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
|
||||
@include mobile-only {
|
||||
padding: 0.85rem 0.75rem;
|
||||
}
|
||||
|
||||
&:hover:not(.disabled, .unclickable) {
|
||||
background-color: var(--background-40);
|
||||
border-color: var(--highlight-color);
|
||||
cursor: pointer;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@include mobile-only {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--highlight-color);
|
||||
transition: fill 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card:hover:not(.disabled) .stat-icon svg {
|
||||
fill: var(--color-green-90);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-60);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
animation: loadingDots 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loadingDots {
|
||||
0%,
|
||||
20% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
309
src/components/plex/PlexProfileCard.vue
Normal file
309
src/components/plex/PlexProfileCard.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<div v-if="username" class="plex-profile-card">
|
||||
<div class="profile-header">
|
||||
<img
|
||||
v-if="userData?.thumb"
|
||||
:src="userData.thumb"
|
||||
alt="Profile"
|
||||
class="profile-avatar"
|
||||
/>
|
||||
<div v-else class="profile-avatar-placeholder">
|
||||
{{ username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<div class="username-row">
|
||||
<h3 class="profile-username">{{ username }}</h3>
|
||||
<svg
|
||||
class="connected-checkmark"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-if="userData?.email" class="profile-email">
|
||||
{{ userData.email }}
|
||||
</div>
|
||||
<div class="profile-badges">
|
||||
<div
|
||||
v-if="userData?.subscription?.active"
|
||||
class="profile-badge plex-pass"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0zm0 230.4C66.9 230.4 25.6 189.1 25.6 128S66.9 25.6 128 25.6 230.4 66.9 230.4 128 189.1 230.4 128 230.4z"
|
||||
/>
|
||||
</svg>
|
||||
Plex Pass
|
||||
</div>
|
||||
<div v-if="userData?.joined_at" class="profile-badge member-since">
|
||||
{{ formatMemberSince(userData.joined_at) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="userData?.two_factor_enabled"
|
||||
class="profile-badge two-factor"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
2FA
|
||||
</div>
|
||||
<div
|
||||
v-if="userData?.experimental_features"
|
||||
class="profile-badge experimental"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path
|
||||
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
||||
></path>
|
||||
</svg>
|
||||
Labs
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
username: string;
|
||||
userData: any;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
function formatMemberSince(dateString: string) {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const years = now.getFullYear() - date.getFullYear();
|
||||
|
||||
if (years === 0) return "New Member";
|
||||
if (years === 1) return "1 Year";
|
||||
return `${years} Years`;
|
||||
} catch {
|
||||
return "Member";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.plex-profile-card {
|
||||
background-color: var(--background-ui);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
border: 1px solid var(--background-40);
|
||||
|
||||
@include mobile-only {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--highlight-color);
|
||||
flex-shrink: 0;
|
||||
|
||||
@include mobile-only {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-avatar-placeholder {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--highlight-color),
|
||||
var(--background-40)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
flex-shrink: 0;
|
||||
|
||||
@include mobile-only {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.username-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.profile-username {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.connected-checkmark {
|
||||
color: var(--color-success-highlight);
|
||||
flex-shrink: 0;
|
||||
animation: checkmarkPop 0.3s ease-out;
|
||||
|
||||
@include mobile-only {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes checkmarkPop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-email {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-60);
|
||||
margin-bottom: 0.4rem;
|
||||
word-break: break-all;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.profile-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
}
|
||||
|
||||
&.plex-pass {
|
||||
background-color: #cc7b19;
|
||||
color: $white;
|
||||
text-transform: uppercase;
|
||||
|
||||
svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
|
||||
@include mobile-only {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.member-since {
|
||||
background-color: var(--background-40);
|
||||
color: var(--text-color-70);
|
||||
}
|
||||
|
||||
&.two-factor {
|
||||
background-color: var(--color-success);
|
||||
color: $white;
|
||||
|
||||
svg {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
|
||||
@include mobile-only {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.experimental {
|
||||
background-color: #8b5cf6;
|
||||
color: $white;
|
||||
|
||||
svg {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
|
||||
@include mobile-only {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
126
src/components/plex/PlexServerInfo.vue
Normal file
126
src/components/plex/PlexServerInfo.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="plex-server-info">
|
||||
<div class="plex-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">
|
||||
<IconServer class="label-icon" style="fill: var(--text-color)" />
|
||||
Plex server name
|
||||
</span>
|
||||
<span class="detail-value">{{ serverName || "Unknown" }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">
|
||||
<IconSync class="label-icon" style="stroke: var(--text-color)" />
|
||||
Last Sync
|
||||
</span>
|
||||
<span class="detail-value">{{ lastSync || "Never" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plex-actions">
|
||||
<seasoned-button @click="$emit('sync')" :disabled="syncing">
|
||||
<IconSync v-if="!syncing" class="button-icon" />
|
||||
{{ syncing ? "Syncing..." : "Sync Library" }}
|
||||
</seasoned-button>
|
||||
<seasoned-button @click="$emit('unlink')">
|
||||
Unlink Account
|
||||
</seasoned-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
|
||||
import IconServer from "@/icons/IconServer.vue";
|
||||
import IconSync from "@/icons/IconSync.vue";
|
||||
|
||||
interface Props {
|
||||
serverName: string;
|
||||
lastSync: string;
|
||||
syncing?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
defineEmits<{
|
||||
sync: [];
|
||||
unlink: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.plex-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background-color: var(--background-ui);
|
||||
border-radius: 0.25rem;
|
||||
|
||||
@include mobile-only {
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.95rem;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.plex-actions {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
|
||||
@include mobile-only {
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
152
src/components/plex/PlexUnlinkModal.vue
Normal file
152
src/components/plex/PlexUnlinkModal.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="modal-overlay" @click="emit('cancel')">
|
||||
<div class="modal-content" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>Unlink Plex Account</h3>
|
||||
<button class="close-btn" @click="emit('cancel')">
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
Are you sure you want to unlink your Plex account? You will lose
|
||||
access to:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Watch history tracking</li>
|
||||
<li>Recently added content notifications</li>
|
||||
<li>Real-time download progress</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="cancel-btn" @click="emit('cancel')">Cancel</button>
|
||||
<button class="confirm-btn" @click="emit('confirm')">
|
||||
Unlink Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconClose from "@/icons/IconClose.vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "confirm"): void;
|
||||
(e: "cancel"): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-body p {
|
||||
margin: 0 0 16px;
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.modal-body ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.modal-body li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.confirm-btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.confirm-btn:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +2,12 @@
|
||||
<li
|
||||
class="sidebar-list-element"
|
||||
:class="{ active, disabled }"
|
||||
:tabindex="disabled ? -1 : 0"
|
||||
role="button"
|
||||
:aria-disabled="disabled"
|
||||
@click="emit('click')"
|
||||
@keydown.enter="emit('click')"
|
||||
@keydown.enter.prevent="emit('click')"
|
||||
@keydown.space.prevent="emit('click')"
|
||||
>
|
||||
<slot></slot>
|
||||
</li>
|
||||
@@ -53,8 +57,10 @@
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.active {
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
|
||||
div > svg,
|
||||
svg {
|
||||
@@ -63,6 +69,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--highlight-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.active > div > svg,
|
||||
&.active > svg {
|
||||
fill: var(--highlight-color);
|
||||
|
||||
@@ -32,9 +32,7 @@
|
||||
<IconThumbsUp v-if="media?.exists_in_plex" />
|
||||
<IconThumbsDown v-else />
|
||||
{{
|
||||
!media?.exists_in_plex
|
||||
? "Not yet available"
|
||||
: "Already available 🎉"
|
||||
!media?.exists_in_plex ? "Not yet available" : "Already available"
|
||||
}}
|
||||
</action-button>
|
||||
|
||||
@@ -46,6 +44,11 @@
|
||||
{{ !requested ? `Request ${type}?` : "Already requested" }}
|
||||
</action-button>
|
||||
|
||||
<action-button v-if="admin && requested" :active="false">
|
||||
<IconTombstone />
|
||||
Remove request
|
||||
</action-button>
|
||||
|
||||
<action-button
|
||||
v-if="plexUserId && media?.exists_in_plex"
|
||||
@click="openInPlex"
|
||||
@@ -66,9 +69,15 @@
|
||||
<action-button
|
||||
v-if="admin === true"
|
||||
:active="showTorrents"
|
||||
@click="showTorrents = !showTorrents"
|
||||
@click="
|
||||
showTorrents = !showTorrents;
|
||||
helmKey++;
|
||||
"
|
||||
>
|
||||
<IconBinoculars />
|
||||
<IconHelm
|
||||
:key="helmKey"
|
||||
:class="showTorrents ? 'helm-spin-forward' : 'helm-spin-reverse'"
|
||||
/>
|
||||
Search for torrents
|
||||
<span v-if="numberOfTorrentResults" class="meta">{{
|
||||
numberOfTorrentResults
|
||||
@@ -176,8 +185,9 @@
|
||||
import IconInfo from "../../icons/IconInfo.vue";
|
||||
import IconRequest from "../../icons/IconRequest.vue";
|
||||
import IconRequested from "../../icons/IconRequested.vue";
|
||||
import IconBinoculars from "../../icons/IconBinoculars.vue";
|
||||
import IconHelm from "../../icons/IconHelm.vue";
|
||||
import IconPlay from "../../icons/IconPlay.vue";
|
||||
import IconTombstone from "../../icons/IconTombstone.vue";
|
||||
import TorrentList from "../torrent/TruncatedTorrentResults.vue";
|
||||
import CastList from "../CastList.vue";
|
||||
import Detail from "./Detail.vue";
|
||||
@@ -215,7 +225,7 @@
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const ASSET_URL = "https://image.tmdb.org/t/p/";
|
||||
const COLORS_URL = "https://colors.schleppe.cloud/colors";
|
||||
const COLORS_API = import.meta.env.VITE_SEASONED_COLORS_API || "";
|
||||
const ASSET_SIZES = ["w500", "w780", "original"];
|
||||
|
||||
const media: Ref<IMovie | IShow> = ref();
|
||||
@@ -226,6 +236,7 @@
|
||||
const compact: Ref<boolean> = ref();
|
||||
const loading: Ref<boolean> = ref();
|
||||
const backdropElement: Ref<HTMLElement> = ref();
|
||||
const helmKey: Ref<number> = ref(0);
|
||||
|
||||
const store = useStore();
|
||||
|
||||
@@ -352,7 +363,7 @@
|
||||
}
|
||||
|
||||
async function colorsFromPoster(posterPath: string) {
|
||||
const url = new URL(COLORS_URL);
|
||||
const url = new URL("/colors", COLORS_API);
|
||||
url.searchParams.append("id", posterPath.replace("/", ""));
|
||||
url.searchParams.append("size", "w342");
|
||||
|
||||
@@ -435,7 +446,7 @@
|
||||
|
||||
> img {
|
||||
width: 100%;
|
||||
border-radius: inherit;
|
||||
border-radius: calc(1.6rem - 1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -590,4 +601,30 @@
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.helm-spin-forward {
|
||||
animation: helm-spin-forward 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
.helm-spin-reverse {
|
||||
animation: helm-spin-reverse 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes helm-spin-forward {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes helm-spin-reverse {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(-270deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
</Detail>
|
||||
|
||||
<Detail
|
||||
v-if="creditedShows.length"
|
||||
v-if="creditedMovies.length"
|
||||
title="movies"
|
||||
:detail="`Credited in ${creditedMovies.length} movies`"
|
||||
>
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="settings__header">Change password</h3>
|
||||
<form class="form">
|
||||
<seasoned-input
|
||||
v-model="oldPassword"
|
||||
placeholder="old password"
|
||||
icon="Keyhole"
|
||||
type="password"
|
||||
/>
|
||||
<div class="change-password">
|
||||
<div class="password-card">
|
||||
<form class="password-form" @submit.prevent>
|
||||
<seasoned-input
|
||||
v-model="oldPassword"
|
||||
placeholder="Current password"
|
||||
icon="Keyhole"
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<seasoned-input
|
||||
v-model="newPassword"
|
||||
placeholder="new password"
|
||||
icon="Keyhole"
|
||||
type="password"
|
||||
/>
|
||||
<div class="password-generator">
|
||||
<button class="generator-toggle" @click="toggleGenerator">
|
||||
<IconKey class="toggle-icon" />
|
||||
<span>{{
|
||||
showGenerator ? "Hide" : "Generate Strong Password"
|
||||
}}</span>
|
||||
</button>
|
||||
<div v-if="showGenerator">
|
||||
<password-generator @password-generated="handleGeneratedPassword" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<seasoned-input
|
||||
v-model="newPasswordRepeat"
|
||||
placeholder="repeat new password"
|
||||
icon="Keyhole"
|
||||
type="password"
|
||||
/>
|
||||
<seasoned-input
|
||||
v-model="newPassword"
|
||||
placeholder="New password"
|
||||
icon="Keyhole"
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<seasoned-button @click="changePassword">change password</seasoned-button>
|
||||
</form>
|
||||
<seasoned-messages v-model:messages="messages" />
|
||||
<seasoned-input
|
||||
v-model="newPasswordRepeat"
|
||||
placeholder="Confirm new password"
|
||||
icon="Keyhole"
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<seasoned-button @click="changePassword" :disabled="loading">
|
||||
{{ loading ? "Updating..." : "Change Password" }}
|
||||
</seasoned-button>
|
||||
</form>
|
||||
<seasoned-messages v-model:messages="messages" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -34,65 +49,99 @@
|
||||
import SeasonedInput from "@/components/ui/SeasonedInput.vue";
|
||||
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
|
||||
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
|
||||
import PasswordGenerator from "@/components/settings/PasswordGenerator.vue";
|
||||
import IconKey from "@/icons/IconKey.vue";
|
||||
import type { Ref } from "vue";
|
||||
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
|
||||
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
|
||||
|
||||
// interface ResetPasswordPayload {
|
||||
// old_password: string;
|
||||
// new_password: string;
|
||||
// }
|
||||
|
||||
const showGenerator = ref(false);
|
||||
const oldPassword: Ref<string> = ref("");
|
||||
const newPassword: Ref<string> = ref("");
|
||||
const newPasswordRepeat: Ref<string> = ref("");
|
||||
const messages: Ref<IErrorMessage[]> = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
function addWarningMessage(message: string, title?: string) {
|
||||
messages.value.push({
|
||||
message,
|
||||
title,
|
||||
type: ErrorMessageTypes.Warning
|
||||
} as IErrorMessage);
|
||||
function handleGeneratedPassword(password: string) {
|
||||
newPassword.value = password;
|
||||
newPasswordRepeat.value = password;
|
||||
}
|
||||
|
||||
function validate() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!oldPassword.value || oldPassword?.value?.length === 0) {
|
||||
addWarningMessage("Missing old password!", "Validation error");
|
||||
reject();
|
||||
}
|
||||
|
||||
if (!newPassword.value || newPassword?.value?.length === 0) {
|
||||
addWarningMessage("Missing new password!", "Validation error");
|
||||
reject();
|
||||
}
|
||||
|
||||
if (newPassword.value !== newPasswordRepeat.value) {
|
||||
addWarningMessage(
|
||||
"Password and password repeat do not match!",
|
||||
"Validation error"
|
||||
);
|
||||
reject();
|
||||
}
|
||||
|
||||
resolve(true);
|
||||
});
|
||||
}
|
||||
|
||||
// TODO seasoned-api /user/password-reset
|
||||
async function changePassword() {
|
||||
async function changePassword(event: CustomEvent) {
|
||||
try {
|
||||
validate();
|
||||
messages.value.push({
|
||||
message: "Password change is currently disabled",
|
||||
title: "Feature Disabled",
|
||||
type: ErrorMessageTypes.Warning
|
||||
} as IErrorMessage);
|
||||
|
||||
// Clear form
|
||||
oldPassword.value = "";
|
||||
newPassword.value = "";
|
||||
newPasswordRepeat.value = "";
|
||||
|
||||
loading.value = false;
|
||||
} catch (error) {
|
||||
console.log("not valid! error:", error); // eslint-disable-line no-console
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// const body: ResetPasswordPayload = {
|
||||
// old_password: oldPassword.value,
|
||||
// new_password: newPassword.value
|
||||
// };
|
||||
// const options = {};
|
||||
// fetch()
|
||||
function toggleGenerator() {
|
||||
showGenerator.value = !showGenerator.value;
|
||||
/*
|
||||
if (showGenerator.value && !generatedPassword.value) {
|
||||
generateWordsPassword();
|
||||
}
|
||||
*/
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.password-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.generator-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--background-ui);
|
||||
border: 1px solid var(--background-40);
|
||||
border-radius: 0.5rem;
|
||||
color: $text-color;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-40);
|
||||
border-color: var(--highlight-color);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: var(--highlight-color);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
|
||||
72
src/components/settings/DangerZoneAction.vue
Normal file
72
src/components/settings/DangerZoneAction.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="danger-zone">
|
||||
<h3 class="danger-zone__title">{{ title }}</h3>
|
||||
<p class="danger-zone__description">
|
||||
{{ description }}
|
||||
</p>
|
||||
<button class="danger-zone__button" @click="$emit('action')">
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
}
|
||||
|
||||
interface Emit {
|
||||
(e: "action"): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
defineEmits<Emit>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.danger-zone {
|
||||
padding: 1.25rem;
|
||||
background: rgba(220, 48, 35, 0.1);
|
||||
border: 1px solid var(--color-error-highlight);
|
||||
border-radius: 0.5rem;
|
||||
|
||||
@include mobile-only {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-error-highlight);
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-70);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__button {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
border: 1px solid var(--color-error-highlight);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-error-highlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
53
src/components/settings/DataExport.vue
Normal file
53
src/components/settings/DataExport.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="data-export">
|
||||
<div class="export-options">
|
||||
<!-- Request History Card -->
|
||||
<RequestHistory :data="requestStats" />
|
||||
|
||||
<!-- Export Data Card -->
|
||||
<ExportSection :data="requestStats" />
|
||||
|
||||
<!-- Local Storage Items -->
|
||||
<StorageManager />
|
||||
|
||||
<!-- Delete Account -->
|
||||
<DangerZoneAction
|
||||
title="Delete Account"
|
||||
description="Permanently delete your account and all associated data. This action cannot be undone."
|
||||
button-text="Delete My Account"
|
||||
@action="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import StorageManager from "./StorageManager.vue";
|
||||
import ExportSection from "./ExportSection.vue";
|
||||
import RequestHistory from "./RequestHistory.vue";
|
||||
import DangerZoneAction from "./DangerZoneAction.vue";
|
||||
|
||||
const requestStats = ref({
|
||||
total: 45,
|
||||
approved: 38,
|
||||
pending: 7
|
||||
});
|
||||
|
||||
function confirmDelete() {
|
||||
const confirmed = confirm(
|
||||
"Are you sure you want to *permanently delete* your account and all associated data? This action cannot be undone."
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.export-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
</style>
|
||||
125
src/components/settings/ExportSection.vue
Normal file
125
src/components/settings/ExportSection.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="settings-section-card">
|
||||
<div class="settings-section-header">
|
||||
<h2>Export Your Data</h2>
|
||||
<p>
|
||||
Download a copy of your account data including requests, watch history,
|
||||
and preferences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Export to JSON & CSV section -->
|
||||
<div class="export-actions">
|
||||
<button
|
||||
class="export-btn"
|
||||
@click="() => exportData('json')"
|
||||
:disabled="exporting"
|
||||
>
|
||||
<IconActivity v-if="exporting" class="spin" />
|
||||
<span v-else>Export as JSON</span>
|
||||
</button>
|
||||
<button
|
||||
class="export-btn"
|
||||
@click="() => exportData('csv')"
|
||||
:disabled="exporting"
|
||||
>
|
||||
<IconActivity v-if="exporting" class="spin" />
|
||||
<span v-else>Export as CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps } from "vue";
|
||||
import IconActivity from "@/icons/IconActivity.vue";
|
||||
|
||||
interface Props {
|
||||
data: any;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const exporting = ref(false);
|
||||
|
||||
async function exportData(format: "json" | "csv") {
|
||||
exporting.value = true;
|
||||
|
||||
// Mock export
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
const data = {
|
||||
username: "user123",
|
||||
requests: props?.data,
|
||||
exportDate: new Date().toISOString()
|
||||
};
|
||||
|
||||
const blob = new Blob(
|
||||
[format === "json" ? JSON.stringify(data, null, 2) : convertToCSV(data)],
|
||||
{ type: format === "json" ? "application/json" : "text/csv" }
|
||||
);
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `seasoned-data-export.${format}`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
exporting.value = false;
|
||||
}
|
||||
|
||||
function convertToCSV(data: any): string {
|
||||
return `Username,Total Requests,Approved,Pending,Export Date\n${data.username},${data.requests.total},${data.requests.approved},${data.requests.pending},${data.exportDate}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/media-queries";
|
||||
@import "scss/shared-settings";
|
||||
|
||||
.export-actions {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
|
||||
@include mobile-only {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
flex: 1;
|
||||
padding: 0.55rem 0.85rem;
|
||||
background-color: var(--highlight-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-green-90);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: white;
|
||||
|
||||
&.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
597
src/components/settings/PasswordGenerator.vue
Normal file
597
src/components/settings/PasswordGenerator.vue
Normal file
@@ -0,0 +1,597 @@
|
||||
<template>
|
||||
<div class="password-generator">
|
||||
<div class="generator-panel">
|
||||
<div class="generator-tabs">
|
||||
<button
|
||||
:class="['tab', { 'tab--active': mode === 'words' }]"
|
||||
@click="mode = 'words'"
|
||||
>
|
||||
Passphrase
|
||||
</button>
|
||||
<button
|
||||
:class="['tab', { 'tab--active': mode === 'chars' }]"
|
||||
@click="mode = 'chars'"
|
||||
>
|
||||
Random
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'words'" class="generator-content">
|
||||
<div class="generator-header">
|
||||
<h4>Passphrase Generator</h4>
|
||||
<p>Create a memorable password using random words</p>
|
||||
</div>
|
||||
|
||||
<div class="password-display" @click="copyPassword">
|
||||
<span class="password-text password-text--mono">{{
|
||||
generatedPassword
|
||||
}}</span>
|
||||
<button
|
||||
class="copy-btn"
|
||||
:title="copied ? 'Copied!' : 'Click to copy'"
|
||||
>
|
||||
{{ copied ? "✓" : "📋" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="generator-options">
|
||||
<div class="option-row">
|
||||
<div class="slider-header">
|
||||
<label>Words</label>
|
||||
<span class="slider-value">{{ wordCount }}</span>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="wordCount"
|
||||
type="range"
|
||||
min="3"
|
||||
max="7"
|
||||
class="slider"
|
||||
@input="generateWordsPassword"
|
||||
/>
|
||||
<div class="slider-labels">
|
||||
<span>3</span>
|
||||
<span>7</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="generator-content">
|
||||
<div class="generator-header">
|
||||
<h4>Random Password Generator</h4>
|
||||
<p>Generate a secure random password</p>
|
||||
</div>
|
||||
|
||||
<div class="password-display" @click="copyPassword">
|
||||
<span class="password-text password-text--mono">{{
|
||||
generatedPassword
|
||||
}}</span>
|
||||
<button
|
||||
class="copy-btn"
|
||||
:title="copied ? 'Copied!' : 'Click to copy'"
|
||||
>
|
||||
{{ copied ? "✓" : "📋" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="generator-options">
|
||||
<div class="option-row">
|
||||
<div class="slider-header">
|
||||
<label>Length</label>
|
||||
<span class="slider-value">{{ charLength }}</span>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="charLength"
|
||||
type="range"
|
||||
min="12"
|
||||
max="46"
|
||||
class="slider"
|
||||
@input="generateCharsPassword"
|
||||
/>
|
||||
<div class="slider-labels">
|
||||
<span>12</span>
|
||||
<span>46</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option-row checkbox-row">
|
||||
<label>
|
||||
<input
|
||||
v-model="includeUppercase"
|
||||
type="checkbox"
|
||||
@change="generateCharsPassword"
|
||||
/>
|
||||
Uppercase (A-Z)
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
v-model="includeLowercase"
|
||||
type="checkbox"
|
||||
@change="generateCharsPassword"
|
||||
/>
|
||||
Lowercase (a-z)
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
v-model="includeNumbers"
|
||||
type="checkbox"
|
||||
@change="generateCharsPassword"
|
||||
/>
|
||||
Numbers (0-9)
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
v-model="includeSymbols"
|
||||
type="checkbox"
|
||||
@change="generateCharsPassword"
|
||||
/>
|
||||
Symbols (!@#$)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="generator-actions">
|
||||
<button class="action-btn action-btn--secondary" @click="regenerate">
|
||||
<IconActivity class="btn-icon" />
|
||||
Regenerate
|
||||
</button>
|
||||
<button class="action-btn action-btn--primary" @click="usePassword">
|
||||
Use This Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import IconActivity from "@/icons/IconActivity.vue";
|
||||
import { useRandomWords } from "@/composables/useRandomWords";
|
||||
|
||||
interface Emit {
|
||||
(e: "passwordGenerated", password: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emit>();
|
||||
|
||||
const mode = ref<"words" | "chars">("words");
|
||||
const generatedPassword = ref("");
|
||||
const copied = ref(false);
|
||||
|
||||
// Words mode options
|
||||
const wordCount = ref(4);
|
||||
const separator = ref("-");
|
||||
|
||||
// Chars mode options
|
||||
const charLength = ref(16);
|
||||
const includeUppercase = ref(true);
|
||||
const includeLowercase = ref(true);
|
||||
const includeNumbers = ref(true);
|
||||
const includeSymbols = ref(true);
|
||||
|
||||
const { getRandomWords } = useRandomWords();
|
||||
|
||||
async function generateWordsPassword() {
|
||||
const words = await getRandomWords(wordCount.value);
|
||||
const password = words.join(separator.value);
|
||||
generatedPassword.value = password;
|
||||
}
|
||||
|
||||
function generateCharsPassword() {
|
||||
let charset = "";
|
||||
|
||||
if (includeUppercase.value) charset += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
if (includeLowercase.value) charset += "abcdefghijklmnopqrstuvwxyz";
|
||||
if (includeNumbers.value) charset += "0123456789";
|
||||
if (includeSymbols.value) charset += "!@#$%^&*()_+-=[]{}|;:,.<>?";
|
||||
|
||||
if (charset === "") charset = "abcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
let password = "";
|
||||
for (let i = 0; i < charLength.value; i++) {
|
||||
password += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
}
|
||||
|
||||
generatedPassword.value = password;
|
||||
}
|
||||
|
||||
async function regenerate() {
|
||||
if (mode.value === "words") {
|
||||
await generateWordsPassword();
|
||||
} else {
|
||||
generateCharsPassword();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPassword() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedPassword.value);
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function usePassword() {
|
||||
emit("passwordGenerated", generatedPassword.value);
|
||||
// TODO: emit
|
||||
// showGenerator.value = false;
|
||||
}
|
||||
|
||||
watch(mode, async () => {
|
||||
if (mode.value === "words") {
|
||||
await generateWordsPassword();
|
||||
} else {
|
||||
generateCharsPassword();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(generateWordsPassword);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.password-generator {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.generator-panel {
|
||||
margin-top: 0.75rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--background-ui);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--background-40);
|
||||
|
||||
@include mobile-only {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.generator-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 0.65rem 1rem;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--background-40);
|
||||
border-radius: 0.25rem;
|
||||
color: $text-color-70;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-40);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--highlight-color);
|
||||
border-color: var(--highlight-color);
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.generator-content {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.generator-header {
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 0.15rem 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: $text-color;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: $text-color-70;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.password-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--background-color);
|
||||
border: 2px solid var(--highlight-color);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-40);
|
||||
}
|
||||
|
||||
@include mobile-only {
|
||||
padding: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.password-text {
|
||||
flex: 1;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--highlight-color);
|
||||
user-select: all;
|
||||
word-break: break-all;
|
||||
word-break: break-word;
|
||||
-webkit-hyphens: auto;
|
||||
hyphens: auto;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
&--mono {
|
||||
font-family: "Courier New", monospace;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.generator-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
label {
|
||||
font-size: 0.95rem;
|
||||
color: $text-color;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&.checkbox-row {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
|
||||
@include mobile-only {
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
|
||||
input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slider-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--highlight-color);
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.slider-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: $text-color-50;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.slider {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: var(--background-40);
|
||||
outline: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
margin: 0.5rem 0;
|
||||
|
||||
@include mobile-only {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--background-40);
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--highlight-color);
|
||||
cursor: grab;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.2s;
|
||||
margin-top: -7px;
|
||||
|
||||
@include mobile-only {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--highlight-color);
|
||||
cursor: grab;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.2s;
|
||||
|
||||
@include mobile-only {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
|
||||
@include mobile-only {
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: var(--background-40);
|
||||
|
||||
@include mobile-only {
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.separator-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--background-40);
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--background-color);
|
||||
color: $text-color;
|
||||
font-size: 0.85rem;
|
||||
font-family: "Courier New", monospace;
|
||||
text-align: center;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--highlight-color);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $text-color-50;
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.generator-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@include mobile-only {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 0.6rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
|
||||
&--secondary {
|
||||
background-color: var(--background-color);
|
||||
color: $text-color;
|
||||
border: 1px solid var(--background-40);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-40);
|
||||
}
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background-color: var(--highlight-color);
|
||||
color: $white;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-green-90);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
349
src/components/settings/PlexSettings.vue
Normal file
349
src/components/settings/PlexSettings.vue
Normal file
@@ -0,0 +1,349 @@
|
||||
<template>
|
||||
<div class="plex-settings">
|
||||
<!-- Unconnected state -->
|
||||
<PlexAuthButton
|
||||
v-if="!showPlexInformation"
|
||||
@auth-success="handleAuthSuccess"
|
||||
@auth-error="handleAuthError"
|
||||
/>
|
||||
|
||||
<!-- Connected state -->
|
||||
<div v-else class="plex-connected">
|
||||
<PlexProfileCard
|
||||
v-if="plexUsername"
|
||||
:username="plexUsername"
|
||||
:userData="plexUserData"
|
||||
/>
|
||||
|
||||
<PlexLibraryStats
|
||||
:movies="libraryStats?.movies"
|
||||
:shows="libraryStats?.['tv shows']"
|
||||
:music="libraryStats?.music"
|
||||
:watchtime="libraryStats?.watchtime || 0"
|
||||
:loading="syncingLibrary"
|
||||
@open-library="showLibraryDetails"
|
||||
/>
|
||||
|
||||
<PlexServerInfo
|
||||
:serverName="plexServer"
|
||||
:lastSync="lastSync"
|
||||
:syncing="syncingServer"
|
||||
@sync="syncLibrary"
|
||||
@unlink="() => (showUnlinkModal = true)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<SeasonedMessages v-model:messages="messages" />
|
||||
|
||||
<!-- Unlink Confirmation Modal -->
|
||||
<PlexUnlinkModal
|
||||
v-if="showUnlinkModal"
|
||||
@confirm="unauthenticatePlex"
|
||||
@cancel="() => (showUnlinkModal = false)"
|
||||
/>
|
||||
|
||||
<!-- Library Details Modal -->
|
||||
<PlexLibraryModal
|
||||
v-if="showLibraryModal && selectedLibrary"
|
||||
:libraryType="selectedLibrary"
|
||||
:details="libraryStats[selectedLibrary]"
|
||||
:serverUrl="plexServerUrl"
|
||||
:serverMachineId="plexMachineId"
|
||||
@close="closeLibraryModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
|
||||
import PlexAuthButton from "@/components/plex/PlexAuthButton.vue";
|
||||
import PlexProfileCard from "@/components/plex/PlexProfileCard.vue";
|
||||
import PlexLibraryStats from "@/components/plex/PlexLibraryStats.vue";
|
||||
import PlexServerInfo from "@/components/plex/PlexServerInfo.vue";
|
||||
import PlexUnlinkModal from "@/components/plex/PlexUnlinkModal.vue";
|
||||
import PlexLibraryModal from "@/components/plex/PlexLibraryModal.vue";
|
||||
import { usePlexAuth } from "@/composables/usePlexAuth";
|
||||
import {
|
||||
fetchPlexServers,
|
||||
fetchPlexUserData,
|
||||
fetchLibraryDetails
|
||||
} from "@/composables/usePlexApi";
|
||||
import type { Ref } from "vue";
|
||||
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
|
||||
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
|
||||
|
||||
const messages: Ref<IErrorMessage[]> = ref([]);
|
||||
const syncingServer = ref(false);
|
||||
const syncingLibrary = ref(false);
|
||||
const showUnlinkModal = ref(false);
|
||||
const plexUsername = ref<string>("");
|
||||
const plexUserData = ref<any>(null);
|
||||
const showPlexInformation = ref<boolean>(false);
|
||||
const hasLocalStorageData = ref<boolean>(false);
|
||||
const showLibraryModal = ref<boolean>(false);
|
||||
const selectedLibrary = ref<string>("");
|
||||
|
||||
const plexServer = ref("");
|
||||
const plexServerUrl = ref("");
|
||||
const plexMachineId = ref("");
|
||||
const lastSync = ref(sessionStorage.getItem("plex_library_last_sync"));
|
||||
const libraryStats = ref({
|
||||
movies: 0,
|
||||
shows: 0,
|
||||
music: 0,
|
||||
watchtime: 0
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "reload"): void;
|
||||
}>();
|
||||
|
||||
// Composables
|
||||
const { getPlexAuthCookie, setPlexAuthCookie, cleanup } = usePlexAuth();
|
||||
|
||||
// ----- Connection check -----
|
||||
function checkPlexConnection() {
|
||||
const authToken = getPlexAuthCookie();
|
||||
showPlexInformation.value = !!authToken;
|
||||
return showPlexInformation.value;
|
||||
}
|
||||
|
||||
// ----- Library loading -----
|
||||
async function loadPlexServer() {
|
||||
// return cached value from sessionStorage if exists
|
||||
const cacheKey = "plex_server_data";
|
||||
const cachedData = sessionStorage.getItem(cacheKey);
|
||||
if (cachedData) {
|
||||
const server = JSON.parse(cachedData);
|
||||
plexServer.value = server?.name;
|
||||
plexServerUrl.value = server?.url;
|
||||
plexMachineId.value = server?.machineIdentifier;
|
||||
return;
|
||||
}
|
||||
|
||||
// get token from cookie
|
||||
const authToken = getPlexAuthCookie();
|
||||
if (!authToken) return;
|
||||
|
||||
// make api call for data
|
||||
syncingServer.value = true;
|
||||
const server = await fetchPlexServers(authToken);
|
||||
|
||||
if (server) {
|
||||
// set server name & id
|
||||
plexServer.value = server?.name;
|
||||
plexServerUrl.value = server?.url;
|
||||
plexMachineId.value = server?.machineIdentifier;
|
||||
// cache in sessionStorage
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(server));
|
||||
|
||||
// set last-sync date
|
||||
const now = new Date().toLocaleString();
|
||||
lastSync.value = now;
|
||||
sessionStorage.setItem("plex_library_last_sync", now);
|
||||
} else {
|
||||
console.log("unable to load plex server informmation");
|
||||
}
|
||||
|
||||
syncingServer.value = false;
|
||||
}
|
||||
|
||||
// ----- User data loading -----
|
||||
async function loadPlexUserData() {
|
||||
// return cached value from sessionStorage if exists
|
||||
const cacheKey = "plex_user_data";
|
||||
const cachedData = sessionStorage.getItem(cacheKey);
|
||||
hasLocalStorageData.value = !!cachedData;
|
||||
if (cachedData) {
|
||||
plexUserData.value = JSON.parse(cachedData);
|
||||
plexUsername.value = plexUserData.value.username;
|
||||
return;
|
||||
}
|
||||
|
||||
// get token from cookie
|
||||
const authToken = getPlexAuthCookie();
|
||||
if (!authToken) return;
|
||||
|
||||
// make api call for data
|
||||
const userData = await fetchPlexUserData(authToken);
|
||||
|
||||
if (userData) {
|
||||
// set plex user data
|
||||
plexUserData.value = userData;
|
||||
plexUsername.value = userData?.username;
|
||||
|
||||
// cache in sessionStorage
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(userData));
|
||||
} else {
|
||||
console.log("unable to load user data from plex");
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Load plex libary details -----
|
||||
async function loadPlexLibraries() {
|
||||
// return cached value from sessionStorage if exists
|
||||
const cacheKey = "plex_library_data";
|
||||
const cachedData = sessionStorage.getItem(cacheKey);
|
||||
hasLocalStorageData.value = !!cachedData;
|
||||
if (cachedData) {
|
||||
libraryStats.value = JSON.parse(cachedData);
|
||||
return;
|
||||
}
|
||||
|
||||
// get token from cookie
|
||||
const authToken = getPlexAuthCookie();
|
||||
if (!authToken) return;
|
||||
|
||||
// make api call for data
|
||||
syncingLibrary.value = true;
|
||||
const library = await fetchLibraryDetails();
|
||||
|
||||
if (library) {
|
||||
libraryStats.value = library;
|
||||
// cache in sessionStorage
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(library));
|
||||
} else {
|
||||
console.log("unable to load plex library details");
|
||||
}
|
||||
|
||||
syncingLibrary.value = false;
|
||||
}
|
||||
|
||||
// ----- OAuth flow (handlers for PlexAuthButton events) -----
|
||||
async function handleAuthSuccess(authToken: string) {
|
||||
setPlexAuthCookie(authToken);
|
||||
checkPlexConnection();
|
||||
const success = await loadAll();
|
||||
|
||||
if (success) {
|
||||
messages.value.push({
|
||||
type: ErrorMessageTypes.Success,
|
||||
title: "Authenticated with Plex",
|
||||
message: "Successfully connected your Plex account"
|
||||
} as IErrorMessage);
|
||||
} else {
|
||||
console.error("[PlexSettings] Error in handleAuthSuccess:");
|
||||
|
||||
messages.value.push({
|
||||
type: ErrorMessageTypes.Error,
|
||||
title: "Authentication failed",
|
||||
message: "An error occurred while connecting to Plex"
|
||||
} as IErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthError(errorMessage: string) {
|
||||
messages.value.push({
|
||||
type: ErrorMessageTypes.Error,
|
||||
title: "Authentication failed",
|
||||
message: errorMessage
|
||||
} as IErrorMessage);
|
||||
}
|
||||
|
||||
// ----- Unlink flow -----
|
||||
async function unauthenticatePlex() {
|
||||
showUnlinkModal.value = false;
|
||||
sessionStorage.removeItem("plex_user_data");
|
||||
sessionStorage.removeItem("plex_server_data");
|
||||
sessionStorage.removeItem("plex_library_data");
|
||||
sessionStorage.removeItem("plex_library_last_sync");
|
||||
document.cookie =
|
||||
"plex_auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Strict";
|
||||
plexUserData.value = null;
|
||||
plexUsername.value = "";
|
||||
showPlexInformation.value = false;
|
||||
emit("reload");
|
||||
|
||||
messages.value.push({
|
||||
type: ErrorMessageTypes.Success,
|
||||
title: "Unlinked Plex account",
|
||||
message: "All browser storage has been clear of plex account"
|
||||
} as IErrorMessage);
|
||||
}
|
||||
|
||||
// ----- Library modal -----
|
||||
function showLibraryDetails(type: string) {
|
||||
selectedLibrary.value = type;
|
||||
document.getElementsByTagName("body")[0].classList.add("no-scroll");
|
||||
showLibraryModal.value = true;
|
||||
}
|
||||
function closeLibraryModal() {
|
||||
showLibraryModal.value = false;
|
||||
document.getElementsByTagName("body")[0].classList.remove("no-scroll");
|
||||
selectedLibrary.value = "";
|
||||
}
|
||||
|
||||
// ----- Sync -----
|
||||
async function syncLibrary() {
|
||||
const authToken = getPlexAuthCookie();
|
||||
if (!authToken) {
|
||||
messages.value.push({
|
||||
type: ErrorMessageTypes.Error,
|
||||
title: "Sync failed",
|
||||
message: "No authentication token found"
|
||||
} as IErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem("plex_user_data");
|
||||
sessionStorage.removeItem("plex_server_data");
|
||||
sessionStorage.removeItem("plex_library_data");
|
||||
|
||||
const success = await loadAll();
|
||||
|
||||
if (success) {
|
||||
messages.value.push({
|
||||
type: ErrorMessageTypes.Success,
|
||||
title: "Library synced",
|
||||
message: "Your Plex library has been successfully synced"
|
||||
} as IErrorMessage);
|
||||
} else {
|
||||
messages.value.push({
|
||||
type: ErrorMessageTypes.Error,
|
||||
title: "Sync failed",
|
||||
message: "An error occurred while syncing your library"
|
||||
} as IErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helper load all ----
|
||||
async function loadAll() {
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
loadPlexServer(),
|
||||
loadPlexUserData(),
|
||||
loadPlexLibraries()
|
||||
]);
|
||||
|
||||
success = true;
|
||||
} catch (error) {
|
||||
console.log("loadall error, some info might be missing");
|
||||
}
|
||||
|
||||
checkPlexConnection();
|
||||
return success;
|
||||
}
|
||||
|
||||
// ---- Lifecycle functions ----
|
||||
onMounted(loadAll);
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.plex-settings {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.plex-connected {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
</style>
|
||||
233
src/components/settings/ProfileHero.vue
Normal file
233
src/components/settings/ProfileHero.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="profile-hero">
|
||||
<div class="profile-hero__main">
|
||||
<div class="profile-hero__avatar">
|
||||
<div class="avatar-large">{{ userInitials }}</div>
|
||||
</div>
|
||||
<div class="profile-hero__info">
|
||||
<h1 class="profile-hero__name">{{ username }}</h1>
|
||||
<span :class="['profile-hero__badge', `badge--${userRole}`]">
|
||||
<a v-if="userRole === 'admin'" href="/admin">{{ userRole }}</a>
|
||||
<span v-else>{{ userRole }}</span>
|
||||
</span>
|
||||
<p class="profile-hero__member">Member since {{ memberSince }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-hero__stats">
|
||||
<div class="stat-large">
|
||||
<span class="stat-large__value">{{ stats.totalRequests }}</span>
|
||||
<span class="stat-large__label">Requests</span>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-large">
|
||||
<span class="stat-large__value">{{ stats.magnetsAdded }}</span>
|
||||
<span class="stat-large__label">Magnets Added</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const username = computed(() => store.getters["user/username"] || "User");
|
||||
const userRole = computed(() =>
|
||||
store.getters["user/admin"] ? "admin" : "user"
|
||||
);
|
||||
|
||||
const userInitials = computed(() => {
|
||||
return username.value.slice(0, 2).toUpperCase();
|
||||
});
|
||||
|
||||
const memberSince = computed(() => {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() - 6);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
});
|
||||
});
|
||||
|
||||
const stats = {
|
||||
totalRequests: 45,
|
||||
magnetsAdded: 127
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/media-queries";
|
||||
|
||||
.profile-hero {
|
||||
background-color: var(--background-color-secondary);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--background-40);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
|
||||
@include mobile-only {
|
||||
flex-direction: column;
|
||||
padding: 1.5rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
|
||||
@include mobile-only {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
|
||||
@include mobile-only {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: 2rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
|
||||
@include mobile-only {
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
&.badge--admin {
|
||||
background-color: var(--color-warning);
|
||||
color: black;
|
||||
}
|
||||
|
||||
&.badge--user {
|
||||
background-color: var(--background-40);
|
||||
}
|
||||
}
|
||||
|
||||
&__member {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-70);
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.75rem;
|
||||
padding-left: 1.75rem;
|
||||
border-left: 1px solid var(--background-40);
|
||||
|
||||
@include mobile-only {
|
||||
width: 100%;
|
||||
padding: 1rem 0 0 0;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--background-40);
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-large {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--highlight-color),
|
||||
var(--color-green-70)
|
||||
);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
|
||||
@include mobile-only {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-large {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
&__value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--highlight-color);
|
||||
line-height: 1;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-70);
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 45px;
|
||||
background-color: var(--background-40);
|
||||
|
||||
@include mobile-only {
|
||||
height: 45px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
103
src/components/settings/RequestHistory.vue
Normal file
103
src/components/settings/RequestHistory.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="export-card">
|
||||
<div class="settings-section-header">
|
||||
<h2>Request History</h2>
|
||||
<p>View and download your complete request history.</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-mini">
|
||||
<span class="stat-mini__value">{{ data.total }}</span>
|
||||
<span class="stat-mini__label">Total</span>
|
||||
</div>
|
||||
<div class="stat-mini">
|
||||
<span class="stat-mini__value">{{ data.approved }}</span>
|
||||
<span class="stat-mini__label">Approved</span>
|
||||
</div>
|
||||
<div class="stat-mini">
|
||||
<span class="stat-mini__value">{{ data.pending }}</span>
|
||||
<span class="stat-mini__label">Pending</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="view-btn" @click="viewHistory">View Full History</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
interface Props {
|
||||
data: any;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const router = useRouter();
|
||||
|
||||
function viewHistory() {
|
||||
router.push({ name: "profile" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/media-queries";
|
||||
@import "scss/shared-settings";
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.stat-mini {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.4rem;
|
||||
background-color: var(--background-color);
|
||||
border-radius: 0.25rem;
|
||||
|
||||
@include mobile-only {
|
||||
padding: 0.45rem 0.35rem;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--highlight-color);
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
margin-top: 0.15rem;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.85rem;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--background-40);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-40);
|
||||
border-color: var(--highlight-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
46
src/components/settings/SecuritySettings.vue
Normal file
46
src/components/settings/SecuritySettings.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="security-settings">
|
||||
<div class="security-settings__intro">
|
||||
<h2 class="security-settings__title">Security</h2>
|
||||
<p class="security-settings__description">
|
||||
Keep your account safe by using a strong, unique password. We recommend
|
||||
using a passphrase or generated password that's hard to guess.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<change-password />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ChangePassword from "@/components/profile/ChangePassword.vue";
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.security-settings {
|
||||
&__intro {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@include mobile-only {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color-70);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
215
src/components/settings/StorageManager.vue
Normal file
215
src/components/settings/StorageManager.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="storage-manager">
|
||||
<StorageSectionBrowser
|
||||
:sections="storageSections"
|
||||
@clear-item="clearItem"
|
||||
/>
|
||||
|
||||
<DangerZoneAction
|
||||
title="Clear All Browser Data"
|
||||
description="Remove all locally stored data at once. This includes preferences, history, and cached information."
|
||||
button-text="Clear All Data"
|
||||
@action="clearAllData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from "vue";
|
||||
import IconCookie from "@/icons/IconCookie.vue";
|
||||
import IconDatabase from "@/icons/IconDatabase.vue";
|
||||
import IconTimer from "@/icons/IconTimer.vue";
|
||||
import StorageSectionBrowser from "./StorageSectionBrowser.vue";
|
||||
import DangerZoneAction from "./DangerZoneAction.vue";
|
||||
import { formatBytes } from "../../utils";
|
||||
|
||||
interface StorageItem {
|
||||
key: string;
|
||||
description: string;
|
||||
size: string;
|
||||
type: "local" | "session" | "cookie";
|
||||
}
|
||||
|
||||
const notifications: {
|
||||
success: (options: {
|
||||
title: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
}) => void;
|
||||
error: (options: {
|
||||
title: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
}) => void;
|
||||
} = inject("notifications");
|
||||
|
||||
const dict = {
|
||||
commandPalette_stats: "Usage statistics for command palette navigation",
|
||||
"theme-preference": "Your selected color theme",
|
||||
plex_user_data: "Cached Plex account information",
|
||||
plex_library_data: "Cached Plex library details per section",
|
||||
plex_server_data: "Cached Plex server information",
|
||||
plex_library_last_sync: "UTC time string for last synced Plex data",
|
||||
plex_auth_token: "Authorized token from Plex.tv",
|
||||
authorization: "This sites user login token"
|
||||
};
|
||||
|
||||
const storageItems = computed<StorageItem[]>(() => {
|
||||
const items: StorageItem[] = [];
|
||||
|
||||
// local storage
|
||||
Object.keys(localStorage).map(key => {
|
||||
items.push({
|
||||
key,
|
||||
description: dict[key] ?? "",
|
||||
size: formatBytes(localStorage[key]?.length || 0),
|
||||
type: "local"
|
||||
});
|
||||
});
|
||||
|
||||
// session storage
|
||||
Object.keys(sessionStorage).map(key => {
|
||||
items.push({
|
||||
key,
|
||||
description: dict[key] ?? "",
|
||||
size: formatBytes(sessionStorage[key]?.length || 0),
|
||||
type: "session"
|
||||
});
|
||||
});
|
||||
|
||||
// cookies
|
||||
if (document.cookie) {
|
||||
document.cookie.split(";").forEach(cookie => {
|
||||
const [key, _] = cookie.trim().split("=");
|
||||
if (key) {
|
||||
items.push({
|
||||
key,
|
||||
description: dict[key] ?? "",
|
||||
size: formatBytes(cookie.length || 0),
|
||||
type: "cookie"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const getTotalSize = (items: StorageItem[]) => {
|
||||
const totalBytes = items.reduce((acc, item) => {
|
||||
const match = item.size.match(/^([\d.]+)\s*(\w+)$/);
|
||||
if (!match) return acc;
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2];
|
||||
return (
|
||||
acc +
|
||||
(unit === "KB"
|
||||
? value * 1024
|
||||
: unit === "MB"
|
||||
? value * 1024 * 1024
|
||||
: value)
|
||||
);
|
||||
}, 0);
|
||||
return formatBytes(totalBytes);
|
||||
};
|
||||
|
||||
const storageSections = computed(() => [
|
||||
{
|
||||
type: "local" as const,
|
||||
title: "LocalStorage",
|
||||
iconComponent: IconDatabase,
|
||||
description:
|
||||
"LocalStorage keeps data permanently on your device, even after closing your browser. It's used to remember your preferences and settings between visits.",
|
||||
items: storageItems.value.filter(item => item.type === "local"),
|
||||
get totalSize() {
|
||||
return getTotalSize(this.items);
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "session" as const,
|
||||
title: "SessionStorage",
|
||||
iconComponent: IconTimer,
|
||||
description:
|
||||
"SessionStorage keeps data temporarily while you browse. It's automatically cleared when you close your browser tab or window.",
|
||||
items: storageItems.value.filter(item => item.type === "session"),
|
||||
get totalSize() {
|
||||
return getTotalSize(this.items);
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "cookie" as const,
|
||||
title: "Cookies",
|
||||
iconComponent: IconCookie,
|
||||
description:
|
||||
"Cookies are small text files stored by your browser. They can be temporary (session cookies) or persistent, and are often used for authentication and tracking your activity.",
|
||||
items: storageItems.value.filter(item => item.type === "cookie"),
|
||||
get totalSize() {
|
||||
return getTotalSize(this.items);
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
function clearItem(key: string, type: "local" | "session" | "cookie") {
|
||||
try {
|
||||
if (type === "local") {
|
||||
localStorage.removeItem(key);
|
||||
} else if (type === "session") {
|
||||
sessionStorage.removeItem(key);
|
||||
} else if (type === "cookie") {
|
||||
document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||
}
|
||||
|
||||
notifications.success({
|
||||
title: "Data Cleared",
|
||||
description: `${key} has been cleared`,
|
||||
timeout: 3000
|
||||
});
|
||||
|
||||
// Force re-render
|
||||
storageItems.value;
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
title: "Error",
|
||||
description: `Failed to clear ${key}`,
|
||||
timeout: 5000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllData() {
|
||||
const confirmed = confirm(
|
||||
"Are you sure you want to clear all locally stored data? This action cannot be undone."
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
document.cookie.split(";").forEach(cookie => {
|
||||
const eqPos = cookie.indexOf("=");
|
||||
const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
|
||||
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
||||
});
|
||||
|
||||
notifications.success({
|
||||
title: "All Data Cleared",
|
||||
description: "All locally stored data has been removed",
|
||||
timeout: 3000
|
||||
});
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
title: "Error",
|
||||
description: "Failed to clear all data",
|
||||
timeout: 5000
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.storage-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
385
src/components/settings/StorageSectionBrowser.vue
Normal file
385
src/components/settings/StorageSectionBrowser.vue
Normal file
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<div class="browser-storage">
|
||||
<div class="settings-section-header">
|
||||
<h2>Browser Storage</h2>
|
||||
<p>
|
||||
Your browser stores data locally to make this site faster and remember
|
||||
your settings. View what's saved on this device and remove items
|
||||
anytime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="storage-sections">
|
||||
<div
|
||||
v-for="section in sections"
|
||||
:key="section.type"
|
||||
:class="`storage-section storage-section--${section.type}`"
|
||||
>
|
||||
<button
|
||||
class="storage-section__header"
|
||||
@click="
|
||||
expandedSections[section.type] = !expandedSections[section.type]
|
||||
"
|
||||
>
|
||||
<div class="storage-section__header-content">
|
||||
<component :is="section.iconComponent" class="section-icon" />
|
||||
<h3 class="storage-section__title">{{ section.title }}</h3>
|
||||
<span class="storage-section__count">{{
|
||||
section.items.length
|
||||
}}</span>
|
||||
<span class="storage-section__size">{{ section.totalSize }}</span>
|
||||
</div>
|
||||
<div class="chevron-container">
|
||||
<transition name="fade">
|
||||
<IconExpandVertical
|
||||
v-if="!expandedSections[section.type]"
|
||||
key="expand"
|
||||
class="storage-section__chevron"
|
||||
/>
|
||||
<IconShrinkVertical
|
||||
v-else
|
||||
key="shrink"
|
||||
class="storage-section__chevron"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
v-if="expandedSections[section.type]"
|
||||
class="storage-section__content"
|
||||
>
|
||||
<p class="storage-section__description">{{ section.description }}</p>
|
||||
<div class="storage-items">
|
||||
<div
|
||||
v-for="item in section.items"
|
||||
:key="item.key"
|
||||
:class="`storage-item storage-item--${section.type}`"
|
||||
>
|
||||
<component :is="section.iconComponent" class="type-icon" />
|
||||
<div class="storage-item__info">
|
||||
<h4 class="storage-item__title">{{ item.key }}</h4>
|
||||
<p class="storage-item__description">
|
||||
<span v-if="item.description">{{ item.description }} · </span>
|
||||
<span class="storage-item__size">{{ item.size }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="storage-item__delete"
|
||||
@click="$emit('clear-item', item.key, section.type)"
|
||||
:title="`Clear ${item.key}`"
|
||||
>
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import IconClose from "@/icons/IconClose.vue";
|
||||
import IconExpandVertical from "@/icons/IconExpandVertical.vue";
|
||||
import IconShrinkVertical from "@/icons/IconShrinkVertical.vue";
|
||||
|
||||
interface StorageItem {
|
||||
key: string;
|
||||
description: string;
|
||||
size: string;
|
||||
type: "local" | "session" | "cookie";
|
||||
}
|
||||
|
||||
interface StorageSection {
|
||||
type: "local" | "session" | "cookie";
|
||||
title: string;
|
||||
description: string;
|
||||
iconComponent: any;
|
||||
items: StorageItem[];
|
||||
totalSize: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
sections: StorageSection[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"clear-item": [key: string, type: "local" | "session" | "cookie"];
|
||||
}>();
|
||||
|
||||
const expandedSections = ref<Record<string, boolean>>({
|
||||
local: false,
|
||||
session: false,
|
||||
cookie: false
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
@import "scss/shared-settings";
|
||||
|
||||
.browser-storage {
|
||||
&__intro {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.storage-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.storage-section {
|
||||
border-radius: 0.5rem;
|
||||
background: var(--background-ui);
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&--local {
|
||||
border-color: rgba(139, 92, 246, 0.2);
|
||||
.section-icon,
|
||||
.type-icon {
|
||||
stroke: #8b5cf6;
|
||||
}
|
||||
}
|
||||
&--session {
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
.section-icon,
|
||||
.type-icon {
|
||||
stroke: #f59e0b;
|
||||
}
|
||||
}
|
||||
&--cookie {
|
||||
border-color: rgba(236, 72, 153, 0.2);
|
||||
.section-icon,
|
||||
.type-icon {
|
||||
fill: #ec4899;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
&:hover {
|
||||
background: var(--background-40);
|
||||
}
|
||||
}
|
||||
|
||||
&__header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
.section-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
&__count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 0.5rem;
|
||||
background: var(--background-40);
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-70);
|
||||
}
|
||||
&__size {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color-50);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: var(--text-color-70);
|
||||
}
|
||||
|
||||
&__content {
|
||||
border-top: 1px solid var(--background-40);
|
||||
}
|
||||
&__description {
|
||||
margin: 0;
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color-70);
|
||||
background: var(--background-color);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.storage-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.storage-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
background: var(--background-color);
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
border-left: 3px solid;
|
||||
|
||||
&--local {
|
||||
border-color: #8b5cf6;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(139, 92, 246, 0.1),
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
&--session {
|
||||
border-color: #f59e0b;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(245, 158, 11, 0.1),
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
&--cookie {
|
||||
border-color: #ec4899;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(236, 72, 153, 0.1),
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
|
||||
&:hover .storage-item__delete {
|
||||
background: var(--color-error-highlight);
|
||||
}
|
||||
&:hover .type-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
margin: auto 1.5rem;
|
||||
@include mobile-only {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin: auto 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
min-width: 0;
|
||||
padding: 0.85rem 0.85rem 0.85rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@include mobile-only {
|
||||
padding: 0.75rem 0.75rem 0.75rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
@include mobile-only {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
&__description {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color-70);
|
||||
line-height: 1.4;
|
||||
@include mobile-only {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
&__size {
|
||||
color: var(--text-color-50);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&__delete {
|
||||
width: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-error);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
@include mobile-only {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: white;
|
||||
transition: transform 0.2s;
|
||||
@include mobile-only {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: var(--color-error-highlight);
|
||||
svg {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
&:active svg {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chevron-container {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// Simple crossfade transition
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.fade-enter, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
462
src/components/settings/StorageSectionServer.vue
Normal file
462
src/components/settings/StorageSectionServer.vue
Normal file
@@ -0,0 +1,462 @@
|
||||
<template>
|
||||
<div class="server-storage">
|
||||
<div class="server-storage__intro">
|
||||
<h2 class="server-storage__title">Server Storage</h2>
|
||||
<p class="server-storage__description">
|
||||
Data stored on our servers to sync across your devices and provide
|
||||
personalized features.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="server-sections">
|
||||
<div
|
||||
v-for="section in serverSections"
|
||||
:key="section.type"
|
||||
:class="`server-section server-section--${section.type}`"
|
||||
>
|
||||
<button
|
||||
class="server-section__header"
|
||||
@click="
|
||||
expandedSections[section.type] = !expandedSections[section.type]
|
||||
"
|
||||
>
|
||||
<div class="server-section__header-content">
|
||||
<component :is="section.iconComponent" class="section-icon" />
|
||||
<h3 class="server-section__title">{{ section.title }}</h3>
|
||||
<span class="server-section__count">{{
|
||||
section.items.length
|
||||
}}</span>
|
||||
<span class="server-section__size">{{ section.totalSize }}</span>
|
||||
</div>
|
||||
<svg
|
||||
class="server-section__chevron"
|
||||
:class="{
|
||||
'server-section__chevron--expanded':
|
||||
expandedSections[section.type]
|
||||
}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="expandedSections[section.type]"
|
||||
class="server-section__content"
|
||||
>
|
||||
<p class="server-section__description">{{ section.description }}</p>
|
||||
<div class="server-items">
|
||||
<div
|
||||
v-for="item in section.items"
|
||||
:key="item.key"
|
||||
:class="`server-item server-item--${section.type}`"
|
||||
>
|
||||
<component :is="section.iconComponent" class="type-icon" />
|
||||
<div class="server-item__info">
|
||||
<h4 class="server-item__title">{{ item.key }}</h4>
|
||||
<p class="server-item__description">
|
||||
<span v-if="item.description">{{ item.description }} · </span>
|
||||
<span class="server-item__size">{{ item.size }}</span>
|
||||
<span v-if="item.lastSynced" class="server-item__synced">
|
||||
· Last synced: {{ item.lastSynced }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="server-item__delete"
|
||||
@click="$emit('clear-item', item.key, section.type)"
|
||||
:title="`Delete ${item.key}`"
|
||||
>
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import IconClose from "@/icons/IconClose.vue";
|
||||
import IconProfile from "@/icons/IconProfile.vue";
|
||||
import IconSettings from "@/icons/IconSettings.vue";
|
||||
import IconActivity from "@/icons/IconActivity.vue";
|
||||
|
||||
interface ServerItem {
|
||||
key: string;
|
||||
description: string;
|
||||
size: string;
|
||||
lastSynced?: string;
|
||||
}
|
||||
|
||||
defineEmits<{
|
||||
"clear-item": [key: string, type: string];
|
||||
}>();
|
||||
|
||||
const expandedSections = ref<Record<string, boolean>>({
|
||||
profile: false,
|
||||
preferences: false,
|
||||
activity: false
|
||||
});
|
||||
|
||||
// Mock server data
|
||||
const serverSections = computed(() => [
|
||||
{
|
||||
type: "profile",
|
||||
title: "Profile Data",
|
||||
iconComponent: IconProfile,
|
||||
description:
|
||||
"Your account information, settings, and preferences stored on our servers.",
|
||||
items: [
|
||||
{
|
||||
key: "user_profile",
|
||||
description: "User account details",
|
||||
size: "2.4 KB",
|
||||
lastSynced: "2 hours ago"
|
||||
},
|
||||
{
|
||||
key: "avatar_image",
|
||||
description: "Profile picture",
|
||||
size: "145 KB",
|
||||
lastSynced: "1 day ago"
|
||||
},
|
||||
{
|
||||
key: "email_preferences",
|
||||
description: "Notification settings",
|
||||
size: "512 Bytes",
|
||||
lastSynced: "3 days ago"
|
||||
}
|
||||
],
|
||||
totalSize: "147.9 KB"
|
||||
},
|
||||
{
|
||||
type: "preferences",
|
||||
title: "Synced Preferences",
|
||||
iconComponent: IconSettings,
|
||||
description:
|
||||
"Settings that sync across all your devices when you sign in.",
|
||||
items: [
|
||||
{
|
||||
key: "theme_settings",
|
||||
description: "Color theme and appearance",
|
||||
size: "1.1 KB",
|
||||
lastSynced: "5 hours ago"
|
||||
},
|
||||
{
|
||||
key: "playback_settings",
|
||||
description: "Video and audio preferences",
|
||||
size: "856 Bytes",
|
||||
lastSynced: "1 day ago"
|
||||
},
|
||||
{
|
||||
key: "library_filters",
|
||||
description: "Saved filters and sorting",
|
||||
size: "2.3 KB",
|
||||
lastSynced: "2 days ago"
|
||||
}
|
||||
],
|
||||
totalSize: "4.3 KB"
|
||||
},
|
||||
{
|
||||
type: "activity",
|
||||
title: "Activity History",
|
||||
iconComponent: IconActivity,
|
||||
description:
|
||||
"Your viewing history and watch progress stored on our servers.",
|
||||
items: [
|
||||
{
|
||||
key: "watch_history",
|
||||
description: "Recently watched items",
|
||||
size: "12.5 KB",
|
||||
lastSynced: "1 hour ago"
|
||||
},
|
||||
{
|
||||
key: "watch_progress",
|
||||
description: "Playback positions",
|
||||
size: "8.2 KB",
|
||||
lastSynced: "30 minutes ago"
|
||||
},
|
||||
{
|
||||
key: "favorites",
|
||||
description: "Starred and favorited content",
|
||||
size: "3.7 KB",
|
||||
lastSynced: "6 hours ago"
|
||||
}
|
||||
],
|
||||
totalSize: "24.4 KB"
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.server-storage {
|
||||
&__intro {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
color: var(--text-color-70);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.server-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.server-section {
|
||||
border-radius: 0.5rem;
|
||||
background: var(--background-ui);
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&--profile {
|
||||
border-color: rgba(59, 130, 246, 0.2);
|
||||
.section-icon,
|
||||
.type-icon {
|
||||
fill: #3b82f6;
|
||||
}
|
||||
}
|
||||
&--preferences {
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
.section-icon,
|
||||
.type-icon {
|
||||
fill: #10b981;
|
||||
}
|
||||
}
|
||||
&--activity {
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
.section-icon,
|
||||
.type-icon {
|
||||
fill: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
&:hover {
|
||||
background: var(--background-40);
|
||||
}
|
||||
}
|
||||
|
||||
&__header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
.section-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
&__count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 0.5rem;
|
||||
background: var(--background-40);
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-70);
|
||||
}
|
||||
&__size {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color-50);
|
||||
font-family: monospace;
|
||||
}
|
||||
&__chevron {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: var(--text-color-70);
|
||||
transition: transform 0.2s ease;
|
||||
&--expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
&__content {
|
||||
border-top: 1px solid var(--background-40);
|
||||
}
|
||||
&__description {
|
||||
margin: 0;
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color-70);
|
||||
background: var(--background-color);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.server-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.server-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
background: var(--background-color);
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
border-left: 3px solid;
|
||||
|
||||
&--profile {
|
||||
border-color: #3b82f6;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(59, 130, 246, 0.1),
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
&--preferences {
|
||||
border-color: #10b981;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(16, 185, 129, 0.1),
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
&--activity {
|
||||
border-color: #f59e0b;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(245, 158, 11, 0.1),
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
|
||||
&:hover .server-item__delete {
|
||||
background: var(--color-error-highlight);
|
||||
}
|
||||
&:hover .type-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
margin: auto 1.5rem;
|
||||
@include mobile-only {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin: auto 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
min-width: 0;
|
||||
padding: 0.85rem 0.85rem 0.85rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@include mobile-only {
|
||||
padding: 0.75rem 0.75rem 0.75rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
@include mobile-only {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
&__description {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color-70);
|
||||
line-height: 1.4;
|
||||
@include mobile-only {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
&__size {
|
||||
color: var(--text-color-50);
|
||||
font-family: monospace;
|
||||
}
|
||||
&__synced {
|
||||
color: var(--text-color-50);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&__delete {
|
||||
width: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-error);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
@include mobile-only {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: white;
|
||||
transition: transform 0.2s;
|
||||
@include mobile-only {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: var(--color-error-highlight);
|
||||
svg {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
&:active svg {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
355
src/components/settings/ThemePreferences.vue
Normal file
355
src/components/settings/ThemePreferences.vue
Normal file
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<div class="theme-preferences">
|
||||
<div class="current-theme">
|
||||
<div class="theme-display">
|
||||
<div class="theme-icon" :data-theme="selectedTheme">
|
||||
<div class="icon-inner"></div>
|
||||
</div>
|
||||
<div class="theme-info">
|
||||
<span class="theme-label">Current Theme</span>
|
||||
<h3 class="theme-name">{{ currentThemeName }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-grid">
|
||||
<button
|
||||
v-for="theme in themes"
|
||||
:key="theme.value"
|
||||
:class="['theme-card', { active: selectedTheme === theme.value }]"
|
||||
@click="selectTheme(theme.value)"
|
||||
>
|
||||
<div class="theme-card__preview" :data-theme="theme.value">
|
||||
<div class="preview-circle"></div>
|
||||
</div>
|
||||
<span class="theme-card__name">{{ theme.label }}</span>
|
||||
<div v-if="selectedTheme === theme.value" class="theme-card__badge">
|
||||
Active
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
|
||||
const themes = [
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "ocean", label: "Ocean" },
|
||||
{ value: "nordic", label: "Nordic" },
|
||||
{ value: "halloween", label: "Halloween" }
|
||||
] as const;
|
||||
|
||||
const { currentTheme, savedTheme, setTheme } = useTheme();
|
||||
const selectedTheme = currentTheme;
|
||||
|
||||
const currentThemeName = computed(
|
||||
() => themes.find(t => t.value === selectedTheme.value)?.label ?? "Auto"
|
||||
);
|
||||
|
||||
function selectTheme(theme: string) {
|
||||
setTheme(theme as any);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
selectedTheme.value = savedTheme.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.current-theme {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem 2rem;
|
||||
background-color: var(--background-ui);
|
||||
border-radius: 1rem;
|
||||
border: 2px solid var(--background-40);
|
||||
|
||||
@include mobile-only {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.theme-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
|
||||
@include mobile-only {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@include mobile-only {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.icon-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
border: 3px solid;
|
||||
}
|
||||
|
||||
&[data-theme="light"] .icon-inner {
|
||||
background: linear-gradient(135deg, #f8f8f8, #e8e8e8);
|
||||
border-color: #01d277;
|
||||
}
|
||||
&[data-theme="dark"] .icon-inner {
|
||||
background: linear-gradient(135deg, #1a1a1a, #0a0a0a);
|
||||
border-color: #01d277;
|
||||
}
|
||||
&[data-theme="ocean"] .icon-inner {
|
||||
background: linear-gradient(135deg, #0f2027, #2c5364);
|
||||
border-color: #00d4ff;
|
||||
}
|
||||
&[data-theme="nordic"] .icon-inner {
|
||||
background: linear-gradient(135deg, #f5f0e8, #d8cdb9);
|
||||
border-color: #3d6e4e;
|
||||
}
|
||||
&[data-theme="halloween"] .icon-inner {
|
||||
background: linear-gradient(135deg, #1a0e2e, #2d1b3d);
|
||||
border-color: #ff6600;
|
||||
}
|
||||
&[data-theme="auto"] .icon-inner {
|
||||
background: conic-gradient(#f8f8f8 0deg 180deg, #1a1a1a 180deg);
|
||||
border-color: #01d277;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85rem;
|
||||
color: $text-color-70;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.25rem;
|
||||
|
||||
@include mobile-only {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 1rem;
|
||||
background-color: var(--background-ui);
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 2px solid transparent;
|
||||
text-align: center;
|
||||
|
||||
@include mobile-only {
|
||||
padding: 0.85rem;
|
||||
border-radius: 0.5rem;
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--highlight-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--highlight-color);
|
||||
background-color: var(--background-40);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&__preview {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.75rem;
|
||||
position: relative;
|
||||
border: 1px solid var(--background-40);
|
||||
|
||||
@include mobile-only {
|
||||
height: 100px;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.preview-circle {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
}
|
||||
}
|
||||
|
||||
&__preview[data-theme="light"] {
|
||||
background: #f8f8f8;
|
||||
.preview-circle {
|
||||
background: #01d277;
|
||||
}
|
||||
&::before {
|
||||
background: #fff;
|
||||
border-color: rgba(8, 28, 36, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__preview[data-theme="dark"] {
|
||||
background: #111;
|
||||
.preview-circle {
|
||||
background: #01d277;
|
||||
}
|
||||
&::before {
|
||||
background: #060708;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__preview[data-theme="ocean"] {
|
||||
background: #0f2027;
|
||||
.preview-circle {
|
||||
background: #00d4ff;
|
||||
}
|
||||
&::before {
|
||||
background: #203a43;
|
||||
border-color: rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__preview[data-theme="nordic"] {
|
||||
background: #f5f0e8;
|
||||
.preview-circle {
|
||||
background: #3d6e4e;
|
||||
}
|
||||
&::before {
|
||||
background: #fffef9;
|
||||
border-color: rgba(61, 110, 78, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__preview[data-theme="halloween"] {
|
||||
background: #1a0e2e;
|
||||
.preview-circle {
|
||||
background: #ff6600;
|
||||
}
|
||||
&::before {
|
||||
background: #2d1b3d;
|
||||
border-color: rgba(255, 102, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__preview[data-theme="auto"] {
|
||||
border-color: black;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#f8f8f8 0%,
|
||||
#f8f8f8 50%,
|
||||
#111 50%,
|
||||
#111 100%
|
||||
);
|
||||
.preview-circle {
|
||||
left: auto;
|
||||
right: 8px;
|
||||
background: #01d277;
|
||||
}
|
||||
&::before {
|
||||
right: auto;
|
||||
width: calc(50% - 10px);
|
||||
background: #fff;
|
||||
border-color: rgba(8, 28, 36, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--text-color);
|
||||
@include mobile-only {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--highlight-color);
|
||||
color: white;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
@include mobile-only {
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
221
src/components/settings/UserProfile.vue
Normal file
221
src/components/settings/UserProfile.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="user-profile">
|
||||
<div class="profile-card">
|
||||
<div class="avatar-circle">{{ userInitials }}</div>
|
||||
<div class="profile-details">
|
||||
<div class="name-row">
|
||||
<span class="username">{{ username }}</span>
|
||||
<span :class="['role-badge', `role-badge--${userRole}`]">{{
|
||||
userRole
|
||||
}}</span>
|
||||
<span
|
||||
v-if="plexUsername"
|
||||
class="role-badge role-badge--plex"
|
||||
:title="`Connected as ${plexUsername}`"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0zm57.7 128.7l-48 48c-.4.4-.9.7-1.4.9-.5.2-1.1.4-1.6.4s-1.1-.1-1.6-.4c-.5-.2-1-.5-1.4-.9l-48-48c-1.6-1.6-1.6-4.1 0-5.7 1.6-1.6 4.1-1.6 5.7 0l41.1 41.1V80c0-2.2 1.8-4 4-4s4 1.8 4 4v84.1l41.1-41.1c1.6-1.6 4.1-1.6 5.7 0 .8.8 1.2 1.8 1.2 2.8s-.4 2.1-1.2 2.9z"
|
||||
/>
|
||||
</svg>
|
||||
Plex
|
||||
</span>
|
||||
</div>
|
||||
<span class="member-info">Member since {{ memberSince }}</span>
|
||||
<span v-if="plexUsername" class="plex-info"
|
||||
>Connected as {{ plexUsername }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
const store = useStore();
|
||||
const plexUsername = ref<string>("");
|
||||
|
||||
const username = computed(() => store.getters["user/username"] || "User");
|
||||
|
||||
const userRole = computed(() =>
|
||||
store.getters["user/admin"] ? "admin" : "user"
|
||||
);
|
||||
|
||||
const userInitials = computed(() => username.value.slice(0, 2).toUpperCase());
|
||||
|
||||
const memberSinceDate = computed(() => {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() - 6);
|
||||
return date;
|
||||
});
|
||||
|
||||
const memberSince = computed(() =>
|
||||
memberSinceDate.value.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
})
|
||||
);
|
||||
|
||||
const monthsActive = computed(() => {
|
||||
const now = new Date();
|
||||
return (
|
||||
(now.getFullYear() - memberSinceDate.value.getFullYear()) * 12 +
|
||||
now.getMonth() -
|
||||
memberSinceDate.value.getMonth()
|
||||
);
|
||||
});
|
||||
|
||||
// Load Plex username from localStorage
|
||||
function loadPlexUsername() {
|
||||
const cachedData = localStorage.getItem("plex_user_data");
|
||||
if (cachedData) {
|
||||
try {
|
||||
const plexData = JSON.parse(cachedData);
|
||||
plexUsername.value = plexData.username || "";
|
||||
} catch (error) {
|
||||
console.error("Error parsing cached Plex data:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPlexUsername();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.user-profile {
|
||||
@include mobile-only {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.85rem;
|
||||
background-color: var(--background-ui);
|
||||
border-radius: 0.25rem;
|
||||
|
||||
@include mobile-only {
|
||||
padding: 0.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-circle {
|
||||
width: 55px;
|
||||
height: 55px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--highlight-color),
|
||||
var(--color-green-70)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: $white;
|
||||
flex-shrink: 0;
|
||||
|
||||
@include mobile-only {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: $text-color;
|
||||
line-height: 1.2;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.member-info {
|
||||
font-size: 0.8rem;
|
||||
color: $text-color-70;
|
||||
line-height: 1.2;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.plex-info {
|
||||
font-size: 0.75rem;
|
||||
color: #cc7b19;
|
||||
line-height: 1.2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
&--admin {
|
||||
background-color: var(--color-warning);
|
||||
color: $black;
|
||||
}
|
||||
|
||||
&--user {
|
||||
background-color: var(--background-40);
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
&--plex {
|
||||
background-color: #cc7b19;
|
||||
color: $white;
|
||||
cursor: help;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -129,6 +129,7 @@
|
||||
font-size: 20px;
|
||||
color: var(--text-color);
|
||||
text-align: center;
|
||||
padding-bottom: 1rem;
|
||||
margin: 0;
|
||||
|
||||
.query {
|
||||
|
||||
@@ -1,58 +1,69 @@
|
||||
<template>
|
||||
<table>
|
||||
<thead class="table__header noselect">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
:class="column === selectedColumn ? 'active' : null"
|
||||
@click="sortTable(column)"
|
||||
<div class="torrent-table">
|
||||
<div class="sort-toggle">
|
||||
<span class="sort-label">Sort by:</span>
|
||||
<div class="sort-options">
|
||||
<button
|
||||
v-for="option in sortOptions"
|
||||
:key="option.value"
|
||||
:class="['sort-btn', { active: selectedSort === option.value }]"
|
||||
@click="changeSort(option.value)"
|
||||
>
|
||||
{{ column }}
|
||||
<span v-if="prevCol === column && direction">↑</span>
|
||||
<span v-if="prevCol === column && !direction">↓</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="torrent in torrents"
|
||||
:key="torrent.magnet"
|
||||
class="table__content"
|
||||
>
|
||||
<td
|
||||
@click="expand($event, torrent.name)"
|
||||
@keydown.enter="expand($event, torrent.name)"
|
||||
<table>
|
||||
<thead class="table__header noselect">
|
||||
<tr>
|
||||
<th
|
||||
class="name-header"
|
||||
:class="selectedSort === 'name' ? 'active' : null"
|
||||
@click="changeSort('name')"
|
||||
>
|
||||
Name
|
||||
<span v-if="selectedSort === 'name'">{{
|
||||
direction ? "↑" : "↓"
|
||||
}}</span>
|
||||
</th>
|
||||
<th class="add-header">Add</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="torrent in sortedTorrents"
|
||||
:key="torrent.magnet"
|
||||
class="table__content"
|
||||
>
|
||||
{{ torrent.name }}
|
||||
</td>
|
||||
<td
|
||||
@click="expand($event, torrent.name)"
|
||||
@keydown.enter="expand($event, torrent.name)"
|
||||
>
|
||||
{{ torrent.seed }}
|
||||
</td>
|
||||
<td
|
||||
@click="expand($event, torrent.name)"
|
||||
@keydown.enter="expand($event, torrent.name)"
|
||||
>
|
||||
{{ torrent.size }}
|
||||
</td>
|
||||
<td
|
||||
class="download"
|
||||
@click="() => emit('magnet', torrent)"
|
||||
@keydown.enter="() => emit('magnet', torrent)"
|
||||
>
|
||||
<IconMagnet />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<td
|
||||
class="torrent-info"
|
||||
@click="expand($event, torrent.name)"
|
||||
@keydown.enter="expand($event, torrent.name)"
|
||||
>
|
||||
<div class="torrent-title">{{ torrent.name }}</div>
|
||||
<div class="torrent-meta">
|
||||
<span class="meta-item">{{ torrent.size }}</span>
|
||||
<span class="meta-separator">•</span>
|
||||
<span class="meta-item">{{ torrent.seed }} seeders</span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="download"
|
||||
@click="() => emit('magnet', torrent)"
|
||||
@keydown.enter="() => emit('magnet', torrent)"
|
||||
>
|
||||
<IconMagnet />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import IconMagnet from "@/icons/IconMagnet.vue";
|
||||
import type { Ref } from "vue";
|
||||
import { sortableSize } from "../../utils";
|
||||
@@ -69,14 +80,55 @@
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emit>();
|
||||
|
||||
const columns: string[] = ["name", "seed", "size", "add"];
|
||||
const sortOptions = [
|
||||
{ value: "name", label: "Name" },
|
||||
{ value: "size", label: "Size" },
|
||||
{ value: "seed", label: "Seeders" }
|
||||
];
|
||||
|
||||
const torrents: Ref<ITorrent[]> = ref(props.torrents);
|
||||
const direction: Ref<boolean> = ref(false);
|
||||
const selectedColumn: Ref<string> = ref(columns[0]);
|
||||
const prevCol: Ref<string> = ref("");
|
||||
const selectedSort: Ref<string> = ref("size");
|
||||
const prevSort: Ref<string> = ref("");
|
||||
|
||||
const sortedTorrents = computed(() => {
|
||||
const sorted = [...torrents.value];
|
||||
|
||||
if (selectedSort.value === "name") {
|
||||
sorted.sort((a, b) =>
|
||||
direction.value
|
||||
? a.name.localeCompare(b.name)
|
||||
: b.name.localeCompare(a.name)
|
||||
);
|
||||
} else if (selectedSort.value === "size") {
|
||||
sorted.sort((a, b) =>
|
||||
direction.value
|
||||
? sortableSize(a.size) - sortableSize(b.size)
|
||||
: sortableSize(b.size) - sortableSize(a.size)
|
||||
);
|
||||
} else if (selectedSort.value === "seed") {
|
||||
sorted.sort((a, b) =>
|
||||
direction.value
|
||||
? parseInt(a.seed, 10) - parseInt(b.seed, 10)
|
||||
: parseInt(b.seed, 10) - parseInt(a.seed, 10)
|
||||
);
|
||||
}
|
||||
|
||||
return sorted;
|
||||
});
|
||||
|
||||
function changeSort(sortBy: string) {
|
||||
if (prevSort.value === sortBy) {
|
||||
direction.value = !direction.value;
|
||||
} else {
|
||||
direction.value = false;
|
||||
selectedSort.value = sortBy;
|
||||
}
|
||||
prevSort.value = sortBy;
|
||||
}
|
||||
|
||||
function expand(event: MouseEvent, text: string) {
|
||||
return;
|
||||
const elementClicked = event.target as HTMLElement;
|
||||
const tableRow = elementClicked.parentElement;
|
||||
const scopedStyleDataVariable = Object.keys(tableRow.dataset)[0];
|
||||
@@ -89,8 +141,6 @@
|
||||
if (existingExpandedElement) {
|
||||
existingExpandedElement.remove();
|
||||
|
||||
// Clicked the same element twice, remove and return
|
||||
// not recreate and collapse
|
||||
if (clickedSameTwice) return;
|
||||
}
|
||||
|
||||
@@ -100,58 +150,12 @@
|
||||
expandedCol.dataset[scopedStyleDataVariable] = "";
|
||||
expandedRow.className = "expanded";
|
||||
expandedCol.innerText = text;
|
||||
expandedCol.colSpan = 4;
|
||||
|
||||
expandedCol.colSpan = 2;
|
||||
|
||||
expandedRow.appendChild(expandedCol);
|
||||
tableRow.insertAdjacentElement("afterend", expandedRow);
|
||||
}
|
||||
|
||||
function sortName() {
|
||||
const torrentsCopy = [...torrents.value];
|
||||
if (direction.value) {
|
||||
torrents.value = torrentsCopy.sort((a, b) => (a.name < b.name ? 1 : -1));
|
||||
} else {
|
||||
torrents.value = torrentsCopy.sort((a, b) => (a.name > b.name ? 1 : -1));
|
||||
}
|
||||
}
|
||||
|
||||
function sortSeed() {
|
||||
const torrentsCopy = [...torrents.value];
|
||||
if (direction.value) {
|
||||
torrents.value = torrentsCopy.sort(
|
||||
(a, b) => parseInt(a.seed, 10) - parseInt(b.seed, 10)
|
||||
);
|
||||
} else {
|
||||
torrents.value = torrentsCopy.sort(
|
||||
(a, b) => parseInt(b.seed, 10) - parseInt(a.seed, 10)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sortSize() {
|
||||
const torrentsCopy = [...torrents.value];
|
||||
if (direction.value) {
|
||||
torrents.value = torrentsCopy.sort((a, b) =>
|
||||
sortableSize(a.size) > sortableSize(b.size) ? 1 : -1
|
||||
);
|
||||
} else {
|
||||
torrents.value = torrentsCopy.sort((a, b) =>
|
||||
sortableSize(a.size) < sortableSize(b.size) ? 1 : -1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sortTable(col, sameDirection = false) {
|
||||
if (prevCol.value === col && sameDirection === false) {
|
||||
direction.value = !direction.value;
|
||||
}
|
||||
|
||||
if (col === "name") sortName();
|
||||
else if (col === "seed") sortSeed();
|
||||
else if (col === "size") sortSize();
|
||||
|
||||
prevCol.value = col;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -159,20 +163,74 @@
|
||||
@import "scss/media-queries";
|
||||
@import "scss/elements";
|
||||
|
||||
.torrent-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sort-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.sort-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-70);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sort-options {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
border: 1px solid var(--highlight-bg, var(--background-color-40));
|
||||
color: var(--text-color-70);
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&:hover {
|
||||
background: var(--highlight-bg, var(--background-color));
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--highlight-color);
|
||||
color: var(--text-color);
|
||||
border-color: var(--highlight-color, $green);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
border-spacing: 0;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
// border-collapse: collapse;
|
||||
max-width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 0.5px solid var(--background-color-40);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@include mobile {
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -181,69 +239,108 @@
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
color: var(--table-header-text-color);
|
||||
color: var(--highlight-bg, var(--table-header-text-color));
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
background-color: var(--table-background-color);
|
||||
background-color: var(--highlight-color);
|
||||
// background-color: black;
|
||||
// color: var(--color-green);
|
||||
background-color: var(--highlight-color, var(--highlight-color));
|
||||
letter-spacing: 0.8px;
|
||||
font-size: 1rem;
|
||||
|
||||
th:last-of-type {
|
||||
padding-right: 0.4rem;
|
||||
padding: 0 0.4rem;
|
||||
border-left: 1px solid var(--highlight-bg, var(--background-color));
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
// first column
|
||||
tr td:first-of-type {
|
||||
// first column - torrent info
|
||||
.torrent-info {
|
||||
position: relative;
|
||||
padding: 0 0.3rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
cursor: default;
|
||||
word-break: break-all;
|
||||
border-left: 1px solid var(--table-background-color);
|
||||
word-break: break-word;
|
||||
border-left: 1px solid var(--highlight-color);
|
||||
|
||||
@include mobile {
|
||||
max-width: 40vw;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.torrent-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
@include mobile {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.torrent-meta {
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
opacity: 70%;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
.meta-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-separator {
|
||||
color: var(--text-color-40);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// all columns except first
|
||||
tr td:not(td:first-of-type) {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// last column
|
||||
// last column - action
|
||||
tr td:last-of-type {
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
border-right: 1px solid var(--table-background-color);
|
||||
border-right: 1px solid var(--highlight-color);
|
||||
max-width: 60px;
|
||||
text-align: center;
|
||||
|
||||
@include mobile {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 21px;
|
||||
display: block;
|
||||
margin: auto;
|
||||
padding: 0.3rem 0;
|
||||
fill: var(--text-color);
|
||||
fill: var(inherit, var(--text-color));
|
||||
|
||||
@include mobile {
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// alternate background color per row
|
||||
tr {
|
||||
background-color: var(--background-color);
|
||||
background-color: var(--highlight-bg, var(--background-90));
|
||||
color: var(--text-color);
|
||||
|
||||
td {
|
||||
border-left: 1px solid var(--highlight-color);
|
||||
fill: var(--text-color);
|
||||
}
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background-color: var(--background-70);
|
||||
tr:nth-child(odd) {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
// last element rounded corner border
|
||||
tr:last-of-type {
|
||||
td {
|
||||
border-bottom: 1px solid var(--table-background-color);
|
||||
border-bottom: 1px solid var(--highlight-color);
|
||||
border-left: 1px solid var(--highlight-color);
|
||||
}
|
||||
|
||||
td:first-of-type {
|
||||
@@ -259,15 +356,16 @@
|
||||
.expanded {
|
||||
padding: 0.25rem 1rem;
|
||||
max-width: 100%;
|
||||
border-left: 1px solid $text-color;
|
||||
border-right: 1px solid $text-color;
|
||||
border-bottom: 1px solid $text-color;
|
||||
border-left: 1px solid var(--text-color);
|
||||
border-right: 1px solid var(--text-color);
|
||||
border-bottom: 1px solid var(--text-color);
|
||||
|
||||
td {
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
padding: 0.5rem 0.15rem;
|
||||
width: 100%;
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
cursor: pointer;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
var(--background-color) 20%,
|
||||
var(--highlight-bg, var(--background-color)) 20%,
|
||||
var(--background-0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
845
src/components/ui/CommandPalette.vue
Normal file
845
src/components/ui/CommandPalette.vue
Normal file
@@ -0,0 +1,845 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div v-if="isOpen" class="command-palette-overlay" @click="close">
|
||||
<div class="command-palette" @click.stop>
|
||||
<div class="command-palette__search">
|
||||
<input
|
||||
v-if="!parameterMode"
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search routes..."
|
||||
class="command-palette__input"
|
||||
@keydown.down.prevent="selectNext"
|
||||
@keydown.up.prevent="selectPrevious"
|
||||
@keydown.enter.prevent="navigateToSelected"
|
||||
@keydown.esc.prevent="close"
|
||||
@keydown="handleInputKeydown"
|
||||
@keydown.ctrl.j.prevent="selectNext"
|
||||
@keydown.ctrl.k.prevent="selectPrevious"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
ref="parameterInput"
|
||||
v-model="parameterValue"
|
||||
type="text"
|
||||
:placeholder="`Enter ${parameterName}...`"
|
||||
class="command-palette__input command-palette__input--parameter"
|
||||
@keydown.enter.prevent="confirmParameter"
|
||||
@keydown.esc.prevent="cancelParameter"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!parameterMode" class="command-palette__results">
|
||||
<div
|
||||
v-for="(route, index) in filteredRoutes"
|
||||
:key="route.path"
|
||||
:class="[
|
||||
'command-palette__item',
|
||||
{ 'command-palette__item--selected': index === selectedIndex }
|
||||
]"
|
||||
@click="navigateTo(route)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
>
|
||||
<div class="command-palette__item-left">
|
||||
<div class="command-palette__item-icon">
|
||||
<component :is="getRouteIcon(route.name)" />
|
||||
</div>
|
||||
<div class="command-palette__item-content">
|
||||
<div class="command-palette__item-title">
|
||||
<span class="command-palette__item-name">{{
|
||||
formatRouteName(route.name)
|
||||
}}</span>
|
||||
<span class="command-palette__item-path">
|
||||
{{ route.path }}
|
||||
<span
|
||||
v-if="routeRequiresInput(route)"
|
||||
class="command-palette__item-param-hint"
|
||||
>
|
||||
(requires {{ getInputParameterName(route) }})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="command-palette__item-description">{{
|
||||
getRouteDescription(route.name)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="command-palette__item-right">
|
||||
<span
|
||||
v-if="route.meta?.requiresAuth"
|
||||
class="command-palette__item-badge command-palette__item-badge--auth"
|
||||
>
|
||||
🔒 Auth
|
||||
</span>
|
||||
<span
|
||||
v-if="route.meta?.requiresPlexAccount"
|
||||
class="command-palette__item-badge command-palette__item-badge--plex"
|
||||
>
|
||||
Plex
|
||||
</span>
|
||||
<span v-if="index < 9" class="command-palette__item-shortcut">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredRoutes.length === 0 && contentResults.length === 0"
|
||||
class="command-palette__empty"
|
||||
>
|
||||
<span v-if="isSearchingContent">Searching content...</span>
|
||||
<span v-else-if="searchDisabled"
|
||||
>Search temporarily disabled due to errors</span
|
||||
>
|
||||
<span v-else>No routes or content found</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredRoutes.length === 0 && contentResults.length > 0"
|
||||
class="command-palette__content-results"
|
||||
>
|
||||
<div class="command-palette__content-header">Movies & Shows</div>
|
||||
<div
|
||||
v-for="(result, index) in contentResults"
|
||||
:key="result.id"
|
||||
:class="[
|
||||
'command-palette__item',
|
||||
{ 'command-palette__item--selected': index === selectedIndex }
|
||||
]"
|
||||
@click="openContent(result)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
>
|
||||
<div class="command-palette__item-left">
|
||||
<div class="command-palette__item-icon">
|
||||
<component
|
||||
:is="result.type === 'movie' ? IconMovie : IconShow"
|
||||
/>
|
||||
</div>
|
||||
<div class="command-palette__item-content">
|
||||
<div class="command-palette__item-title">
|
||||
<span class="command-palette__item-name">{{
|
||||
result.title
|
||||
}}</span>
|
||||
</div>
|
||||
<span class="command-palette__item-description">
|
||||
{{ result.type === "movie" ? "Movie" : "TV Show" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
import type { RouteRecordNormalized } from "vue-router";
|
||||
import type { Component } from "vue";
|
||||
import IconMovie from "@/icons/IconMovie.vue";
|
||||
import IconActivity from "@/icons/IconActivity.vue";
|
||||
import IconProfile from "@/icons/IconProfile.vue";
|
||||
import IconInbox from "@/icons/IconInbox.vue";
|
||||
import IconSearch from "@/icons/IconSearch.vue";
|
||||
import IconEdit from "@/icons/IconEdit.vue";
|
||||
import IconSettings from "@/icons/IconSettings.vue";
|
||||
import IconKey from "@/icons/IconKey.vue";
|
||||
import IconMagnet from "@/icons/IconMagnet.vue";
|
||||
import IconProfileLock from "@/icons/IconProfileLock.vue";
|
||||
import IconShow from "@/icons/IconShow.vue";
|
||||
import IconBinoculars from "@/icons/IconBinoculars.vue";
|
||||
import { elasticSearchMoviesAndShows } from "@/api";
|
||||
import type { IAutocompleteResult } from "@/interfaces/IAutocompleteSearch";
|
||||
import { trackCommand, getCommandScore } from "@/utils/commandTracking";
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const isOpen = ref(false);
|
||||
const searchQuery = ref("");
|
||||
const selectedIndex = ref(0);
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const parameterInput = ref<HTMLInputElement | null>(null);
|
||||
const parameterMode = ref(false);
|
||||
const parameterName = ref("");
|
||||
const parameterValue = ref("");
|
||||
const pendingRoute = ref<RouteRecordNormalized | null>(null);
|
||||
const contentResults = ref<IAutocompleteResult[]>([]);
|
||||
const isSearchingContent = ref(false);
|
||||
const searchDisabled = ref(false);
|
||||
const searchErrorCount = ref(0);
|
||||
const lastSearchTime = ref(0);
|
||||
const SEARCH_COOLDOWN = 500; // ms between searches
|
||||
const MAX_ERRORS = 3; // Disable after 3 errors
|
||||
|
||||
const routeMetadata: Record<
|
||||
string,
|
||||
{
|
||||
icon: Component;
|
||||
description: string;
|
||||
requiresInput?: boolean;
|
||||
inputParamName?: string;
|
||||
}
|
||||
> = {
|
||||
home: { icon: IconMovie, description: "Browse movies and TV shows" },
|
||||
discover: {
|
||||
icon: IconBinoculars,
|
||||
description: "Discover movies by category"
|
||||
},
|
||||
activity: { icon: IconActivity, description: "View Plex server activity" },
|
||||
profile: { icon: IconProfile, description: "Manage your profile" },
|
||||
"requests-list": null,
|
||||
list: { icon: IconInbox, description: "Browse custom lists" },
|
||||
search: {
|
||||
icon: IconSearch,
|
||||
description: "Search for content",
|
||||
requiresInput: true,
|
||||
inputParamName: "query"
|
||||
},
|
||||
register: { icon: IconEdit, description: "Create a new account" },
|
||||
settings: { icon: IconSettings, description: "Configure your preferences" },
|
||||
signin: { icon: IconKey, description: "Sign in to your account" },
|
||||
torrents: { icon: IconMagnet, description: "Manage torrents" },
|
||||
"password-gen": {
|
||||
icon: IconKey,
|
||||
description: "Generate secure passwords"
|
||||
},
|
||||
admin: { icon: IconProfileLock, description: "Admin dashboard" }
|
||||
};
|
||||
|
||||
const routes = computed(() => {
|
||||
return router.getRoutes().filter(route => {
|
||||
return (
|
||||
routeMetadata[route?.name?.toString() ?? ""] &&
|
||||
route.name &&
|
||||
route.name !== "NotFound"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const filteredRoutes = computed(() => {
|
||||
let filtered: RouteRecordNormalized[];
|
||||
|
||||
if (!searchQuery.value) {
|
||||
filtered = routes.value;
|
||||
} else {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
filtered = routes.value.filter(route => {
|
||||
const name = String(route.name).toLowerCase();
|
||||
const path = route.path.toLowerCase();
|
||||
return name.includes(query) || path.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by command score (most used + recent first)
|
||||
return filtered.sort((a, b) => {
|
||||
const scoreA = getCommandScore(String(a.name));
|
||||
const scoreB = getCommandScore(String(b.name));
|
||||
return scoreB - scoreA;
|
||||
});
|
||||
});
|
||||
|
||||
function formatRouteName(name: string | symbol | undefined): string {
|
||||
if (!name) return "";
|
||||
const str = String(name);
|
||||
return str
|
||||
.split("-")
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getRouteIcon(name: string | symbol | undefined): Component {
|
||||
if (!name) return IconMovie;
|
||||
const routeName = String(name);
|
||||
return routeMetadata[routeName]?.icon || IconMovie;
|
||||
}
|
||||
|
||||
function getRouteDescription(name: string | symbol | undefined): string {
|
||||
if (!name) return "";
|
||||
const routeName = String(name);
|
||||
return routeMetadata[routeName]?.description || "";
|
||||
}
|
||||
|
||||
function open() {
|
||||
isOpen.value = true;
|
||||
searchQuery.value = "";
|
||||
selectedIndex.value = 0;
|
||||
// Reset search state when opening
|
||||
searchErrorCount.value = 0;
|
||||
searchDisabled.value = false;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false;
|
||||
searchQuery.value = "";
|
||||
selectedIndex.value = 0;
|
||||
parameterMode.value = false;
|
||||
parameterName.value = "";
|
||||
parameterValue.value = "";
|
||||
pendingRoute.value = null;
|
||||
}
|
||||
|
||||
function scrollSelectedIntoView() {
|
||||
nextTick(() => {
|
||||
const selectedElement = document.querySelector(
|
||||
".command-palette__item--selected"
|
||||
);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "nearest"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function selectNext() {
|
||||
const maxIndex = Math.max(
|
||||
filteredRoutes.value.length - 1,
|
||||
contentResults.value.length - 1
|
||||
);
|
||||
if (selectedIndex.value < maxIndex) {
|
||||
selectedIndex.value++;
|
||||
scrollSelectedIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
function selectPrevious() {
|
||||
if (selectedIndex.value > 0) {
|
||||
selectedIndex.value--;
|
||||
scrollSelectedIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToSelected() {
|
||||
// Check if we have route results
|
||||
if (filteredRoutes.value.length > 0) {
|
||||
const route = filteredRoutes.value[selectedIndex.value];
|
||||
if (route) {
|
||||
navigateTo(route);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have content results
|
||||
if (contentResults.value.length > 0) {
|
||||
const result = contentResults.value[selectedIndex.value];
|
||||
if (result) {
|
||||
openContent(result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasParameter(path: string): boolean {
|
||||
return path.includes(":");
|
||||
}
|
||||
|
||||
function extractParameterName(path: string): string {
|
||||
const match = path.match(/:([^/]+)/);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
function routeRequiresInput(route: RouteRecordNormalized): boolean {
|
||||
const routeName = String(route.name);
|
||||
return routeMetadata[routeName]?.requiresInput || hasParameter(route.path);
|
||||
}
|
||||
|
||||
function getInputParameterName(route: RouteRecordNormalized): string {
|
||||
const routeName = String(route.name);
|
||||
if (routeMetadata[routeName]?.inputParamName) {
|
||||
return routeMetadata[routeName].inputParamName!;
|
||||
}
|
||||
return extractParameterName(route.path);
|
||||
}
|
||||
|
||||
function navigateTo(route: RouteRecordNormalized) {
|
||||
if (routeRequiresInput(route)) {
|
||||
// Enter parameter mode
|
||||
parameterMode.value = true;
|
||||
parameterName.value = getInputParameterName(route);
|
||||
parameterValue.value = "";
|
||||
pendingRoute.value = route;
|
||||
setTimeout(() => {
|
||||
parameterInput.value?.focus();
|
||||
}, 50);
|
||||
} else {
|
||||
// Track the command usage
|
||||
trackCommand(String(route.name), "route", { routePath: route.path });
|
||||
router.push(route.path);
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function confirmParameter() {
|
||||
if (!pendingRoute.value || !parameterValue.value.trim()) return;
|
||||
|
||||
const routeName = String(pendingRoute.value.name);
|
||||
const metadata = routeMetadata[routeName];
|
||||
|
||||
// Track the command usage
|
||||
trackCommand(routeName, "route", { routePath: pendingRoute.value.path });
|
||||
|
||||
// Check if this route uses query parameters instead of path parameters
|
||||
if (metadata?.inputParamName) {
|
||||
router.push({
|
||||
path: pendingRoute.value.path,
|
||||
query: { [metadata.inputParamName]: parameterValue.value.trim() }
|
||||
});
|
||||
} else {
|
||||
// Traditional path parameter replacement
|
||||
const path = pendingRoute.value.path.replace(
|
||||
/:([^/]+)/,
|
||||
parameterValue.value.trim()
|
||||
);
|
||||
router.push(path);
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
function cancelParameter() {
|
||||
parameterMode.value = false;
|
||||
parameterName.value = "";
|
||||
parameterValue.value = "";
|
||||
pendingRoute.value = null;
|
||||
setTimeout(() => {
|
||||
searchInput.value?.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
async function searchContent() {
|
||||
// Prevent searching if already searching, disabled, or on cooldown
|
||||
if (isSearchingContent.value || searchDisabled.value) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastSearchTime.value < SEARCH_COOLDOWN) return;
|
||||
lastSearchTime.value = now;
|
||||
|
||||
const ELASTIC_URL = import.meta.env.VITE_ELASTIC_URL;
|
||||
const ELASTIC_API_KEY = import.meta.env.VITE_ELASTIC_API_KEY;
|
||||
|
||||
// Don't search if elastic is not configured
|
||||
if (!ELASTIC_URL || !ELASTIC_API_KEY) {
|
||||
contentResults.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isSearchingContent.value = true;
|
||||
try {
|
||||
const response = await elasticSearchMoviesAndShows(searchQuery.value, 10);
|
||||
const results: IAutocompleteResult[] = response.hits.hits.map(
|
||||
(item: any) => ({
|
||||
title: item._source?.original_name || item._source.original_title,
|
||||
id: item._source.id,
|
||||
type: item._source.type === "movie" ? "movie" : "show"
|
||||
})
|
||||
);
|
||||
|
||||
// Sort content results by command score (most used + recent first)
|
||||
const sortedResults = results.sort((a, b) => {
|
||||
const scoreA = getCommandScore(`${a.type}:${a.id}`);
|
||||
const scoreB = getCommandScore(`${b.type}:${b.id}`);
|
||||
return scoreB - scoreA;
|
||||
});
|
||||
|
||||
contentResults.value = sortedResults;
|
||||
// Reset error count on success
|
||||
searchErrorCount.value = 0;
|
||||
} catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
contentResults.value = [];
|
||||
|
||||
// Increment error count and disable if threshold reached
|
||||
searchErrorCount.value++;
|
||||
if (searchErrorCount.value >= MAX_ERRORS) {
|
||||
searchDisabled.value = true;
|
||||
console.warn(
|
||||
`Content search disabled after ${MAX_ERRORS} consecutive errors`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isSearchingContent.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openContent(result: IAutocompleteResult) {
|
||||
// Track content opening with unique ID
|
||||
const contentId = `${result.type}:${result.id}`;
|
||||
trackCommand(contentId, "content");
|
||||
|
||||
store.dispatch("popup/open", {
|
||||
id: result.id,
|
||||
type: result.type
|
||||
});
|
||||
close();
|
||||
}
|
||||
|
||||
function handleInputKeydown(event: KeyboardEvent) {
|
||||
// Check for number keys 1-9 to select routes or content
|
||||
const num = parseInt(event.key);
|
||||
if (!isNaN(num) && num >= 1 && num <= 9) {
|
||||
const index = num - 1;
|
||||
|
||||
// Try routes first
|
||||
if (index < filteredRoutes.value.length) {
|
||||
event.preventDefault();
|
||||
navigateTo(filteredRoutes.value[index]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try content results
|
||||
if (index < contentResults.value.length) {
|
||||
event.preventDefault();
|
||||
openContent(contentResults.value[index]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
if (isOpen.value) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Escape" && isOpen.value) {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
watch(isOpen, newValue => {
|
||||
if (newValue) {
|
||||
document.body.style.overflow = "hidden";
|
||||
setTimeout(() => {
|
||||
searchInput.value?.focus();
|
||||
}, 50);
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
});
|
||||
|
||||
let searchTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
watch(searchQuery, () => {
|
||||
selectedIndex.value = 0;
|
||||
// Don't clear content results immediately - let debounce handle it
|
||||
});
|
||||
|
||||
// Trigger content search when no routes match (with debouncing)
|
||||
watch(filteredRoutes, newRoutes => {
|
||||
// Clear existing timeout
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
if (newRoutes.length === 0 && searchQuery.value.length > 0) {
|
||||
// Debounce search to avoid clearing results while typing fast
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchContent();
|
||||
}, 300);
|
||||
} else if (newRoutes.length > 0) {
|
||||
// Clear content results when routes are found
|
||||
contentResults.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables.scss";
|
||||
@import "scss/media-queries.scss";
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.command-palette-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding-top: 15vh;
|
||||
|
||||
@include mobile {
|
||||
padding-top: 10vh;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette {
|
||||
background: var(--background-color-secondary);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
width: 90%;
|
||||
max-width: 640px;
|
||||
max-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
@include mobile {
|
||||
width: 95%;
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette__search {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--text-color-10);
|
||||
}
|
||||
|
||||
.command-palette__input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1.1rem;
|
||||
border: none;
|
||||
background: var(--background-ui);
|
||||
color: var(--text-color);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-color-50);
|
||||
}
|
||||
|
||||
&--parameter {
|
||||
background: var(--color-success);
|
||||
color: var(--color-success-text);
|
||||
font-weight: 500;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-success-text);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
font-size: 1rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette__results {
|
||||
overflow-y: auto;
|
||||
max-height: 50vh;
|
||||
padding: 0.5rem;
|
||||
|
||||
@include mobile {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette__item {
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color 0.15s ease;
|
||||
gap: 1rem;
|
||||
|
||||
&:hover,
|
||||
&--selected {
|
||||
background: var(--background-ui);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
padding: 0.875rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette__item-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@include mobile {
|
||||
gap: 0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette__item-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-color-70);
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette__item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.command-palette__item-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.command-palette__item-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
|
||||
@include mobile {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette__item-path {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color-50);
|
||||
font-weight: 400;
|
||||
|
||||
@include mobile {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette__item-param-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-70);
|
||||
font-style: italic;
|
||||
margin-left: 0.25rem;
|
||||
|
||||
@include mobile {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette__item-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-70);
|
||||
line-height: 1.4;
|
||||
|
||||
@include mobile {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette__item-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
@include mobile {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette__item-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
|
||||
&--auth {
|
||||
background: var(--color-warning);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
&--plex {
|
||||
background: var(--color-success);
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette__item-shortcut {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--background-ui);
|
||||
color: var(--text-color-70);
|
||||
border: 1px solid var(--text-color-10);
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
|
||||
@include mobile {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
min-width: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.command-palette__empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-color-50);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.command-palette__content-results {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.command-palette__content-header {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-color-50);
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,48 +0,0 @@
|
||||
<template>
|
||||
<div class="darkToggle">
|
||||
<span @click="toggleDarkmode" @keydown.enter="toggleDarkmode">{{
|
||||
darkmodeToggleIcon
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
function systemDarkModeEnabled() {
|
||||
const computedStyle = window.getComputedStyle(document.body);
|
||||
if (computedStyle?.colorScheme != null) {
|
||||
return computedStyle.colorScheme.includes("dark");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const darkmode = ref(systemDarkModeEnabled());
|
||||
const darkmodeToggleIcon = computed(() => {
|
||||
return darkmode.value ? "🌝" : "🌚";
|
||||
});
|
||||
|
||||
function toggleDarkmode() {
|
||||
darkmode.value = !darkmode.value;
|
||||
document.body.className = darkmode.value ? "dark" : "light";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.darkToggle {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
cursor: pointer;
|
||||
position: fixed;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-right: 2px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: active, fullwidth: fullWidth }"
|
||||
@click="emit('click')"
|
||||
@click="event => emit('click', event)"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
@@ -15,7 +15,7 @@
|
||||
}
|
||||
|
||||
interface Emit {
|
||||
(e: "click");
|
||||
(e: "click", event?: MouseEvent);
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
@@ -37,7 +37,6 @@
|
||||
min-height: 45px;
|
||||
padding: 5px 10px 4px 10px;
|
||||
margin: 0;
|
||||
margin-right: 0.3rem;
|
||||
color: $text-color;
|
||||
background: $background-color-secondary;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
import { ref, computed } from "vue";
|
||||
import IconKey from "@/icons/IconKey.vue";
|
||||
import IconEmail from "@/icons/IconEmail.vue";
|
||||
import IconBinoculars from "@/icons/IconBinoculars.vue";
|
||||
import IconSearch from "@/icons/IconSearch.vue";
|
||||
import type { Ref } from "vue";
|
||||
|
||||
interface Props {
|
||||
@@ -53,7 +53,7 @@
|
||||
const inputIcon = computed(() => {
|
||||
if (props.type === "password") return IconKey;
|
||||
if (props.type === "email") return IconEmail;
|
||||
if (props.type === "torrents") return IconBinoculars;
|
||||
if (props.type === "torrents") return IconSearch;
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -81,7 +81,6 @@
|
||||
display: flex;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
max-width: 35rem;
|
||||
border: 1px solid var(--text-color-50);
|
||||
background-color: var(--background-color-secondary);
|
||||
|
||||
|
||||
125
src/composables/usePlexApi.ts
Normal file
125
src/composables/usePlexApi.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { ref } from "vue";
|
||||
import { API_HOSTNAME } from "../api";
|
||||
|
||||
// Shared constants - generated once and reused
|
||||
export const CLIENT_IDENTIFIER = `seasoned-plex-app-${Math.random().toString(36).substring(7)}`;
|
||||
export const APP_NAME = window.location.hostname;
|
||||
|
||||
async function fetchPlexServers(authToken: string) {
|
||||
try {
|
||||
const url =
|
||||
"https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1";
|
||||
const options = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"X-Plex-Token": authToken,
|
||||
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch Plex servers");
|
||||
}
|
||||
|
||||
const servers = await response.json();
|
||||
const ownedServer = servers.find(
|
||||
(s: any) => s.owned && s.provides === "server"
|
||||
);
|
||||
|
||||
if (ownedServer) {
|
||||
const connection =
|
||||
ownedServer.connections?.find((c: any) => c.local === false) ||
|
||||
ownedServer.connections?.[0];
|
||||
|
||||
return {
|
||||
name: ownedServer.name,
|
||||
url: connection?.uri,
|
||||
machineIdentifier: ownedServer.clientIdentifier
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[PlexAPI] Error fetching Plex servers:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlexUserData(authToken: string) {
|
||||
try {
|
||||
const url = "https://plex.tv/api/v2/user";
|
||||
const options = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"X-Plex-Product": APP_NAME,
|
||||
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
|
||||
"X-Plex-Token": authToken
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch Plex user info");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Convert Unix timestamp to ISO date string if needed
|
||||
let joinedDate = null;
|
||||
if (data.joinedAt) {
|
||||
if (typeof data.joinedAt === "number") {
|
||||
joinedDate = new Date(data.joinedAt * 1000).toISOString();
|
||||
} else {
|
||||
joinedDate = data.joinedAt;
|
||||
}
|
||||
}
|
||||
|
||||
const userData = {
|
||||
id: data.id,
|
||||
uuid: data.uuid,
|
||||
username: data.username || data.title || "Plex User",
|
||||
email: data.email,
|
||||
thumb: data.thumb,
|
||||
joined_at: joinedDate,
|
||||
two_factor_enabled: data.twoFactorEnabled || false,
|
||||
experimental_features: data.experimentalFeatures || false,
|
||||
subscription: {
|
||||
active: data.subscription?.active,
|
||||
plan: data.subscription?.plan,
|
||||
features: data.subscription?.features
|
||||
},
|
||||
profile: {
|
||||
auto_select_audio: data.profile?.autoSelectAudio,
|
||||
default_audio_language: data.profile?.defaultAudioLanguage,
|
||||
default_subtitle_language: data.profile?.defaultSubtitleLanguage
|
||||
},
|
||||
entitlements: data.entitlements || [],
|
||||
roles: data.roles || [],
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
return userData;
|
||||
} catch (error) {
|
||||
console.error("[PlexAPI] Error fetching Plex user data:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch library details
|
||||
async function fetchLibraryDetails() {
|
||||
try {
|
||||
const url = `${API_HOSTNAME}/api/v2/plex/library`;
|
||||
const options: RequestInit = { credentials: "include" };
|
||||
return await fetch(url, options).then(resp => resp.json());
|
||||
} catch (error) {
|
||||
console.error("[PlexAPI] error fetching library:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { fetchPlexServers, fetchPlexUserData, fetchLibraryDetails };
|
||||
202
src/composables/usePlexAuth.ts
Normal file
202
src/composables/usePlexAuth.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { ref } from "vue";
|
||||
import { CLIENT_IDENTIFIER, APP_NAME } from "./usePlexApi";
|
||||
|
||||
export function usePlexAuth() {
|
||||
const loading = ref(false);
|
||||
const plexPopup = ref<Window | null>(null);
|
||||
const pollInterval = ref<number | null>(null);
|
||||
|
||||
// Generate a PIN for Plex OAuth
|
||||
async function generatePlexPin() {
|
||||
try {
|
||||
const url = "https://plex.tv/api/v2/pins?strong=true";
|
||||
const options = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"X-Plex-Product": APP_NAME,
|
||||
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) throw new Error("Failed to generate PIN");
|
||||
const data = await response.json();
|
||||
return { id: data.id, code: data.code };
|
||||
} catch (error) {
|
||||
console.error("[PlexAuth] Error generating PIN:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check PIN status
|
||||
async function checkPin(pinId: number, pinCode: string) {
|
||||
try {
|
||||
const url = `https://plex.tv/api/v2/pins/${pinId}?code=${pinCode}`;
|
||||
const options = {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data.authToken;
|
||||
} catch (error) {
|
||||
console.error("[PlexAuth] Error checking PIN:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Construct auth URL
|
||||
function constructAuthUrl(pinCode: string) {
|
||||
const params = new URLSearchParams({
|
||||
clientID: CLIENT_IDENTIFIER,
|
||||
code: pinCode,
|
||||
"context[device][product]": APP_NAME
|
||||
});
|
||||
return `https://app.plex.tv/auth#?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Start polling for PIN
|
||||
function startPolling(
|
||||
pinId: number,
|
||||
pinCode: string,
|
||||
onSuccess: (token: string) => void
|
||||
) {
|
||||
pollInterval.value = window.setInterval(async () => {
|
||||
const authToken = await checkPin(pinId, pinCode);
|
||||
if (authToken) {
|
||||
stopPolling();
|
||||
if (plexPopup.value && !plexPopup.value.closed) {
|
||||
plexPopup.value.close();
|
||||
}
|
||||
onSuccess(authToken);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Stop polling
|
||||
function stopPolling() {
|
||||
if (pollInterval.value) {
|
||||
clearInterval(pollInterval.value);
|
||||
pollInterval.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Set cookie
|
||||
function setPlexAuthCookie(authToken: string) {
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 30);
|
||||
const domain = window.location.hostname;
|
||||
document.cookie = `plex_auth_token=${authToken}; domain=.${domain}; path=/; expires=${expires.toUTCString()}; SameSite=Strict`;
|
||||
}
|
||||
|
||||
// Get cookie
|
||||
function getPlexAuthCookie(): string | null {
|
||||
const key = "plex_auth_token";
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${key}=`);
|
||||
if (parts.length === 2) {
|
||||
return parts.pop()?.split(";").shift() || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Open authentication popup
|
||||
async function openAuthPopup(
|
||||
onSuccess: (token: string) => void,
|
||||
onError: (msg: string) => void
|
||||
) {
|
||||
loading.value = true;
|
||||
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
plexPopup.value = window.open(
|
||||
"about:blank",
|
||||
"PlexAuth",
|
||||
`width=${width},height=${height},left=${left},top=${top}`
|
||||
);
|
||||
|
||||
if (!plexPopup.value) {
|
||||
onError("Please allow popups for this site to authenticate with Plex");
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Add loading screen
|
||||
if (plexPopup.value.document) {
|
||||
plexPopup.value.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Connecting to Plex...</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; display: flex; justify-content: center; align-items: center;
|
||||
height: 100vh; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #1c3a13; color: #fcfcf7; }
|
||||
.spinner { border: 4px solid rgba(252, 252, 247, 0.3); border-top: 4px solid #fcfcf7;
|
||||
border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px; }
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body><div class="loader"><div class="spinner"></div><p>Connecting to Plex...</p></div></body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
const pin = await generatePlexPin();
|
||||
if (!pin) {
|
||||
if (plexPopup.value && !plexPopup.value.closed) plexPopup.value.close();
|
||||
onError("Could not generate Plex authentication PIN");
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const authUrl = constructAuthUrl(pin.code);
|
||||
if (plexPopup.value && !plexPopup.value.closed) {
|
||||
plexPopup.value.location.href = authUrl;
|
||||
} else {
|
||||
onError("Authentication window was closed");
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
startPolling(pin.id, pin.code, onSuccess);
|
||||
|
||||
// Check if popup closed
|
||||
const popupChecker = setInterval(() => {
|
||||
if (plexPopup.value && plexPopup.value.closed) {
|
||||
clearInterval(popupChecker);
|
||||
stopPolling();
|
||||
|
||||
if (loading.value) {
|
||||
loading.value = false;
|
||||
// onError("Plex authentication window was closed");
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
function cleanup() {
|
||||
stopPolling();
|
||||
if (plexPopup.value && !plexPopup.value.closed) {
|
||||
plexPopup.value.close();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
setPlexAuthCookie,
|
||||
getPlexAuthCookie,
|
||||
openAuthPopup,
|
||||
cleanup
|
||||
};
|
||||
}
|
||||
741
src/composables/useRandomWords.ts
Normal file
741
src/composables/useRandomWords.ts
Normal file
@@ -0,0 +1,741 @@
|
||||
// Composable for fetching random words for password generation
|
||||
// Uses Random Word API with fallback to EFF Diceware word list
|
||||
|
||||
export function useRandomWords() {
|
||||
// EFF Diceware short word list (optimized for memorability)
|
||||
// Source: https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases
|
||||
const FALLBACK_WORDS = [
|
||||
"able",
|
||||
"acid",
|
||||
"aged",
|
||||
"also",
|
||||
"area",
|
||||
"army",
|
||||
"away",
|
||||
"baby",
|
||||
"back",
|
||||
"ball",
|
||||
"band",
|
||||
"bank",
|
||||
"base",
|
||||
"bath",
|
||||
"bear",
|
||||
"beat",
|
||||
"been",
|
||||
"beer",
|
||||
"bell",
|
||||
"belt",
|
||||
"best",
|
||||
"bike",
|
||||
"bill",
|
||||
"bird",
|
||||
"blow",
|
||||
"blue",
|
||||
"boat",
|
||||
"body",
|
||||
"bold",
|
||||
"bolt",
|
||||
"bomb",
|
||||
"bond",
|
||||
"bone",
|
||||
"book",
|
||||
"boom",
|
||||
"born",
|
||||
"boss",
|
||||
"both",
|
||||
"bowl",
|
||||
"bulk",
|
||||
"burn",
|
||||
"bush",
|
||||
"busy",
|
||||
"cage",
|
||||
"cake",
|
||||
"call",
|
||||
"calm",
|
||||
"came",
|
||||
"camp",
|
||||
"card",
|
||||
"care",
|
||||
"cart",
|
||||
"case",
|
||||
"cash",
|
||||
"cast",
|
||||
"cell",
|
||||
"chat",
|
||||
"chip",
|
||||
"city",
|
||||
"clad",
|
||||
"clay",
|
||||
"clip",
|
||||
"club",
|
||||
"clue",
|
||||
"coal",
|
||||
"coat",
|
||||
"code",
|
||||
"coil",
|
||||
"coin",
|
||||
"cold",
|
||||
"come",
|
||||
"cook",
|
||||
"cool",
|
||||
"cope",
|
||||
"copy",
|
||||
"cord",
|
||||
"core",
|
||||
"cork",
|
||||
"cost",
|
||||
"crab",
|
||||
"crew",
|
||||
"crop",
|
||||
"crow",
|
||||
"curl",
|
||||
"cute",
|
||||
"damp",
|
||||
"dare",
|
||||
"dark",
|
||||
"dash",
|
||||
"data",
|
||||
"date",
|
||||
"dawn",
|
||||
"days",
|
||||
"dead",
|
||||
"deaf",
|
||||
"deal",
|
||||
"dean",
|
||||
"dear",
|
||||
"debt",
|
||||
"deck",
|
||||
"deed",
|
||||
"deep",
|
||||
"deer",
|
||||
"demo",
|
||||
"deny",
|
||||
"desk",
|
||||
"dial",
|
||||
"dice",
|
||||
"died",
|
||||
"diet",
|
||||
"disc",
|
||||
"dish",
|
||||
"disk",
|
||||
"dock",
|
||||
"does",
|
||||
"dome",
|
||||
"done",
|
||||
"doom",
|
||||
"door",
|
||||
"dose",
|
||||
"down",
|
||||
"drag",
|
||||
"draw",
|
||||
"drew",
|
||||
"drip",
|
||||
"drop",
|
||||
"drug",
|
||||
"drum",
|
||||
"dual",
|
||||
"duck",
|
||||
"dull",
|
||||
"dumb",
|
||||
"dump",
|
||||
"dune",
|
||||
"dunk",
|
||||
"dust",
|
||||
"duty",
|
||||
"each",
|
||||
"earl",
|
||||
"earn",
|
||||
"ease",
|
||||
"east",
|
||||
"easy",
|
||||
"edge",
|
||||
"edit",
|
||||
"else",
|
||||
"even",
|
||||
"ever",
|
||||
"evil",
|
||||
"exam",
|
||||
"exit",
|
||||
"face",
|
||||
"fact",
|
||||
"fade",
|
||||
"fail",
|
||||
"fair",
|
||||
"fake",
|
||||
"fall",
|
||||
"fame",
|
||||
"farm",
|
||||
"fast",
|
||||
"fate",
|
||||
"fear",
|
||||
"feed",
|
||||
"feel",
|
||||
"feet",
|
||||
"fell",
|
||||
"felt",
|
||||
"fern",
|
||||
"file",
|
||||
"fill",
|
||||
"film",
|
||||
"find",
|
||||
"fine",
|
||||
"fire",
|
||||
"firm",
|
||||
"fish",
|
||||
"fist",
|
||||
"five",
|
||||
"flag",
|
||||
"flat",
|
||||
"fled",
|
||||
"flew",
|
||||
"flip",
|
||||
"flow",
|
||||
"folk",
|
||||
"fond",
|
||||
"food",
|
||||
"fool",
|
||||
"foot",
|
||||
"ford",
|
||||
"fork",
|
||||
"form",
|
||||
"fort",
|
||||
"foul",
|
||||
"four",
|
||||
"free",
|
||||
"from",
|
||||
"fuel",
|
||||
"full",
|
||||
"fund",
|
||||
"gain",
|
||||
"game",
|
||||
"gang",
|
||||
"gate",
|
||||
"gave",
|
||||
"gear",
|
||||
"gene",
|
||||
"gift",
|
||||
"girl",
|
||||
"give",
|
||||
"glad",
|
||||
"glow",
|
||||
"glue",
|
||||
"goal",
|
||||
"goat",
|
||||
"gods",
|
||||
"goes",
|
||||
"gold",
|
||||
"golf",
|
||||
"gone",
|
||||
"good",
|
||||
"gray",
|
||||
"grew",
|
||||
"grey",
|
||||
"grid",
|
||||
"grim",
|
||||
"grin",
|
||||
"grip",
|
||||
"grow",
|
||||
"gulf",
|
||||
"hair",
|
||||
"half",
|
||||
"hall",
|
||||
"halt",
|
||||
"hand",
|
||||
"hang",
|
||||
"hard",
|
||||
"harm",
|
||||
"hate",
|
||||
"have",
|
||||
"hawk",
|
||||
"head",
|
||||
"heal",
|
||||
"hear",
|
||||
"heat",
|
||||
"held",
|
||||
"hell",
|
||||
"help",
|
||||
"herb",
|
||||
"here",
|
||||
"hero",
|
||||
"hide",
|
||||
"high",
|
||||
"hill",
|
||||
"hint",
|
||||
"hire",
|
||||
"hold",
|
||||
"hole",
|
||||
"holy",
|
||||
"home",
|
||||
"hood",
|
||||
"hook",
|
||||
"hope",
|
||||
"horn",
|
||||
"host",
|
||||
"hour",
|
||||
"huge",
|
||||
"hung",
|
||||
"hunt",
|
||||
"hurt",
|
||||
"icon",
|
||||
"idea",
|
||||
"inch",
|
||||
"into",
|
||||
"iron",
|
||||
"item",
|
||||
"jail",
|
||||
"jane",
|
||||
"jazz",
|
||||
"jean",
|
||||
"john",
|
||||
"join",
|
||||
"joke",
|
||||
"juan",
|
||||
"jump",
|
||||
"june",
|
||||
"jury",
|
||||
"just",
|
||||
"keen",
|
||||
"keep",
|
||||
"kent",
|
||||
"kept",
|
||||
"kick",
|
||||
"kids",
|
||||
"kill",
|
||||
"kind",
|
||||
"king",
|
||||
"kiss",
|
||||
"knee",
|
||||
"knew",
|
||||
"know",
|
||||
"lack",
|
||||
"lady",
|
||||
"laid",
|
||||
"lake",
|
||||
"lamb",
|
||||
"lamp",
|
||||
"land",
|
||||
"lane",
|
||||
"last",
|
||||
"late",
|
||||
"lead",
|
||||
"leaf",
|
||||
"lean",
|
||||
"left",
|
||||
"lend",
|
||||
"lens",
|
||||
"less",
|
||||
"levy",
|
||||
"lied",
|
||||
"life",
|
||||
"lift",
|
||||
"like",
|
||||
"lily",
|
||||
"line",
|
||||
"link",
|
||||
"lion",
|
||||
"list",
|
||||
"live",
|
||||
"load",
|
||||
"loan",
|
||||
"lock",
|
||||
"lodge",
|
||||
"loft",
|
||||
"logo",
|
||||
"long",
|
||||
"look",
|
||||
"loop",
|
||||
"lord",
|
||||
"lose",
|
||||
"loss",
|
||||
"lost",
|
||||
"loud",
|
||||
"love",
|
||||
"luck",
|
||||
"lung",
|
||||
"made",
|
||||
"maid",
|
||||
"mail",
|
||||
"main",
|
||||
"make",
|
||||
"male",
|
||||
"mall",
|
||||
"many",
|
||||
"mark",
|
||||
"mars",
|
||||
"mask",
|
||||
"mass",
|
||||
"mate",
|
||||
"math",
|
||||
"mayo",
|
||||
"maze",
|
||||
"meal",
|
||||
"mean",
|
||||
"meat",
|
||||
"meet",
|
||||
"melt",
|
||||
"menu",
|
||||
"mess",
|
||||
"mice",
|
||||
"mild",
|
||||
"mile",
|
||||
"milk",
|
||||
"mill",
|
||||
"mind",
|
||||
"mine",
|
||||
"mint",
|
||||
"miss",
|
||||
"mist",
|
||||
"mode",
|
||||
"mood",
|
||||
"moon",
|
||||
"more",
|
||||
"most",
|
||||
"move",
|
||||
"much",
|
||||
"mule",
|
||||
"must",
|
||||
"myth",
|
||||
"nail",
|
||||
"name",
|
||||
"navy",
|
||||
"near",
|
||||
"neat",
|
||||
"neck",
|
||||
"need",
|
||||
"news",
|
||||
"next",
|
||||
"nice",
|
||||
"nick",
|
||||
"nine",
|
||||
"noah",
|
||||
"node",
|
||||
"none",
|
||||
"noon",
|
||||
"norm",
|
||||
"nose",
|
||||
"note",
|
||||
"noun",
|
||||
"nuts",
|
||||
"okay",
|
||||
"once",
|
||||
"ones",
|
||||
"only",
|
||||
"onto",
|
||||
"open",
|
||||
"oral",
|
||||
"oven",
|
||||
"over",
|
||||
"pace",
|
||||
"pack",
|
||||
"page",
|
||||
"paid",
|
||||
"pain",
|
||||
"pair",
|
||||
"palm",
|
||||
"park",
|
||||
"part",
|
||||
"pass",
|
||||
"past",
|
||||
"path",
|
||||
"peak",
|
||||
"pick",
|
||||
"pier",
|
||||
"pike",
|
||||
"pile",
|
||||
"pill",
|
||||
"pine",
|
||||
"pink",
|
||||
"pipe",
|
||||
"plan",
|
||||
"play",
|
||||
"plot",
|
||||
"plug",
|
||||
"plus",
|
||||
"poem",
|
||||
"poet",
|
||||
"pole",
|
||||
"poll",
|
||||
"pond",
|
||||
"pony",
|
||||
"pool",
|
||||
"poor",
|
||||
"pope",
|
||||
"pork",
|
||||
"port",
|
||||
"pose",
|
||||
"post",
|
||||
"pour",
|
||||
"pray",
|
||||
"prep",
|
||||
"prey",
|
||||
"pull",
|
||||
"pump",
|
||||
"pure",
|
||||
"push",
|
||||
"quit",
|
||||
"race",
|
||||
"rack",
|
||||
"rage",
|
||||
"raid",
|
||||
"rail",
|
||||
"rain",
|
||||
"rank",
|
||||
"rare",
|
||||
"rate",
|
||||
"rays",
|
||||
"read",
|
||||
"real",
|
||||
"rear",
|
||||
"rely",
|
||||
"rent",
|
||||
"rest",
|
||||
"rice",
|
||||
"rich",
|
||||
"ride",
|
||||
"ring",
|
||||
"rise",
|
||||
"risk",
|
||||
"road",
|
||||
"rock",
|
||||
"rode",
|
||||
"role",
|
||||
"roll",
|
||||
"roof",
|
||||
"room",
|
||||
"root",
|
||||
"rope",
|
||||
"rose",
|
||||
"ross",
|
||||
"ruin",
|
||||
"rule",
|
||||
"rush",
|
||||
"ruth",
|
||||
"safe",
|
||||
"saga",
|
||||
"sage",
|
||||
"said",
|
||||
"sail",
|
||||
"sake",
|
||||
"sale",
|
||||
"salt",
|
||||
"same",
|
||||
"sand",
|
||||
"sank",
|
||||
"save",
|
||||
"says",
|
||||
"scan",
|
||||
"scar",
|
||||
"seal",
|
||||
"seat",
|
||||
"seed",
|
||||
"seek",
|
||||
"seem",
|
||||
"seen",
|
||||
"self",
|
||||
"sell",
|
||||
"semi",
|
||||
"send",
|
||||
"sent",
|
||||
"sept",
|
||||
"sets",
|
||||
"shed",
|
||||
"ship",
|
||||
"shop",
|
||||
"shot",
|
||||
"show",
|
||||
"shut",
|
||||
"sick",
|
||||
"side",
|
||||
"sign",
|
||||
"silk",
|
||||
"sing",
|
||||
"sink",
|
||||
"site",
|
||||
"size",
|
||||
"skin",
|
||||
"skip",
|
||||
"slam",
|
||||
"slap",
|
||||
"slip",
|
||||
"slow",
|
||||
"snap",
|
||||
"snow",
|
||||
"soft",
|
||||
"soil",
|
||||
"sold",
|
||||
"sole",
|
||||
"some",
|
||||
"song",
|
||||
"soon",
|
||||
"sort",
|
||||
"soul",
|
||||
"spot",
|
||||
"star",
|
||||
"stay",
|
||||
"stem",
|
||||
"step",
|
||||
"stir",
|
||||
"stop",
|
||||
"such",
|
||||
"suit",
|
||||
"sung",
|
||||
"sunk",
|
||||
"sure",
|
||||
"swim",
|
||||
"tail",
|
||||
"take",
|
||||
"tale",
|
||||
"talk",
|
||||
"tall",
|
||||
"tank",
|
||||
"tape",
|
||||
"task",
|
||||
"team",
|
||||
"tear",
|
||||
"tech",
|
||||
"tell",
|
||||
"tend",
|
||||
"tent",
|
||||
"term",
|
||||
"test",
|
||||
"text",
|
||||
"than",
|
||||
"that",
|
||||
"them",
|
||||
"then",
|
||||
"they",
|
||||
"thin",
|
||||
"this",
|
||||
"thus",
|
||||
"tide",
|
||||
"tied",
|
||||
"tier",
|
||||
"ties",
|
||||
"till",
|
||||
"time",
|
||||
"tiny",
|
||||
"tips",
|
||||
"tire",
|
||||
"told",
|
||||
"toll",
|
||||
"tone",
|
||||
"tony",
|
||||
"took",
|
||||
"tool",
|
||||
"tops",
|
||||
"torn",
|
||||
"toss",
|
||||
"tour",
|
||||
"town",
|
||||
"tray",
|
||||
"tree",
|
||||
"trek",
|
||||
"trim",
|
||||
"trio",
|
||||
"trip",
|
||||
"true",
|
||||
"tube",
|
||||
"tune",
|
||||
"turn",
|
||||
"twin",
|
||||
"type",
|
||||
"unit",
|
||||
"upon",
|
||||
"used",
|
||||
"user",
|
||||
"vary",
|
||||
"vast",
|
||||
"verb",
|
||||
"very",
|
||||
"vice",
|
||||
"view",
|
||||
"visa",
|
||||
"void",
|
||||
"vote",
|
||||
"wade",
|
||||
"wage",
|
||||
"wait",
|
||||
"wake",
|
||||
"walk",
|
||||
"wall",
|
||||
"ward",
|
||||
"warm",
|
||||
"warn",
|
||||
"wash",
|
||||
"wave",
|
||||
"ways",
|
||||
"weak",
|
||||
"wear",
|
||||
"week",
|
||||
"well",
|
||||
"went",
|
||||
"were",
|
||||
"west",
|
||||
"what",
|
||||
"when",
|
||||
"whom",
|
||||
"wide",
|
||||
"wife",
|
||||
"wild",
|
||||
"will",
|
||||
"wind",
|
||||
"wine",
|
||||
"wing",
|
||||
"wire",
|
||||
"wise",
|
||||
"wish",
|
||||
"with",
|
||||
"wolf",
|
||||
"wood",
|
||||
"wool",
|
||||
"word",
|
||||
"wore",
|
||||
"work",
|
||||
"worm",
|
||||
"worn",
|
||||
"wrap",
|
||||
"yard",
|
||||
"yeah",
|
||||
"year",
|
||||
"your",
|
||||
"zone",
|
||||
"zoom"
|
||||
];
|
||||
|
||||
// Try to fetch random words from API, fallback to local list
|
||||
async function getRandomWords(count = 4): Promise<string[]> {
|
||||
try {
|
||||
// Try Random Word API first
|
||||
const response = await fetch(
|
||||
`https://random-word-api.herokuapp.com/word?number=${count}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const words = await response.json();
|
||||
if (Array.isArray(words) && words.length === count) {
|
||||
return words;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[RandomWords] API failed, using fallback words:", error);
|
||||
}
|
||||
|
||||
// Fallback: pick random words from local list
|
||||
const words: string[] = [];
|
||||
const usedIndices = new Set<number>();
|
||||
|
||||
while (words.length < count) {
|
||||
const index = Math.floor(Math.random() * FALLBACK_WORDS.length);
|
||||
if (!usedIndices.has(index)) {
|
||||
usedIndices.add(index);
|
||||
words.push(FALLBACK_WORDS[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return words;
|
||||
}
|
||||
|
||||
return {
|
||||
getRandomWords
|
||||
};
|
||||
}
|
||||
299
src/composables/useTautulliStats.ts
Normal file
299
src/composables/useTautulliStats.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { API_HOSTNAME } from "../api";
|
||||
|
||||
export interface WatchStats {
|
||||
totalHours: number;
|
||||
totalPlays: number;
|
||||
moviePlays: number;
|
||||
episodePlays: number;
|
||||
musicPlays: number;
|
||||
lastWatched: WatchContent[];
|
||||
}
|
||||
|
||||
interface DayStats {
|
||||
date: string;
|
||||
plays: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface HomeStatItem {
|
||||
rating_key: number;
|
||||
title: string;
|
||||
total_plays?: number;
|
||||
total_duration?: number;
|
||||
users_watched?: string;
|
||||
last_play?: number;
|
||||
grandparent_thumb?: string;
|
||||
thumb?: string;
|
||||
content_rating?: string;
|
||||
labels?: string[];
|
||||
media_type?: string;
|
||||
}
|
||||
|
||||
export interface WatchContent {
|
||||
title: string;
|
||||
plays: number;
|
||||
duration: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface PlaysGraphData {
|
||||
categories: string[];
|
||||
series: {
|
||||
name: string;
|
||||
data: number[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function tautulliRequest(
|
||||
resource: string,
|
||||
params: Record<string, any> = {}
|
||||
) {
|
||||
try {
|
||||
const queryParams = new URLSearchParams(params);
|
||||
const url = new URL(
|
||||
`/api/v1/user/stats/${resource}?${queryParams}`,
|
||||
API_HOSTNAME
|
||||
);
|
||||
const options: RequestInit = {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
credentials: "include"
|
||||
};
|
||||
|
||||
const resp = await fetch(url, options);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Tautulli API request failed: ${resp.statusText}`);
|
||||
}
|
||||
|
||||
const response = await resp.json();
|
||||
if (response?.success !== true) {
|
||||
throw new Error(response?.message || "Unknown API error");
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`[Tautulli] Error with ${resource}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch home statistics (pre-aggregated by Tautulli!)
|
||||
export async function fetchHomeStats(
|
||||
timeRange = 30,
|
||||
statsType: "plays" | "duration" = "plays"
|
||||
): Promise<WatchStats> {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
days: timeRange,
|
||||
type: statsType,
|
||||
grouping: 0
|
||||
};
|
||||
|
||||
const stats = await tautulliRequest("home_stats", params);
|
||||
|
||||
// Extract stats from the response
|
||||
let totalPlays = 0;
|
||||
let totalHours = 0;
|
||||
let moviePlays = 0;
|
||||
let episodePlays = 0;
|
||||
let musicPlays = 0;
|
||||
|
||||
// Find the relevant stat sections
|
||||
const topMovies = stats.find((s: any) => s.stat_id === "top_movies");
|
||||
const topTV = stats.find((s: any) => s.stat_id === "top_tv");
|
||||
const topMusic = stats.find((s: any) => s.stat_id === "top_music");
|
||||
|
||||
if (topMovies?.rows) {
|
||||
moviePlays = topMovies.rows.reduce(
|
||||
(sum: number, item: any) => sum + (item.total_plays || 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
if (topTV?.rows) {
|
||||
episodePlays = topTV.rows.reduce(
|
||||
(sum: number, item: any) => sum + (item.total_plays || 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
if (topMusic?.rows) {
|
||||
musicPlays = topMusic.rows.reduce(
|
||||
(sum: number, item: any) => sum + (item.total_plays || 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
totalPlays = moviePlays + episodePlays + musicPlays;
|
||||
|
||||
// Calculate total hours from duration
|
||||
if (statsType === "duration") {
|
||||
const totalDuration = [topMovies, topTV, topMusic].reduce((sum, stat) => {
|
||||
if (!stat?.rows) return sum;
|
||||
return (
|
||||
sum +
|
||||
stat.rows.reduce(
|
||||
(s: number, item: any) => s + (item.total_duration || 0),
|
||||
0
|
||||
)
|
||||
);
|
||||
}, 0);
|
||||
totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours
|
||||
}
|
||||
|
||||
// Get "last_watched" stat which contains recent items
|
||||
const limit = 12;
|
||||
const lastWatched = stats
|
||||
.find((s: any) => s.stat_id === "last_watched")
|
||||
.rows.slice(0, limit)
|
||||
.map((item: any) => ({
|
||||
title: item.title || item.full_title || "Unknown",
|
||||
plays: item.total_plays || 0,
|
||||
duration: Math.round((item.total_duration || 0) / 60), // Convert to minutes
|
||||
type: item.media_type || "unknown"
|
||||
}));
|
||||
|
||||
return {
|
||||
totalHours,
|
||||
totalPlays,
|
||||
moviePlays,
|
||||
episodePlays,
|
||||
musicPlays,
|
||||
lastWatched
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Tautulli] Error fetching home stats:", error);
|
||||
return {
|
||||
totalHours: 0,
|
||||
totalPlays: 0,
|
||||
moviePlays: 0,
|
||||
episodePlays: 0,
|
||||
musicPlays: 0,
|
||||
lastWatched: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch plays by date (already aggregated by Tautulli!)
|
||||
export async function fetchPlaysByDate(
|
||||
timeRange = 30,
|
||||
yAxis: "plays" | "duration" = "plays"
|
||||
): Promise<DayStats[]> {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
days: timeRange,
|
||||
y_axis: yAxis,
|
||||
grouping: 0
|
||||
};
|
||||
|
||||
const data: PlaysGraphData = await tautulliRequest("plays_by_date", params);
|
||||
|
||||
// Sum all series data for each date
|
||||
return data.categories.map((date, index) => {
|
||||
const totalValue = data.series
|
||||
.filter(s => s.name !== "Total")
|
||||
.reduce((sum, series) => sum + (series.data[index] || 0), 0);
|
||||
|
||||
return {
|
||||
date,
|
||||
plays: yAxis === "plays" ? totalValue : 0,
|
||||
duration: yAxis === "duration" ? totalValue : 0
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Tautulli] Error fetching plays by date:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch plays by day of week (already aggregated!)
|
||||
export async function fetchPlaysByDayOfWeek(
|
||||
timeRange = 30,
|
||||
yAxis: "plays" | "duration" = "plays"
|
||||
): Promise<{
|
||||
labels: string[];
|
||||
movies: number[];
|
||||
episodes: number[];
|
||||
music: number[];
|
||||
}> {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
days: timeRange,
|
||||
y_axis: yAxis,
|
||||
grouping: 0
|
||||
};
|
||||
|
||||
const data: PlaysGraphData = await tautulliRequest(
|
||||
"plays_by_dayofweek",
|
||||
params
|
||||
);
|
||||
|
||||
// Map series names to our expected format
|
||||
const movies =
|
||||
data.series.find(s => s.name === "Movies")?.data || new Array(7).fill(0);
|
||||
const episodes =
|
||||
data.series.find(s => s.name === "TV")?.data || new Array(7).fill(0);
|
||||
const music =
|
||||
data.series.find(s => s.name === "Music")?.data || new Array(7).fill(0);
|
||||
|
||||
return {
|
||||
labels: data.categories,
|
||||
movies,
|
||||
episodes,
|
||||
music
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Tautulli] Error fetching plays by day of week:", error);
|
||||
return {
|
||||
labels: [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday"
|
||||
],
|
||||
movies: new Array(7).fill(0),
|
||||
episodes: new Array(7).fill(0),
|
||||
music: new Array(7).fill(0)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch plays by hour of day (already aggregated!)
|
||||
export async function fetchPlaysByHourOfDay(
|
||||
timeRange = 30,
|
||||
yAxis: "plays" | "duration" = "plays"
|
||||
): Promise<{ labels: string[]; data: number[] }> {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
days: timeRange,
|
||||
y_axis: yAxis,
|
||||
grouping: 0
|
||||
};
|
||||
|
||||
const data: PlaysGraphData = await tautulliRequest(
|
||||
"plays_by_hourofday",
|
||||
params
|
||||
);
|
||||
|
||||
// Sum all series data for each hour
|
||||
const hourlyData = data.categories.map((hour, index) =>
|
||||
data.series.reduce((sum, series) => sum + (series.data[index] || 0), 0)
|
||||
);
|
||||
|
||||
return {
|
||||
labels: data.categories.map(h => `${h}:00`),
|
||||
data: hourlyData
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Tautulli] Error fetching plays by hour:", error);
|
||||
return {
|
||||
labels: Array.from({ length: 24 }, (_, i) => `${i}:00`),
|
||||
data: new Array(24).fill(0)
|
||||
};
|
||||
}
|
||||
}
|
||||
56
src/composables/useTheme.ts
Normal file
56
src/composables/useTheme.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
type Theme = "light" | "dark" | "auto";
|
||||
|
||||
const currentTheme = ref<Theme>("auto");
|
||||
|
||||
function systemDarkModeEnabled(): boolean {
|
||||
const computedStyle = window.getComputedStyle(document.body);
|
||||
if (computedStyle?.colorScheme != null) {
|
||||
return computedStyle.colorScheme.includes("dark");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
if (theme === "auto") {
|
||||
const systemDark = systemDarkModeEnabled();
|
||||
document.body.className = systemDark ? "dark" : "light";
|
||||
} else {
|
||||
document.body.className = theme;
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const savedTheme = computed(
|
||||
() => (localStorage.getItem("theme-preference") as Theme) || "auto"
|
||||
);
|
||||
|
||||
function initTheme() {
|
||||
const theme = savedTheme.value;
|
||||
currentTheme.value = theme;
|
||||
applyTheme(theme);
|
||||
|
||||
// Listen for system theme changes when in auto mode
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaQuery.addEventListener("change", e => {
|
||||
const currentSetting = localStorage.getItem("theme-preference") as Theme;
|
||||
if (currentSetting === "auto") {
|
||||
document.body.className = e.matches ? "dark" : "light";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setTheme(theme: Theme) {
|
||||
currentTheme.value = theme;
|
||||
localStorage.setItem("theme-preference", theme);
|
||||
applyTheme(theme);
|
||||
}
|
||||
|
||||
return {
|
||||
currentTheme,
|
||||
savedTheme,
|
||||
initTheme,
|
||||
setTheme
|
||||
};
|
||||
}
|
||||
7
src/icons/IconBooks.vue
Normal file
7
src/icons/IconBooks.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M23.5 18h-2.8562L21.9 17.3719c0.9875-0.4938 1.3875-1.6969 0.8937-2.6813L16.9969 3.1031c-0.2375-0.4781-0.65-0.8343-1.1563-1.0031-0.5062-0.1687-1.05-0.1312-1.525 0.1094l-2.2125 1.1062c-0.9875 0.4938-1.3875 1.6969-0.8937 2.6813V6H7V3c0-0.5531-0.4469-1-1-1H2C1.4469 2 1 2.4469 1 3v14c0 0.5531 0.4469 1 1 1H0.5C0.225 18 0 18.225 0 18.5v3C0 21.775 0.225 22 0.5 22h23c0.275 0 0.5-0.225 0.5-0.5v-3c0-0.275-0.225-0.5-0.5-0.5zM18.7906 16.6906 14.775 8.6562 16.9875 7.55l4.0156 8.0344zM15.2094 3.9969l1.3281 2.6594-2.2125 1.1062-1.3281-2.6594zM17.0031 17.5844c0.075 0.1531 0.1688 0.2906 0.2782 0.4156H12c0.5531 0 1-0.4469 1-1V9.5812zM5 16H3v-1h2zM5 14H3V6h2zM11 8v8H7V8zM5 4v1H3V4zM23 21H1v-2h22z" />
|
||||
<rect width="1.999992" height="1.000008" x="7.999992" y="9" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/icons/IconBox.vue
Normal file
7
src/icons/IconBox.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M22.5594 5.75v0l-3.6688-4.5625C18.7969 1.0687 18.6531 1 18.5 1h-13C5.35 1 5.2062 1.0687 5.1094 1.1875L1.4406 5.75C1.1656 6.0938 1 6.5281 1 7v3.5C1 10.775 1.225 11 1.5 11H3v10c0 1.1031 0.8969 2 2 2h14c1.1031 0 2-0.8969 2-2V11h1.5c0.2749 0 0.4999-0.225 0.4999-0.5V7c0-0.4719-0.1656-0.9094-0.4405-1.25zM5.7406 2h12.5219l2.4125 3H3.325zM19 21H5V11h14zM3 10V7h18v3z" />
|
||||
<path d="M9.5 15h5c0.8281 0 1.5-0.6719 1.5-1.5S15.3281 12 14.5 12h-5C8.6719 12 8 12.6719 8 13.5S8.6719 15 9.5 15zM9.5 13h5c0.275 0 0.5 0.225 0.5 0.5S14.775 14 14.5 14h-5C9.225 14 9 13.775 9 13.5S9.225 13 9.5 13z" />
|
||||
</svg>
|
||||
</template>
|
||||
16
src/icons/IconCalendar.vue
Normal file
16
src/icons/IconCalendar.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768">
|
||||
<path d="M0 32h160v64h-160v-64z" />
|
||||
<path d="M288 32h192v64h-192v-64z" />
|
||||
<path d="M608 32h160v64h-160v-64z" />
|
||||
<path d="M192 0h64v224h-64v-224z" />
|
||||
<path d="M512 0h64v224h-64v-224z" />
|
||||
<path d="M288 128h192v64h-192v-64z" />
|
||||
<path
|
||||
d="M704 128h-96v64h96v512h-640v-512h96v-64h-96c-35.3 0-64 28.7-64 64v512c0 35.3 28.7 64 64 64h640c35.3 0 64-28.7 64-64v-512c0-35.3-28.7-64-64-64z"
|
||||
/>
|
||||
<path
|
||||
d="M128 304v320c0 8.8 7.2 16 16 16h480c8.8 0 16-7.2 16-16v-320c0-8.8-7.2-16-16-16h-480c-8.8 0-16 7.2-16 16zM160 480h128v128h-128v-128zM448 480v128h-128v-128h128zM320 448v-128h128v128h-128zM480 608v-128h128v128h-128zM608 448h-128v-128h128v128zM288 320v128h-128v-128h128z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
7
src/icons/IconChart.vue
Normal file
7
src/icons/IconChart.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M9 17c1.1031 0 2-0.8969 2-2 0-0.3656-0.1-0.7125-0.2719-1.0062L13.0094 10c0.1 0 0.1968-94e-4 0.2937-0.0219l1.8344 2.2938C15.05 12.4969 15 12.7438 15 13c0 1.1032 0.8969 2 2 2s2-0.8969 2-2c0-0.4875-0.175-0.9375-0.4688-1.2843l2.8156-7.7469C22.2843 3.8032 22.9999 2.9844 22.9999 2c0-1.1031-0.8968-2-1.9999-2-1.1032 0-2 0.8969-2 2 0 0.4875 0.175 0.9375 0.4687 1.2844l-2.8 7.7-1.8063-2.2563C14.9499 8.5031 15 8.2563 15 8c0-1.1031-0.8969-2-2-2s-2 0.8969-2 2c0 0.3656 0.1 0.7125 0.2718 1.0063L8.9906 13C7.8906 13.0063 7 13.9 7 15c0 1.1031 0.8969 2 2 2zM17 14c-0.55 0-1-0.45-1-1s0.45-1 1-1 1 0.45 1 1-0.45 1-1 1zM21 1c0.55 0 1 0.45 1 1s-0.45 1-1 1-1-0.45-1-1 0.45-1 1-1zM13 7c0.55 0 1 0.45 1 1s-0.45 1-1 1-1-0.45-1-1 0.45-1 1-1zM9 14c0.55 0 1 0.45 1 1s-0.45 1-1 1-1-0.45-1-1 0.45-1 1-1z" />
|
||||
<path d="M23.7063 20.2938l-2.25-2.25-1.4157 1.4156 0.5438 0.5437H4V3.4156l0.5438 0.5438 1.4156-1.4156-2.25-2.25c-0.3906-0.3907-1.025-0.3907-1.4156 0l-2.25 2.25 1.4156 1.4156L2 3.4156V21c0 0.5531 0.4469 1 1 1h17.5844l-0.5437 0.5438 1.4156 1.4156 2.25-2.25c0.3906-0.3937 0.3906-1.025 0-1.4156z" />
|
||||
</svg>
|
||||
</template>
|
||||
6
src/icons/IconCheck.vue
Normal file
6
src/icons/IconCheck.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M22.5438 3.0438 7 18.5844 1.4563 13.0438 0.0438 14.4563l6.25 6.25C6.4875 20.9 6.7438 21 7 21s0.5125-0.0968 0.7062-0.2937l16.25-16.25z" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/icons/IconChristmasTree.vue
Normal file
7
src/icons/IconChristmasTree.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M8.2 2.9 9.8938 4.1687 9.0375 6.3125c-0.0812 0.2-0.0218 0.4281 0.1406 0.5656 0.0938 0.0781 0.2094 0.1188 0.3251 0.1188 0.0874 0 0.1781-0.025 0.2562-0.0719l2.2437-1.3469 2.2438 1.3469c0.1844 0.1094 0.4156 0.0906 0.5781-0.0469s0.2219-0.3625 0.1438-0.5625l-0.8344-2.15 1.6687-1.2719c0.1719-0.1312 0.2407-0.3531 0.1719-0.5562C15.9063 2.1375 15.7156 2 15.5 2h-1.9719L12.425 0.2344C12.3312 0.0875 12.1719 0 12 0s-0.3312 0.0875-0.425 0.2344L10.4719 2H8.5C8.2844 2 8.0938 2.1375 8.025 2.3406 7.9563 2.5469 8.0281 2.7719 8.2 2.9zM10.75 3c0.1719 0 0.3312-0.0875 0.425-0.2344L12 1.4438l0.825 1.3218C12.9156 2.9125 13.0781 3 13.25 3h0.7687l-0.7906 0.6031C13.05 3.7375 12.9844 3.975 13.0656 4.1813l0.4407 1.1406-1.25-0.75C12.1781 4.525 12.0875 4.5 12 4.5s-0.1781 0.025-0.2562 0.0719l-1.2281 0.7375 0.45-1.1219c0.0843-0.2094 0.0156-0.45-0.1657-0.5844L10 3z" />
|
||||
<path d="M20.4906 21.1281c-1.4406-0.8093-2.6312-1.6531-3.5625-2.5125 0.5344-0.1875 1.0688-0.4406 1.5938-0.7625 0.2906-0.1781 0.4719-0.4906 0.4781-0.8343 63e-4-0.3438-0.1594-0.6625-0.4437-0.8532-1.2969-0.8656-2.2907-1.7218-2.9688-2.5625 0.3906-0.2187 0.775-0.5281 1.1438-0.925 0.1875-0.2 0.2843-0.4687 0.2687-0.7406s-0.1437-0.5281-0.35-0.7031c-1.5281-1.2969-2.8281-2.9219-3.7625-4.7031-0.1718-0.3282-0.5125-0.5344-0.8843-0.5344-0.3719 0-0.7125 0.2062-0.8844 0.5344-0.9344 1.7781-2.2344 3.4062-3.7625 4.7031-0.2094 0.1781-0.3344 0.4312-0.35 0.7031s0.0813 0.5406 0.2688 0.7406c0.3719 0.3969 0.7531 0.7063 1.1437 0.925C7.7407 14.4406 6.747 15.3 5.4501 16.1656c-0.2844 0.1907-0.4532 0.5125-0.4438 0.8532 63e-4 0.3406 0.1875 0.6562 0.4781 0.8343 0.525 0.3188 1.0563 0.575 1.5907 0.7625-0.9313 0.8594-2.1219 1.7032-3.5625 2.5125-0.3406 0.1907-0.5375 0.5625-0.5063 0.9532 0.0313 0.3906 0.2875 0.725 0.6532 0.8593C6.3532 23.9219 8.922 24 12.0032 24c3.0781 0 5.6469-0.0781 8.3438-1.0594 0.3656-0.1343 0.6218-0.4687 0.6531-0.8593 0.0281-0.3907-0.1688-0.7625-0.5095-0.9532zM12 22c-2.0906 0-3.8437-0.0344-5.5312-0.3562 1.3999-0.9657 2.4968-1.9657 3.3218-3.0313 0.2281-0.2937 0.275-0.6875 0.1188-1.025-0.1563-0.3375-0.4813-0.5625-0.85-0.5843-0.3594-0.0219-0.7219-0.0875-1.0875-0.1969 1.3906-1.1032 2.3531-2.2125 2.925-3.3594 0.1593-0.3219 0.1375-0.7031-0.0625-1.0031-0.2001-0.3-0.5438-0.4688-0.9032-0.4406-0.1249 93e-4-0.2531-0.0157-0.3781-0.0657 0.9157-0.8968 1.7407-1.8968 2.4469-2.9718 0.7062 1.0718 1.5312 2.075 2.4469 2.9718-0.125 0.05-0.2531 0.075-0.3781 0.0657-0.3594-0.025-0.7032 0.1437-0.9032 0.4406s-0.225 0.6812-0.0625 1.0031c0.5719 1.1469 1.5344 2.2563 2.925 3.3594-0.3656 0.1094-0.7281 0.175-1.0875 0.1969-0.3687 0.0218-0.6968 0.2468-0.85 0.5843-0.1531 0.3375-0.1062 0.7313 0.1188 1.025 0.825 1.0656 1.9219 2.0656 3.3219 3.0313C15.8438 21.9656 14.0906 22 12 22z" />
|
||||
</svg>
|
||||
</template>
|
||||
8
src/icons/IconClapboardPlay.vue
Normal file
8
src/icons/IconClapboardPlay.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M23.5 8H10.9375L22.625 4.9844c0.2656-0.0688 0.4281-0.3375 0.3594-0.6063l-1-4c-0.0656-0.2688-0.3375-0.4313-0.6063-0.3625L3.85 4.3968C3.4594 4.1468 2.9969 4 2.5 4 1.1219 4 0 5.1218 0 6.5V22c0 1.1031 0.8969 2 2 2h20c1.1031 0 2-0.8969 2-2V8.5C24 8.225 23.775 8 23.5 8zM8.5 12C8.8156 11.5812 9 11.0625 9 10.5S8.8125 9.4188 8.5 9h3.7938l-3 3zM13.7063 9h3.5843l-3 3h-3.5844zM18.7062 9h3.5844l-3 3h-3.5844zM23 9.7062V12h-2.2938zM6.5 8C5.6719 8 5 7.3281 5 6.5 5 6.0344 4.8719 5.6 4.65 5.2281l2.7687-0.6937 3.8875 2.3375L6.9375 8zM15.2719 5.85 12.6687 6.5219 8.7938 4.1938l2.6281-0.6563zM12.7906 3.1938l2.6281-0.6563 3.8125 2.2906L16.6281 5.5zM21.8937 4.1406 20.5906 4.4781l-3.8-2.2843 4.3438-1.0875zM1 6.5C1 5.6719 1.6719 5 2.5 5S4 5.6719 4 6.5C4 7.8781 5.1219 9 6.5 9 7.3281 9 8 9.6719 8 10.5S7.3281 12 6.5 12h-4C1.6719 12 1 11.3281 1 10.5zM2 22v-9.05C2.1625 12.9844 2.3281 13 2.5 13H22v9z" />
|
||||
<path d="M3 11c0.55 0 1-0.45 1-1S3.55 9 3 9 2 9.45 2 10s0.45 1 1 1z" />
|
||||
<path d="M9.2375 20.925C9.3188 20.975 9.4094 21 9.5 21c0.075 0 0.1531-0.0187 0.225-0.0531l6-3C15.8937 17.8625 16 17.6875 16 17.5s-0.1063-0.3625-0.275-0.4469l-6-3c-0.1563-0.0781-0.3375-0.0687-0.4875 0.0219C9.0906 14.1656 9 14.3281 9 14.5v6c0 0.1718 0.0906 0.3343 0.2375 0.425zM10 15.3094 14.3812 17.5 10 19.6906z" />
|
||||
</svg>
|
||||
</template>
|
||||
8
src/icons/IconClipboardText.vue
Normal file
8
src/icons/IconClipboardText.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M19 3h-1v2h1v17H5V5h1V3H5C3.8969 3 3 3.8969 3 5v17c0 1.1031 0.8969 2 2 2h14c1.1031 0 2-0.8969 2-2V5c0-1.1031-0.8969-2-2-2z" />
|
||||
<path d="M7.5 5h9C16.775 5 17 4.775 17 4.5v-1C17 2.6719 16.3281 2 15.5 2h-0.5594c-0.1187-0.4938-0.4156-0.9469-0.8594-1.3031C13.5187 0.2469 12.7812 0 12 0c-0.7813 0-1.5188 0.2469-2.0813 0.6969C9.4718 1.0531 9.1781 1.5062 9.0593 2H8.5C7.6718 2 7 2.6719 7 3.5v1C7 4.775 7.225 5 7.5 5zM8 3.5C8 3.225 8.225 3 8.5 3h1C9.775 3 10 2.775 10 2.5c0-0.3781 0.1937-0.7437 0.5437-1.0219C10.9281 1.1688 11.4469 1 12 1s1.0718 0.1688 1.4562 0.4781C13.8062 1.7594 14 2.1219 14 2.5 14 2.775 14.225 3 14.5 3h1C15.775 3 16 3.225 16 3.5V4H8z" />
|
||||
<path d="M17 8H7V7h10zM13 10H7v1h6zM17 12H7v1h10zM15 14H7v1h8zM17 16H7v1h10zM16 18H7v1h9z" />
|
||||
</svg>
|
||||
</template>
|
||||
8
src/icons/IconClock.vue
Normal file
8
src/icons/IconClock.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path
|
||||
d="M16 3c-7.18 0-13 5.82-13 13s5.82 13 13 13 13-5.82 13-13-5.82-13-13-13zM16 26.667c-5.891 0-10.667-4.776-10.667-10.667s4.776-10.667 10.667-10.667c5.891 0 10.667 4.776 10.667 10.667s-4.776 10.667-10.667 10.667z"
|
||||
/>
|
||||
<path d="M17.167 9.333h-2.333v8l7 4.2 1.167-1.9-5.833-3.467z" />
|
||||
</svg>
|
||||
</template>
|
||||
13
src/icons/IconCompass.vue
Normal file
13
src/icons/IconCompass.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768">
|
||||
<path
|
||||
d="M737.8 234.5c-19.3-45.7-47-86.8-82.3-122-35.3-35.3-76.3-62.9-122-82.3-47.4-20-97.7-30.2-149.5-30.2s-102.1 10.2-149.5 30.2c-45.7 19.3-86.8 47-122 82.3-35.3 35.3-62.9 76.3-82.3 122-20 47.4-30.2 97.7-30.2 149.5s10.2 102.1 30.2 149.5c19.3 45.7 47 86.8 82.3 122 35.3 35.3 76.3 62.9 122 82.3 47.4 20 97.7 30.2 149.5 30.2s102.1-10.2 149.5-30.2c45.7-19.3 86.8-47 122-82.3 35.3-35.3 62.9-76.3 82.3-122 20-47.4 30.2-97.7 30.2-149.5s-10.2-102.1-30.2-149.5zM384 704c-176.4 0-320-143.6-320-320s143.6-320 320-320c176.4 0 320 143.6 320 320s-143.6 320-320 320z"
|
||||
/>
|
||||
<path
|
||||
d="M384 96c-76.9 0-149.3 30-203.6 84.4s-84.4 126.7-84.4 203.6 30 149.3 84.4 203.6c54.3 54.4 126.7 84.4 203.6 84.4s149.3-30 203.6-84.4c54.4-54.3 84.4-126.7 84.4-203.6s-30-149.3-84.4-203.6c-54.3-54.4-126.7-84.4-203.6-84.4zM384 640c-141.2 0-256-114.8-256-256s114.8-256 256-256c141.2 0 256 114.8 256 256s-114.8 256-256 256z"
|
||||
/>
|
||||
<path
|
||||
d="M520.8 225.7l-192 96c-3.1 1.5-5.6 4.1-7.2 7.2l-96 192c-3.1 6.2-1.9 13.6 3 18.5 3.1 3.1 7.2 4.7 11.3 4.7 2.4 0 4.9-0.6 7.2-1.7l192-96c3.1-1.5 5.6-4.1 7.2-7.2l96-192c3.1-6.2 1.9-13.6-3-18.5s-12.3-6.1-18.5-3zM340.4 363l64.6 64.6-129.2 64.6 64.6-129.2zM427.6 405l-64.6-64.6 129.2-64.6-64.6 129.2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
23
src/icons/IconCookie.vue
Normal file
23
src/icons/IconCookie.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<svg
|
||||
id="icon-cookie"
|
||||
viewBox="0 0 32 32"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="transition-duration: 0s"
|
||||
@click="$emit('click')"
|
||||
@keydown="event => $emit('keydown', event)"
|
||||
>
|
||||
<circle cx="10" cy="21" r="2" fill="inherit" />
|
||||
<circle cx="23" cy="20" r="2" fill="inherit" />
|
||||
<circle cx="13" cy="10" r="2" fill="inherit" />
|
||||
<circle cx="14" cy="15" r="1" fill="inherit" />
|
||||
<circle cx="23" cy="5" r="2" fill="inherit" />
|
||||
<circle cx="29" cy="3" r="1" fill="inherit" />
|
||||
<circle cx="16" cy="23" r="1" fill="inherit" />
|
||||
<path
|
||||
fill="inherit"
|
||||
d="M16 30C8.3 30 2 23.7 2 16S8.3 2 16 2c0.1 0 0.2 0 0.3 0l1.4 0.1-0.3 1.2c-0.1 0.4-0.2 0.9-0.2 1.3 0 2.8 2.2 5 5 5 1 0 2-0.3 2.9-0.9l1.3 1.5c-0.4 0.4-0.6 0.9-0.6 1.4 0 1.3 1.3 2.4 2.7 1.9l1.2-0.5 0.2 1.3C30 14.9 30 15.5 30 16c0 7.7-6.3 14-14 14zM15.3 4C9 4.4 4 9.6 4 16c0 6.6 5.4 12 12 12s12-5.4 12-12c0-0.1 0-0.3 0-0.4-2.3 0.1-4.2-1.7-4.2-4 0-0.1 0-0.1 0-0.2-0.5 0.1-1 0.2-1.6 0.2-3.9 0-7-3.1-7-7 0-0.2 0-0.4 0.1-0.6z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
6
src/icons/IconCross.vue
Normal file
6
src/icons/IconCross.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<polygon points="20.956242 4.456242 19.543734 3.043734 11.999977 10.584352 4.456219 3.043734 3.043711 4.456242 10.584328 12 3.043711 19.543758 4.456219 20.956266 11.999977 13.415648 19.543734 20.956266 20.956242 19.543758 13.415625 12" />
|
||||
</svg>
|
||||
</template>
|
||||
11
src/icons/IconCrown.vue
Normal file
11
src/icons/IconCrown.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M24 6.5C24 5.6719 23.3281 5 22.5 5S21 5.6719 21 6.5c0 0.3063 0.0938 0.5937 0.2531 0.8313-0.1187 0.1093-0.2344 0.2187-0.35 0.3281-1.5968 1.4969-2.7062 2.5375-4.5125 3.175-1.075-1.5625-2.1218-3.6719-3.1218-6.2875C13.7156 4.1781 14 3.6219 14 3c0-1.1031-0.8969-2-2-2s-2 0.8969-2 2c0 0.6219 0.2844 1.1781 0.7313 1.5438-1 2.6156-2.0469 4.725-3.1219 6.2875-1.8063-0.6375-2.9156-1.6782-4.5125-3.175-0.1156-0.1063-0.2312-0.2157-0.35-0.3282C2.9063 7.0938 3 6.8063 3 6.5 3 5.6719 2.3282 5 1.5 5 0.6719 5 0 5.6719 0 6.5 0 7.1531 0.4188 7.7094 1.0032 7.9156 0.9907 8.0594 1.0094 8.2094 1.0657 8.3531 2.1282 11.1656 2.8001 14.2125 3.0063 17H2.0001v2h1.0718c-31e-4 0.3656-0.0187 0.725-0.0406 1.075-0.6 0.1969-1.0312 0.7625-1.0312 1.425 0 0.8281 0.6718 1.5 1.5 1.5h17c0.8281 0 1.5-0.6719 1.5-1.5 0-0.6625-0.4313-1.2281-1.0282-1.425-0.0218-0.35-0.0375-0.7094-0.0406-1.075h1.0688v-2h-1.0063c0.2063-2.7875 0.8781-5.8344 1.9406-8.6469 0.0532-0.1437 0.0719-0.2937 0.0625-0.4375C23.5813 7.7094 24.0001 7.1531 24 6.5zM12 2c0.55 0 1 0.45 1 1s-0.45 1-1 1-1-0.45-1-1 0.45-1 1-1zM1.5 6C1.775 6 2 6.225 2 6.5S1.775 7 1.5 7 1 6.775 1 6.5 1.225 6 1.5 6zM20.5 22h-17C3.225 22 3 21.775 3 21.5S3.225 21 3.5 21h17c0.275 0 0.5 0.225 0.5 0.5S20.775 22 20.5 22zM18.9625 20H5.0375c0.0188-0.3281 0.0281-0.6594 0.0313-1h13.8593c63e-4 0.3406 0.0157 0.6719 0.0344 1zM5.0094 17c-0.1313-1.8906-0.4594-3.8875-0.9688-5.8625 1.0344 0.7812 2.1688 1.4063 3.6969 1.825 0.3937 0.1094 0.8156-0.0344 1.0625-0.3594 1.1281-1.4875 2.1844-3.4125 3.2031-5.8468 1.0188 2.4343 2.075 4.3593 3.2032 5.8468 0.2468 0.325 0.6656 0.4688 1.0624 0.3594 1.5282-0.4187 2.6625-1.0437 3.6969-1.825-0.5094 1.9719-0.8406 3.9719-0.9687 5.8625zM22.5 7C22.225 7 22 6.775 22 6.5S22.225 6 22.5 6 23 6.225 23 6.5 22.775 7 22.5 7z" />
|
||||
<path d="M11 15.5c0 0.2761-0.2239 0.5-0.5 0.5S10 15.7761 10 15.5 10.2239 15 10.5 15 11 15.2239 11 15.5z" />
|
||||
<path d="M14 15.5c0 0.2761-0.2239 0.5-0.5 0.5S13 15.7761 13 15.5 13.2239 15 13.5 15 14 15.2239 14 15.5z" />
|
||||
<path d="M17 15.5c0 0.2761-0.2239 0.5-0.5 0.5S16 15.7761 16 15.5 16.2239 15 16.5 15 17 15.2239 17 15.5z" />
|
||||
<path d="M8 15.5C8 15.7761 7.7761 16 7.5 16S7 15.7761 7 15.5 7.2239 15 7.5 15 8 15.2239 8 15.5z" />
|
||||
<path d="M12 12c-0.55 0-1 0.45-1 1s0.45 1 1 1 1-0.45 1-1-0.45-1-1-1zM12 13c0 0 0 0 0 0z" />
|
||||
</svg>
|
||||
</template>
|
||||
18
src/icons/IconDatabase.vue
Normal file
18
src/icons/IconDatabase.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<svg
|
||||
id="icon-database"
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="transition-duration: 0s"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
@click="$emit('click')"
|
||||
@keydown="event => $emit('keydown', event)"
|
||||
>
|
||||
<path
|
||||
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
19
src/icons/IconDiscover.vue
Normal file
19
src/icons/IconDiscover.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
width="100%"
|
||||
height="100%"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M23.0562 7.3281C22.4531 5.9 21.5875 4.6156 20.4844 3.5156 19.3812 2.4125 18.1 1.55 16.6719 0.9438 15.1906 0.3187 13.6187 0 12 0S8.8094 0.3187 7.3281 0.9438C5.9 1.5469 4.6156 2.4125 3.5156 3.5156 2.4125 4.6188 1.55 5.9 0.9438 7.3281 0.3188 8.8094 0 10.3813 0 12s0.3188 3.1906 0.9438 4.6719c0.6031 1.4281 1.4687 2.7125 2.5718 3.8125C4.6188 21.5875 5.9 22.45 7.3281 23.0562 8.8094 23.6813 10.3813 24 12 24s3.1906-0.3187 4.6719-0.9438c1.4281-0.6031 2.7125-1.4687 3.8125-2.5718 1.1031-1.1032 1.9656-2.3844 2.5718-3.8125C23.6813 15.1906 24 13.6187 24 12s-0.3187-3.1906-0.9438-4.6719zM12 22C6.4875 22 2 17.5125 2 12S6.4875 2 12 2 22 6.4875 22 12 17.5125 22 12 22z"
|
||||
/>
|
||||
<path
|
||||
d="M12 3C9.5969 3 7.3344 3.9375 5.6375 5.6375S3 9.5969 3 12s0.9375 4.6656 2.6375 6.3625C7.3344 20.0625 9.5969 21 12 21s4.6656-0.9375 6.3625-2.6375C20.0625 16.6656 21 14.4031 21 12s-0.9375-4.6656-2.6375-6.3625C16.6656 3.9375 14.4031 3 12 3zM12 20c-4.4125 0-8-3.5875-8-8s3.5875-8 8-8 8 3.5875 8 8-3.5875 8-8 8z"
|
||||
/>
|
||||
<path
|
||||
d="M16.275 7.0531l-6 3c-0.0969 0.0469-0.175 0.1281-0.225 0.225l-3 6c-0.0969 0.1938-0.0594 0.425 0.0937 0.5782 0.0969 0.0968 0.2251 0.1468 0.3532 0.1468 0.075 0 0.1531-0.0187 0.225-0.0531l6-3c0.0969-0.0469 0.175-0.1281 0.225-0.225l3-6c0.0969-0.1938 0.0594-0.425-0.0938-0.5781C16.7 6.9937 16.4688 6.9563 16.275 7.0531zM10.6375 11.3438l2.0188 2.0187-4.0376 2.0187zM13.3625 12.6563l-2.0187-2.0188 4.0375-2.0187z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
10
src/icons/IconDisposal.vue
Normal file
10
src/icons/IconDisposal.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M7 5c1.1031 0 2-0.8969 2-2S8.1031 1 7 1 5 1.8969 5 3 5.8969 5 7 5zM7 2c0.55 0 1 0.45 1 1S7.55 4 7 4 6 3.55 6 3 6.45 2 7 2z" />
|
||||
<path d="M21.0187 11.8031 19.1813 21h-3.3626l-1.8406-9.1969-1.9625 0.3938 2 10C14.1094 22.6656 14.5188 23 14.9969 23h5c0.4781 0 0.8875-0.3375 0.9812-0.8031l2-10z" />
|
||||
<path d="M16 14.5c0 0.8281 0.6719 1.5 1.5 1.5s1.5-0.6719 1.5-1.5-0.6719-1.5-1.5-1.5-1.5 0.6719-1.5 1.5zM18 14.5c0 0.275-0.225 0.5-0.5 0.5S17 14.775 17 14.5 17.225 14 17.5 14 18 14.225 18 14.5z" />
|
||||
<path d="M17 11.5V12h1v-0.5c0-0.2469-0.0156-0.8969-0.1625-1.5719-0.1969-0.9-0.5562-1.5218-1.0656-1.85L16.35 7.8063 15.8094 8.6469l0.4218 0.2719C16.7625 9.2625 17 10.4625 17 11.5z" />
|
||||
<path d="M9.9875 9.9219c0.1562 0.0937 0.3344 0.1406 0.5125 0.1406 0.1812 0 0.3656-0.05 0.525-0.15L14.775 7.6l-1.05-1.7031-3.2062 1.9781C9.8906 7.4344 8.7687 6.5656 7.7719 5.3625c0 0 0 0 0 0s0 0 0 0v0C7.6844 5.2562 7.575 5.1687 7.45 5.1062 7.1125 4.9375 6.7063 4.9719 6.4031 5.2L3.0625 7.7C2.9406 7.7906 2.8406 7.9094 2.7719 8.0469l-1.9094 3.75 1.7813 0.9062 1.8031-3.5437 1.5562-1.1625v3.9187l-0.9469 5.6719-3.1 4.0563 1.5875 1.2156 3.25-4.25c0.1-0.1313 0.1657-0.2813 0.1907-0.4437l0.5562-3.3219L8.9531 17.2 8.0125 22.8375l1.9719 0.3281 1-6c0.0406-0.2343-63e-4-0.475-0.1282-0.6781L8 11.7219V8.4407c1.0687 0.9281 1.9343 1.45 1.9875 1.4812z" />
|
||||
</svg>
|
||||
</template>
|
||||
6
src/icons/IconEarth.vue
Normal file
6
src/icons/IconEarth.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M23.0562 7.3281C22.4531 5.9 21.5875 4.6156 20.4844 3.5156 19.3812 2.4125 18.1 1.55 16.6719 0.9438 15.1906 0.3187 13.6187 0 12 0S8.8094 0.3187 7.3281 0.9438C5.9 1.5469 4.6156 2.4125 3.5156 3.5156 2.4125 4.6188 1.55 5.9 0.9438 7.3281 0.3188 8.8094 0 10.3813 0 12s0.3188 3.1906 0.9438 4.6719c0.6031 1.4281 1.4687 2.7125 2.5718 3.8125C4.6188 21.5875 5.9 22.45 7.3281 23.0562 8.8094 23.6813 10.3813 24 12 24s3.1906-0.3187 4.6719-0.9438c1.4281-0.6031 2.7125-1.4687 3.8125-2.5718 1.1031-1.1032 1.9656-2.3844 2.5718-3.8125C23.6813 15.1906 24 13.6187 24 12s-0.3187-3.1906-0.9438-4.6719zM20.5781 6.8625C20.45 6.925 20.3219 7.0062 20.1969 7.1031l-0.0657 0.05-0.0468 0.0688c-0.4688 0.6937-1.2031 0.9062-1.6907 0.7562-0.2625-0.0812-0.3968-0.2406-0.3968-0.4781 0-0.675-0.3594-1.2-0.6469-1.625C17.1 5.5063 16.95 5.2719 17 5.1c0.0438-0.15 0.2688-0.4594 1.2219-0.925 0.9375 0.7469 1.7375 1.6594 2.3562 2.6875zM12 2c0.2031 0 0.4031 62e-4 0.6 0.0187-0.1 0.0657-0.2187 0.1188-0.3531 0.1813-0.3907 0.1781-0.9282 0.425-1.2094 1.1125l-94e-4 0.0187-62e-4 0.0188c-0.6438 2.0375-1.5063 2.8375-2.0782 3.3656-0.4094 0.3781-0.7625 0.7063-0.7687 1.25-63e-4 0.4719 0.2468 0.9719 0.9312 1.8438 0.5469 0.6968 1.0719 1.05 1.6063 1.0875 0.675 0.0437 1.15-0.4188 1.5343-0.7906 0.1938-0.1875 0.3938-0.3844 0.5438-0.4344 0.0406-0.0125 0.1312-0.0438 0.3594 0.1812 0.8312 0.8313 1.5093 1.05 2.0062 1.2125 0.4594 0.15 0.6688 0.2188 0.9031 0.6656 0.3907 0.7407 0.9813 1.0282 1.4125 1.2375 0.4719 0.2282 0.5313 0.2876 0.5313 0.5313 0 0.1594 63e-4 0.3312 94e-4 0.5094 0.0187 0.6375 0.0437 1.6031-0.2438 1.9C17.7281 15.9531 17.6593 16 17.5031 16c-0.9969 0-1.3969 0.8875-1.6625 1.475-0.075 0.1687-0.2 0.4406-0.2812 0.5281-0.6532-0.0906-1.4532 0.6563-2.6938 1.8688-0.275 0.2687-0.6375 0.625-0.925 0.875 0.0313-0.3313 0.1063-0.8282 0.2688-1.5344 0.2187-0.9594 0.5312-1.9938 0.7562-2.5156 0.1469-0.3407 0.1875-0.8719-0.4562-1.4563-0.3469-0.3125-0.825-0.5937-1.2907-0.8656-0.3343-0.1938-0.6468-0.3781-0.8875-0.5594-0.2687-0.2031-0.3187-0.3062-0.325-0.325 0-0.1844 0.0344-0.3937 0.0656-0.6156 0.1282-0.8344 0.3188-2.0969-1.3656-2.8344-0.175-0.0781-0.3594-0.15-0.5344-0.2187-1.4093-0.5563-2.8625-1.1313-3.1218-5C6.8437 3.0781 9.2968 2 12 2zM2.8625 16.0594c0.5969 0.1031 1.0312-0.3344 1.3531-0.6563 0.1063-0.1062 0.3281-0.3281 0.4188-0.35 0.0437 0.0188 0.3593 0.1875 0.9031 1.625 0.3187 0.8438 0.3344 1.7125 0.35 2.7157 31e-4 0.1687 62e-4 0.3406 94e-4 0.5218-1.3063-1.0093-2.3563-2.3343-3.0344-3.8562zM6.9219 20.6125c-0.025-0.4281-0.0313-0.8375-0.0406-1.2375-0.0188-1.0531-0.0344-2.0437-0.4157-3.0531-0.5562-1.4688-1.0281-2.1188-1.6312-2.25-0.5844-0.125-1.0156 0.3094-1.3313 0.625-0.1281 0.1281-0.3906 0.3906-0.475 0.375-0.0344-63e-4-0.3344-0.1031-0.8718-1.3063C2.0531 13.1906 2 12.6031 2 12 2 9.6719 2.8 7.525 4.1406 5.825 4.3219 7.1688 4.6812 8.2063 5.225 8.9719c0.7531 1.0625 1.7219 1.4437 2.575 1.7812 0.175 0.0688 0.3406 0.1344 0.5031 0.2063 0.9813 0.4281 0.9063 0.9312 0.7782 1.7656-0.0375 0.2531-0.0782 0.5156-0.0782 0.775 0 0.7406 0.8313 1.225 1.7094 1.7407 0.4188 0.2468 0.8531 0.4999 1.1219 0.7437 0.2312 0.2094 0.2187 0.2969 0.2094 0.3187-0.2625 0.6094-0.6125 1.775-0.8469 2.8376-0.1281 0.5749-0.2125 1.0875-0.2531 1.4843-0.0563 0.5844-0.0125 0.9313 0.1437 1.1594 0.0563 0.0813 0.1313 0.1469 0.2156 0.1938-1.5906-0.1125-3.0812-0.5969-4.3812-1.3657zM12 22c-0.05 0-0.0969 0-0.1469 0 0.3938-0.1406 0.875-0.6 1.7063-1.4125 0.4-0.3906 0.8125-0.7938 1.1781-1.1062 0.4594-0.3907 0.6531-0.4688 0.7062-0.4813 0.2157 0.0312 0.5907 94e-4 0.9094-0.3937 0.1625-0.2032 0.275-0.4532 0.3938-0.7188 0.2812-0.625 0.45-0.8844 0.7531-0.8844 0.3906 0 0.7312-0.1375 0.9812-0.3937 0.5844-0.6 0.5532-1.675 0.5282-2.625C19.0031 13.8125 19 13.65 19 13.5031c0-0.9031-0.6344-1.2093-1.0969-1.4312-0.3719-0.1813-0.725-0.35-0.9625-0.8-0.425-0.8094-0.9625-0.9844-1.4812-1.1531C15.0156 9.975 14.5125 9.8094 13.85 9.15c-0.4407-0.4406-0.9063-0.5843-1.3844-0.4219C12.1 8.85 11.8187 9.125 11.5437 9.3938c-0.2781 0.2687-0.5375 0.525-0.7687 0.5093-0.1313-93e-4-0.4188-0.1125-0.8875-0.7062-0.4782-0.6094-0.7219-1.0156-0.7188-1.2094 31e-4-0.1125 0.1719-0.275 0.45-0.5312 0.6063-0.5625 1.6219-1.5032 2.3469-3.775 0.125-0.2938 0.3281-0.3969 0.6937-0.5657 0.375-0.1718 0.8438-0.3875 1.1125-0.95 1.2781 0.2313 2.4719 0.7032 3.5313 1.3688-0.7406 0.4219-1.1313 0.8313-1.2656 1.2969-0.1844 0.6343 0.1718 1.1562 0.4843 1.6156 0.2438 0.3562 0.4751 0.6937 0.4751 1.0594 0 0.675 0.4218 1.225 1.1031 1.4343 0.1844 0.0563 0.3906 0.0875 0.6062 0.0875 0.7188 0 1.5594-0.3344 2.1563-1.1656 0.0656-0.0469 0.1281-0.0812 0.1844-0.1094C21.6594 9.0375 22 10.4781 22 12c0 5.5125-4.4875 10-10 10z" />
|
||||
</svg>
|
||||
</template>
|
||||
8
src/icons/IconEasterEgg.vue
Normal file
8
src/icons/IconEasterEgg.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M18.75 5.1719c-0.7969-1.5188-1.725-2.7313-2.7562-3.6031C14.7594 0.5281 13.4187 0 12 0S9.2406 0.5281 8.0063 1.5688C6.9719 2.4406 6.0438 3.6531 5.25 5.1719 3.8625 7.8188 3 11.2 3 14c0 2.7125 0.8438 5.2031 2.3719 7.0094 0.8 0.9438 1.7625 1.6812 2.8594 2.1906C9.3781 23.7313 10.6437 24 12 24s2.6219-0.2687 3.7688-0.8c1.0968-0.5093 2.0593-1.2468 2.8593-2.1906C20.1563 19.2031 21 16.7125 21 14c0-2.8-0.8625-6.1812-2.25-8.8281zM17.6594 18.95l-0.8063-0.8063c-0.1937-0.1937-0.5125-0.1937-0.7062 0l-1.6469 1.65-1.6469-1.6468c-0.1937-0.1938-0.5125-0.1938-0.7062 0L10.5 19.7937 8.8531 18.1469c-0.1937-0.1938-0.5125-0.1938-0.7062 0l-1.3875 1.3875c-0.5313-0.6625-0.9563-1.4438-1.2532-2.3188l0.6407 0.6406c0.1937 0.1938 0.5125 0.1938 0.7062 0L8.5 16.2094l1.6469 1.6468c0.1937 0.1938 0.5125 0.1938 0.7062 0L12.5 16.2094l1.6469 1.6468c0.1937 0.1938 0.5125 0.1938 0.7062 0L16.5 16.2094l1.6469 1.6468c0.0219 0.0219 0.0469 0.0438 0.0719 0.0594-0.1625 0.3656-0.35 0.7125-0.5594 1.0344zM17.4094 6.9906c0.1969 0.4375 0.3781 0.8907 0.5406 1.35L17.5 8.7938 15.8531 7.1469c-0.1937-0.1938-0.5125-0.1938-0.7062 0L13.5 8.7938 11.8531 7.1469c-0.1937-0.1938-0.5125-0.1938-0.7062 0L9.5 8.7938 7.8531 7.1469c-0.1937-0.1938-0.5125-0.1938-0.7062 0L6.1031 8.1875c0.3844-1.0594 0.8625-2.0719 1.4188-2.9594l1.625 1.625c0.1937 0.1938 0.5125 0.1938 0.7062 0L11.5 5.2062l1.6469 1.6469c0.1937 0.1938 0.5125 0.1938 0.7062 0L15.5 5.2062l1.6469 1.6469c0.075 0.075 0.1656 0.1219 0.2625 0.1375zM18.875 12.1719c0.025 0.1906 0.0469 0.3812 0.0625 0.5687C18.8531 12.8969 18.6875 13 18.5 13c-0.275 0-0.5-0.225-0.5-0.5s0.225-0.5 0.5-0.5c0.15 0 0.2844 0.0656 0.375 0.1719zM18.5 16.7937l-1.6469-1.6468c-0.1937-0.1938-0.5125-0.1938-0.7062 0L14.5 16.7937l-1.6469-1.6468c-0.1938-0.1938-0.5125-0.1938-0.7062 0L10.5 16.7937 8.8531 15.1469c-0.1938-0.1938-0.5125-0.1938-0.7062 0L6.5 16.7937 5.0875 15.3812C5.0312 14.9375 5 14.4781 5 14.0031c0-0.3625 0.0156-0.7375 0.05-1.1219C5.2187 13.525 5.8031 14 6.5 14 7.3281 14 8 13.3281 8 12.5S7.3281 11 6.5 11c-0.5719 0-1.0688 0.3218-1.3219 0.7906 0.0906-0.5906 0.2125-1.1938 0.3656-1.7938 0.1125-93e-4 0.2219-0.0593 0.3094-0.1437L7.5 8.2062 9.1469 9.8531c0.1937 0.1938 0.5125 0.1938 0.7062 0L11.5 8.2062l1.6469 1.6469c0.1937 0.1938 0.5125 0.1938 0.7062 0L15.5 8.2062l1.6469 1.6469C17.2437 9.95 17.3719 10 17.5 10s0.2562-0.05 0.3531-0.1469l0.4407-0.4406c0.1531 0.5312 0.2843 1.0687 0.3906 1.6C18.625 11.0062 18.5625 11 18.5 11c-0.8281 0-1.5 0.6719-1.5 1.5s0.6719 1.5 1.5 1.5c0.175 0 0.3438-0.0312 0.5-0.0844 0 0.0281 0 0.0563 0 0.0844 0 0.9344-0.1125 1.8125-0.3281 2.6187zM6 12.5C6 12.225 6.225 12 6.5 12S7 12.225 7 12.5 6.775 13 6.5 13 6 12.775 6 12.5zM12 2c1.3437 0 2.5625 0.7844 3.5969 2.0094-0.1563-0.0313-0.3281 0.0156-0.45 0.1375L13.5 5.7937 11.8531 4.1469c-0.1937-0.1938-0.5125-0.1938-0.7062 0L9.5 5.7937 8.1 4.3938C9.1906 2.95 10.5219 2 12 2zM12 22c-1.8031 0-3.3687-0.6312-4.5594-1.7344L8.5 19.2063l1.6469 1.6468c0.1937 0.1938 0.5125 0.1938 0.7062 0L12.5 19.2063l1.6469 1.6468C14.2438 20.95 14.3719 21 14.5 21c0.1282 0 0.2563-0.05 0.3532-0.1469L16.5 19.2063l0.5532 0.5531C15.8157 21.175 14.0657 22 12 22z" />
|
||||
<path d="M10.5 11C9.6719 11 9 11.6719 9 12.5S9.6719 14 10.5 14 12 13.3281 12 12.5 11.3281 11 10.5 11zM10.5 13c-0.275 0-0.5-0.225-0.5-0.5s0.225-0.5 0.5-0.5 0.5 0.225 0.5 0.5-0.225 0.5-0.5 0.5z" />
|
||||
<path d="M14.5 11c-0.8281 0-1.5 0.6719-1.5 1.5s0.6719 1.5 1.5 1.5 1.5-0.6719 1.5-1.5-0.6719-1.5-1.5-1.5zM14.5 13c-0.275 0-0.5-0.225-0.5-0.5s0.225-0.5 0.5-0.5 0.5 0.225 0.5 0.5-0.225 0.5-0.5 0.5z" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/icons/IconExit.vue
Normal file
7
src/icons/IconExit.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M1.5531 0.1062C1.6969 0.0344 1.85 0 2.0062 0H14.5C14.775 0 15 0.225 15 0.5V5h-1V1H3.6656L10.6 6.2C10.8531 6.3875 11 6.6844 11 7v10h3v-4h1v4.5c0 0.275-0.225 0.5-0.5 0.5H11v5c0 0.3781-0.2125 0.725-0.5531 0.8937C10.3063 23.9656 10.1532 24 10 24c-0.2125 0-0.425-0.0688-0.6-0.2l-8-6C1.1469 17.6125 1 17.3156 1 17V1c0-0.3782 0.2125-0.725 0.5531-0.8938zM3 16.5 9 21V7.5L3 3z" />
|
||||
<path d="M13 10V8h6.5844l-2.5438-2.5437 1.4157-1.4157 4.2499 4.25c0.3907 0.3907 0.3907 1.025 0 1.4157l-4.2499 4.25-1.4157-1.4157L19.5844 10z" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/icons/IconExpandHorizontal.vue
Normal file
7
src/icons/IconExpandHorizontal.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M23.7063 11.2938l-6.2501-6.25-1.4156 1.4156L21.5844 12l-5.5407 5.5437 1.4157 1.4157 6.25-6.25c0.3875-0.3938 0.3875-1.025-31e-4-1.4156z" />
|
||||
<path d="M6.5437 5.0437l-6.25 6.2501c-0.3906 0.3906-0.3906 1.0249 0 1.4156l6.25 6.25 1.4157-1.4156L2.4156 12 7.9594 6.4563z" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/icons/IconExpandVertical.vue
Normal file
7
src/icons/IconExpandVertical.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M12 2.4156l5.5437 5.5438 1.4157-1.4157-6.25-6.25c-0.3907-0.3906-1.025-0.3906-1.4157 0l-6.25 6.25 1.4157 1.4157z" />
|
||||
<path d="M12 21.5844 6.4563 16.0437 5.0406 17.4594l6.25 6.25c0.1938 0.1937 0.45 0.2937 0.7063 0.2937 0.2562 0 0.5125-0.0969 0.7062-0.2937l6.25-6.25-1.4156-1.4157z" />
|
||||
</svg>
|
||||
</template>
|
||||
6
src/icons/IconFilm.vue
Normal file
6
src/icons/IconFilm.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M23.5 4h-23C0.225 4 0 4.225 0 4.5v15C0 19.775 0.225 20 0.5 20h23c0.275 0 0.5-0.225 0.5-0.5v-15C24 4.225 23.775 4 23.5 4zM4 5v2H2V5zM2 14h2v2H2zM2 13v-2h2v2zM2 10V8h2v2zM2 19v-2h2v2zM18 18H6V6h12zM22 16h-2v-2h2zM22 13h-2v-2h2zM22 10h-2V8h2zM20 19v-2h2v2zM22 7h-2V5h2z" />
|
||||
</svg>
|
||||
</template>
|
||||
8
src/icons/IconFingerprint.vue
Normal file
8
src/icons/IconFingerprint.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M21 11.5c0-0.2-94e-4-0.3969-0.0281-0.5937-0.1438-2.8844-0.8625-5.3719-2.1-7.2375C17.2812 1.2688 14.9031 0 12 0S6.7188 1.2688 5.1281 3.6688C3.7344 5.7688 3 8.6469 3 12c0 2.7719 0.1781 5.925 1.5031 8.2688 0.6844 1.2093 1.6375 2.1375 2.8344 2.7531 1.2719 0.6563 2.7969 0.975 4.6625 0.975s3.3906-0.3187 4.6625-0.975c1.1969-0.6187 2.15-1.5438 2.8343-2.7531C20.8218 17.925 21 14.7719 21 12c0-0.1687-32e-4-0.3344-63e-4-0.5zM6.7938 4.775C8.0156 2.9344 9.7656 2 12 2c1.3844 0 2.5844 0.3594 3.5844 1.0687C15.225 3.025 14.8625 3 14.5 3c-1.5625 0-3.8063 0.2875-5.6156 1.6656C6.9719 6.1188 6 8.4188 6 11.5 6 13.9813 8.0187 16 10.5 16c2.4812 0 4.5-2.0187 4.5-4.5V11h-1v0.5c0 1.9313-1.5688 3.5-3.5 3.5C8.5687 15 7 13.4313 7 11.5 7 4.975 11.7 4 14.5 4c0.8312 0 1.6625 0.1406 2.4469 0.4094 0.0875 0.1187 0.175 0.2375 0.2593 0.3656 0.2375 0.3563 0.45 0.7406 0.6407 1.1563C16.8687 5.3406 15.7219 5 14.5 5 10.9156 5 8 7.9156 8 11.5 8 12.8781 9.1219 14 10.5 14s2.5-1.1219 2.5-2.5c0-0.8281 0.6719-1.5 1.5-1.5s1.5 0.6719 1.5 1.5c0 3.0313-2.4687 5.5-5.5 5.5-2.975 0-5.4063-2.375-5.4969-5.3312 0.0438-2.8125 0.6625-5.1907 1.7907-6.8938zM17.7531 19.2875C16.7062 21.1375 14.8781 22 12 22s-4.7094-0.8625-5.7531-2.7125c-0.0406-0.0719-0.0782-0.1438-0.1156-0.2156C7.3156 19.6781 8.6344 20 10 20v-1c-1.6187 0-3.1687-0.5156-4.4531-1.4625-0.2063-0.75-0.3375-1.5563-0.4188-2.3813C6.3 16.8719 8.2688 18 10.5 18c3.5844 0 6.5-2.9156 6.5-6.5 0-1.3781-1.1219-2.5-2.5-2.5S12 10.1219 12 11.5c0 0.8281-0.6719 1.5-1.5 1.5S9 12.3281 9 11.5C9 8.4687 11.4687 6 14.5 6c1.5656 0 2.9812 0.6562 3.9844 1.7125C18.825 8.9844 19 10.4281 19 12c0 2.5125-0.1469 5.3437-1.2469 7.2875z" />
|
||||
<path d="M10 11.5V12h1v-0.5C11 9.5688 12.5688 8 14.5 8H15V7h-0.5C12.0187 7 10 9.0188 10 11.5z" />
|
||||
<path d="M16 7v1c0.4719 0 0.9563 0.3875 1.3313 1.0625C17.7625 9.8406 18 10.8844 18 12c0 3.8531-2.1906 8-7 8v1c2.5031 0 4.5969-1 6.0531-2.8906 0.6375-0.8281 1.1313-1.8 1.4688-2.8844C18.8406 14.1937 19 13.1125 19 12.0031 19 9.1969 17.6813 7 16 7z" />
|
||||
</svg>
|
||||
</template>
|
||||
6
src/icons/IconFlag3.vue
Normal file
6
src/icons/IconFlag3.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M21.9906 3.4062C21.875 2.8 21.2219 2.2906 19.9875 1.85c-0.9281-0.3313-2.1687-0.6063-3.4031-0.7563-1.0375-0.125-1.9781-0.1562-2.7188-0.0875-0.9906 0.0938-1.575 0.3626-1.7875 0.8282C12.0094 1.9875 11.9813 2.1437 12 2.3031v2.3c-0.525 0.1781-1.25 0.3219-2.1531 0.3625C8.1563 5.0437 6.4094 4.7531 5 4.1687V3.7281c0.5969-0.3469 1-0.9937 1-1.7312 0-1.1032-0.8969-2-2-2S2 0.8969 2 2c0 0.7375 0.4031 1.3844 1 1.7313V24h2V12.2469c1.3906 0.5093 2.9344 0.7375 4.3438 0.7375 1.425 0 2.7093-0.2344 3.5468-0.6469 0.6188-0.3031 0.9875-0.6938 1.0969-1.1625C13.9969 11.1375 14 11.1 14 11.0625V9.0187c1.0687-0.0968 2.6656 0.0375 4.1562 0.3626 0.8469 0.1843 1.5719 0.4125 2.0969 0.6562 0.5844 0.275 0.7438 0.4906 0.7531 0.5531 0.0438 0.2375 0.2532 0.4063 0.4907 0.4063 0.0156 0 0.0312 0 0.0468-31e-4 0.2563-0.0251 0.4531-0.2407 0.4531-0.4969V3.5c32e-4-0.0312 0-0.0625-62e-4-0.0938zM4 1c0.55 0 1 0.45 1 1S4.55 3 4 3 3 2.55 3 2 3.45 1 4 1zM12.45 11.4406c-1.5156 0.7438-4.8812 0.7969-7.45-0.2687v-5.925c1.4719 0.5406 3.2125 0.8 4.8938 0.7218 0.8812-0.0406 1.6937-0.175 2.35-0.3875 0.2843-0.0937 0.5375-0.1999 0.7531-0.3187v5.7281c-0.0563 0.1438-0.2563 0.3094-0.5469 0.45zM21 9.3c-0.1-0.0562-0.2062-0.1094-0.3218-0.1656-0.5907-0.2782-1.3907-0.5281-2.3094-0.7282C16.8094 8.0656 15.1719 7.925 14 8.0187V3.875c-31e-4-0.3469-0.1562-0.7781-0.6625-1.2438 0 0-31e-4 0-31e-4-31e-4-0.2281-0.2062-0.3031-0.3344-0.3281-0.3906 0.1312-0.1 0.6437-0.2875 1.9062-0.2656 1.0969 0.0187 2.4375 0.2 3.5907 0.4812 0.7593 0.1844 1.4031 0.4063 1.8593 0.6375 0.4406 0.2219 0.5969 0.4 0.6344 0.475V9.3z" />
|
||||
</svg>
|
||||
</template>
|
||||
9
src/icons/IconGhost.vue
Normal file
9
src/icons/IconGhost.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M23.9031 11.5719C23.7375 11.2219 23.3844 11 23 11h-2c-0.55 0-1-0.45-1-1V9c0-2.1344-0.8312-4.1407-2.3375-5.6531C16.1563 1.8375 14.15 1.0031 12.0125 1h-0.0281C9.85 1.0031 7.8438 1.8375 6.3375 3.3469 4.8313 4.8593 4 6.8656 4 9v1c0 0.55-0.45 1-1 1H1c-0.3875 0-0.7375 0.2219-0.9031 0.5719s-0.1156 0.7625 0.1313 1.0625C2.7032 15.6562 3.9375 18.725 4 22.0187 4.0094 22.5625 4.4563 23 5 23c1.3282 0 2.0344-0.8063 2.5032-1.3406C7.9375 21.1594 8.1125 21 8.5 21s0.5625 0.1594 0.9969 0.6594C9.9656 22.1937 10.6719 23 12 23c1.3282 0 2.0344-0.8063 2.5031-1.3406C14.9375 21.1594 15.1125 21 15.5 21s0.5625 0.1594 0.9969 0.6594C16.9656 22.1937 17.6719 23 19 23c0.5438 0 0.9906-0.4375 1-0.9813 0.0625-3.2937 1.2969-6.3656 3.775-9.3843 0.2438-0.3 0.2938-0.7125 0.1281-1.0625zM2.9969 13H3c0.7688 0 1.4688-0.2906 2-0.7656v4.5781c-0.0563-0.1469-0.1125-0.2906-0.1719-0.4375C4.3625 15.2375 3.7469 14.1094 2.9969 13zM15.5 19c-1.3281 0-2.0344 0.8063-2.5031 1.3406C12.5625 20.8406 12.3875 21 12 21s-0.5625-0.1594-0.9969-0.6594C10.5344 19.8063 9.8281 19 8.5 19S6.4656 19.8063 6 20.3375V10.05c0-0.0156 0-0.0344 0-0.05V9c0-3.3031 2.6875-5.9937 5.9875-6h0.0219C15.3125 3.0063 18 5.6969 18 9v11.3375C17.5313 19.8031 16.825 19 15.5 19zM19.1719 16.3781C19.1125 16.525 19.0563 16.6688 19 16.8156v-4.5812C19.5313 12.7094 20.2313 13 21 13h31e-4c-0.75 1.1094-1.3656 2.2375-1.8312 3.3781z" />
|
||||
<path d="M12 12c-0.5531 0-1.0875 0.2875-1.4594 0.7844C10.1906 13.25 10 13.8594 10 14.5s0.1906 1.25 0.5406 1.7156C10.9156 16.7156 11.4469 17 12 17s1.0875-0.2875 1.4594-0.7844C13.8094 15.75 14 15.1406 14 14.5s-0.1906-1.25-0.5406-1.7156C13.0875 12.2875 12.5531 12 12 12zM12 16c-0.5406 0-1-0.6875-1-1.5s0.4594-1.5 1-1.5 1 0.6875 1 1.5-0.4562 1.5-1 1.5z" />
|
||||
<path d="M11 8.5C11 7.6719 10.3281 7 9.5 7S8 7.6719 8 8.5 8.6719 10 9.5 10 11 9.3281 11 8.5zM9.5 9C9.225 9 9 8.775 9 8.5S9.225 8 9.5 8 10 8.225 10 8.5 9.775 9 9.5 9z" />
|
||||
<path d="M14.5 7C13.6719 7 13 7.6719 13 8.5s0.6719 1.5 1.5 1.5S16 9.3281 16 8.5 15.3281 7 14.5 7zM14.5 9C14.225 9 14 8.775 14 8.5S14.225 8 14.5 8 15 8.225 15 8.5 14.775 9 14.5 9z" />
|
||||
</svg>
|
||||
</template>
|
||||
9
src/icons/IconHalloween.vue
Normal file
9
src/icons/IconHalloween.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M22.2438 7.1125c-0.5782-0.9469-1.2625-1.6969-2.0313-2.2281C19.3625 4.2969 18.45 4 17.5 4c-0.7437 0-1.4656 0.1844-2.1531 0.5438-0.7938-1.7375-1.9719-2.8532-2.8438-3.4907-0.9937-0.7312-1.8093-1.0156-1.8406-1.025-0.1812-0.0625-0.3812-0.0156-0.5156 0.1188l-1 1C9.05 1.2438 8.9969 1.3782 9 1.5157 9.0031 1.6531 9.0656 1.7844 9.1688 1.875c0.2531 0.2281 0.9468 1.0031 0.7874 1.5688-0.1125 0.4-0.6687 0.7375-1.5843 0.9656C7.7688 4.1375 7.1437 4.0031 6.5 4.0031c-0.95 0-1.8625 0.2969-2.7094 0.8844-0.7719 0.5344-1.4531 1.2844-2.0312 2.2281C0.625 8.9688 0 11.4157 0 14c0 2.5844 0.625 5.0313 1.7562 6.8844 0.5782 0.9469 1.2625 1.6969 2.0313 2.2281 0.8468 0.5875 1.7594 0.8844 2.7094 0.8844 0.7562 0 1.4906-0.1875 2.1843-0.5625 1.0594 0.3719 2.1719 0.5625 3.3157 0.5625 1.1406 0 2.2562-0.1875 3.3156-0.5625 0.6969 0.3719 1.4281 0.5625 2.1844 0.5625 0.95 0 1.8625-0.2969 2.7093-0.8844 0.7719-0.5344 1.4532-1.2844 2.0313-2.2281C23.375 19.0313 24 16.5844 24 14c0-2.5843-0.625-5.0312-1.7562-6.8875zM10.6156 1.0906c0.2906 0.1344 0.7781 0.3844 1.325 0.7906 0.8656 0.6407 1.5688 1.4219 2.1031 2.3282C13.375 4.0718 12.6937 4 11.9969 4c-0.4157 0-0.825 0.025-1.2313 0.075 0.0656-0.1156 0.1156-0.2406 0.1531-0.3688 0.2438-0.8812-0.3125-1.725-0.7187-2.2zM17.5 22c-0.3344 0-0.6687-0.0687-0.9969-0.2094 0.4563-0.2625 0.8907-0.5656 1.2969-0.9093l0.3813-0.3219-0.6438-0.7656-0.3812 0.325c-1.4282 1.2031-3.2407 1.8719-5.1094 1.8812 0 0 0 0 0 0-0.0157 0-0.0282 0-0.0438 0s-0.0281 0-0.0437 0c0 0 0 0 0 0-1.8688-93e-4-3.6844-0.6781-5.1125-1.8812l-0.3813-0.3219-0.6437 0.7656 0.3812 0.3219c0.4063 0.3438 0.8406 0.6469 1.2969 0.9094-0.3281 0.1375-0.6594 0.2093-0.9969 0.2093-2.4406 0-4.5-3.6625-4.5-7.9999 0-4.3375 2.0594-8 4.5-8 0.3344 0 0.6688 0.0687 0.9969 0.2093C7.0437 6.475 6.6094 6.7781 6.2031 7.1219L5.8219 7.4438 6.4656 8.2094 6.8469 7.8875c1.4312-1.2031 3.2437-1.8718 5.1125-1.8812 0 0 0 0 0 0 0.0156 0 0.0281 0 0.0437 0s0.0281 0 0.0438 0c0 0 0 0 0 0 1.8687 94e-4 3.6844 0.6781 5.1125 1.8812l0.3812 0.3219 0.6438-0.7656-0.3813-0.3219c-0.4062-0.3438-0.8406-0.6469-1.2968-0.9094 0.3281-0.1375 0.6593-0.2093 0.9968-0.2093 2.4406 0 4.5 3.6625 4.5 8C22 18.3375 19.9406 22 17.5 22z" />
|
||||
<path d="M9.9406 12.7344c0.0875-0.1625 0.0781-0.3594-0.025-0.5125l-2-3C7.8219 9.0812 7.6656 9 7.5 9S7.1781 9.0844 7.0844 9.2219l-2 3c-0.1031 0.1531-0.1125 0.35-0.025 0.5125S5.3156 13 5.5 13h4c0.1844 0 0.3531-0.1 0.4406-0.2656zM6.4344 12 7.5 10.4 8.5656 12z" />
|
||||
<path d="M16.9094 9.2219C16.8156 9.0813 16.6594 9 16.4938 9s-0.3219 0.0844-0.4157 0.2219l-1.9999 3c-0.1032 0.1531-0.1125 0.35-0.0251 0.5125C14.1406 12.8969 14.3094 13 14.4938 13h4c0.1844 0 0.3531-0.1 0.4406-0.2656s0.0781-0.3594-0.025-0.5125zM15.4281 12l1.0657-1.6 1.0656 1.6z" />
|
||||
<path d="M17.5 15h-2c-0.1312 0-0.2594 0.0531-0.3531 0.1469l-0.6531 0.6469-0.6407-0.6469C13.7594 15.0531 13.6313 15 13.5 15h-3c-0.1312 0-0.2594 0.0531-0.3531 0.1469L9.5 15.7938 8.8531 15.1469C8.7594 15.0531 8.6312 15 8.5 15h-2C6.225 15 6 15.225 6 15.5c0 0.0562 94e-4 0.1094 0.025 0.1563 0.0531 1.1718 0.6906 2.2593 1.8063 3.075C8.95 19.55 10.4313 20 12 20c1.5688 0 3.05-0.45 4.1688-1.2687 1.1156-0.8157 1.7531-1.9032 1.8062-3.075C17.9907 15.6063 18 15.5531 18 15.5c0-0.275-0.225-0.5-0.5-0.5zM15.5781 17.925C14.6281 18.6188 13.3594 19 12 19s-2.6281-0.3812-3.5781-1.075c-0.7344-0.5375-1.2-1.2063-1.35-1.925h1.2187l0.8531 0.8531c0.1938 0.1938 0.5125 0.1938 0.7063 0L10.7031 16h2.5844l0.8469 0.8531C14.2281 16.9469 14.3562 17 14.4875 17v0c0.1312 0 0.2594-0.0531 0.3531-0.1469L15.7 16h1.2219c-0.1438 0.7188-0.6094 1.3875-1.3438 1.925z" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/icons/IconHelm.vue
Normal file
7
src/icons/IconHelm.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M12 8c-2.2062 0-4 1.7937-4 4s1.7937 4 4 4c2.2062 0 4-1.7937 4-4s-1.7937-4-4-4zM12 14c-1.1031 0-2-0.8969-2-2s0.8969-2 2-2 2 0.8969 2 2-0.8969 2-2 2z" />
|
||||
<path d="M24 13v-2h-3.0531C20.7594 9.3031 20.1 7.7125 19.0344 6.3813l2.1593-2.1594-1.4156-1.4156-2.1594 2.1625C16.2875 3.9 14.6969 3.2407 13 3.0563V0h-2v3.0532C9.3031 3.2407 7.7125 3.9 6.3812 4.9657L4.2219 2.8063 2.8062 4.2219 4.9656 6.3813C3.8969 7.7125 3.2375 9.3032 3.0531 11H0v2h3.0531c0.1875 1.6969 0.8469 3.2875 1.9125 4.6188l-2.1594 2.1594 1.4157 1.4156 2.1593-2.1594C7.7125 20.1032 9.3031 20.7625 11 20.9469V24h2v-3.0531c1.6969-0.1875 3.2875-0.8469 4.6187-1.9125l2.1594 2.1594 1.4156-1.4156-2.1625-2.1594C20.1 16.2875 20.7593 14.6969 20.9437 13zM19.9375 11h-2.0219c-0.1531-0.9094-0.5125-1.7531-1.0281-2.475l1.4281-1.4281C19.1781 8.2031 19.7562 9.5406 19.9375 11zM12 17c-2.7563 0-5-2.2438-5-5s2.2438-5 5-5c2.7563 0 5 2.2438 5 5s-2.2438 5-5 5zM16.9031 5.6813 15.475 7.1094C14.7531 6.5937 13.9094 6.2344 13 6.0812V4.0625c1.4594 0.1812 2.7969 0.7594 3.9031 1.6188zM11 4.0625v2.0219c-0.9094 0.1531-1.7531 0.5125-2.475 1.0281L7.0969 5.6844C8.2031 4.8219 9.5406 4.2438 11 4.0625zM5.6813 7.0969 7.1094 8.525C6.5937 9.2469 6.2344 10.0906 6.0812 11H4.0625c0.1812-1.4594 0.7594-2.7969 1.6188-3.9031zM4.0625 13h2.0219c0.1531 0.9094 0.5125 1.7531 1.0281 2.475l-1.4281 1.4281C4.8219 15.7969 4.2438 14.4594 4.0625 13zM7.0969 18.3188 8.525 16.8906c0.7219 0.5157 1.5656 0.875 2.475 1.0282v2.0187c-1.4594-0.1812-2.7969-0.7594-3.9031-1.6187zM13 19.9375v-2.0219c0.9094-0.1531 1.7531-0.5125 2.475-1.0281l1.4281 1.4281c-1.1062 0.8625-2.4437 1.4406-3.9031 1.6219zM18.3188 16.9031 16.8906 15.475c0.5157-0.7219 0.875-1.5656 1.0282-2.475h2.0187c-0.1812 1.4594-0.7594 2.7969-1.6187 3.9031z" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/icons/IconMailboxEmpty.vue
Normal file
7
src/icons/IconMailboxEmpty.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M18.5 3H2.9156C2.7094 2.4188 2.1531 2 1.5 2 0.6719 2 0 2.6719 0 3.5v14C0 17.775 0.225 18 0.5 18h2C2.775 18 3 17.775 3 17.5V17h6v4.5C9 21.775 9.225 22 9.5 22h5c0.275 0 0.5-0.225 0.5-0.5V17h7c1.1031 0 2-0.8969 2-2V8.5C24 5.4688 21.5312 3 18.5 3zM2 17H1V3.5C1 3.225 1.225 3 1.5 3S2 3.225 2 3.5zM13 21h-2v-4h2zM22 15H3V5h15.5C20.4312 5 22 6.5687 22 8.5z" />
|
||||
<path d="M19.5 9H10V8.5C10 8.225 9.775 8 9.5 8h-4C5.225 8 5 8.225 5 8.5v4C5 12.775 5.225 13 5.5 13h4c0.275 0 0.5-0.225 0.5-0.5V12h6v1.5c0 0.275 0.225 0.5 0.5 0.5h3c0.275 0 0.5-0.225 0.5-0.5v-4C20 9.225 19.775 9 19.5 9zM9 12H6V9h3zM19 13h-2v-1.5c0-0.275-0.225-0.5-0.5-0.5H10v-1h9z" />
|
||||
</svg>
|
||||
</template>
|
||||
6
src/icons/IconMailboxFull.vue
Normal file
6
src/icons/IconMailboxFull.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M18.5 5H9V4h1.5C10.775 4 11 3.775 11 3.5v-3C11 0.225 10.775 0 10.5 0h-4C6.225 0 6 0.225 6 0.5V5H2.9156C2.7094 4.4187 2.1531 4 1.5 4 0.6719 4 0 4.6719 0 5.5v14C0 19.775 0.225 20 0.5 20h2C2.775 20 3 19.775 3 19.5V19h6v4.5C9 23.775 9.225 24 9.5 24h5c0.275 0 0.5-0.225 0.5-0.5V19h7c1.1031 0 2-0.8969 2-2v-6.5C24 7.4688 21.5312 5 18.5 5zM2 19H1V5.5C1 5.225 1.225 5 1.5 5S2 5.225 2 5.5zM9 11v3H6v-3zM10 1v2H8.5C8.225 3 8 3.225 8 3.5V10H7V1zM13 23h-2v-4h2zM22 17H3V7h3v3H5.5C5.225 10 5 10.225 5 10.5v4C5 14.775 5.225 15 5.5 15h4c0.275 0 0.5-0.225 0.5-0.5v-4c0-0.275-0.225-0.5-0.5-0.5H9V7h9.5c1.9313 0 3.5 1.5687 3.5 3.5z" />
|
||||
</svg>
|
||||
</template>
|
||||
6
src/icons/IconMedalEmpty.vue
Normal file
6
src/icons/IconMedalEmpty.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M19.5 6.7875l-0.625-0.625 0.2313-0.8594C19.35 4.3875 18.8031 3.4437 17.8906 3.2l-0.8531-0.2281-0.2313-0.8594C16.5594 1.1969 15.6125 0.6531 14.7 0.8969L13.8469 1.125 13.2187 0.4969c-0.6719-0.6688-1.7625-0.6657-2.4312 0l-0.625 0.625-0.8594-0.2313C8.3875 0.6469 7.4437 1.1937 7.2 2.1062L6.9719 2.9594 6.1125 3.1906C5.1968 3.4375 4.6531 4.3844 4.8968 5.2969L5.125 6.15 4.4968 6.7812c-0.6687 0.6719-0.6656 1.7625 0 2.4313l0.625 0.625-0.2312 0.8594c-0.2438 0.9156 0.3031 1.8593 1.2156 2.1062l1.8907 0.5063V23c0 0.4031 0.2437 0.7688 0.6187 0.925 0.375 0.1563 0.8031 0.0688 1.0906-0.2156L12 21.4156l2.2937 2.2938C14.4844 23.9 14.7406 24.0031 15 24.0031c0.1281 0 0.2594-0.025 0.3812-0.075C15.7562 23.7719 16 23.4094 16 23.0031v-9.6906l1.8875-0.5063c0.9156-0.2468 1.4594-1.1937 1.2156-2.1062L18.875 9.8469l0.6281-0.6282c0.6656-0.6718 0.6656-1.7625-31e-4-2.4312zM12 11c-1.6531 0-3-1.3469-3-3s1.3469-3 3-3 3 1.3469 3 3-1.3469 3-3 3zM12.7062 19.2938c-0.3906-0.3907-1.0249-0.3907-1.4156 0L10 20.5844v-8.0031C10.6125 12.85 11.2906 13 12 13c0.7093 0 1.3875-0.15 2-0.4187v8.0031zM18.7938 8.5125 17.9625 9.3438c-0.125 0.125-0.175 0.3093-0.1281 0.4843l0.3031 1.1313c0.1031 0.3813-0.1281 0.7781-0.5094 0.8812L16 12.2782V11c0.6281-0.8343 1-1.875 1-2.9968 0-2.7563-2.2437-5-5-5-2.7562 0-5 2.2437-5 5C7 9.125 7.3719 10.1657 8 11v1.275l-1.6312-0.4375c-0.3813-0.1031-0.6094-0.5-0.5094-0.8812l0.3031-1.1344C6.2094 9.65 6.1594 9.4657 6.0344 9.3375L5.2063 8.5094c-0.2813-0.2812-0.2782-0.7375 0-1.0188l0.8312-0.8312c0.125-0.125 0.175-0.3094 0.1282-0.4844L5.8625 5.0437C5.7594 4.6625 5.9907 4.2656 6.3719 4.1625l1.1344-0.3031c0.1718-0.0469 0.3062-0.1813 0.3531-0.3532L8.1625 2.375c0.1032-0.3813 0.5-0.6094 0.8813-0.5094l1.1344 0.3031c0.1718 0.0469 0.3562-31e-4 0.4843-0.1281l0.8282-0.8281c0.2812-0.2813 0.7375-0.2781 1.0187 0l0.8344 0.825c0.125 0.125 0.3094 0.175 0.4844 0.1281l1.1312-0.3031c0.3813-0.1031 0.7782 0.1281 0.8813 0.5094l0.3031 1.1343c0.0469 0.1719 0.1812 0.3063 0.3531 0.3532l1.1313 0.3031c0.3812 0.1031 0.6094 0.5 0.5094 0.8812l-0.3032 1.1344c-0.0468 0.1719 32e-4 0.3563 0.1282 0.4844l0.8281 0.8281c0.2812 0.2844 0.2812 0.7406 31e-4 1.0219z" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/icons/IconMusic.vue
Normal file
7
src/icons/IconMusic.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path
|
||||
d="M28 4.667v19.333c0 3.133-2.533 5.667-5.667 5.667s-5.667-2.533-5.667-5.667c0-3.133 2.533-5.667 5.667-5.667 1.067 0 2.067 0.3 2.933 0.8v-12.133l-13.333 3.8v16.2c0 3.133-2.533 5.667-5.667 5.667s-5.667-2.533-5.667-5.667c0-3.133 2.533-5.667 5.667-5.667 1.067 0 2.067 0.3 2.933 0.8v-17.8c0-0.6 0.4-1.133 0.967-1.267l14.667-4.133c0.133-0.033 0.267-0.067 0.4-0.067 0.733 0 1.333 0.6 1.333 1.333zM6.333 24c-1.833 0-3.333 1.5-3.333 3.333s1.5 3.333 3.333 3.333 3.333-1.5 3.333-3.333-1.5-3.333-3.333-3.333zM25.667 7.2l-11.333 3.2v-2.2l11.333-3.2v2.2zM22.333 20.667c-1.833 0-3.333 1.5-3.333 3.333s1.5 3.333 3.333 3.333 3.333-1.5 3.333-3.333-1.5-3.333-3.333-3.333z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
6
src/icons/IconPaperPlane.vue
Normal file
6
src/icons/IconPaperPlane.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M23.6562 1.2438c-0.2968-0.2563-0.7125-0.3188-1.0687-0.1563l-22 10c-0.3844 0.175-0.6156 0.5688-0.5844 0.9875 0.0313 0.4187 0.3188 0.7719 0.7219 0.8875L7 14.7531V22c0 0.4406 0.2875 0.8281 0.7094 0.9563C7.8062 22.9844 7.9031 23 8 23c0.3281 0 0.6437-0.1625 0.8312-0.4469l3.5126-5.2625 5.2093 2.6063c0.2657 0.1312 0.575 0.1406 0.8469 0.0219 0.2719-0.1188 0.4781-0.35 0.5594-0.6344l5-17C24.0688 1.9063 23.95 1.5 23.6562 1.2438zM3.8875 11.7844l14.0781-6.4-9.6031 7.6844c-0.0281-0.0125-0.0593-0.0219-0.0875-0.0313zM9 18.6969V14c0-0.05-31e-4-0.1-0.0125-0.15l10.4719-8.3781-8.2281 9.8875c-0.0219 0.0281-0.0438 0.0562-0.0626 0.0843zM17.3781 17.5719l-4.7218-2.3625 8.3749-10.0657z" />
|
||||
</svg>
|
||||
</template>
|
||||
6
src/icons/IconPlanet.vue
Normal file
6
src/icons/IconPlanet.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M23.85 4.7438c-0.3531-0.6532-1.2219-0.8782-2.5812-0.6688-0.9375 0.1438-2.1282 0.4969-3.4813 1.0281C16.1656 3.7438 14.1406 3 12 3 9.5969 3 7.3344 3.9375 5.6375 5.6375S3 9.5969 3 12c0 0.7188 0.0844 1.4219 0.2469 2.1031-1.0438 0.9438-1.8688 1.825-2.4156 2.5813-0.7969 1.1031-1.0188 1.9437-0.6813 2.5718 0.1188 0.2219 0.375 0.5157 0.9031 0.6563C1.2781 19.9719 1.5375 20 1.8187 20c1.2594 0 2.9719-0.55 4.3001-1.0625 0.0343-0.0125 0.0656-0.025 0.1-0.0375C7.8344 20.2563 9.8594 21 12 21c2.4031 0 4.6657-0.9375 6.3625-2.6375C20.0625 16.6656 21 14.4031 21 12c0-0.7188-0.0844-1.4219-0.2468-2.1031 0.2343-0.2125 0.4593-0.4219 0.675-0.6313 0.9468-0.9156 1.6437-1.7406 2.0687-2.4437 0.5188-0.8531 0.6344-1.5532 0.3531-2.0781zM12 5c3.2625 0 6.0094 2.2437 6.7844 5.2687-1.6031 1.3094-3.5281 2.6657-5.6125 3.9469-2.1031 1.2938-4.0469 2.3156-5.7438 3.0813C5.9406 16.0125 5 14.1125 5 12c0-3.8594 3.1406-7 7-7zM1.3125 18.9438c-0.1031-0.0282-0.2344-0.0782-0.2812-0.1657-0.0938-0.175 93e-4-0.675 0.6093-1.5062 0.4469-0.6157 1.1032-1.3344 1.9282-2.1063 0.4093 1.0875 1.0281 2.0938 1.8406 2.9656-1.9688 0.7313-3.4125 0.9969-4.0969 0.8126zM19 12c0 3.8594-3.1406 7-7 7-1.3344 0-2.5844-0.375-3.6469-1.0281 1.6938-0.7938 3.5063-1.7782 5.3407-2.9063 1.9312-1.1875 3.7343-2.4437 5.2812-3.6687 0.0156 0.2 0.025 0.4 0.025 0.6031zM20.7312 8.5469c-0.1 0.0968-0.1999 0.1906-0.3031 0.2875-0.4094-1.0906-1.0281-2.0938-1.8437-2.9656 1.1-0.4094 2.0656-0.6844 2.8312-0.8032 1-0.1531 1.4594-0.0125 1.55 0.1563 0.1469 0.2625-0.1375 1.2969-2.2344 3.325z" />
|
||||
</svg>
|
||||
</template>
|
||||
10
src/icons/IconPlex.vue
Normal file
10
src/icons/IconPlex.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M16 2c-7.732 0-14 6.268-14 14s6.268 14 14 14 14-6.268 14-14-6.268-14-14-14zM16 28c-6.627 0-12-5.373-12-12s5.373-12 12-12c6.627 0 12 5.373 12 12s-5.373 12-12 12z"
|
||||
/>
|
||||
<path
|
||||
d="M13.333 10.667c-0.368 0-0.667 0.299-0.667 0.667v9.333c0 0.245 0.135 0.469 0.349 0.585 0.215 0.117 0.477 0.104 0.683-0.032l6.667-4.667c0.188-0.131 0.301-0.349 0.301-0.583s-0.113-0.452-0.301-0.583l-6.667-4.667c-0.109-0.076-0.239-0.115-0.365-0.115zM14.667 13.115l4.448 3.115-4.448 3.115v-6.229z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
9
src/icons/IconPodium.vue
Normal file
9
src/icons/IconPodium.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M22 9h-5V4c0-1.1031-0.8969-2-2-2H9C7.8969 2 7 2.8969 7 4v2H2C0.8969 6 0 6.8969 0 8v13c0 0.5531 0.4469 1 1.0001 1h22c0.5531 0 1-0.4469 1-1V11c0-1.1031-0.8969-2-2.0001-2zM15 4v16H9V4zM2 8h5v12H2zM22 20h-5v-9h5z" />
|
||||
<path d="M4.7031 12.6187C5.25 12.2062 6 11.6437 6 10.5c0-0.45-0.1719-0.85-0.4812-1.125C5.2469 9.1313 4.8844 8.9969 4.5 8.9969S3.7531 9.1313 3.4812 9.375C3.1719 9.65 3 10.05 3 10.5V11h1v-0.5C4 10.0219 4.4156 10 4.5 10c0.1375 0 0.2656 0.0438 0.3562 0.125C4.9531 10.2094 5 10.3375 5 10.5031c0 0.6125-0.3406 0.9-0.8969 1.3188C3.6125 12.1875 3 12.6438 3 13.5 3 13.775 3.225 14 3.5 14H6v-1H4.2344c0.1156-0.1156 0.2718-0.2344 0.4687-0.3813z" />
|
||||
<path d="M19.5 16H18v1h1.5c0.8281 0 1.5-0.6719 1.5-1.5 0-0.6719-0.4437-1.2406-1.05-1.4312L20.9 12.8c0.1125-0.15 0.1312-0.3531 0.0469-0.525C20.8625 12.1063 20.6875 12 20.5 12H18v1h1.5l-0.9 1.2c-0.1125 0.15-0.1313 0.3532-0.0469 0.525C18.6375 14.8938 18.8125 15 19 15h0.5c0.275 0 0.5 0.225 0.5 0.5S19.775 16 19.5 16z" />
|
||||
<path d="M12 6.7062V10h1V5.5c0-0.2031-0.1219-0.3844-0.3094-0.4625s-0.4031-0.0344-0.5437 0.1094L10.7938 6.5 11.5 7.2062z" />
|
||||
</svg>
|
||||
</template>
|
||||
6
src/icons/IconPuzzle.vue
Normal file
6
src/icons/IconPuzzle.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M22.9719 2.8125c-1.7157-0.9469-4.025-1.0844-5.7469-0.3375-0.5094 0.2219-0.9063 0.6469-1.0938 1.1719-0.1843 0.5219-0.1437 1.1 0.1125 1.5843 0.1282 0.2438 0.1938 0.5 0.1938 0.7657 0 0.6-0.1875 2-1.9375 2-1.7219 0-1.9063-1.3969-1.9063-2 0-0.2656 0.0657-0.5219 0.1938-0.7657 0.2562-0.4875 0.2969-1.0624 0.1125-1.5843-0.1844-0.525-0.5844-0.9532-1.0938-1.1719-1.5468-0.6687-4.0812-0.6281-5.6437 0.0937C5.675 2.7937 5.3062 3.1969 5.125 3.7031 4.9437 4.2094 4.9687 4.7531 5.2 5.2375c0.6937 1.45 0.8406 2.3875 0.4906 3.1344 0 0 0 0 0 0C5.0125 8.0531 4.2531 7.9344 3.5 8.0281c-0.8094 0.1-1.5844 0.4282-2.3032 0.975C1.125 9.0563 1.0625 9.1219 1.0062 9.1937c-1.0844 1.425-1.3062 3.1344-0.5937 4.575 0.325 0.6594 0.825 1.2125 1.4468 1.6063 0.6407 0.4062 1.3782 0.6187 2.1375 0.6187 0.5719 0 1.125-0.1187 1.6438-0.3531 31e-4 0 62e-4-31e-4 94e-4-31e-4 0.3875 0.8531 0.2593 1.8187-0.4188 3.1312-0.25 0.4844-0.2937 1.0375-0.125 1.5532 0.1688 0.5093 0.525 0.9218 1.0031 1.1593 0.8344 0.4157 1.8532 0.5094 2.6157 0.5094 0.0625 0 0.125 0 0.1875-31e-4 1.0125-0.0219 2.0375-0.2125 2.8187-0.5281 0.5094-0.2063 0.9157-0.6188 1.1125-1.1344 0.2-0.5219 0.1719-1.1094-0.0781-1.6125-0.1156-0.2313-0.1719-0.475-0.1719-0.725 0-0.6 0.1844-2 1.9063-2 1.75 0 1.9375 1.3969 1.9375 2 0 0.2656-0.0656 0.5219-0.1938 0.7656-0.2562 0.4875-0.2968 1.0625-0.1125 1.5844 0.1844 0.525 0.5844 0.9531 1.0938 1.1719 0.7406 0.3219 1.5906 0.4781 2.4562 0.4781 1.1438 0 2.3157-0.275 3.2907-0.8156C23.6063 20.8219 24 20.1531 24 19.425V4.5625c0-0.7281-0.3937-1.4-1.0281-1.75zM22 4.5625v14.875c-1.1625 0.6406-2.8375 0.7438-3.9812 0.25 0.2781-0.5312 0.4187-1.1 0.4187-1.6875 0-1.0875-0.3562-2.0812-1.0031-2.7937C16.7188 14.4157 15.7031 14 14.5 14s-2.2156 0.4188-2.925 1.2125c-0.6437 0.7188-0.9812 1.6844-0.9812 2.7875 0 0.5594 0.1281 1.1031 0.3781 1.6125 31e-4 31e-4 31e-4 94e-4 31e-4 94e-4-1.1719 0.4687-3.0938 0.5093-3.9688 0.0812 0-31e-4 0-62e-4 32e-4-93e-4 0.4437-0.8594 0.7093-1.6344 0.8093-2.3688 0.1219-0.8844 63e-4-1.7281-0.3468-2.5062-0.4563-1.0032-1.6438-1.45-2.6532-0.9938C4.5593 13.9406 4.2844 14 3.9968 14c-0.7656 0-1.4531-0.4281-1.7937-1.1125-0.3594-0.7219-0.2437-1.5781 0.3125-2.3688 1.0469-0.7406 1.9031-0.5281 2.3281-0.3312 0.275 0.1281 0.5625 0.1875 0.8469 0.1875 0.7437 0 1.4594-0.4156 1.8-1.125 0.3593-0.7531 0.4687-1.5937 0.3218-2.5031C7.6999 6.0531 7.4468 5.3 7.0093 4.3844c1.0406-0.4781 2.9844-0.5125 4.0031-0.0719-0.2781 0.5313-0.4187 1.1-0.4187 1.6875 0 1.1031 0.3406 2.0688 0.9812 2.7875C12.2843 9.5812 13.2937 10 14.4999 10c1.2032 0 2.2188-0.4156 2.9344-1.2063C18.0812 8.0812 18.4374 7.0875 18.4374 6c0-0.5906-0.1406-1.1594-0.4187-1.6875 1.1468-0.4969 2.8187-0.3907 3.9812 0.25z" />
|
||||
</svg>
|
||||
</template>
|
||||
11
src/icons/IconReel.vue
Normal file
11
src/icons/IconReel.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M9 3C6.5969 3 4.3344 3.9375 2.6375 5.6375v0C0.9375 7.3344 0 9.5969 0 12s0.9375 4.6656 2.6375 6.3625C4.3344 20.0625 6.5969 21 9 21s4.6656-0.9375 6.3625-2.6375C17.0625 16.6656 18 14.4031 18 12c0-1.7031-0.4719-3.3375-1.35-4.7469 2.3031 1.1938 2.5812 3.4906 2.8531 5.7282 0.1531 1.2656 0.3 2.4625 0.8188 3.3875C20.9375 17.4656 21.975 18 23.5 18H24v-1h-0.5c-1.4875 0-2.0125-0.5969-2.3031-1.1187-0.4219-0.7532-0.5563-1.8563-0.6969-3.0219-0.1688-1.3906-0.3625-2.9656-1.1-4.2938-0.7906-1.4218-2.0719-2.3406-3.9094-2.8-0.0406-0.0437-0.0844-0.0843-0.125-0.1281C13.6656 3.9375 11.4031 3 9 3zM13.95 16.95c-2.7281 2.7281-7.1688 2.7281-9.9 0-1.3656-1.3656-2.0469-3.1563-2.0469-4.95S2.6844 8.4156 4.05 7.05c2.7281-2.7281 7.1688-2.7281 9.9 0 2.7281 2.7281 2.7281 7.1719 0 9.9z" />
|
||||
<path d="M8 12c0 0.55 0.45 1 1 1s1-0.45 1-1-0.45-1-1-1-1 0.45-1 1zM9 12c0 0 0 0 0 0z" />
|
||||
<path d="M6.7625 13.4719c0.0406-63e-4 0.0844-0.0157 0.1219-0.0344 0.1937-0.0781 0.3125-0.2656 0.3125-0.4625 0-0.0625-0.0125-0.125-0.0344-0.1844l-63e-4-0.0156c-31e-4-63e-4-31e-4-94e-4-62e-4-0.0156-0.1-0.2438-0.15-0.5-0.15-0.7563 0-0.2625 0.05-0.5219 0.1531-0.7656 0.05-0.1219 0.05-0.2594 0-0.3844-0.05-0.1219-0.1469-0.2188-0.2719-0.2719L4.1093 9.4343c-0.25-0.1031-0.5344 94e-4-0.6469 0.2563L3.4468 9.725c-31e-4 62e-4-62e-4 0.0125-62e-4 0.0187-0.5907 1.4438-0.5844 3.1031 0.0156 4.5532 0.1062 0.2562 0.3969 0.375 0.6531 0.2718zM4.2125 10.5625l1.8656 0.7719c-0.1 0.4375-0.1 0.8969 0 1.3375l-1.8625 0.7718c-0.2844-0.9375-0.2844-1.95-31e-4-2.8812z" />
|
||||
<path d="M10.4375 14.1156c-0.1031-0.2531-0.3938-0.3781-0.6469-0.275l-0.0156 63e-4c-63e-4 31e-4-94e-4 31e-4-0.0156 63e-4-0.2438 0.1-0.5 0.15-0.7563 0.15-0.2625 0-0.5219-0.05-0.7656-0.1532-0.1219-0.05-0.2594-0.05-0.3844 0s-0.2188 0.1469-0.2719 0.2719l-1.1437 2.7688c-0.1032 0.25 93e-4 0.5343 0.2562 0.6468l0.0344 0.0157c62e-4 31e-4 0.0125 62e-4 0.0187 62e-4C8.1906 18.15 9.85 18.1438 11.3 17.5438c0.1937-0.0813 0.3093-0.2656 0.3093-0.4625 0-0.0625-0.0124-0.1281-0.0375-0.1906l-1.0968-2.6531c-94e-4-0.0407-0.0219-0.0813-0.0375-0.122zM10.4438 16.7875c-0.9375 0.2813-1.9469 0.2813-2.8813 31e-4l0.7719-1.8656C8.5531 14.975 8.7781 15 9.0031 15s0.45-0.025 0.6688-0.075z" />
|
||||
<path d="M11.2375 10.5281c-0.0406 63e-4-0.0844 0.0157-0.1219 0.0344-0.2531 0.1031-0.3781 0.3938-0.275 0.6469l63e-4 0.0156c31e-4 63e-4 31e-4 94e-4 63e-4 0.0156 0.1 0.2438 0.15 0.5 0.15 0.7563 0 0.2625-0.05 0.5219-0.1532 0.7656-0.05 0.1219-0.05 0.2594 0 0.3844 0.05 0.1219 0.1469 0.2188 0.2719 0.2719l2.7688 1.1437c0.25 0.1032 0.5343-93e-4 0.6468-0.2562l0.0157-0.0344c31e-4-62e-4 62e-4-0.0125 62e-4-0.0187 0.2938-0.7157 0.4375-1.4844 0.4375-2.2532 0-0.7843-0.1531-1.5687-0.4562-2.2999-0.1063-0.2563-0.3969-0.375-0.6532-0.2719zM13.7875 13.4375l-1.8656-0.7719c0.1-0.4375 0.1-0.8969 0-1.3375l1.8625-0.7719c0.2844 0.9375 0.2844 1.9501 31e-4 2.8813z" />
|
||||
<path d="M11.3062 6.4625 11.2719 6.4469c-63e-4-32e-4-0.0125-63e-4-0.0188-63e-4C9.8094 5.85 8.15 5.8562 6.7 6.4562 6.4437 6.5625 6.325 6.8531 6.4281 7.1094L7.525 9.7625c62e-4 0.0406 0.0156 0.0844 0.0344 0.125 0.1031 0.2531 0.3937 0.3781 0.65 0.275l0.0156-63e-4c63e-4-31e-4 94e-4-31e-4 0.0156-62e-4 0.2438-0.1 0.5-0.15 0.7563-0.15 0.2625 0 0.5219 0.05 0.7656 0.1531 0.1219 0.05 0.2594 0.05 0.3844 0 0.1219-0.05 0.2187-0.1469 0.2719-0.2719l1.1468-2.7719c0.025-0.0625 0.0375-0.1281 0.0375-0.1906-31e-4-0.1906-0.1125-0.3719-0.2969-0.4562zM10.4375 7.2125 9.6656 9.0781c-0.2187-0.05-0.4437-0.075-0.6687-0.075s-0.45 0.025-0.6688 0.075L7.5563 7.2156c0.9375-0.2844 1.95-0.2844 2.8812-31e-4z" />
|
||||
</svg>
|
||||
</template>
|
||||
17
src/icons/IconServer.vue
Normal file
17
src/icons/IconServer.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M23 10V6c0-0.4719-0.1656-0.9094-0.4406-1.2531v0l-3.6688-4.5594C18.7969 0.0687 18.6531 0 18.5 0h-13C5.35 0 5.2062 0.0687 5.1094 0.1875L1.4406 4.75C1.1656 5.0906 1 5.5281 1 6v4c0 0.5969 0.2625 1.1344 0.6781 1.5C1.2625 11.8656 1 12.4031 1 13v2c0 0.5969 0.2625 1.1344 0.6781 1.5C1.2625 16.8656 1 17.4031 1 18v4c0 1.1031 0.8969 2 2 2h18c1.1031 0 2-0.8969 2-2v-4c0-0.5969-0.2625-1.1344-0.6781-1.5C22.7375 16.1344 23 15.5969 23 15v-2c0-0.5969-0.2625-1.1344-0.6781-1.5C22.7375 11.1344 23 10.5969 23 10zM5.7406 1h12.5219l2.4125 3H3.325zM21 22H3l-31e-4-4c0 0 0 0 31e-4 0v-1h18zM21.0031 15c0 0-31e-4 0 0 0L21 16H3v-1l-31e-4-2c0 0 0 0 31e-4 0v-1h18v1zM3 11V6h18v5z"
|
||||
/>
|
||||
<rect width="3" height="1.000008" x="16.999992" y="7.999992" />
|
||||
<rect width="1.000008" height="1.000008" x="15" y="7.999992" />
|
||||
<rect width="3" height="1.000008" x="16.999992" y="13.000008" />
|
||||
<rect width="1.000008" height="1.000008" x="15" y="13.000008" />
|
||||
<rect width="1.000008" height="1.000008" x="4.000008" y="18" />
|
||||
<rect width="1.000008" height="1.000008" x="6" y="18" />
|
||||
<rect width="1.000008" height="1.000008" x="7.999992" y="18" />
|
||||
<rect width="1.000008" height="1.000008" x="10.000008" y="18" />
|
||||
<rect width="3" height="1.000008" x="16.999992" y="19.999992" />
|
||||
<rect width="1.000008" height="1.000008" x="15" y="19.999992" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/icons/IconShrinkHorizontal.vue
Normal file
7
src/icons/IconShrinkHorizontal.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M23.9562 6.4563 22.5406 5.0406l-6.25 6.25c-0.3906 0.3907-0.3906 1.025 0 1.4157l6.25 6.25 1.4156-1.4157L18.4156 12z" />
|
||||
<path d="M1.4562 5.0437 0.0437 6.4563 5.5844 12 0.0437 17.5437l1.4157 1.4157 6.25-6.25c0.3906-0.3907 0.3906-1.025 0-1.4157z" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/icons/IconShrinkVertical.vue
Normal file
7
src/icons/IconShrinkVertical.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M11.2938 16.2938l-6.25 6.25 1.4156 1.4156L12 18.4156l5.5437 5.5438 1.4157-1.4156-6.25-6.25c-0.3938-0.3907-1.025-0.3907-1.4156 0z" />
|
||||
<path d="M12 8c0.2562 0 0.5125-0.0969 0.7062-0.2937l6.25-6.2501-1.4125-1.4125L12 5.5844 6.4563 0.0437 5.0406 1.4594l6.25 6.25C11.4875 7.9031 11.7438 8 12 8z" />
|
||||
</svg>
|
||||
</template>
|
||||
6
src/icons/IconSine.vue
Normal file
6
src/icons/IconSine.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M23.8531 13.1469 22.5 11.7938 21.7938 12.5l0.5 0.5h-9.6563c-0.5187-1.5813-1.0563-3.1969-1.7375-4.4469C10.4844 7.7875 10.0438 7.2156 9.5594 6.8 8.9406 6.2687 8.2469 6 7.4969 6S6.0531 6.2688 5.4344 6.8C4.95 7.2156 4.5125 7.7875 4.0938 8.5531c-0.4094 0.75-0.7657 1.6313-1.1 2.5594V2.7062l0.5 0.5L4.2063 2.5 2.8531 1.1469c-0.1937-0.1938-0.5125-0.1938-0.7062 0L0.7938 2.5 1.5 3.2062l0.5-0.5V23h1v-9h7.8625c0.5188 1.5812 1.0563 3.1969 1.7375 4.4469 0.4156 0.7656 0.8563 1.3375 1.3406 1.7531 0.6188 0.5312 1.3125 0.8 2.0625 0.8s1.4438-0.2688 2.0625-0.8c0.4844-0.4156 0.9219-0.9875 1.3407-1.7531 0.6812-1.25 1.2187-2.8657 1.7375-4.4469h1.1531l-0.5 0.5 0.7062 0.7062 1.3532-1.3531c0.1937-0.1937 0.1937-0.5125-32e-4-0.7062zM5.8531 9.5094C6.6781 8 7.2969 8 7.5 8s0.8219 0 1.6469 1.5094C9.6594 10.45 10.1063 11.7125 10.5344 13H4.4656c0.4282-1.2875 0.875-2.55 1.3875-3.4906zM17.6469 17.4906C16.8219 19 16.2031 19 16 19s-0.8219 0-1.6469-1.5094C13.8406 16.55 13.3937 15.2875 12.9656 14h6.0656c-0.425 1.2875-0.8718 2.55-1.3843 3.4906z" />
|
||||
</svg>
|
||||
</template>
|
||||
11
src/icons/IconSnowman.vue
Normal file
11
src/icons/IconSnowman.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M12 13c-0.55 0-1 0.45-1 1s0.45 1 1 1 1-0.45 1-1-0.45-1-1-1zM12 14c0 0 0 0 0 0z" />
|
||||
<path d="M12 16c-0.55 0-1 0.45-1 1s0.45 1 1 1 1-0.45 1-1-0.45-1-1-1zM12 17c0 0 0 0 0 0z" />
|
||||
<path d="M12 19c-0.55 0-1 0.45-1 1s0.45 1 1 1 1-0.45 1-1-0.45-1-1-1zM12 20c0 0 0 0 0 0z" />
|
||||
<path d="M11 5.5C11 5.7761 10.7761 6 10.5 6S10 5.7761 10 5.5 10.2239 5 10.5 5 11 5.2239 11 5.5z" />
|
||||
<path d="M14 5.5C14 5.7761 13.7761 6 13.5 6S13 5.7761 13 5.5 13.2239 5 13.5 5 14 5.2239 14 5.5z" />
|
||||
<path d="M24 10V9h-2.2938l1.5-1.5L22.5 6.7938l-1.5 1.5V6h-1v3.2937l-2.2 2.2001c-0.0313-0.0344-0.0625-0.0657-0.0969-0.1001 0.1844-0.25 0.2938-0.5593 0.2938-0.8906 0-0.4406-0.1907-0.8375-0.4938-1.1125 0.325-0.75 0.4938-1.5625 0.4938-2.3875 0-1.4906-0.5532-2.9125-1.5282-4H19V2h-2c0-1.1032-0.8969-2-2-2H9C7.8969 0 7 0.8969 7 2H5v1h2.5281C6.5563 4.0875 6 5.5094 6 7 6 7.825 6.1688 8.6375 6.4938 9.3875 6.1907 9.6625 6 10.0594 6 10.5c0 0.3344 0.1094 0.6438 0.2938 0.8906-0.0313 0.0344-0.0657 0.0657-0.0969 0.1L4 9.2938V6H3v2.2938l-1.5-1.5L0.7938 7.5l1.5 1.5H0v1h3.2938l2.2625 2.2625C4.5531 13.625 4 15.2813 4 17c0 1.4031 0.3688 2.7844 1.0688 3.9969C5.2719 21.35 5.5031 21.6844 5.7563 22H3v2h18v-2h-2.7562c0.2531-0.3156 0.4843-0.6531 0.6874-1.0031C19.6312 19.7844 20 18.4031 20 17c0-1.7187-0.5531-3.375-1.5531-4.7375L20.7094 10zM7.5 11C7.225 11 7 10.775 7 10.5S7.225 10 7.5 10h9c0.275 0 0.5 0.225 0.5 0.5S16.775 11 16.5 11zM14.3125 3.7344C15.3719 4.4844 16 5.7 16 7c0 0.7031-0.1844 1.3937-0.5344 2v0h-1.2312C14.7094 8.4687 15 7.7687 15 7h-1c0 1.1031-0.8969 2-2 2s-2-0.8969-2-2H9c0 0.7687 0.2906 1.4687 0.7657 2H8.5344C8.1844 8.3969 8 7.7031 8 7 8 5.7031 8.6313 4.4875 9.6875 3.7344 10.3657 3.2531 11.1657 3 12 3c0.8344 0 1.6344 0.2531 2.3125 0.7344zM9 1h6c0.55 0 1 0.45 1 1H8c0-0.55 0.45-1 1-1zM15.9625 12.4938C17.2594 13.6344 18 15.275 18 17c0 2.0344-0.9969 3.8844-2.6812 5H8.6813C6.9969 20.8844 6 19.0344 6 17c0-1.725 0.7406-3.3656 2.0375-4.5062C8.2406 12.3156 8.4563 12.15 8.6812 12h6.6375c0.2251 0.15 0.4407 0.3156 0.6438 0.4938z" />
|
||||
</svg>
|
||||
</template>
|
||||
6
src/icons/IconSpotlights.vue
Normal file
6
src/icons/IconSpotlights.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M23.4406 17.9219c-0.1812-0.2375-0.4-0.4563-0.6562-0.6563l-7.2781-6.1562 4.4031-9.6969c0.2031-0.4437 0.0531-0.9687-0.35-1.2437-0.4031-0.2719-0.9469-0.2157-1.2844 0.1375l-6.9375 7.275-8.6906-7.3438c-0.3781-0.3187-0.9313-0.3156-1.3031 94e-4-0.3719 0.325-0.4532 0.8719-0.1875 1.2906l6.3718 10.0469-6.6874 7.0125c-0.1032 0.1031-0.1969 0.2125-0.2844 0.325C0.1938 19.4 0 19.9469 0 20.5c0 0.5532 0.1938 1.1 0.5594 1.5781 0.3094 0.4094 0.7407 0.7657 1.275 1.0625C2.8282 23.6938 4.1313 24 5.5001 24c1.3687 0 2.6718-0.3062 3.6687-0.8594 0.5344-0.2968 0.9625-0.6562 1.275-1.0625 0.1281-0.1687 0.2375-0.3499 0.3219-0.5343v0l1.2843-2.8282 1.3063 2.0625c0.0593 0.1032 0.1281 0.2032 0.2 0.3 0.3093 0.4094 0.7406 0.7657 1.275 1.0625C15.8281 22.6938 17.1313 23 18.5 23c1.3688 0 2.6719-0.3062 3.6688-0.8594 0.5343-0.2969 0.9625-0.6562 1.275-1.0625 0.3656-0.4781 0.5593-1.025 0.5593-1.5781s-0.1968-1.1-0.5625-1.5781zM9.9844 18.4313C9.75 18.2219 9.475 18.0312 9.1688 17.8594 8.1719 17.3063 6.8688 17 5.5 17c-0.1281 0-0.2562 31e-4-0.3812 94e-4l3.5125-3.6844 2.1406 3.375zM11.2656 15.6125 9.3438 12.5813l2.8343-2.975 1.3125 1.1093zM13.9188 9.7688l-1.05-0.8876 2.775-2.9125zM6.3438 5.9812 9.9562 9.0375 8.95 10.0938zM5.5 22C3.3969 22 2 21.0969 2 20.5c0-0.1375 0.075-0.2906 0.2125-0.4469v0l0.0719-0.075C2.7938 19.4844 3.9563 19 5.5 19 7.6031 19 9 19.9031 9 20.5c0 0.0594-0.0125 0.1188-0.0406 0.1813l-0.0156 0.0375C8.6656 21.2937 7.3594 22 5.5 22zM14.6469 13.0031 18.2 16.0062c-1.2563 0.0407-2.4438 0.3407-3.3656 0.8532-0.4282 0.2375-0.7875 0.5125-1.0719 0.8219l-0.7219-1.1375zM18.5 21c-1.7094 0-2.9531-0.5969-3.3594-1.1406l-0.0781-0.1219c-0.0437-0.0813-0.0656-0.1625-0.0656-0.2375 0-0.5969 1.3969-1.5 3.5-1.5s3.5 0.9031 3.5 1.5S20.6031 21 18.5 21z" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/icons/IconStar.vue
Normal file
7
src/icons/IconStar.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768">
|
||||
<path
|
||||
d="M767 312c-3.1-12-12.8-21.1-25-23.4l-231.1-44.3-97.5-225c-5.1-11.7-16.6-19.3-29.4-19.3s-24.3 7.6-29.4 19.3l-97.5 225-231.1 44.3c-12.2 2.3-21.9 11.4-25 23.4s1 24.7 10.5 32.6l176.6 147.1-59.1 236.5c-3.1 12.4 1.5 25.5 11.7 33.3 10.2 7.7 24.1 8.6 35.2 2.3l208.1-118.9 208.1 118.9c4.9 2.8 10.4 4.2 15.9 4.2 6.8 0 13.6-2.2 19.3-6.5 10.2-7.7 14.8-20.8 11.7-33.3l-59.1-236.5 176.6-147.1c9.5-7.9 13.6-20.6 10.5-32.6zM523.5 455.4c-9.4 7.9-13.5 20.4-10.6 32.3l45.9 183.3-158.9-90.8c-4.9-2.8-10.4-4.2-15.9-4.2s-11 1.4-15.9 4.2l-158.9 90.8 45.8-183.2c3-11.9-1.1-24.5-10.6-32.3l-140-116.8 181.3-34.8c10.4-2 19.1-9 23.3-18.7l75-172.7 74.9 172.8c4.2 9.7 12.9 16.7 23.3 18.7l181.3 34.8-140 116.6z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
7
src/icons/IconStop2.vue
Normal file
7
src/icons/IconStop2.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M23.7063 6.7375 17.2625 0.2938C17.075 0.1063 16.8219 0 16.5563 0H7.4438C7.1782 0 6.925 0.1063 6.7375 0.2938L0.2938 6.7375C0.1063 6.925 0 7.1781 0 7.4438v9.1125c0 0.2656 0.1063 0.5187 0.2938 0.7062l6.4437 6.4437C6.925 23.8937 7.1782 24 7.4438 24h9.1125c0.2656 0 0.5187-0.1063 0.7062-0.2938l6.4438-6.4437C23.8938 17.075 24 16.8219 24 16.5563V7.4438c0-0.2657-0.1062-0.5188-0.2937-0.7063zM22 16.1406 16.1406 22H7.8594L2 16.1406V7.8594L7.8594 2h8.2844L22 7.8594z" />
|
||||
<path d="M16 7.5219c0-0.4094-0.1687-0.8063-0.4625-1.0938C15.2469 6.1437 14.8656 5.9906 14.4656 6c-0.1812 31e-4-0.3562 0.0406-0.5218 0.1031C13.7719 5.4687 13.1906 5 12.5 5s-1.275 0.4687-1.4469 1.1062C10.8813 6.0375 10.6969 6 10.5 6 9.6719 6 9 6.6719 9 7.5v0.5843C8.85 8.0312 8.6938 8.0031 8.5313 8c-0.4-94e-4-0.7813 0.1437-1.0719 0.4281-0.2937 0.2875-0.4625 0.6844-0.4625 1.0938V13c0 1.7687 0.3156 3.2594 0.9094 4.3094C8.525 18.4 9.4438 19 10.4969 19h3.3312c1.2157 0 2.3188-0.6688 2.8813-1.7469l2.1094-4.0437c31e-4-63e-4 62e-4-94e-4 93e-4-0.0157 0.125-0.2406 0.1813-0.5093 0.1657-0.7812-0.0438-0.75-0.6406-1.3563-1.3906-1.4063-0.5094-0.0343-1.0001 0.1875-1.3063 0.5969L16 12zM17.1 12.2c0.1031-0.1375 0.2656-0.2094 0.4375-0.2 0.2438 0.0156 0.4469 0.2219 0.4625 0.4688 63e-4 0.0874-0.0125 0.1718-0.05 0.25-31e-4 31e-4-31e-4 62e-4-63e-4 93e-4l-2.1156 4.0594C15.4375 17.5375 14.675 18 13.8312 18H10.5c-0.8688 0-1.4156-0.6438-1.7219-1.1844C8.2687 15.9156 8 14.5969 8 13V9.5219c0-0.1406 0.0594-0.2782 0.1594-0.3813 0.0968-0.0937 0.2218-0.1469 0.35-0.1437 0.275 62e-4 0.4875 0.225 0.4875 0.5v3.5h1V7.5c0-0.275 0.225-0.5 0.5-0.5s0.5 0.225 0.5 0.5V12h1V6.5c0-0.275 0.225-0.5 0.5-0.5 0.2749 0 0.4999 0.225 0.4999 0.5V12h1.0001V7.5c0-0.275 0.2156-0.4938 0.4875-0.5 0.1312-31e-4 0.2531 0.0469 0.3499 0.1437 0.1032 0.1001 0.1594 0.2375 0.1594 0.3813V13.5c0 0.2156 0.1375 0.4062 0.3406 0.475s0.4282-31e-4 0.5594-0.175z" />
|
||||
</svg>
|
||||
</template>
|
||||
16
src/icons/IconSync.vue
Normal file
16
src/icons/IconSync.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<polyline points="1 20 1 14 7 14"></polyline>
|
||||
<path
|
||||
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
10
src/icons/IconSyncGear.vue
Normal file
10
src/icons/IconSyncGear.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%">
|
||||
<path d="M19.1406 5H15v2h6c0.5531 0 1-0.4469 1-1V0h-2v3.0593c-0.725-0.6468-1.525-1.2093-2.3844-1.6656C15.9 0.4812 13.9563 0 12 0 10.3813 0 8.8094 0.3187 7.3282 0.9437 5.9 1.5468 4.6157 2.4125 3.5156 3.5156 2.4156 4.6187 1.55 5.8999 0.9438 7.3281 0.3188 8.8093 0 10.3812 0 12h2C2 6.4875 6.4875 1.9999 12 1.9999c2.7031 0 5.275 1.1 7.1406 3.0001z" />
|
||||
<path d="M4.8594 19H9v-2H3c-0.5531 0-1 0.4469-1 1v6h2v-3.0593c0.725 0.6468 1.525 1.2093 2.3844 1.6656C8.1031 23.5188 10.0437 24 12 24v-1.9999c-2.7031 0-5.275-1.1-7.1406-3.0001z" />
|
||||
<path d="M3 12h1c0-4.4125 3.5875-8 8-8V3C9.5969 3 7.3375 3.9375 5.6375 5.6375S3 9.5969 3 12z" />
|
||||
<path d="M18.5 15c-0.8281 0-1.5 0.6719-1.5 1.5s0.6719 1.5 1.5 1.5 1.5-0.6719 1.5-1.5-0.6719-1.5-1.5-1.5zM18.5 17c-0.275 0-0.5-0.225-0.5-0.5s0.225-0.5 0.5-0.5 0.5 0.225 0.5 0.5-0.225 0.5-0.5 0.5z" />
|
||||
<path d="M23.5812 17.6937 21.9906 16.775C21.9969 16.6844 22 16.5906 22 16.4937c0-0.0937-31e-4-0.1875-94e-4-0.2812l1.5906-0.9188c0.1157-0.0656 0.2-0.175 0.2344-0.3031s0.0157-0.2656-0.05-0.3781l-1-1.7313c-0.1375-0.2374-0.4437-0.3218-0.6812-0.1843l-1.5907 0.9187c-0.0781-0.0531-0.1562-0.1031-0.2406-0.1531s-0.1656-0.0937-0.25-0.1312v-1.8376c0-0.2749-0.225-0.4999-0.5-0.4999h-2c-0.275 0-0.5 0.225-0.5 0.4999v1.8376c-0.0812 0.0406-0.1656 0.0843-0.25 0.1312-0.0813 0.0469-0.1625 0.1-0.2406 0.1531l-1.5907-0.9125c-0.2375-0.1375-0.5437-0.0562-0.6812 0.1844l-1 1.7313c-0.0657 0.1156-0.0844 0.25-0.05 0.3781s0.1187 0.2375 0.2344 0.3031l1.5906 0.9188c-63e-4 0.0906-94e-4 0.1843-94e-4 0.2812 0 0.0938 31e-4 0.1875 94e-4 0.2813L13.425 17.7c-0.1157 0.0656-0.2 0.175-0.2344 0.3031s-0.0157 0.2656 0.05 0.3781l1 1.7313c0.1375 0.2375 0.4437 0.3219 0.6812 0.1844l1.5907-0.9188c0.0781 0.0532 0.1562 0.1032 0.2406 0.1531 0.0844 0.05 0.1656 0.0938 0.25 0.1313V21.5c0 0.275 0.225 0.5 0.5 0.5h2c0.275 0 0.5-0.225 0.5-0.5v-1.8375c0.0812-0.0406 0.1656-0.0844 0.25-0.1313 0.0813-0.0468 0.1625-0.1 0.2406-0.1531l1.5907 0.9125c0.2375 0.1375 0.5437 0.0563 0.6812-0.1844l1-1.7312c0.0656-0.1156 0.0844-0.25 0.05-0.3781-0.0344-0.125-0.1187-0.2344-0.2344-0.3032zM22.1469 19.1781l-1.45-0.8312c-0.1844-0.1063-0.4157-0.0844-0.575 0.0562-0.1125 0.0969-0.2375 0.1875-0.3719 0.2656-0.1438 0.0813-0.2781 0.1438-0.4125 0.1907C19.1344 18.9281 19 19.1187 19 19.3312v1.6719h-1v-1.6719c0-0.2125-0.1344-0.4031-0.3375-0.4718-0.1344-0.0469-0.2719-0.1094-0.4125-0.1907-0.1344-0.0781-0.2594-0.1656-0.3719-0.2656-0.1625-0.1375-0.3938-0.1593-0.575-0.0562l-1.4469 0.8344-0.5-0.8657 1.4469-0.8343c0.1844-0.1063 0.2813-0.3188 0.2406-0.5282-0.0281-0.1406-0.0406-0.2937-0.0406-0.4531 0-0.1625 0.0125-0.3125 0.0406-0.45 0.0407-0.2093-0.0562-0.4219-0.2406-0.5281l-1.45-0.8344 0.5-0.8656 1.45 0.8313c0.1844 0.1062 0.4156 0.0843 0.575-0.0563 0.1125-0.0968 0.2375-0.1875 0.3719-0.2656 0.1437-0.0813 0.2781-0.1437 0.4125-0.1906C17.8656 14.0719 18 13.8813 18 13.6688V11.997h1v1.6718c0 0.2125 0.1344 0.4031 0.3375 0.4719 0.1344 0.0469 0.2719 0.1094 0.4125 0.1906 0.1344 0.0781 0.2594 0.1656 0.3719 0.2656 0.1625 0.1375 0.3937 0.1594 0.575 0.0563l1.4468-0.8344 0.5 0.8656-1.4468 0.8344c-0.1844 0.1062-0.2813 0.3187-0.2407 0.5281 0.0281 0.1406 0.0406 0.2937 0.0406 0.4531 0 0.1625-0.0125 0.3125-0.0406 0.45-0.0406 0.2094 0.0563 0.4219 0.2407 0.5281l1.4468 0.8344z" />
|
||||
</svg>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user