Compare commits

...

11 Commits

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

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

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

* Add section page component for browsing discover categories

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

* Add discover icon (compass navigation) for main navigation

* Update navigation to use IconDiscover for discover route

* Add discover and section page routes with interfaces

* Update home page and components to integrate with discover navigation

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

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

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

- Add icon-converter.mjs script to transform SVG files into Vue components
- Converts kebab-case filenames to PascalCase (e.g., clipboard-text.svg → IconClipboardText.vue)
- Wraps SVG content in proper Vue template structure
- Sets width/height to 100% for consistent sizing
- Add 38 new icon components for future use (IconHelm, IconMailboxFull, IconCheck, IconWarning, IconClipboardText, IconExpandVertical, IconShrinkVertical, and more)
2026-03-11 00:08:29 +01:00
c8262a3bda Feat: Misc improvements (#107)
* Expand SCSS variables for improved theming

* Redesign 404 page with dynamic movie quotes

* Add password generator page

* Add missing Plex authentication page

* Improve torrent table and torrents page

* Enhance toast notification component

* Enhance popup components

* Refine UI components and remove DarkmodeToggle

* Add user profile component for settings

* Update autocomplete dropdown component

* Update register page

* Redesign signin and register pages with improved UX

* Improve torrent table with sort toggle and highlight colors

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

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

* Enhance Graph component with improved styling and options

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

* Refactor ActivityPage with enhanced stats and visualizations

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

* Improve Plex authentication check with cookie fallback

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

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

* Integrate CommandPalette into main application

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

* Add theme composable and utility improvements

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

* Add Plex integration composables and icons

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

* Add storage management components for data & privacy section

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

* Add theme, password, and security settings components

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

* Add Plex settings and authentication components

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

* Redesign settings page with two-column layout and ProfileHero

- Create ProfileHero component with avatar and user info
- Create RequestHistory component for Plex requests (placeholder)
- Redesign SettingsPage with modern two-column grid layout
- Add shared-settings.scss for consistent styling
- Organize sections: Appearance, Security, Integrations, Data & Privacy
- Implement responsive mobile layout
- Standardize typography (h2: 1.5rem, 700 weight)
- Add compact modifier for tighter sections
2026-03-08 21:16:36 +01:00
131 changed files with 11835 additions and 1017 deletions

View File

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

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

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

View File

@@ -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;

View File

@@ -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
};

View File

@@ -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>

View File

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

View File

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

View File

@@ -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>

View File

@@ -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(() => {

View File

@@ -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;

View File

@@ -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);

View File

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

View File

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

View File

@@ -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> = [];

View File

@@ -41,7 +41,7 @@
const signinNavigationIcon: INavigationIcon = {
title: "Signin",
route: "/signin",
route: "/login",
icon: IconProfileLock
};

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);

View File

@@ -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>

View File

@@ -51,7 +51,7 @@
</Detail>
<Detail
v-if="creditedShows.length"
v-if="creditedMovies.length"
title="movies"
:detail="`Credited in ${creditedMovies.length} movies`"
>

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

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

View File

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

View File

@@ -129,6 +129,7 @@
font-size: 20px;
color: var(--text-color);
text-align: center;
padding-bottom: 1rem;
margin: 0;
.query {

View File

@@ -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>

View File

@@ -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%
);
}

View 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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);

View 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 };

View 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
};
}

View 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
};
}

View 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)
};
}
}

View 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
View 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
View 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>

View 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
View 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
View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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>

View 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>

View 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>

View 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
View 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>

View 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
View 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>

View 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>

View 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
View 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>

View 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
View 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
View 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>

View 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
View 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>

View 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>

View 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>

View 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
View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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>

View 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
View 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
View 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>

View 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
View 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
View 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
View 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>

View 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>

View 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.825 5.1875c-0.3031-1.0594-0.9844-2.1563-1.9188-3.0938-0.9343-0.9343-2.0343-1.6156-3.0937-1.9187-1.2438-0.3563-2.3688-0.15-3.0781 0.5594v0l-5 5C10.1313 6.3375 9.8906 7.2 10.0406 8.2l-4.4312 4.4094c-0.5469 0.5468-0.7282 1.3656-0.525 2.2718l-4.6032 4.6031c-31e-4 32e-4-31e-4 32e-4-62e-4 63e-4-0.4469 0.4531-0.5875 1.1344-0.3813 1.8687 0.1531 0.5438 0.4906 1.1032 0.9532 1.5719C1.7062 23.6 2.5281 24 3.2531 24v0c0.475 0 0.9094-0.1719 1.2188-0.4844l4.6093-4.6094c0.9219 0.2156 1.7563 0.0375 2.3094-0.5156l4.4313-4.4281c0.3468 0.0468 0.6781 0.05 0.9906 62e-4 0.5719-0.0812 1.075-0.325 1.4531-0.7031l5-5c0.7125-0.7125 0.9156-1.8344 0.5594-3.0781zM16.8531 11.8531c-0.0625 0.0625-0.1593 0.1063-0.2812 0.1313-0.15-0.0188-0.3063-0.05-0.4719-0.0969-0.8187-0.2344-1.6875-0.7812-2.4469-1.5375-0.7593-0.7594-1.3031-1.625-1.5375-2.4438-0.0468-0.1687-0.0812-0.3281-0.1-0.4812 0.025-0.1156 0.0688-0.2094 0.1313-0.2719l3.1-3.1c0.3312 0.9844 0.9812 1.9906 1.8469 2.8594 0.3937 0.3937 0.8187 0.7437 1.2562 1.0375l-2.5562 2.55 0.7062 0.7062 2.7438-2.7437C19.4781 8.575 19.7156 8.675 19.95 8.7531zM18.2656 2.0969c0.7282 0.2062 1.5406 0.7219 2.2282 1.4094 0.6875 0.6874 1.2031 1.5 1.4093 2.2281 0.1469 0.5093 0.125 0.9375-0.05 1.1156-0.1781 0.1781-0.6062 0.1969-1.1156 0.05-0.7281-0.2062-1.5406-0.7219-2.2281-1.4094s-1.2031-1.5-1.4094-2.2281c-0.1469-0.5094-0.125-0.9375 0.05-1.1156v0c0.175-0.175 0.6031-0.1938 1.1156-0.05zM11.6719 9.3938c0.3187 0.5656 0.7469 1.1312 1.275 1.6593 0.5344 0.5344 1.1031 0.9625 1.675 1.2844l-4.6282 4.625C9.45 16.85 8.7625 16.4813 8.1375 15.8594c-0.6219-0.6188-0.9906-1.3063-1.1031-1.8469zM3.1719 21.9875c-0.1438-0.0375-0.4157-0.1688-0.7-0.4594-0.3032-0.3094-0.4282-0.6031-0.4625-0.7469l4.8593-4.8593c0.1657 0.2219 0.3563 0.4406 0.5594 0.6437 0.1969 0.1969 0.4063 0.3781 0.6219 0.5407z" />
</svg>
</template>

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