mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-04-24 16:53:37 +00:00
Refactor data management with browser and server storage sections
- Split LocalStorageManager into modular StorageManager component - Create StorageSectionBrowser for localStorage/sessionStorage/cookies UI - Create StorageSectionServer for server-side data management (mock) - Extract ExportSection component from DataExport - Add storage type icons (IconCookie, IconDatabase, IconTimer) - Implement collapsible storage sections with visual indicators - Add colored borders and gradients per storage type - Display item counts and total size in section headers - Improve delete button layout using CSS Grid - Reduce DataExport from ~824 lines to focused component
This commit is contained in:
@@ -1,106 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="data-export">
|
<div class="data-export">
|
||||||
<!-- Info Header -->
|
|
||||||
<div class="data-export__header">
|
|
||||||
<div class="data-export__info">
|
|
||||||
<IconInfo class="info-icon" />
|
|
||||||
<span>
|
|
||||||
Full transparency and control over your data. Everything is stored
|
|
||||||
locally on your device—no servers, no tracking. You own your data.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="export-options">
|
<div class="export-options">
|
||||||
<!-- Export Data Card -->
|
|
||||||
<div class="export-card">
|
|
||||||
<div class="export-header">
|
|
||||||
<h4>Export Your Data</h4>
|
|
||||||
<p>
|
|
||||||
Download a copy of your account data including requests, watch
|
|
||||||
history, and preferences.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="export-actions">
|
|
||||||
<button
|
|
||||||
class="export-btn"
|
|
||||||
@click="exportData('json')"
|
|
||||||
:disabled="exporting"
|
|
||||||
>
|
|
||||||
<IconActivity v-if="exporting" class="spin" />
|
|
||||||
<span v-else>Export as JSON</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="export-btn"
|
|
||||||
@click="exportData('csv')"
|
|
||||||
:disabled="exporting"
|
|
||||||
>
|
|
||||||
<IconActivity v-if="exporting" class="spin" />
|
|
||||||
<span v-else>Export as CSV</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Request History Card -->
|
<!-- Request History Card -->
|
||||||
<div class="export-card">
|
<RequestHistory :data="requestStats" />
|
||||||
<div class="export-header">
|
|
||||||
<h4>Request History</h4>
|
|
||||||
<p>View and download your complete request history.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats-grid">
|
<!-- Export Data Card -->
|
||||||
<div class="stat-mini">
|
<ExportSection :data="requestStats" />
|
||||||
<span class="stat-mini__value">{{ requestStats.total }}</span>
|
|
||||||
<span class="stat-mini__label">Total</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-mini">
|
|
||||||
<span class="stat-mini__value">{{ requestStats.approved }}</span>
|
|
||||||
<span class="stat-mini__label">Approved</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-mini">
|
|
||||||
<span class="stat-mini__value">{{ requestStats.pending }}</span>
|
|
||||||
<span class="stat-mini__label">Pending</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="view-btn" @click="viewHistory">View Full History</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Local Storage Items -->
|
<!-- Local Storage Items -->
|
||||||
<div class="storage-section">
|
<StorageManager />
|
||||||
<h4 class="storage-section__title">Browser Storage</h4>
|
|
||||||
<div class="storage-items">
|
|
||||||
<div
|
|
||||||
v-for="item in storageItems"
|
|
||||||
:key="item.key"
|
|
||||||
class="storage-item"
|
|
||||||
>
|
|
||||||
<div class="storage-item__info">
|
|
||||||
<h5 class="storage-item__title">{{ item.title }}</h5>
|
|
||||||
<p class="storage-item__description">
|
|
||||||
{{ item.description }} ·
|
|
||||||
<span class="storage-item__size">{{ item.size }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="storage-item__delete"
|
|
||||||
@click="clearItem(item.key, item.title)"
|
|
||||||
:title="`Clear ${item.title}`"
|
|
||||||
>
|
|
||||||
<IconClose />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Clear All Local Data -->
|
|
||||||
<DangerZoneAction
|
|
||||||
title="Clear All Local Data"
|
|
||||||
description="Remove all locally stored data at once. This includes preferences, history, and cached information."
|
|
||||||
button-text="Clear All Data"
|
|
||||||
@action="clearAllData"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Delete Account -->
|
<!-- Delete Account -->
|
||||||
<DangerZoneAction
|
<DangerZoneAction
|
||||||
@@ -110,748 +18,36 @@
|
|||||||
@action="confirmDelete"
|
@action="confirmDelete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
<div v-if="showDeleteModal" class="modal-overlay" @click="cancelDelete">
|
|
||||||
<div class="modal-content" @click.stop>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Delete Account</h3>
|
|
||||||
<button class="close-btn" @click="cancelDelete">
|
|
||||||
<IconClose />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="warning-box">
|
|
||||||
<span class="warning-icon">⚠️</span>
|
|
||||||
<p>
|
|
||||||
<strong>Warning:</strong> This action is permanent and cannot be
|
|
||||||
undone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p>All of the following will be permanently deleted:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Your account and profile information</li>
|
|
||||||
<li>All request history</li>
|
|
||||||
<li>Watch history and preferences</li>
|
|
||||||
<li>Plex account connection</li>
|
|
||||||
</ul>
|
|
||||||
<p>Type <strong>DELETE</strong> to confirm:</p>
|
|
||||||
<input
|
|
||||||
v-model="deleteConfirmation"
|
|
||||||
type="text"
|
|
||||||
placeholder="Type DELETE"
|
|
||||||
class="confirm-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="cancel-btn" @click="cancelDelete">Cancel</button>
|
|
||||||
<button
|
|
||||||
class="confirm-delete-btn"
|
|
||||||
@click="deleteAccount"
|
|
||||||
:disabled="deleteConfirmation !== 'DELETE'"
|
|
||||||
>
|
|
||||||
Delete Account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, inject } from "vue";
|
import { ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import StorageManager from "./StorageManager.vue";
|
||||||
import { clearCommandHistory } from "@/utils/commandTracking";
|
import ExportSection from "./ExportSection.vue"
|
||||||
import IconActivity from "@/icons/IconActivity.vue";
|
import RequestHistory from "./RequestHistory.vue"
|
||||||
import IconClose from "@/icons/IconClose.vue";
|
|
||||||
import IconInfo from "@/icons/IconInfo.vue";
|
|
||||||
import DangerZoneAction from "./DangerZoneAction.vue";
|
import DangerZoneAction from "./DangerZoneAction.vue";
|
||||||
|
|
||||||
interface StorageItem {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
size: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const notifications: {
|
|
||||||
success: (options: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
timeout?: number;
|
|
||||||
}) => void;
|
|
||||||
error: (options: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
timeout?: number;
|
|
||||||
}) => void;
|
|
||||||
} = inject("notifications");
|
|
||||||
|
|
||||||
const exporting = ref(false);
|
|
||||||
const showDeleteModal = ref(false);
|
|
||||||
const deleteConfirmation = ref("");
|
|
||||||
|
|
||||||
const requestStats = ref({
|
const requestStats = ref({
|
||||||
total: 45,
|
total: 45,
|
||||||
approved: 38,
|
approved: 38,
|
||||||
pending: 7
|
pending: 7
|
||||||
});
|
});
|
||||||
|
|
||||||
const storageItems = computed<StorageItem[]>(() => {
|
|
||||||
const items: StorageItem[] = [];
|
|
||||||
|
|
||||||
// Command palette stats
|
|
||||||
const commandStats = localStorage.getItem("commandPalette_stats");
|
|
||||||
if (commandStats) {
|
|
||||||
items.push({
|
|
||||||
key: "commandPalette_stats",
|
|
||||||
title: "Command Palette History",
|
|
||||||
description: "Usage statistics for command palette navigation",
|
|
||||||
size: formatBytes(commandStats.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plex user data
|
|
||||||
const plexData = localStorage.getItem("plex_user_data");
|
|
||||||
if (plexData) {
|
|
||||||
items.push({
|
|
||||||
key: "plex_user_data",
|
|
||||||
title: "Plex User Data",
|
|
||||||
description: "Cached Plex account information",
|
|
||||||
size: formatBytes(plexData.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme preference
|
|
||||||
const theme = localStorage.getItem("theme");
|
|
||||||
if (theme) {
|
|
||||||
items.push({
|
|
||||||
key: "theme",
|
|
||||||
title: "Theme Preference",
|
|
||||||
description: "Your selected color theme",
|
|
||||||
size: formatBytes(theme.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Color scheme
|
|
||||||
const colorScheme = localStorage.getItem("color-scheme");
|
|
||||||
if (colorScheme) {
|
|
||||||
items.push({
|
|
||||||
key: "color-scheme",
|
|
||||||
title: "Color Scheme",
|
|
||||||
description: "Light or dark mode preference",
|
|
||||||
size: formatBytes(colorScheme.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
});
|
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportData(format: "json" | "csv") {
|
|
||||||
exporting.value = true;
|
|
||||||
|
|
||||||
// Mock export
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
username: "user123",
|
|
||||||
requests: requestStats.value,
|
|
||||||
exportDate: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
const blob = new Blob(
|
|
||||||
[format === "json" ? JSON.stringify(data, null, 2) : convertToCSV(data)],
|
|
||||||
{ type: format === "json" ? "application/json" : "text/csv" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = `seasoned-data-export.${format}`;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
exporting.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertToCSV(data: any): string {
|
|
||||||
return `Username,Total Requests,Approved,Pending,Export Date\n${data.username},${data.requests.total},${data.requests.approved},${data.requests.pending},${data.exportDate}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewHistory() {
|
|
||||||
router.push({ name: "profile" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
showDeleteModal.value = true;
|
|
||||||
deleteConfirmation.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelDelete() {
|
|
||||||
showDeleteModal.value = false;
|
|
||||||
deleteConfirmation.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteAccount() {
|
|
||||||
if (deleteConfirmation.value === "DELETE") {
|
|
||||||
alert("Account deletion would be processed here");
|
|
||||||
showDeleteModal.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearItem(key: string, title: string) {
|
|
||||||
try {
|
|
||||||
// Special handling for command history
|
|
||||||
if (key === "commandPalette_stats") {
|
|
||||||
clearCommandHistory();
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.success({
|
|
||||||
title: "Data Cleared",
|
|
||||||
description: `${title} has been cleared`,
|
|
||||||
timeout: 3000
|
|
||||||
});
|
|
||||||
|
|
||||||
// Force re-render
|
|
||||||
storageItems.value;
|
|
||||||
} catch (error) {
|
|
||||||
notifications.error({
|
|
||||||
title: "Error",
|
|
||||||
description: `Failed to clear ${title}`,
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAllData() {
|
|
||||||
const confirmed = confirm(
|
const confirmed = confirm(
|
||||||
"Are you sure you want to clear all locally stored data? This action cannot be undone."
|
"Are you sure you want to *permanently delete* your account and all associated data? This action cannot be undone."
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.clear();
|
|
||||||
clearCommandHistory();
|
|
||||||
|
|
||||||
notifications.success({
|
|
||||||
title: "All Data Cleared",
|
|
||||||
description: "All locally stored data has been removed",
|
|
||||||
timeout: 3000
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
notifications.error({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to clear all data",
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "scss/variables";
|
|
||||||
@import "scss/media-queries";
|
|
||||||
|
|
||||||
.data-export {
|
|
||||||
&__header {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
margin-bottom: 0.85rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--background-ui);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
border-left: 3px solid var(--highlight-color);
|
|
||||||
|
|
||||||
.info-icon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
fill: var(--highlight-color);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-color-70);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-options {
|
.export-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
gap: 2rem;
|
||||||
|
|
||||||
.export-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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-header {
|
|
||||||
margin-bottom: 0.85rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
margin-bottom: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin: 0 0 0.25rem 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.55rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.55rem 0.85rem;
|
|
||||||
background-color: var(--highlight-color);
|
|
||||||
color: $white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-green-90);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
fill: $white;
|
|
||||||
|
|
||||||
&.spin {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--background-40);
|
|
||||||
border-color: var(--highlight-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.storage-section {
|
|
||||||
&__title {
|
|
||||||
margin: 0 0 0.65rem 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-color);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.storage-items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storage-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0;
|
|
||||||
background: var(--background-color);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.storage-item__delete {
|
|
||||||
background: var(--color-error-highlight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.85rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
margin: 0 0 0.25rem 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-color);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__description {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-color-70);
|
|
||||||
line-height: 1.4;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__size {
|
|
||||||
color: var(--text-color-50);
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__delete {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 70px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--color-error);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
width: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
fill: white;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-error-highlight);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
svg {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 1rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 0.5rem;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background-color: var(--background-color-secondary);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
max-width: 500px;
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--background-40);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.25rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--background-40);
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: 1.5rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: 0 0 1.5rem 0;
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-box {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background-color: var(--color-warning);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-icon {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
color: $black;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 2px solid var(--background-40);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background-color: var(--background-color);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-error-highlight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-top: 1px solid var(--background-40);
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 1rem;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 0.65rem 1.25rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid var(--background-40) !important;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--background-ui);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-delete-btn {
|
|
||||||
background-color: var(--color-error-highlight);
|
|
||||||
color: $white;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
127
src/components/settings/ExportSection.vue
Normal file
127
src/components/settings/ExportSection.vue
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings-section-card">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<h2>Export Your Data</h2>
|
||||||
|
<p>
|
||||||
|
Download a copy of your account data including requests, watch history,
|
||||||
|
and preferences.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export to JSON & CSV section -->
|
||||||
|
<div class="export-actions">
|
||||||
|
<button
|
||||||
|
class="export-btn"
|
||||||
|
@click="() => exportData('json')"
|
||||||
|
:disabled="exporting"
|
||||||
|
>
|
||||||
|
<IconActivity v-if="exporting" class="spin" />
|
||||||
|
<span v-else>Export as JSON</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="export-btn"
|
||||||
|
@click="() => exportData('csv')"
|
||||||
|
:disabled="exporting"
|
||||||
|
>
|
||||||
|
<IconActivity v-if="exporting" class="spin" />
|
||||||
|
<span v-else>Export as CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, defineProps } from "vue";
|
||||||
|
import IconActivity from "@/icons/IconActivity.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const exporting = ref(false);
|
||||||
|
|
||||||
|
async function exportData(format: "json" | "csv") {
|
||||||
|
exporting.value = true;
|
||||||
|
|
||||||
|
// Mock export
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
username: "user123",
|
||||||
|
requests: props?.data,
|
||||||
|
exportDate: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob(
|
||||||
|
[format === "json" ? JSON.stringify(data, null, 2) : convertToCSV(data)],
|
||||||
|
{ type: format === "json" ? "application/json" : "text/csv" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `seasoned-data-export.${format}`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
exporting.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToCSV(data: any): string {
|
||||||
|
return `Username,Total Requests,Approved,Pending,Export Date\n${data.username},${data.requests.total},${data.requests.approved},${data.requests.pending},${data.exportDate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/media-queries";
|
||||||
|
@import "scss/shared-settings";
|
||||||
|
|
||||||
|
.export-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
background-color: var(--highlight-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-green-90);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: white;
|
||||||
|
|
||||||
|
&.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="storage-manager">
|
|
||||||
<div class="storage-manager__header">
|
|
||||||
<p class="storage-manager__description">
|
|
||||||
Full transparency and control over your data. Everything listed here is
|
|
||||||
stored locally on your device—no servers, no tracking. You own your
|
|
||||||
data.
|
|
||||||
</p>
|
|
||||||
<div class="storage-manager__info">
|
|
||||||
<IconInfo class="info-icon" />
|
|
||||||
<span
|
|
||||||
>Your browser stores this data to improve your experience. Clear
|
|
||||||
individual items or wipe everything—it's your choice.</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="storage-items">
|
|
||||||
<div v-for="item in storageItems" :key="item.key" class="storage-item">
|
|
||||||
<div class="storage-item__info">
|
|
||||||
<h4 class="storage-item__title">{{ item.title }}</h4>
|
|
||||||
<p class="storage-item__description">
|
|
||||||
{{ item.description }} ·
|
|
||||||
<span class="storage-item__size">{{ item.size }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="storage-item__delete"
|
|
||||||
@click="clearItem(item.key, item.title)"
|
|
||||||
:title="`Clear ${item.title}`"
|
|
||||||
>
|
|
||||||
<IconClose />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DangerZoneAction
|
|
||||||
title="Clear Everything"
|
|
||||||
description="Remove all locally stored data at once. This includes preferences, history, and cached information."
|
|
||||||
button-text="Clear All Data"
|
|
||||||
@action="clearAllData"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, inject } from "vue";
|
|
||||||
import { clearCommandHistory } from "@/utils/commandTracking";
|
|
||||||
import IconInfo from "@/icons/IconInfo.vue";
|
|
||||||
import IconClose from "@/icons/IconClose.vue";
|
|
||||||
import DangerZoneAction from "./DangerZoneAction.vue";
|
|
||||||
|
|
||||||
interface StorageItem {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
size: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifications: {
|
|
||||||
success: (options: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
timeout?: number;
|
|
||||||
}) => void;
|
|
||||||
error: (options: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
timeout?: number;
|
|
||||||
}) => void;
|
|
||||||
} = inject("notifications");
|
|
||||||
|
|
||||||
const storageItems = computed<StorageItem[]>(() => {
|
|
||||||
const items: StorageItem[] = [];
|
|
||||||
|
|
||||||
// Command palette stats
|
|
||||||
const commandStats = localStorage.getItem("commandPalette_stats");
|
|
||||||
if (commandStats) {
|
|
||||||
items.push({
|
|
||||||
key: "commandPalette_stats",
|
|
||||||
title: "Command Palette History",
|
|
||||||
description: "Usage statistics for command palette navigation",
|
|
||||||
size: formatBytes(commandStats.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plex user data
|
|
||||||
const plexData = localStorage.getItem("plex_user_data");
|
|
||||||
if (plexData) {
|
|
||||||
items.push({
|
|
||||||
key: "plex_user_data",
|
|
||||||
title: "Plex User Data",
|
|
||||||
description: "Cached Plex account information",
|
|
||||||
size: formatBytes(plexData.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme preference
|
|
||||||
const theme = localStorage.getItem("theme");
|
|
||||||
if (theme) {
|
|
||||||
items.push({
|
|
||||||
key: "theme",
|
|
||||||
title: "Theme Preference",
|
|
||||||
description: "Your selected color theme",
|
|
||||||
size: formatBytes(theme.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Color scheme
|
|
||||||
const colorScheme = localStorage.getItem("color-scheme");
|
|
||||||
if (colorScheme) {
|
|
||||||
items.push({
|
|
||||||
key: "color-scheme",
|
|
||||||
title: "Color Scheme",
|
|
||||||
description: "Light or dark mode preference",
|
|
||||||
size: formatBytes(colorScheme.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
});
|
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearItem(key: string, title: string) {
|
|
||||||
try {
|
|
||||||
// Special handling for command history
|
|
||||||
if (key === "commandPalette_stats") {
|
|
||||||
clearCommandHistory();
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.success({
|
|
||||||
title: "Data Cleared",
|
|
||||||
description: `${title} has been cleared`,
|
|
||||||
timeout: 3000
|
|
||||||
});
|
|
||||||
|
|
||||||
// Force re-render
|
|
||||||
storageItems.value;
|
|
||||||
} catch (error) {
|
|
||||||
notifications.error({
|
|
||||||
title: "Error",
|
|
||||||
description: `Failed to clear ${title}`,
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAllData() {
|
|
||||||
const confirmed = confirm(
|
|
||||||
"Are you sure you want to clear all locally stored data? This action cannot be undone."
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.clear();
|
|
||||||
clearCommandHistory();
|
|
||||||
|
|
||||||
notifications.success({
|
|
||||||
title: "All Data Cleared",
|
|
||||||
description: "All locally stored data has been removed",
|
|
||||||
timeout: 3000
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
notifications.error({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to clear all data",
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
@import "scss/variables";
|
|
||||||
@import "scss/media-queries";
|
|
||||||
|
|
||||||
.storage-manager {
|
|
||||||
&__header {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__description {
|
|
||||||
margin: 0 0 0.75rem 0;
|
|
||||||
color: var(--text-color);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--background-ui);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
border-left: 3px solid var(--highlight-color);
|
|
||||||
|
|
||||||
.info-icon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
fill: var(--highlight-color);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-color-70);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.storage-items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storage-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0;
|
|
||||||
background: var(--background-ui);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.storage-item__delete {
|
|
||||||
background: var(--color-error-highlight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.85rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
margin: 0 0 0.3rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-color);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__description {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-color-70);
|
|
||||||
line-height: 1.4;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__size {
|
|
||||||
color: var(--text-color-50);
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__delete {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 70px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--color-error);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
width: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
fill: white;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-error-highlight);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
svg {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
215
src/components/settings/StorageManager.vue
Normal file
215
src/components/settings/StorageManager.vue
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<div class="storage-manager">
|
||||||
|
<StorageSectionBrowser
|
||||||
|
:sections="storageSections"
|
||||||
|
@clear-item="clearItem"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DangerZoneAction
|
||||||
|
title="Clear All Browser Data"
|
||||||
|
description="Remove all locally stored data at once. This includes preferences, history, and cached information."
|
||||||
|
button-text="Clear All Data"
|
||||||
|
@action="clearAllData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject } from "vue";
|
||||||
|
import IconCookie from "@/icons/IconCookie.vue";
|
||||||
|
import IconDatabase from "@/icons/IconDatabase.vue";
|
||||||
|
import IconTimer from "@/icons/IconTimer.vue";
|
||||||
|
import StorageSectionBrowser from "./StorageSectionBrowser.vue";
|
||||||
|
import DangerZoneAction from "./DangerZoneAction.vue";
|
||||||
|
import { formatBytes } from "../../utils";
|
||||||
|
|
||||||
|
interface StorageItem {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
size: string;
|
||||||
|
type: "local" | "session" | "cookie";
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications: {
|
||||||
|
success: (options: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
timeout?: number;
|
||||||
|
}) => void;
|
||||||
|
error: (options: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
timeout?: number;
|
||||||
|
}) => void;
|
||||||
|
} = inject("notifications");
|
||||||
|
|
||||||
|
const dict = {
|
||||||
|
commandPalette_stats: "Usage statistics for command palette navigation",
|
||||||
|
"theme-preference": "Your selected color theme",
|
||||||
|
plex_user_data: "Cached Plex account information",
|
||||||
|
plex_library_data: "Cached Plex library details per section",
|
||||||
|
plex_server_data: "Cached Plex server information",
|
||||||
|
plex_library_last_sync: "UTC time string for last synced Plex data",
|
||||||
|
plex_auth_token: "Authorized token from Plex.tv",
|
||||||
|
authorization: "This sites user login token"
|
||||||
|
};
|
||||||
|
|
||||||
|
const storageItems = computed<StorageItem[]>(() => {
|
||||||
|
const items: StorageItem[] = [];
|
||||||
|
|
||||||
|
// local storage
|
||||||
|
Object.keys(localStorage).map(key => {
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
description: dict[key] ?? "",
|
||||||
|
size: formatBytes(localStorage[key]?.length || 0),
|
||||||
|
type: "local"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// session storage
|
||||||
|
Object.keys(sessionStorage).map(key => {
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
description: dict[key] ?? "",
|
||||||
|
size: formatBytes(sessionStorage[key]?.length || 0),
|
||||||
|
type: "session"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// cookies
|
||||||
|
if (document.cookie) {
|
||||||
|
document.cookie.split(";").forEach(cookie => {
|
||||||
|
const [key, _] = cookie.trim().split("=");
|
||||||
|
if (key) {
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
description: dict[key] ?? "",
|
||||||
|
size: formatBytes(cookie.length || 0),
|
||||||
|
type: "cookie"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTotalSize = (items: StorageItem[]) => {
|
||||||
|
const totalBytes = items.reduce((acc, item) => {
|
||||||
|
const match = item.size.match(/^([\d.]+)\s*(\w+)$/);
|
||||||
|
if (!match) return acc;
|
||||||
|
const value = parseFloat(match[1]);
|
||||||
|
const unit = match[2];
|
||||||
|
return (
|
||||||
|
acc +
|
||||||
|
(unit === "KB"
|
||||||
|
? value * 1024
|
||||||
|
: unit === "MB"
|
||||||
|
? value * 1024 * 1024
|
||||||
|
: value)
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
return formatBytes(totalBytes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const storageSections = computed(() => [
|
||||||
|
{
|
||||||
|
type: "local" as const,
|
||||||
|
title: "LocalStorage",
|
||||||
|
iconComponent: IconDatabase,
|
||||||
|
description:
|
||||||
|
"LocalStorage keeps data permanently on your device, even after closing your browser. It's used to remember your preferences and settings between visits.",
|
||||||
|
items: storageItems.value.filter(item => item.type === "local"),
|
||||||
|
get totalSize() {
|
||||||
|
return getTotalSize(this.items);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "session" as const,
|
||||||
|
title: "SessionStorage",
|
||||||
|
iconComponent: IconTimer,
|
||||||
|
description:
|
||||||
|
"SessionStorage keeps data temporarily while you browse. It's automatically cleared when you close your browser tab or window.",
|
||||||
|
items: storageItems.value.filter(item => item.type === "session"),
|
||||||
|
get totalSize() {
|
||||||
|
return getTotalSize(this.items);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "cookie" as const,
|
||||||
|
title: "Cookies",
|
||||||
|
iconComponent: IconCookie,
|
||||||
|
description:
|
||||||
|
"Cookies are small text files stored by your browser. They can be temporary (session cookies) or persistent, and are often used for authentication and tracking your activity.",
|
||||||
|
items: storageItems.value.filter(item => item.type === "cookie"),
|
||||||
|
get totalSize() {
|
||||||
|
return getTotalSize(this.items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
function clearItem(key: string, type: "local" | "session" | "cookie") {
|
||||||
|
try {
|
||||||
|
if (type === "local") {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} else if (type === "session") {
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
} else if (type === "cookie") {
|
||||||
|
document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.success({
|
||||||
|
title: "Data Cleared",
|
||||||
|
description: `${key} has been cleared`,
|
||||||
|
timeout: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force re-render
|
||||||
|
storageItems.value;
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error({
|
||||||
|
title: "Error",
|
||||||
|
description: `Failed to clear ${key}`,
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllData() {
|
||||||
|
const confirmed = confirm(
|
||||||
|
"Are you sure you want to clear all locally stored data? This action cannot be undone."
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
document.cookie.split(";").forEach(cookie => {
|
||||||
|
const eqPos = cookie.indexOf("=");
|
||||||
|
const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
|
||||||
|
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
||||||
|
});
|
||||||
|
|
||||||
|
notifications.success({
|
||||||
|
title: "All Data Cleared",
|
||||||
|
description: "All locally stored data has been removed",
|
||||||
|
timeout: 3000
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to clear all data",
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.storage-manager {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
366
src/components/settings/StorageSectionBrowser.vue
Normal file
366
src/components/settings/StorageSectionBrowser.vue
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
<template>
|
||||||
|
<div class="browser-storage">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<h2>Browser Storage</h2>
|
||||||
|
<p>
|
||||||
|
Your browser stores data locally to make this site faster and remember
|
||||||
|
your settings. View what's saved on this device and remove items
|
||||||
|
anytime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="storage-sections">
|
||||||
|
<div
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section.type"
|
||||||
|
:class="`storage-section storage-section--${section.type}`"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="storage-section__header"
|
||||||
|
@click="
|
||||||
|
expandedSections[section.type] = !expandedSections[section.type]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="storage-section__header-content">
|
||||||
|
<component :is="section.iconComponent" class="section-icon" />
|
||||||
|
<h3 class="storage-section__title">{{ section.title }}</h3>
|
||||||
|
<span class="storage-section__count">{{
|
||||||
|
section.items.length
|
||||||
|
}}</span>
|
||||||
|
<span class="storage-section__size">{{ section.totalSize }}</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="storage-section__chevron"
|
||||||
|
:class="{
|
||||||
|
'storage-section__chevron--expanded':
|
||||||
|
expandedSections[section.type]
|
||||||
|
}"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="expandedSections[section.type]"
|
||||||
|
class="storage-section__content"
|
||||||
|
>
|
||||||
|
<p class="storage-section__description">{{ section.description }}</p>
|
||||||
|
<div class="storage-items">
|
||||||
|
<div
|
||||||
|
v-for="item in section.items"
|
||||||
|
:key="item.key"
|
||||||
|
:class="`storage-item storage-item--${section.type}`"
|
||||||
|
>
|
||||||
|
<component :is="section.iconComponent" class="type-icon" />
|
||||||
|
<div class="storage-item__info">
|
||||||
|
<h4 class="storage-item__title">{{ item.key }}</h4>
|
||||||
|
<p class="storage-item__description">
|
||||||
|
<span v-if="item.description">{{ item.description }} · </span>
|
||||||
|
<span class="storage-item__size">{{ item.size }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="storage-item__delete"
|
||||||
|
@click="$emit('clear-item', item.key, section.type)"
|
||||||
|
:title="`Clear ${item.key}`"
|
||||||
|
>
|
||||||
|
<IconClose />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import IconClose from "@/icons/IconClose.vue";
|
||||||
|
|
||||||
|
interface StorageItem {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
size: string;
|
||||||
|
type: "local" | "session" | "cookie";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StorageSection {
|
||||||
|
type: "local" | "session" | "cookie";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
iconComponent: any;
|
||||||
|
items: StorageItem[];
|
||||||
|
totalSize: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
sections: StorageSection[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
"clear-item": [key: string, type: "local" | "session" | "cookie"];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const expandedSections = ref<Record<string, boolean>>({
|
||||||
|
local: false,
|
||||||
|
session: false,
|
||||||
|
cookie: false
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
@import "scss/shared-settings";
|
||||||
|
|
||||||
|
.browser-storage {
|
||||||
|
&__intro {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-section {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--background-ui);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
|
||||||
|
&--local {
|
||||||
|
border-color: rgba(139, 92, 246, 0.2);
|
||||||
|
.section-icon,
|
||||||
|
.type-icon {
|
||||||
|
stroke: #8b5cf6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&--session {
|
||||||
|
border-color: rgba(245, 158, 11, 0.2);
|
||||||
|
.section-icon,
|
||||||
|
.type-icon {
|
||||||
|
stroke: #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&--cookie {
|
||||||
|
border-color: rgba(236, 72, 153, 0.2);
|
||||||
|
.section-icon,
|
||||||
|
.type-icon {
|
||||||
|
fill: #ec4899;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
&:hover {
|
||||||
|
background: var(--background-40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
.section-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
&__count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
background: var(--background-40);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
}
|
||||||
|
&__size {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color-50);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
&__chevron {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
stroke: var(--text-color-70);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
&--expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__content {
|
||||||
|
border-top: 1px solid var(--background-40);
|
||||||
|
}
|
||||||
|
&__description {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
background: var(--background-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
background: var(--background-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-left: 3px solid;
|
||||||
|
|
||||||
|
&--local {
|
||||||
|
border-color: #8b5cf6;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(139, 92, 246, 0.1),
|
||||||
|
var(--background-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&--session {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(245, 158, 11, 0.1),
|
||||||
|
var(--background-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&--cookie {
|
||||||
|
border-color: #ec4899;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(236, 72, 153, 0.1),
|
||||||
|
var(--background-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .storage-item__delete {
|
||||||
|
background: var(--color-error-highlight);
|
||||||
|
}
|
||||||
|
&:hover .type-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-icon {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
margin: auto 1.5rem;
|
||||||
|
@include mobile-only {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin: auto 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.85rem 0.85rem 0.85rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 0.75rem 0.75rem 0.75rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
line-height: 1.4;
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__size {
|
||||||
|
color: var(--text-color-50);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__delete {
|
||||||
|
width: 70px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-error);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
@include mobile-only {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: white;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
@include mobile-only {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-error-highlight);
|
||||||
|
svg {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:active svg {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
462
src/components/settings/StorageSectionServer.vue
Normal file
462
src/components/settings/StorageSectionServer.vue
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
<template>
|
||||||
|
<div class="server-storage">
|
||||||
|
<div class="server-storage__intro">
|
||||||
|
<h2 class="server-storage__title">Server Storage</h2>
|
||||||
|
<p class="server-storage__description">
|
||||||
|
Data stored on our servers to sync across your devices and provide
|
||||||
|
personalized features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="server-sections">
|
||||||
|
<div
|
||||||
|
v-for="section in serverSections"
|
||||||
|
:key="section.type"
|
||||||
|
:class="`server-section server-section--${section.type}`"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="server-section__header"
|
||||||
|
@click="
|
||||||
|
expandedSections[section.type] = !expandedSections[section.type]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="server-section__header-content">
|
||||||
|
<component :is="section.iconComponent" class="section-icon" />
|
||||||
|
<h3 class="server-section__title">{{ section.title }}</h3>
|
||||||
|
<span class="server-section__count">{{
|
||||||
|
section.items.length
|
||||||
|
}}</span>
|
||||||
|
<span class="server-section__size">{{ section.totalSize }}</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="server-section__chevron"
|
||||||
|
:class="{
|
||||||
|
'server-section__chevron--expanded':
|
||||||
|
expandedSections[section.type]
|
||||||
|
}"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="expandedSections[section.type]"
|
||||||
|
class="server-section__content"
|
||||||
|
>
|
||||||
|
<p class="server-section__description">{{ section.description }}</p>
|
||||||
|
<div class="server-items">
|
||||||
|
<div
|
||||||
|
v-for="item in section.items"
|
||||||
|
:key="item.key"
|
||||||
|
:class="`server-item server-item--${section.type}`"
|
||||||
|
>
|
||||||
|
<component :is="section.iconComponent" class="type-icon" />
|
||||||
|
<div class="server-item__info">
|
||||||
|
<h4 class="server-item__title">{{ item.key }}</h4>
|
||||||
|
<p class="server-item__description">
|
||||||
|
<span v-if="item.description">{{ item.description }} · </span>
|
||||||
|
<span class="server-item__size">{{ item.size }}</span>
|
||||||
|
<span v-if="item.lastSynced" class="server-item__synced">
|
||||||
|
· Last synced: {{ item.lastSynced }}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="server-item__delete"
|
||||||
|
@click="$emit('clear-item', item.key, section.type)"
|
||||||
|
:title="`Delete ${item.key}`"
|
||||||
|
>
|
||||||
|
<IconClose />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import IconClose from "@/icons/IconClose.vue";
|
||||||
|
import IconProfile from "@/icons/IconProfile.vue";
|
||||||
|
import IconSettings from "@/icons/IconSettings.vue";
|
||||||
|
import IconActivity from "@/icons/IconActivity.vue";
|
||||||
|
|
||||||
|
interface ServerItem {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
size: string;
|
||||||
|
lastSynced?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
"clear-item": [key: string, type: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const expandedSections = ref<Record<string, boolean>>({
|
||||||
|
profile: false,
|
||||||
|
preferences: false,
|
||||||
|
activity: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock server data
|
||||||
|
const serverSections = computed(() => [
|
||||||
|
{
|
||||||
|
type: "profile",
|
||||||
|
title: "Profile Data",
|
||||||
|
iconComponent: IconProfile,
|
||||||
|
description:
|
||||||
|
"Your account information, settings, and preferences stored on our servers.",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "user_profile",
|
||||||
|
description: "User account details",
|
||||||
|
size: "2.4 KB",
|
||||||
|
lastSynced: "2 hours ago"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "avatar_image",
|
||||||
|
description: "Profile picture",
|
||||||
|
size: "145 KB",
|
||||||
|
lastSynced: "1 day ago"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "email_preferences",
|
||||||
|
description: "Notification settings",
|
||||||
|
size: "512 Bytes",
|
||||||
|
lastSynced: "3 days ago"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalSize: "147.9 KB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "preferences",
|
||||||
|
title: "Synced Preferences",
|
||||||
|
iconComponent: IconSettings,
|
||||||
|
description:
|
||||||
|
"Settings that sync across all your devices when you sign in.",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "theme_settings",
|
||||||
|
description: "Color theme and appearance",
|
||||||
|
size: "1.1 KB",
|
||||||
|
lastSynced: "5 hours ago"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "playback_settings",
|
||||||
|
description: "Video and audio preferences",
|
||||||
|
size: "856 Bytes",
|
||||||
|
lastSynced: "1 day ago"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "library_filters",
|
||||||
|
description: "Saved filters and sorting",
|
||||||
|
size: "2.3 KB",
|
||||||
|
lastSynced: "2 days ago"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalSize: "4.3 KB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "activity",
|
||||||
|
title: "Activity History",
|
||||||
|
iconComponent: IconActivity,
|
||||||
|
description:
|
||||||
|
"Your viewing history and watch progress stored on our servers.",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "watch_history",
|
||||||
|
description: "Recently watched items",
|
||||||
|
size: "12.5 KB",
|
||||||
|
lastSynced: "1 hour ago"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "watch_progress",
|
||||||
|
description: "Playback positions",
|
||||||
|
size: "8.2 KB",
|
||||||
|
lastSynced: "30 minutes ago"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "favorites",
|
||||||
|
description: "Starred and favorited content",
|
||||||
|
size: "3.7 KB",
|
||||||
|
lastSynced: "6 hours ago"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalSize: "24.4 KB"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.server-storage {
|
||||||
|
&__intro {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-section {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--background-ui);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
|
||||||
|
&--profile {
|
||||||
|
border-color: rgba(59, 130, 246, 0.2);
|
||||||
|
.section-icon,
|
||||||
|
.type-icon {
|
||||||
|
fill: #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&--preferences {
|
||||||
|
border-color: rgba(16, 185, 129, 0.2);
|
||||||
|
.section-icon,
|
||||||
|
.type-icon {
|
||||||
|
fill: #10b981;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&--activity {
|
||||||
|
border-color: rgba(245, 158, 11, 0.2);
|
||||||
|
.section-icon,
|
||||||
|
.type-icon {
|
||||||
|
fill: #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
&:hover {
|
||||||
|
background: var(--background-40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
.section-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
&__count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
background: var(--background-40);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
}
|
||||||
|
&__size {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color-50);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
&__chevron {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
stroke: var(--text-color-70);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
&--expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__content {
|
||||||
|
border-top: 1px solid var(--background-40);
|
||||||
|
}
|
||||||
|
&__description {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
background: var(--background-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
background: var(--background-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-left: 3px solid;
|
||||||
|
|
||||||
|
&--profile {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(59, 130, 246, 0.1),
|
||||||
|
var(--background-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&--preferences {
|
||||||
|
border-color: #10b981;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(16, 185, 129, 0.1),
|
||||||
|
var(--background-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&--activity {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(245, 158, 11, 0.1),
|
||||||
|
var(--background-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .server-item__delete {
|
||||||
|
background: var(--color-error-highlight);
|
||||||
|
}
|
||||||
|
&:hover .type-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-icon {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
margin: auto 1.5rem;
|
||||||
|
@include mobile-only {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin: auto 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.85rem 0.85rem 0.85rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 0.75rem 0.75rem 0.75rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
line-height: 1.4;
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__size {
|
||||||
|
color: var(--text-color-50);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
&__synced {
|
||||||
|
color: var(--text-color-50);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__delete {
|
||||||
|
width: 70px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-error);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
@include mobile-only {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: white;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
@include mobile-only {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-error-highlight);
|
||||||
|
svg {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:active svg {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
src/icons/IconCookie.vue
Normal file
23
src/icons/IconCookie.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
id="icon-cookie"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="transition-duration: 0s"
|
||||||
|
@click="$emit('click')"
|
||||||
|
@keydown="event => $emit('keydown', event)"
|
||||||
|
>
|
||||||
|
<circle cx="10" cy="21" r="2" fill="inherit" />
|
||||||
|
<circle cx="23" cy="20" r="2" fill="inherit" />
|
||||||
|
<circle cx="13" cy="10" r="2" fill="inherit" />
|
||||||
|
<circle cx="14" cy="15" r="1" fill="inherit" />
|
||||||
|
<circle cx="23" cy="5" r="2" fill="inherit" />
|
||||||
|
<circle cx="29" cy="3" r="1" fill="inherit" />
|
||||||
|
<circle cx="16" cy="23" r="1" fill="inherit" />
|
||||||
|
<path
|
||||||
|
fill="inherit"
|
||||||
|
d="M16 30C8.3 30 2 23.7 2 16S8.3 2 16 2c0.1 0 0.2 0 0.3 0l1.4 0.1-0.3 1.2c-0.1 0.4-0.2 0.9-0.2 1.3 0 2.8 2.2 5 5 5 1 0 2-0.3 2.9-0.9l1.3 1.5c-0.4 0.4-0.6 0.9-0.6 1.4 0 1.3 1.3 2.4 2.7 1.9l1.2-0.5 0.2 1.3C30 14.9 30 15.5 30 16c0 7.7-6.3 14-14 14zM15.3 4C9 4.4 4 9.6 4 16c0 6.6 5.4 12 12 12s12-5.4 12-12c0-0.1 0-0.3 0-0.4-2.3 0.1-4.2-1.7-4.2-4 0-0.1 0-0.1 0-0.2-0.5 0.1-1 0.2-1.6 0.2-3.9 0-7-3.1-7-7 0-0.2 0-0.4 0.1-0.6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
18
src/icons/IconDatabase.vue
Normal file
18
src/icons/IconDatabase.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
id="icon-database"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="transition-duration: 0s"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
@click="$emit('click')"
|
||||||
|
@keydown="event => $emit('keydown', event)"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
17
src/icons/IconTimer.vue
Normal file
17
src/icons/IconTimer.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
id="icon-timer"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="transition-duration: 0s"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
@click="$emit('click')"
|
||||||
|
@keydown="event => $emit('keydown', event)"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user