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:
2026-03-08 20:56:46 +01:00
parent 9c6e6938e9
commit e8a0598e8f
9 changed files with 1238 additions and 1141 deletions

View File

@@ -1,106 +1,14 @@
<template>
<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 deviceno servers, no tracking. You own your data.
</span>
</div>
</div>
<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 -->
<div class="export-card">
<div class="export-header">
<h4>Request History</h4>
<p>View and download your complete request history.</p>
</div>
<RequestHistory :data="requestStats" />
<div class="stats-grid">
<div class="stat-mini">
<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>
<!-- Export Data Card -->
<ExportSection :data="requestStats" />
<!-- Local Storage Items -->
<div class="storage-section">
<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"
/>
<StorageManager />
<!-- Delete Account -->
<DangerZoneAction
@@ -110,748 +18,36 @@
@action="confirmDelete"
/>
</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>
</template>
<script setup lang="ts">
import { ref, computed, inject } from "vue";
import { useRouter } from "vue-router";
import { clearCommandHistory } from "@/utils/commandTracking";
import IconActivity from "@/icons/IconActivity.vue";
import IconClose from "@/icons/IconClose.vue";
import IconInfo from "@/icons/IconInfo.vue";
import { ref } from "vue";
import StorageManager from "./StorageManager.vue";
import ExportSection from "./ExportSection.vue"
import RequestHistory from "./RequestHistory.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({
total: 45,
approved: 38,
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() {
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(
"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;
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";
.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 {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.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);
}
gap: 2rem;
}
</style>

View 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>

View File

@@ -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 deviceno 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 everythingit'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>

View 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>

View 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>

View 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
View 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>

View 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
View 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>