From c309016299c4c7f87a936f1cffff760d7fc5a074 Mon Sep 17 00:00:00 2001 From: Kevin Date: Sun, 8 Mar 2026 21:16:36 +0100 Subject: [PATCH] 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 --- src/api.ts | 34 +- src/components/plex/PlexAuthButton.vue | 143 ++++ src/components/plex/PlexLibraryItem.vue | 230 ++++++ src/components/plex/PlexLibraryModal.vue | 382 +++++++++ src/components/plex/PlexLibraryStats.vue | 214 +++++ src/components/plex/PlexProfileCard.vue | 309 ++++++++ src/components/plex/PlexServerInfo.vue | 127 +++ src/components/plex/PlexUnlinkModal.vue | 152 ++++ src/components/profile/ChangePassword.vue | 185 +++-- src/components/settings/DangerZoneAction.vue | 72 ++ src/components/settings/DataExport.vue | 53 ++ src/components/settings/ExportSection.vue | 127 +++ src/components/settings/PasswordGenerator.vue | 597 ++++++++++++++ src/components/settings/PlexSettings.vue | 349 +++++++++ src/components/settings/ProfileHero.vue | 233 ++++++ src/components/settings/RequestHistory.vue | 103 +++ src/components/settings/SecuritySettings.vue | 46 ++ src/components/settings/StorageManager.vue | 215 +++++ .../settings/StorageSectionBrowser.vue | 366 +++++++++ .../settings/StorageSectionServer.vue | 462 +++++++++++ src/components/settings/ThemePreferences.vue | 355 +++++++++ src/composables/usePlexApi.ts | 125 +++ src/composables/usePlexAuth.ts | 201 +++++ src/composables/useRandomWords.ts | 741 ++++++++++++++++++ src/composables/useTheme.ts | 56 ++ src/icons/IconClock.vue | 8 + src/icons/IconCookie.vue | 23 + src/icons/IconDatabase.vue | 18 + src/icons/IconMusic.vue | 7 + src/icons/IconPlex.vue | 10 + src/icons/IconServer.vue | 17 + src/icons/IconSync.vue | 16 + src/icons/IconTimer.vue | 17 + src/main.ts | 6 +- src/modules/user.ts | 7 +- src/pages/SettingsPage.vue | 121 ++- src/scss/shared-settings.scss | 30 + src/utils.ts | 14 + src/utils/plexHelpers.ts | 176 +++++ 39 files changed, 6232 insertions(+), 115 deletions(-) create mode 100644 src/components/plex/PlexAuthButton.vue create mode 100644 src/components/plex/PlexLibraryItem.vue create mode 100644 src/components/plex/PlexLibraryModal.vue create mode 100644 src/components/plex/PlexLibraryStats.vue create mode 100644 src/components/plex/PlexProfileCard.vue create mode 100644 src/components/plex/PlexServerInfo.vue create mode 100644 src/components/plex/PlexUnlinkModal.vue create mode 100644 src/components/settings/DangerZoneAction.vue create mode 100644 src/components/settings/DataExport.vue create mode 100644 src/components/settings/ExportSection.vue create mode 100644 src/components/settings/PasswordGenerator.vue create mode 100644 src/components/settings/PlexSettings.vue create mode 100644 src/components/settings/ProfileHero.vue create mode 100644 src/components/settings/RequestHistory.vue create mode 100644 src/components/settings/SecuritySettings.vue create mode 100644 src/components/settings/StorageManager.vue create mode 100644 src/components/settings/StorageSectionBrowser.vue create mode 100644 src/components/settings/StorageSectionServer.vue create mode 100644 src/components/settings/ThemePreferences.vue create mode 100644 src/composables/usePlexApi.ts create mode 100644 src/composables/usePlexAuth.ts create mode 100644 src/composables/useRandomWords.ts create mode 100644 src/composables/useTheme.ts create mode 100644 src/icons/IconClock.vue create mode 100644 src/icons/IconCookie.vue create mode 100644 src/icons/IconDatabase.vue create mode 100644 src/icons/IconMusic.vue create mode 100644 src/icons/IconPlex.vue create mode 100644 src/icons/IconServer.vue create mode 100644 src/icons/IconSync.vue create mode 100644 src/icons/IconTimer.vue create mode 100644 src/scss/shared-settings.scss create mode 100644 src/utils/plexHelpers.ts diff --git a/src/api.ts b/src/api.ts index aaae015..3deb5da 100644 --- a/src/api.ts +++ b/src/api.ts @@ -262,17 +262,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 +378,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 +392,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 +413,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 +557,7 @@ const elasticSearchMoviesAndShows = async (query: string, count = 22) => { }; export { + API_HOSTNAME, getMovie, getShow, getPerson, @@ -554,12 +574,14 @@ export { getRequestStatus, linkPlexAccount, unlinkPlexAccount, + plexRecentlyAddedInLibrary, register, login, logout, getSettings, updateSettings, fetchGraphData, + watchLink, getEmoji, elasticSearchMoviesAndShows }; diff --git a/src/components/plex/PlexAuthButton.vue b/src/components/plex/PlexAuthButton.vue new file mode 100644 index 0000000..9a7a7a1 --- /dev/null +++ b/src/components/plex/PlexAuthButton.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/src/components/plex/PlexLibraryItem.vue b/src/components/plex/PlexLibraryItem.vue new file mode 100644 index 0000000..f71d735 --- /dev/null +++ b/src/components/plex/PlexLibraryItem.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/src/components/plex/PlexLibraryModal.vue b/src/components/plex/PlexLibraryModal.vue new file mode 100644 index 0000000..93135e4 --- /dev/null +++ b/src/components/plex/PlexLibraryModal.vue @@ -0,0 +1,382 @@ + + + + + diff --git a/src/components/plex/PlexLibraryStats.vue b/src/components/plex/PlexLibraryStats.vue new file mode 100644 index 0000000..27d3f7c --- /dev/null +++ b/src/components/plex/PlexLibraryStats.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/src/components/plex/PlexProfileCard.vue b/src/components/plex/PlexProfileCard.vue new file mode 100644 index 0000000..5f9a923 --- /dev/null +++ b/src/components/plex/PlexProfileCard.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/src/components/plex/PlexServerInfo.vue b/src/components/plex/PlexServerInfo.vue new file mode 100644 index 0000000..296b4c3 --- /dev/null +++ b/src/components/plex/PlexServerInfo.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/src/components/plex/PlexUnlinkModal.vue b/src/components/plex/PlexUnlinkModal.vue new file mode 100644 index 0000000..eff171e --- /dev/null +++ b/src/components/plex/PlexUnlinkModal.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/components/profile/ChangePassword.vue b/src/components/profile/ChangePassword.vue index 1ae5d3b..13b4bfb 100644 --- a/src/components/profile/ChangePassword.vue +++ b/src/components/profile/ChangePassword.vue @@ -1,31 +1,46 @@ @@ -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 = ref(""); const newPassword: Ref = ref(""); const newPasswordRepeat: Ref = ref(""); const messages: Ref = 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(); + } + */ } + + diff --git a/src/components/settings/DangerZoneAction.vue b/src/components/settings/DangerZoneAction.vue new file mode 100644 index 0000000..a3afde2 --- /dev/null +++ b/src/components/settings/DangerZoneAction.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/src/components/settings/DataExport.vue b/src/components/settings/DataExport.vue new file mode 100644 index 0000000..17b0b5b --- /dev/null +++ b/src/components/settings/DataExport.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/src/components/settings/ExportSection.vue b/src/components/settings/ExportSection.vue new file mode 100644 index 0000000..8f74e6f --- /dev/null +++ b/src/components/settings/ExportSection.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/src/components/settings/PasswordGenerator.vue b/src/components/settings/PasswordGenerator.vue new file mode 100644 index 0000000..076a0f0 --- /dev/null +++ b/src/components/settings/PasswordGenerator.vue @@ -0,0 +1,597 @@ + + + + + diff --git a/src/components/settings/PlexSettings.vue b/src/components/settings/PlexSettings.vue new file mode 100644 index 0000000..25defef --- /dev/null +++ b/src/components/settings/PlexSettings.vue @@ -0,0 +1,349 @@ + + + + + diff --git a/src/components/settings/ProfileHero.vue b/src/components/settings/ProfileHero.vue new file mode 100644 index 0000000..e93048f --- /dev/null +++ b/src/components/settings/ProfileHero.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/src/components/settings/RequestHistory.vue b/src/components/settings/RequestHistory.vue new file mode 100644 index 0000000..e5a5c5a --- /dev/null +++ b/src/components/settings/RequestHistory.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/src/components/settings/SecuritySettings.vue b/src/components/settings/SecuritySettings.vue new file mode 100644 index 0000000..8b774dd --- /dev/null +++ b/src/components/settings/SecuritySettings.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/components/settings/StorageManager.vue b/src/components/settings/StorageManager.vue new file mode 100644 index 0000000..ba90f29 --- /dev/null +++ b/src/components/settings/StorageManager.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/src/components/settings/StorageSectionBrowser.vue b/src/components/settings/StorageSectionBrowser.vue new file mode 100644 index 0000000..f769835 --- /dev/null +++ b/src/components/settings/StorageSectionBrowser.vue @@ -0,0 +1,366 @@ + + + + + diff --git a/src/components/settings/StorageSectionServer.vue b/src/components/settings/StorageSectionServer.vue new file mode 100644 index 0000000..3b3d7f3 --- /dev/null +++ b/src/components/settings/StorageSectionServer.vue @@ -0,0 +1,462 @@ + + + + + diff --git a/src/components/settings/ThemePreferences.vue b/src/components/settings/ThemePreferences.vue new file mode 100644 index 0000000..2b24499 --- /dev/null +++ b/src/components/settings/ThemePreferences.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/src/composables/usePlexApi.ts b/src/composables/usePlexApi.ts new file mode 100644 index 0000000..b9f0c4b --- /dev/null +++ b/src/composables/usePlexApi.ts @@ -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 }; diff --git a/src/composables/usePlexAuth.ts b/src/composables/usePlexAuth.ts new file mode 100644 index 0000000..ea29b53 --- /dev/null +++ b/src/composables/usePlexAuth.ts @@ -0,0 +1,201 @@ +import { ref } from "vue"; +import { CLIENT_IDENTIFIER, APP_NAME } from "./usePlexApi"; + +export function usePlexAuth() { + const loading = ref(false); + const plexPopup = ref(null); + const pollInterval = ref(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); + document.cookie = `plex_auth_token=${authToken}; 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(` + + + Connecting to Plex... + + +

Connecting to Plex...

+ + `); + } + + 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 + }; +} diff --git a/src/composables/useRandomWords.ts b/src/composables/useRandomWords.ts new file mode 100644 index 0000000..1da7b28 --- /dev/null +++ b/src/composables/useRandomWords.ts @@ -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 { + 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(); + + 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 + }; +} diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts new file mode 100644 index 0000000..09856d1 --- /dev/null +++ b/src/composables/useTheme.ts @@ -0,0 +1,56 @@ +import { ref, computed } from "vue"; + +type Theme = "light" | "dark" | "auto"; + +const currentTheme = ref("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(() => { + return (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 + }; +} diff --git a/src/icons/IconClock.vue b/src/icons/IconClock.vue new file mode 100644 index 0000000..1c8b679 --- /dev/null +++ b/src/icons/IconClock.vue @@ -0,0 +1,8 @@ + diff --git a/src/icons/IconCookie.vue b/src/icons/IconCookie.vue new file mode 100644 index 0000000..4aa1254 --- /dev/null +++ b/src/icons/IconCookie.vue @@ -0,0 +1,23 @@ + diff --git a/src/icons/IconDatabase.vue b/src/icons/IconDatabase.vue new file mode 100644 index 0000000..27163e6 --- /dev/null +++ b/src/icons/IconDatabase.vue @@ -0,0 +1,18 @@ + diff --git a/src/icons/IconMusic.vue b/src/icons/IconMusic.vue new file mode 100644 index 0000000..81cc3ce --- /dev/null +++ b/src/icons/IconMusic.vue @@ -0,0 +1,7 @@ + diff --git a/src/icons/IconPlex.vue b/src/icons/IconPlex.vue new file mode 100644 index 0000000..260ba56 --- /dev/null +++ b/src/icons/IconPlex.vue @@ -0,0 +1,10 @@ + diff --git a/src/icons/IconServer.vue b/src/icons/IconServer.vue new file mode 100644 index 0000000..3a29d12 --- /dev/null +++ b/src/icons/IconServer.vue @@ -0,0 +1,17 @@ + diff --git a/src/icons/IconSync.vue b/src/icons/IconSync.vue new file mode 100644 index 0000000..cac8a29 --- /dev/null +++ b/src/icons/IconSync.vue @@ -0,0 +1,16 @@ + diff --git a/src/icons/IconTimer.vue b/src/icons/IconTimer.vue new file mode 100644 index 0000000..4fb6428 --- /dev/null +++ b/src/icons/IconTimer.vue @@ -0,0 +1,17 @@ + diff --git a/src/main.ts b/src/main.ts index c773a08..fb4beb0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,10 +2,14 @@ import { createApp } from "vue"; import router from "./routes"; import store from "./store"; import Toast from "./plugins/Toast"; +import { useTheme } from "./composables/useTheme"; import App from "./App.vue"; -store.dispatch("darkmodeModule/findAndSetDarkmodeSupported"); +// Initialize theme before mounting +const { initTheme } = useTheme(); +initTheme(); + store.dispatch("user/initUserFromCookie"); const app = createApp(App); diff --git a/src/modules/user.ts b/src/modules/user.ts index f81c30c..2ac1bdd 100644 --- a/src/modules/user.ts +++ b/src/modules/user.ts @@ -16,12 +16,13 @@ export interface CookieOptions { /** * Read a cookie value. */ -export function getCookie(name: string): string | null { +export function getAuthorizationCookie(): string | null { + const key = 'authorization'; const array = document.cookie.split(";"); let match = null; array.forEach((item: string) => { - const query = `${name}=`; + const query = `${key}=`; if (!item.trim().startsWith(query)) return; match = item.trim().substring(query.length); }); @@ -132,7 +133,7 @@ const userModule: Module = { /* โ”€โ”€ Actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ actions: { async initUserFromCookie({ dispatch }): Promise { - const jwtToken = getCookie("authorization"); + const jwtToken = getAuthorizationCookie(); if (!jwtToken) return null; const token = parseJwt(jwtToken); diff --git a/src/pages/SettingsPage.vue b/src/pages/SettingsPage.vue index 697981e..971937b 100644 --- a/src/pages/SettingsPage.vue +++ b/src/pages/SettingsPage.vue @@ -1,21 +1,48 @@ - diff --git a/src/scss/shared-settings.scss b/src/scss/shared-settings.scss new file mode 100644 index 0000000..ec0d26c --- /dev/null +++ b/src/scss/shared-settings.scss @@ -0,0 +1,30 @@ +@import "./media-queries.scss"; + +.settings-section-card { + padding: 0.85rem; + background-color: var(--background-ui); + border-radius: 0.25rem; + border-left: 3px solid var(--highlight-color); + + @include mobile-only { + padding: 0.75rem; + } +} + +.settings-section-header { + margin-bottom: 1rem; + + h2 { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--text-color); + } + + p { + margin: 0; + color: var(--text-color-70); + font-size: 0.95rem; + line-height: 1.6; + } +} diff --git a/src/utils.ts b/src/utils.ts index 4d5bfd5..703037d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -129,3 +129,17 @@ export function convertSecondsToHumanReadable(_value, values = null) { return value; } + +export function formatNumber(n: number) { + if (!n?.toString()) return n; + + return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); +} + +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; +} diff --git a/src/utils/plexHelpers.ts b/src/utils/plexHelpers.ts new file mode 100644 index 0000000..490839c --- /dev/null +++ b/src/utils/plexHelpers.ts @@ -0,0 +1,176 @@ +export function getLibraryIcon(type: string): string { + const icons: Record = { + movies: "๐ŸŽฌ", + "tv shows": "๐Ÿ“บ", + music: "๐ŸŽต" + }; + return icons[type] || "๐Ÿ“"; +} + +export function getLibraryIconComponent(type: string): string { + const components: Record = { + movies: "IconMovie", + "tv shows": "IconShow", + music: "IconMusic" + }; + return components[type] || "IconMovie"; +} + +export function getLibraryTitle(type: string): string { + const titles: Record = { + movies: "Movies", + "tv shows": "TV Shows", + music: "Music" + }; + return titles[type] || type; +} + +export function formatDate(dateString: string): string { + try { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric" + }); + } catch { + return dateString; + } +} + +export function formatMemberSince(dateString: string): 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"; + } +} + +export function processLibraryItem( + item: any, + libraryType: string, + authToken: string, + serverUrl: string, + machineIdentifier: string +) { + // Get poster/thumbnail URL + let posterUrl = null; + + // For TV tv shows, prefer grandparentThumb (show poster) over thumb (episode thumbnail) + if (libraryType === "tv shows") { + if (item.grandparentThumb) { + posterUrl = `${serverUrl}${item.grandparentThumb}?X-Plex-Token=${authToken}`; + } else if (item.thumb) { + posterUrl = `${serverUrl}${item.thumb}?X-Plex-Token=${authToken}`; + } + } + // For music, prefer grandparentThumb (artist/album) over thumb + else if (libraryType === "music") { + if (item.grandparentThumb) { + posterUrl = `${serverUrl}${item.grandparentThumb}?X-Plex-Token=${authToken}`; + } else if (item.thumb) { + posterUrl = `${serverUrl}${item.thumb}?X-Plex-Token=${authToken}`; + } + } + // For movies and other types, use thumb + else if (item.thumb) { + posterUrl = `${serverUrl}${item.thumb}?X-Plex-Token=${authToken}`; + } + + // Build Plex Web App URL + // Format: https://app.plex.tv/desktop/#!/server/{machineId}/details?key=%2Flibrary%2Fmetadata%2F{ratingKey} + const ratingKey = item.ratingKey || item.key; + let plexUrl = null; + if (ratingKey && machineIdentifier) { + const encodedKey = encodeURIComponent(`/library/metadata/${ratingKey}`); + plexUrl = `https://app.plex.tv/desktop/#!/server/${machineIdentifier}/details?key=${encodedKey}`; + } + + // For tv shows, use grandparent data (show info) instead of episode info + const title = + libraryType === "tv shows" && item.grandparentTitle + ? item.grandparentTitle + : item.title; + + const year = + libraryType === "tv shows" && item.grandparentYear + ? item.grandparentYear + : item.year || item.parentYear || new Date().getFullYear(); + + const baseItem = { + title, + year, + poster: posterUrl, + fallbackIcon: getLibraryIcon(libraryType), + rating: item.rating ? Math.round(item.rating * 10) / 10 : null, + type: libraryType, + ratingKey, + plexUrl + }; + + if (libraryType === "tv shows") { + return { + ...baseItem, + episodes: item.leafCount || 0 + }; + } + if (libraryType === "music") { + return { + ...baseItem, + artist: item.parentTitle || "Unknown Artist", + tracks: item.leafCount || 0 + }; + } + + return baseItem; +} + +export function calculateGenreStats(metadata: any[]) { + const genreMap = new Map(); + + metadata.forEach((item: any) => { + if (item.Genre) { + item.Genre.forEach((genre: any) => { + genreMap.set(genre.tag, (genreMap.get(genre.tag) || 0) + 1); + }); + } + }); + + return Array.from(genreMap.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([name, count]) => ({ name, count })); +} + +export function calculateDuration(metadata: any[], libraryType: string) { + let totalDuration = 0; + let totalEpisodes = 0; + let totalTracks = 0; + + metadata.forEach((item: any) => { + if (item.duration) { + totalDuration += item.duration; + } + + if (libraryType === "tv shows" && item.leafCount) { + totalEpisodes += item.leafCount; + } else if (libraryType === "music" && item.leafCount) { + totalTracks += item.leafCount; + } + }); + + const hours = Math.round(totalDuration / (1000 * 60 * 60)); + const formattedDuration = `${hours.toLocaleString()} hours`; + + return { + totalDuration: formattedDuration, + totalEpisodes, + totalTracks + }; +}