Refactor settings page with improved component structure

- Split SettingsPage into two-column layout with ProfileHero component
- Extract SecuritySettings component with user-friendly messaging
- Create RequestHistory component for Plex request tracking
- Optimize ThemePreferences component (reduced from ~368 to cleaner structure)
- Improve PasswordGenerator slider UX with better visual feedback
- Standardize typography across all settings sections (h2: 1.5rem, 700 weight)
- Add shared-settings.scss for consistent styling patterns
- Remove redundant ChangePassword description (now in SecuritySettings)
This commit is contained in:
2026-03-08 20:56:34 +01:00
parent b1f1fa8780
commit 9c6e6938e9
8 changed files with 801 additions and 715 deletions

View File

@@ -1,12 +1,7 @@
<template> <template>
<div class="change-password"> <div class="change-password">
<div class="password-card"> <div class="password-card">
<p class="password-info"> <form class="password-form" @submit.prevent>
Update your password to keep your account secure. Use a strong password
with at least 8 characters.
</p>
<form class="password-form" @submit.prevent="changePassword">
<seasoned-input <seasoned-input
v-model="oldPassword" v-model="oldPassword"
placeholder="Current password" placeholder="Current password"
@@ -72,49 +67,8 @@
newPasswordRepeat.value = password; newPasswordRepeat.value = password;
} }
function addWarningMessage(message: string, title?: string) { async function changePassword(event: CustomEvent) {
messages.value.push({
message,
title,
type: ErrorMessageTypes.Warning
} as IErrorMessage);
}
function validate() {
return;
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);
});
}
async function changePassword() {
try { try {
await validate();
loading.value = true;
// API call disabled for now
// TODO: Implement actual password change API call
// await api.changePassword({ oldPassword, newPassword });
messages.value.push({ messages.value.push({
message: "Password change is currently disabled", message: "Password change is currently disabled",
title: "Feature Disabled", title: "Feature Disabled",
@@ -153,20 +107,6 @@
gap: 0.65rem; gap: 0.65rem;
} }
.password-info {
margin: 0;
padding: 0.65rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
font-size: 0.9rem;
border-left: 3px solid var(--highlight-color);
@include mobile-only {
padding: 0.6rem;
font-size: 0.85rem;
}
}
.password-form { .password-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -36,7 +36,10 @@
<div class="generator-options"> <div class="generator-options">
<div class="option-row"> <div class="option-row">
<label>Number of words: {{ wordCount }}</label> <div class="slider-header">
<label>Words</label>
<span class="slider-value">{{ wordCount }}</span>
</div>
<input <input
v-model.number="wordCount" v-model.number="wordCount"
type="range" type="range"
@@ -45,7 +48,10 @@
class="slider" class="slider"
@input="generateWordsPassword" @input="generateWordsPassword"
/> />
<span class="option-value">{{ wordCount }}</span> <div class="slider-labels">
<span>3</span>
<span>7</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -70,7 +76,10 @@
<div class="generator-options"> <div class="generator-options">
<div class="option-row"> <div class="option-row">
<label>Length: {{ charLength }}</label> <div class="slider-header">
<label>Length</label>
<span class="slider-value">{{ charLength }}</span>
</div>
<input <input
v-model.number="charLength" v-model.number="charLength"
type="range" type="range"
@@ -79,7 +88,10 @@
class="slider" class="slider"
@input="generateCharsPassword" @input="generateCharsPassword"
/> />
<span class="option-value">{{ charLength }}</span> <div class="slider-labels">
<span>12</span>
<span>46</span>
</div>
</div> </div>
<div class="option-row checkbox-row"> <div class="option-row checkbox-row">
@@ -133,7 +145,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue"; import { ref, watch, onMounted } from "vue";
import IconActivity from "@/icons/IconActivity.vue"; import IconActivity from "@/icons/IconActivity.vue";
import { useRandomWords } from "@/composables/useRandomWords"; import { useRandomWords } from "@/composables/useRandomWords";
@@ -360,12 +372,12 @@
.option-row { .option-row {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.75rem;
label { label {
font-size: 0.85rem; font-size: 0.95rem;
color: $text-color; color: $text-color;
font-weight: 500; font-weight: 600;
line-height: 1.2; line-height: 1.2;
} }
@@ -396,38 +408,121 @@
} }
} }
.slider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.slider-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--highlight-color);
min-width: 2.5rem;
text-align: right;
}
.slider-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: $text-color-50;
margin-top: 0.25rem;
padding: 0 0.25rem;
}
.slider { .slider {
width: 100%; width: 100%;
height: 6px; height: 10px;
border-radius: 3px; border-radius: 5px;
background: var(--background-40); background: var(--background-40);
outline: none; outline: none;
appearance: none; appearance: none;
cursor: pointer;
transition: background 0.2s;
margin: 0.5rem 0;
@include mobile-only {
height: 12px;
}
&:hover {
background: var(--background-40);
}
&::-webkit-slider-thumb { &::-webkit-slider-thumb {
appearance: none; appearance: none;
width: 18px; width: 24px;
height: 18px; height: 24px;
border-radius: 50%; border-radius: 50%;
background: var(--highlight-color); background: var(--highlight-color);
cursor: pointer; cursor: grab;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
margin-top: -7px;
@include mobile-only {
width: 28px;
height: 28px;
margin-top: -8px;
}
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
}
&:active {
cursor: grabbing;
transform: scale(1.05);
}
} }
&::-moz-range-thumb { &::-moz-range-thumb {
width: 18px; width: 24px;
height: 18px; height: 24px;
border-radius: 50%; border-radius: 50%;
background: var(--highlight-color); background: var(--highlight-color);
cursor: pointer; cursor: grab;
border: none; border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
@include mobile-only {
width: 28px;
height: 28px;
}
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
}
&:active {
cursor: grabbing;
transform: scale(1.05);
} }
} }
.option-value { &::-webkit-slider-runnable-track {
font-size: 0.9rem; height: 10px;
font-weight: 600; border-radius: 5px;
color: var(--highlight-color);
text-align: center; @include mobile-only {
height: 12px;
}
}
&::-moz-range-track {
height: 10px;
border-radius: 5px;
background: var(--background-40);
@include mobile-only {
height: 12px;
}
}
} }
.separator-input { .separator-input {

View File

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

View File

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

View File

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

View File

@@ -16,10 +16,7 @@
<button <button
v-for="theme in themes" v-for="theme in themes"
:key="theme.value" :key="theme.value"
:class="[ :class="['theme-card', { active: selectedTheme === theme.value }]"
'theme-card',
{ 'theme-card--active': selectedTheme === theme.value }
]"
@click="selectTheme(theme.value)" @click="selectTheme(theme.value)"
> >
<div class="theme-card__preview" :data-theme="theme.value"> <div class="theme-card__preview" :data-theme="theme.value">
@@ -35,65 +32,31 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { computed, onMounted } from "vue";
import { useTheme } from "@/composables/useTheme";
interface Theme { const themes = [
value: string;
label: string;
}
const themes: Theme[] = [
{ value: "auto", label: "Auto" }, { value: "auto", label: "Auto" },
{ value: "light", label: "Light" }, { value: "light", label: "Light" },
{ value: "dark", label: "Dark" }, { value: "dark", label: "Dark" },
{ value: "ocean", label: "Ocean" }, { value: "ocean", label: "Ocean" },
{ value: "nordic", label: "Nordic" }, { value: "nordic", label: "Nordic" },
{ value: "halloween", label: "Halloween" } { value: "halloween", label: "Halloween" }
]; ] as const;
const selectedTheme = ref("auto"); const { currentTheme, savedTheme, setTheme } = useTheme();
const selectedTheme = currentTheme;
const currentThemeName = computed(() => { const currentThemeName = computed(
const theme = themes.find(t => t.value === selectedTheme.value); () => themes.find(t => t.value === selectedTheme.value)?.label ?? "Auto"
return theme ? theme.label : "Auto"; );
});
function systemDarkModeEnabled() {
const computedStyle = window.getComputedStyle(document.body);
if (computedStyle?.colorScheme != null) {
return computedStyle.colorScheme.includes("dark");
}
return false;
}
function selectTheme(theme: string) { function selectTheme(theme: string) {
selectedTheme.value = theme; setTheme(theme as any);
if (theme === "auto") {
// Use system preference
const systemDark = systemDarkModeEnabled();
document.body.className = systemDark ? "dark" : "light";
// Listen for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", e => {
if (selectedTheme.value === "auto") {
document.body.className = e.matches ? "dark" : "light";
}
});
} else {
// Manual theme selection
document.body.className = theme;
}
// Save preference to localStorage
localStorage.setItem("theme-preference", theme);
} }
onMounted(() => { onMounted(() => {
// Load saved preference or default to auto selectedTheme.value = savedTheme.value;
const savedTheme = localStorage.getItem("theme-preference") || "auto";
selectTheme(savedTheme);
}); });
</script> </script>
@@ -113,7 +76,6 @@
padding: 1.5rem; padding: 1.5rem;
border-radius: 0.75rem; border-radius: 0.75rem;
} }
}
.theme-display { .theme-display {
display: flex; display: flex;
@@ -142,40 +104,32 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 50%; border-radius: 50%;
border: 3px solid;
} }
&[data-theme="light"] .icon-inner { &[data-theme="light"] .icon-inner {
background: linear-gradient(135deg, #f8f8f8 0%, #e8e8e8 100%); background: linear-gradient(135deg, #f8f8f8, #e8e8e8);
border: 3px solid #01d277; border-color: #01d277;
} }
&[data-theme="dark"] .icon-inner { &[data-theme="dark"] .icon-inner {
background: linear-gradient(135deg, #1a1a1a 0%, #0a0a0a 100%); background: linear-gradient(135deg, #1a1a1a, #0a0a0a);
border: 3px solid #01d277; border-color: #01d277;
} }
&[data-theme="ocean"] .icon-inner { &[data-theme="ocean"] .icon-inner {
background: linear-gradient(135deg, #0f2027 0%, #2c5364 100%); background: linear-gradient(135deg, #0f2027, #2c5364);
border: 3px solid #00d4ff; border-color: #00d4ff;
} }
&[data-theme="nordic"] .icon-inner { &[data-theme="nordic"] .icon-inner {
background: linear-gradient(135deg, #f5f0e8 0%, #d8cdb9 100%); background: linear-gradient(135deg, #f5f0e8, #d8cdb9);
border: 3px solid #3d6e4e; border-color: #3d6e4e;
} }
&[data-theme="halloween"] .icon-inner { &[data-theme="halloween"] .icon-inner {
background: linear-gradient(135deg, #1a0e2e 0%, #2d1b3d 100%); background: linear-gradient(135deg, #1a0e2e, #2d1b3d);
border: 3px solid #ff6600; border-color: #ff6600;
} }
&[data-theme="auto"] .icon-inner { &[data-theme="auto"] .icon-inner {
background: conic-gradient( background: conic-gradient(#f8f8f8 0deg 180deg, #1a1a1a 180deg);
from 0deg, border-color: #01d277;
#f8f8f8 0deg 180deg,
#1a1a1a 180deg 360deg
);
border: 3px solid #01d277;
} }
} }
@@ -186,7 +140,7 @@
gap: 0.35rem; gap: 0.35rem;
} }
.theme-label { span {
font-size: 0.85rem; font-size: 0.85rem;
color: $text-color-70; color: $text-color-70;
text-transform: uppercase; text-transform: uppercase;
@@ -198,7 +152,7 @@
} }
} }
.theme-name { h3 {
margin: 0; margin: 0;
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
@@ -208,6 +162,7 @@
font-size: 1.4rem; font-size: 1.4rem;
} }
} }
}
.theme-grid { .theme-grid {
display: grid; display: grid;
@@ -218,7 +173,6 @@
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 1rem; gap: 1rem;
} }
}
.theme-card { .theme-card {
display: flex; display: flex;
@@ -235,6 +189,12 @@
@include mobile-only { @include mobile-only {
padding: 0.85rem; padding: 0.85rem;
border-radius: 0.5rem; border-radius: 0.5rem;
&:hover {
transform: none;
}
&:active {
transform: scale(0.97);
}
} }
&:hover { &:hover {
@@ -243,22 +203,12 @@
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
} }
&--active { &.active {
border-color: var(--highlight-color); border-color: var(--highlight-color);
background-color: var(--background-40); background-color: var(--background-40);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
@include mobile-only {
&:hover {
transform: none;
}
&:active {
transform: scale(0.97);
}
}
&__preview { &__preview {
width: 100%; width: 100%;
height: 120px; height: 120px;
@@ -275,163 +225,99 @@
.preview-circle { .preview-circle {
position: absolute; position: absolute;
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
border-radius: 50%; border-radius: 50%;
} }
&::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
height: 20px;
border-radius: 4px;
border: 1px solid;
}
} }
// Light Theme Preview
&__preview[data-theme="light"] { &__preview[data-theme="light"] {
background: #f8f8f8; background: #f8f8f8;
.preview-circle { .preview-circle {
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
background: #01d277; background: #01d277;
} }
&::before { &::before {
content: ""; background: #fff;
position: absolute; border-color: rgba(8, 28, 36, 0.1);
top: 8px;
left: 8px;
right: 8px;
height: 20px;
background: #ffffff;
border-radius: 4px;
border: 1px solid rgba(8, 28, 36, 0.1);
} }
} }
// Dark Theme Preview
&__preview[data-theme="dark"] { &__preview[data-theme="dark"] {
background: #111111; background: #111;
.preview-circle { .preview-circle {
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
background: #01d277; background: #01d277;
} }
&::before { &::before {
content: ""; background: #060708;
position: absolute; border-color: rgba(255, 255, 255, 0.1);
top: 8px;
left: 8px;
right: 8px;
height: 20px;
background: rgba(6, 7, 8, 1);
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
} }
} }
// Ocean Theme Preview
&__preview[data-theme="ocean"] { &__preview[data-theme="ocean"] {
background: #0f2027; background: #0f2027;
.preview-circle { .preview-circle {
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
background: #00d4ff; background: #00d4ff;
} }
&::before { &::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
height: 20px;
background: #203a43; background: #203a43;
border-radius: 4px; border-color: rgba(0, 212, 255, 0.2);
border: 1px solid rgba(0, 212, 255, 0.2);
} }
} }
// Nordic Theme Preview
&__preview[data-theme="nordic"] { &__preview[data-theme="nordic"] {
background: #f5f0e8; background: #f5f0e8;
.preview-circle { .preview-circle {
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
background: #3d6e4e; background: #3d6e4e;
} }
&::before { &::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
height: 20px;
background: #fffef9; background: #fffef9;
border-radius: 4px; border-color: rgba(61, 110, 78, 0.2);
border: 1px solid rgba(61, 110, 78, 0.2);
} }
} }
// Halloween Theme Preview
&__preview[data-theme="halloween"] { &__preview[data-theme="halloween"] {
background: #1a0e2e; background: #1a0e2e;
.preview-circle { .preview-circle {
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
background: #ff6600; background: #ff6600;
} }
&::before { &::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
height: 20px;
background: #2d1b3d; background: #2d1b3d;
border-radius: 4px; border-color: rgba(255, 102, 0, 0.2);
border: 1px solid rgba(255, 102, 0, 0.2);
} }
} }
// Auto Theme Preview (split)
&__preview[data-theme="auto"] { &__preview[data-theme="auto"] {
border-color: black;
background: linear-gradient( background: linear-gradient(
135deg, 135deg,
#f8f8f8 0%, #f8f8f8 0%,
#f8f8f8 50%, #f8f8f8 50%,
#111111 50%, #111 50%,
#111111 100% #111 100%
); );
.preview-circle { .preview-circle {
bottom: 8px; left: auto;
right: 8px; right: 8px;
width: 30px;
height: 30px;
background: #01d277; background: #01d277;
} }
&::before { &::before {
content: ""; right: auto;
position: absolute;
top: 8px;
left: 8px;
width: calc(50% - 10px); width: calc(50% - 10px);
height: 20px; background: #fff;
background: #ffffff; border-color: rgba(8, 28, 36, 0.1);
border-radius: 4px;
border: 1px solid rgba(8, 28, 36, 0.1);
} }
} }
@@ -440,7 +326,6 @@
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
color: var(--text-color); color: var(--text-color);
@include mobile-only { @include mobile-only {
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -452,7 +337,7 @@
right: 0.5rem; right: 0.5rem;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
background-color: var(--highlight-color); background-color: var(--highlight-color);
color: $white; color: white;
border-radius: 1rem; border-radius: 1rem;
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 600; font-weight: 600;
@@ -466,4 +351,5 @@
} }
} }
} }
}
</style> </style>

View File

@@ -2,58 +2,30 @@
<section class="settings"> <section class="settings">
<div class="settings__container"> <div class="settings__container">
<!-- Profile Hero Card --> <!-- Profile Hero Card -->
<div class="profile-hero"> <ProfileHero />
<div class="profile-hero__main">
<div class="profile-hero__avatar">
<div class="avatar-large">{{ userInitials }}</div>
</div>
<div class="profile-hero__info">
<h1 class="profile-hero__name">{{ username }}</h1>
<span :class="['profile-hero__badge', `badge--${userRole}`]">
<a v-if="userRole === 'admin'" href="/admin">{{ userRole }}</a>
<span v-else>{{ userRole }}</span>
</span>
<p class="profile-hero__member">Member since {{ memberSince }}</p>
</div>
</div>
<div class="profile-hero__stats">
<div class="stat-large">
<span class="stat-large__value">{{ stats.totalRequests }}</span>
<span class="stat-large__label">Requests</span>
</div>
<div class="stat-divider"></div>
<div class="stat-large">
<span class="stat-large__value">{{ stats.magnetsAdded }}</span>
<span class="stat-large__label">Magnets Added</span>
</div>
</div>
</div>
<!-- Settings Grid --> <!-- Settings Grid -->
<div class="settings__grid"> <div class="settings__grid">
<!-- Left Column: Quick Settings --> <!-- Left Column -->
<div class="settings__column settings__column--left"> <div class="settings__column">
<section class="settings-section settings-section--compact"> <section class="settings-section settings-section--compact">
<h2 class="section-header">Appearance</h2> <div class="settings-section-header"><h2>Appearance</h2></div>
<theme-preferences /> <theme-preferences />
</section> </section>
<section class="settings-section settings-section--compact"> <section class="settings-section settings-section--compact">
<h2 class="section-header">Security</h2> <security-settings />
<change-password />
</section> </section>
</div> </div>
<!-- Right Column: Data-Heavy Sections --> <!-- Right Column -->
<div class="settings__column settings__column--right"> <div class="settings__column">
<section class="settings-section"> <section class="settings-section">
<h2 class="section-header">Integrations</h2> <div class="settings-section-header"><h2>Integrations</h2></div>
<plex-settings @reload="reloadSettings" /> <plex-settings @reload="reloadSettings" />
</section> </section>
<section class="settings-section"> <section class="settings-section">
<h2 class="section-header">Data & Privacy</h2>
<data-export /> <data-export />
</section> </section>
</div> </div>
@@ -63,12 +35,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject, computed, onMounted } from "vue"; import { inject, onMounted } from "vue";
import { useStore } from "vuex"; import { useStore } from "vuex";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import ProfileHero from "@/components/settings/ProfileHero.vue";
import ThemePreferences from "@/components/settings/ThemePreferences.vue"; import ThemePreferences from "@/components/settings/ThemePreferences.vue";
import PlexSettings from "@/components/settings/PlexSettings.vue"; import PlexSettings from "@/components/settings/PlexSettings.vue";
import ChangePassword from "@/components/profile/ChangePassword.vue"; import SecuritySettings from "@/components/settings/SecuritySettings.vue";
import DataExport from "@/components/settings/DataExport.vue"; import DataExport from "@/components/settings/DataExport.vue";
import { getSettings } from "../api"; import { getSettings } from "../api";
@@ -78,29 +51,6 @@
error; error;
} = inject("notifications"); } = inject("notifications");
const username = computed(() => store.getters["user/username"] || "User");
const userRole = computed(() =>
store.getters["user/admin"] ? "admin" : "user"
);
const userInitials = computed(() => {
return username.value.slice(0, 2).toUpperCase();
});
const memberSince = computed(() => {
const date = new Date();
date.setMonth(date.getMonth() - 6);
return date.toLocaleDateString("en-US", {
month: "short",
year: "numeric"
});
});
const stats = {
totalRequests: 45,
magnetsAdded: 127
};
function displayWarningIfMissingPlexAccount() { function displayWarningIfMissingPlexAccount() {
if (route.query?.missingPlexAccount === "true") { if (route.query?.missingPlexAccount === "true") {
notifications.error({ notifications.error({
@@ -127,13 +77,14 @@
<style lang="scss" scoped> <style lang="scss" scoped>
@import "scss/variables"; @import "scss/variables";
@import "scss/media-queries"; @import "scss/media-queries";
@import "scss/shared-settings";
.settings { .settings {
min-height: calc(100vh - var(--header-size)); min-height: calc(100vh - var(--header-size));
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
@include mobile-only { @include mobile-only {
padding: 1rem; padding: 0.5rem;
} }
&__container { &__container {
@@ -167,184 +118,6 @@
@include mobile-only { @include mobile-only {
gap: 1rem; gap: 1rem;
} }
&--left {
// Quick settings - lighter, more concise
}
&--right {
// Data-heavy sections
}
}
}
.profile-hero {
background-color: var(--background-color-secondary);
border-radius: 0.75rem;
padding: 1.5rem;
border: 1px solid var(--background-40);
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
@include mobile-only {
flex-direction: column;
padding: 1.5rem 1.25rem;
border-radius: 0.5rem;
text-align: center;
gap: 1rem;
}
&__main {
display: flex;
align-items: center;
gap: 1.5rem;
@include mobile-only {
flex-direction: column;
gap: 0.75rem;
}
}
&__avatar {
flex-shrink: 0;
}
&__info {
display: flex;
flex-direction: column;
gap: 0.35rem;
@include mobile-only {
align-items: center;
}
}
&__name {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
line-height: 1.1;
@include mobile-only {
font-size: 1.5rem;
}
}
&__badge {
display: inline-block;
padding: 0.25rem 0.7rem;
border-radius: 2rem;
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
width: fit-content;
@include mobile-only {
padding: 0.2rem 0.6rem;
font-size: 0.7rem;
}
&.badge--admin {
background-color: var(--color-warning);
color: $black;
}
&.badge--user {
background-color: var(--background-40);
}
}
&__member {
margin: 0;
font-size: 0.85rem;
color: $text-color-70;
@include mobile-only {
font-size: 0.8rem;
}
}
&__stats {
display: flex;
align-items: center;
gap: 1.75rem;
padding-left: 1.75rem;
border-left: 1px solid var(--background-40);
@include mobile-only {
width: 100%;
padding: 1rem 0 0 0;
border-left: none;
border-top: 1px solid var(--background-40);
justify-content: center;
gap: 1.25rem;
}
}
}
.avatar-large {
width: 70px;
height: 70px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--highlight-color),
var(--color-green-70)
);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
font-weight: 700;
color: $white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
@include mobile-only {
width: 80px;
height: 80px;
font-size: 2rem;
}
}
.stat-large {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
&__value {
font-size: 1.75rem;
font-weight: 700;
color: var(--highlight-color);
line-height: 1;
@include mobile-only {
font-size: 1.75rem;
}
}
&__label {
font-size: 0.75rem;
color: $text-color-70;
text-transform: uppercase;
font-weight: 500;
letter-spacing: 0.5px;
@include mobile-only {
font-size: 0.75rem;
}
}
}
.stat-divider {
width: 1px;
height: 45px;
background-color: var(--background-40);
@include mobile-only {
height: 45px;
} }
} }
@@ -355,36 +128,16 @@
border: 1px solid var(--background-40); border: 1px solid var(--background-40);
@include mobile-only { @include mobile-only {
padding: 1rem; padding: 0.5rem;
} }
&--compact { &--compact {
// Tighter spacing for quick settings // Tighter padding for quick settings, but same header size
.section-header { padding: 1rem;
font-size: 1.25rem;
margin-bottom: 0.85rem;
padding-bottom: 0.65rem;
@include mobile-only { @include mobile-only {
font-size: 1.2rem; padding: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.6rem;
} }
} }
} }
}
.section-header {
margin: 0 0 1rem 0;
font-size: 1.5rem;
font-weight: 600;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--background-40);
@include mobile-only {
font-size: 1.3rem;
margin-bottom: 0.85rem;
padding-bottom: 0.65rem;
}
}
</style> </style>

View File

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