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
This commit is contained in:
2026-03-08 21:16:36 +01:00
committed by GitHub
parent 081240c83e
commit c309016299
39 changed files with 6232 additions and 115 deletions

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,214 @@
<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,127 @@
<template>
<div class="plex-server-info">
<div class="plex-details">
<div class="detail-row">
<span class="detail-label">
<IconServer class="label-icon" />
Plex server name
</span>
<span class="detail-value">{{ serverName || "Unknown" }}</span>
</div>
<div class="detail-row">
<span class="detail-label">
<IconSync class="label-icon" />
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 {
color: var(--text-color-60);
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>