Enhance Plex integration with real API data and interactive library modal

Major improvements to Plex integration:
- Replace Vuex store dependency with localStorage-based connection detection
- Fetch and display real Plex user data (username, email, subscription, 2FA status)
- Add user badges: Plex Pass, member years, 2FA, experimental features
- Properly format Unix timestamp joined dates
- Remove success message box, add elegant checkmark icon next to username
- Add Plex connection badge to main user profile

Real-time Plex API integration:
- Fetch actual library counts from Plex server (movies, shows, music)
- Display real server name from user's Plex account
- Load recently added items with actual titles, years, and ratings
- Calculate real genre statistics from library metadata
- Compute actual duration totals from item metadata
- Count actual episodes (TV shows) and tracks (music)
- Sync library on demand with fresh data from Plex API

Interactive library modal:
- Replace toast messages with rich modal showing library details
- Display recently added items with poster images
- Show genre distribution with animated bar charts
- Add loading states with animated dots
- Disable empty library cards
- Modal appears above header with proper z-index
- Blur backdrop for better focus
- Fully responsive mobile design

Store Plex data in localStorage:
- Cache user profile data including subscription info
- Store auth token in secure cookie (30 day expiration)
- Load from cache for instant display on page load
- Refresh data on authentication and manual sync

Add Plex connection indicator to user profile:
- Orange Plex badge in settings profile header
- Shows 'Connected as [username]' below member info
- Loads username from localStorage on mount
This commit is contained in:
2026-02-27 17:34:38 +01:00
parent 9c7e0bd3b3
commit 77c89fa520
6 changed files with 1676 additions and 258 deletions

View File

@@ -14,7 +14,17 @@
type="password"
/>
<password-generator @password-generated="handleGeneratedPassword" />
<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="newPassword"
@@ -45,10 +55,12 @@
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";
const showGenerator = ref(false);
const oldPassword: Ref<string> = ref("");
const newPassword: Ref<string> = ref("");
const newPasswordRepeat: Ref<string> = ref("");
@@ -69,7 +81,7 @@
}
function validate() {
return
return;
return new Promise((resolve, reject) => {
if (!oldPassword.value || oldPassword?.value?.length === 0) {
addWarningMessage("Missing old password!", "Validation error");
@@ -120,6 +132,15 @@
loading.value = false;
}
}
function toggleGenerator() {
showGenerator.value = !showGenerator.value;
/*
if (showGenerator.value && !generatedPassword.value) {
generateWordsPassword();
}
*/
}
</script>
<style lang="scss" scoped>
@@ -151,4 +172,36 @@
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

@@ -1,11 +1,6 @@
<template>
<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" class="generator-panel">
<div class="generator-panel">
<div class="generator-tabs">
<button
:class="['tab', { 'tab--active': mode === 'words' }]"
@@ -28,7 +23,9 @@
</div>
<div class="password-display" @click="copyPassword">
<span class="password-text">{{ generatedPassword }}</span>
<span class="password-text password-text--mono">{{
generatedPassword
}}</span>
<button
class="copy-btn"
:title="copied ? 'Copied!' : 'Click to copy'"
@@ -136,8 +133,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import IconKey from "@/icons/IconKey.vue";
import { ref, computed, watch, onMounted } from "vue";
import IconActivity from "@/icons/IconActivity.vue";
interface Emit {
@@ -146,7 +142,6 @@
const emit = defineEmits<Emit>();
const showGenerator = ref(false);
const mode = ref<"words" | "chars">("words");
const generatedPassword = ref("");
const copied = ref(false);
@@ -256,13 +251,6 @@
]
};
function toggleGenerator() {
showGenerator.value = !showGenerator.value;
if (showGenerator.value && !generatedPassword.value) {
generateWordsPassword();
}
}
function getRandomWord(category: keyof typeof wordLists): string {
const words = wordLists[category];
return words[Math.floor(Math.random() * words.length)];
@@ -323,7 +311,8 @@
function usePassword() {
emit("passwordGenerated", generatedPassword.value);
showGenerator.value = false;
// TODO: emit
// showGenerator.value = false;
}
watch(mode, () => {
@@ -333,6 +322,8 @@
generateCharsPassword();
}
});
onMounted(generateWordsPassword);
</script>
<style lang="scss" scoped>
@@ -343,32 +334,6 @@
margin-bottom: 1rem;
}
.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);
}
.generator-panel {
margin-top: 0.75rem;
padding: 1rem;
@@ -455,11 +420,14 @@
.password-text {
flex: 1;
font-size: 1.1rem;
font-size: 1.8rem;
font-weight: 500;
color: var(--highlight-color);
word-break: break-all;
user-select: all;
word-break: break-all;
word-break: break-word;
-webkit-hyphens: auto;
hyphens: auto;
@include mobile-only {
font-size: 0.95rem;
@@ -467,7 +435,6 @@
&--mono {
font-family: "Courier New", monospace;
font-size: 1rem;
@include mobile-only {
font-size: 0.85rem;

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,28 @@
<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>
@@ -20,6 +40,7 @@
import { useStore } from "vuex";
const store = useStore();
const plexUsername = ref<string>("");
const username = computed(() => store.getters["user/username"] || "User");
@@ -50,6 +71,23 @@
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>
@@ -136,6 +174,19 @@
}
}
.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;
@@ -143,6 +194,9 @@
text-transform: uppercase;
font-weight: 600;
line-height: 1;
display: inline-flex;
align-items: center;
gap: 0.25rem;
&--admin {
background-color: var(--color-warning);
@@ -153,5 +207,15 @@
background-color: var(--background-40);
color: $text-color;
}
&--plex {
background-color: #cc7b19;
color: $white;
cursor: help;
svg {
flex-shrink: 0;
}
}
}
</style>

View File

@@ -0,0 +1,25 @@
<template>
<section class="admin">
<h1 class="admin__title">Password gen</h1>
<div class="form">
<password-generator />
</div>
</section>
</template>
<script setup lang="ts">
import PasswordGenerator from "@/components/settings/PasswordGenerator.vue";
function handleGeneratedPassword() {
return;
}
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
</style>

View File

@@ -73,12 +73,17 @@ const routes: RouteRecordRaw[] = [
// 'user-requests-router-view': require('./components/MoviesList.vue')
// }
// },
{
name: "password-gen",
path: "/password",
component: () => import("./pages/GenPasswordPage.vue")
},
{
name: "admin",
path: "/admin",
meta: { requiresAuth: true },
component: () => import("./pages/AdminPage.vue")
},
}
// {
// path: "*",
// redirect: "/"