mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-10 19:39:10 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
25
src/pages/GenPasswordPage.vue
Normal file
25
src/pages/GenPasswordPage.vue
Normal 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>
|
||||
@@ -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: "/"
|
||||
|
||||
Reference in New Issue
Block a user