refactor frontend

This commit is contained in:
Julian Freeman
2026-04-16 16:26:21 -04:00
parent 6cc694754f
commit dabd8789f4
14 changed files with 1574 additions and 1357 deletions

View File

@@ -1,11 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import SortDropdown from "./components/SortDropdown.vue"; import BrowserDataView from "./components/browser-data/BrowserDataView.vue";
import { import ConfigurationView from "./components/config/ConfigurationView.vue";
browserIconOptions, import AppSidebar from "./components/sidebar/AppSidebar.vue";
browserIconSrc, import { useBrowserManager } from "./composables/useBrowserManager";
configurationIconSrc,
} from "./features/browser-assistant/icons";
import { useBrowserAssistant } from "./features/browser-assistant/useBrowserAssistant";
const { const {
activeSection, activeSection,
@@ -44,199 +41,42 @@ const {
sortedProfiles, sortedProfiles,
toggleBookmarkProfiles, toggleBookmarkProfiles,
toggleExtensionProfiles, toggleExtensionProfiles,
} = useBrowserAssistant(); } = useBrowserManager();
</script> </script>
<template> <template>
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar"> <AppSidebar
<div class="sidebar-toolbar"> :browsers="browsers"
<div class="sidebar-title-group"> :current-browser-id="currentBrowser?.browserId ?? null"
<h1>Browser Assistant</h1> :page="page"
<p>Local Chromium profile manager</p> :loading="loading"
</div> :configs-loading="configsLoading"
</div> :browser-monogram="browserMonogram"
@select-browser="selectedBrowserId = $event; page = 'browserData'"
<div v-if="browsers.length" class="browser-nav"> @select-configuration="page = 'configuration'"
<button @refresh="refreshAll"
v-for="browser in browsers" />
:key="browser.browserId"
class="browser-nav-item"
:class="[browser.browserFamilyId ?? browser.browserId, { active: browser.browserId === currentBrowser?.browserId }]"
type="button"
@click="selectedBrowserId = browser.browserId; page = 'browserData'"
>
<div class="browser-nav-icon">
<img
v-if="browserIconSrc(browser.iconKey ?? browser.browserFamilyId)"
:src="browserIconSrc(browser.iconKey ?? browser.browserFamilyId) ?? undefined"
:alt="`${browser.browserName} icon`"
/>
<span v-else>{{ browserMonogram(browser.browserId) }}</span>
</div>
<div class="browser-nav-body">
<strong>{{ browser.browserName }}</strong>
<span>{{ browser.dataRoot }}</span>
</div>
</button>
</div>
<div v-else class="sidebar-empty">
<p>No supported Chromium browser data was found yet.</p>
</div>
<button
class="browser-nav-item utility sidebar-utility-nav"
:class="{ active: page === 'configuration' }"
type="button"
@click="page = 'configuration'"
>
<div class="browser-nav-icon config-nav-icon">
<img :src="configurationIconSrc" alt="Configuration icon" />
</div>
<div class="browser-nav-body">
<strong>Configuration</strong>
<span>Manage custom scan sources and paths</span>
</div>
</button>
<button class="refresh-button sidebar-refresh" type="button" @click="refreshAll">
{{ loading || configsLoading ? "Refreshing..." : "Refresh" }}
</button>
</aside>
<main class="content-panel"> <main class="content-panel">
<template v-if="page === 'configuration'"> <template v-if="page === 'configuration'">
<section class="content-scroll-area"> <ConfigurationView
<section class="content-section"> :config-error="configError"
<div v-if="configError" class="inline-error"> :configs-loading="configsLoading"
{{ configError }} :browser-configs="browserConfigs"
</div> :create-config-form="createConfigForm"
:saving-config="savingConfig"
<div class="config-form-card"> :config-monogram="configMonogram"
<div class="config-form-header"> :is-deleting-config="isDeletingConfig"
<h3>Add Custom Browser</h3> @update-name="createConfigForm.name = $event"
<p>Provide a name, executable path, and Chromium user data path.</p> @update-executable-path="createConfigForm.executablePath = $event"
</div> @update-user-data-path="createConfigForm.userDataPath = $event"
<div class="config-form-layout"> @update-icon-key="createConfigForm.iconKey = $event"
<div class="config-form-fields"> @pick-executable-path="pickExecutablePath"
<label class="field-group"> @pick-user-data-path="pickUserDataPath"
<span>Name</span> @create-config="createCustomBrowserConfig"
<input v-model="createConfigForm.name" placeholder="Work Chrome" /> @delete-config="deleteCustomBrowserConfig"
</label> />
<label class="field-group">
<span>Executable Path</span>
<div class="path-input-row">
<input
v-model="createConfigForm.executablePath"
placeholder="C:\Program Files\...\chrome.exe"
/>
<button
class="secondary-button"
type="button"
@click="pickExecutablePath"
>
Browse File
</button>
</div>
</label>
<label class="field-group">
<span>User Data Path</span>
<div class="path-input-row">
<input
v-model="createConfigForm.userDataPath"
placeholder="C:\Users\...\User Data"
/>
<button
class="secondary-button"
type="button"
@click="pickUserDataPath"
>
Browse Folder
</button>
</div>
</label>
</div>
<div class="config-form-side">
<label class="field-group">
<span>Icon</span>
<div class="icon-option-grid">
<button
v-for="option in browserIconOptions"
:key="option.key"
class="icon-option-button"
:class="{ active: createConfigForm.iconKey === option.key }"
type="button"
@click="createConfigForm.iconKey = option.key"
>
<img :src="option.src" :alt="option.label" />
<span>{{ option.label }}</span>
</button>
</div>
</label>
<div class="config-form-actions">
<button
class="primary-button"
type="button"
:disabled="savingConfig"
@click="createCustomBrowserConfig"
>
{{ savingConfig ? "Saving..." : "Add Config" }}
</button>
</div>
</div>
</div>
</div>
<div v-if="configsLoading" class="empty-card">
<p>Loading browser configs...</p>
</div>
<div v-else class="stack-list">
<article
v-for="config in browserConfigs"
:key="config.id"
class="config-card"
>
<div class="config-card-header">
<div class="config-card-lead">
<div class="browser-nav-icon config-icon">
<img
v-if="browserIconSrc(config.iconKey ?? config.browserFamilyId)"
:src="browserIconSrc(config.iconKey ?? config.browserFamilyId) ?? undefined"
:alt="`${config.name} icon`"
/>
<span v-else>{{ configMonogram(config) }}</span>
</div>
<div>
<div class="config-title-row">
<h4>{{ config.name }}</h4>
</div>
</div>
</div>
<button
v-if="config.deletable"
class="danger-button"
type="button"
:disabled="isDeletingConfig(config.id)"
@click="deleteCustomBrowserConfig(config.id)"
>
{{ isDeletingConfig(config.id) ? "Deleting..." : "Delete" }}
</button>
</div>
<div class="config-meta">
<div class="config-meta-row">
<span class="config-label">Executable</span>
<p :title="config.executablePath">{{ config.executablePath || "Not resolved" }}</p>
</div>
<div class="config-meta-row">
<span class="config-label">User Data</span>
<p :title="config.userDataPath">{{ config.userDataPath }}</p>
</div>
</div>
</article>
</div>
</section>
</section>
</template> </template>
<template v-else-if="loading"> <template v-else-if="loading">
@@ -255,208 +95,31 @@ const {
</section> </section>
</template> </template>
<template v-else-if="currentBrowser"> <BrowserDataView
<section class="section-tabs"> v-else-if="currentBrowser"
<button :current-browser="currentBrowser"
class="section-tab" :active-section="activeSection"
:class="{ active: activeSection === 'profiles' }" :profile-sort-key="profileSortKey"
type="button" :extension-sort-key="extensionSortKey"
@click="activeSection = 'profiles'" :bookmark-sort-key="bookmarkSortKey"
> :sorted-profiles="sortedProfiles"
<span>Profiles</span> :sorted-extensions="sortedExtensions"
<span class="count-pill">{{ sectionCount("profiles") }}</span> :sorted-bookmarks="sortedBookmarks"
</button> :open-profile-error="openProfileError"
<button :section-count="sectionCount"
class="section-tab" :is-opening-profile="isOpeningProfile"
:class="{ active: activeSection === 'extensions' }" :extension-monogram="extensionMonogram"
type="button" :extension-profiles-expanded="extensionProfilesExpanded"
@click="activeSection = 'extensions'" :bookmark-profiles-expanded="bookmarkProfilesExpanded"
> :domain-from-url="domainFromUrl"
<span>Extensions</span> @update:active-section="activeSection = $event"
<span class="count-pill">{{ sectionCount("extensions") }}</span> @update:profile-sort-key="profileSortKey = $event"
</button> @update:extension-sort-key="extensionSortKey = $event"
<button @update:bookmark-sort-key="bookmarkSortKey = $event"
class="section-tab" @open-profile="(browserId, profileId) => openBrowserProfile(browserId, profileId)"
:class="{ active: activeSection === 'bookmarks' }" @toggle-extension-profiles="toggleExtensionProfiles"
type="button" @toggle-bookmark-profiles="toggleBookmarkProfiles"
@click="activeSection = 'bookmarks'" />
>
<span>Bookmarks</span>
<span class="count-pill">{{ sectionCount("bookmarks") }}</span>
</button>
</section>
<div class="content-scroll-area">
<section v-if="activeSection === 'profiles'" class="content-section">
<div v-if="openProfileError" class="inline-error">
{{ openProfileError }}
</div>
<div class="sort-bar">
<SortDropdown
v-model="profileSortKey"
label="Sort by"
:options="[
{ label: 'Name', value: 'name' },
{ label: 'Email', value: 'email' },
{ label: 'Profile ID', value: 'id' },
]"
/>
</div>
<div v-if="sortedProfiles.length" class="stack-list">
<article v-for="profile in sortedProfiles" :key="profile.id" class="profile-card">
<div class="profile-avatar">
<img
v-if="profile.avatarDataUrl"
:src="profile.avatarDataUrl"
:alt="`${profile.name} avatar`"
/>
<span v-else>{{ profile.avatarLabel }}</span>
</div>
<div class="profile-body">
<div class="profile-topline">
<h4>{{ profile.name }}</h4>
<div class="profile-actions">
<button
class="card-action-button"
:disabled="isOpeningProfile(currentBrowser.browserId, profile.id)"
type="button"
@click="openBrowserProfile(currentBrowser.browserId, profile.id)"
>
{{
isOpeningProfile(currentBrowser.browserId, profile.id)
? "Opening..."
: "Open"
}}
</button>
<span class="badge neutral">{{ profile.id }}</span>
</div>
</div>
<p class="profile-email">{{ profile.email || "No email found" }}</p>
</div>
</article>
</div>
<div v-else class="empty-card">
<p>No profile directories were found for this browser.</p>
</div>
</section>
<section v-else-if="activeSection === 'extensions'" class="content-section">
<div class="sort-bar">
<SortDropdown
v-model="extensionSortKey"
label="Sort by"
:options="[
{ label: 'Name', value: 'name' },
{ label: 'Extension ID', value: 'id' },
]"
/>
</div>
<div v-if="sortedExtensions.length" class="stack-list">
<article
v-for="extension in sortedExtensions"
:key="extension.id"
class="extension-card"
>
<div class="extension-icon">
<img
v-if="extension.iconDataUrl"
:src="extension.iconDataUrl"
:alt="`${extension.name} icon`"
/>
<span v-else>{{ extensionMonogram(extension.name) }}</span>
</div>
<div class="extension-body">
<div class="extension-topline">
<h4>{{ extension.name }}</h4>
<span v-if="extension.version" class="badge neutral">
v{{ extension.version }}
</span>
</div>
<p class="meta-line">{{ extension.id }}</p>
<div class="source-disclosure">
<button
class="disclosure-button"
type="button"
@click="toggleExtensionProfiles(extension.id)"
>
<span>Profiles</span>
<span class="badge neutral">{{ extension.profileIds.length }}</span>
</button>
<div
v-if="extensionProfilesExpanded(extension.id)"
class="disclosure-panel"
>
<span
v-for="profileId in extension.profileIds"
:key="`${extension.id}-${profileId}`"
class="badge"
>
{{ profileId }}
</span>
</div>
</div>
</div>
</article>
</div>
<div v-else class="empty-card">
<p>No extensions were discovered for this browser.</p>
</div>
</section>
<section v-else class="content-section">
<div class="sort-bar">
<SortDropdown
v-model="bookmarkSortKey"
label="Sort by"
:options="[
{ label: 'Name', value: 'title' },
{ label: 'URL', value: 'url' },
]"
/>
</div>
<div v-if="sortedBookmarks.length" class="bookmark-list">
<article v-for="bookmark in sortedBookmarks" :key="bookmark.url" class="bookmark-card">
<div class="bookmark-body">
<div class="bookmark-topline">
<h4>{{ bookmark.title }}</h4>
<span class="badge neutral">{{ domainFromUrl(bookmark.url) }}</span>
</div>
<p class="bookmark-url" :title="bookmark.url">{{ bookmark.url }}</p>
<div class="source-disclosure">
<button
class="disclosure-button"
type="button"
@click="toggleBookmarkProfiles(bookmark.url)"
>
<span>Profiles</span>
<span class="badge neutral">{{ bookmark.profileIds.length }}</span>
</button>
<div
v-if="bookmarkProfilesExpanded(bookmark.url)"
class="disclosure-panel"
>
<span
v-for="profileId in bookmark.profileIds"
:key="`${bookmark.url}-${profileId}`"
class="badge"
>
{{ profileId }}
</span>
</div>
</div>
</div>
</article>
</div>
<div v-else class="empty-card">
<p>No bookmarks were discovered for this browser.</p>
</div>
</section>
</div>
</template>
<template v-else> <template v-else>
<section class="state-panel"> <section class="state-panel">

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import SortDropdown from "../SortDropdown.vue";
import type { BookmarkSortKey, BookmarkSummary } from "../../types/browser";
defineProps<{
bookmarks: BookmarkSummary[];
sortKey: BookmarkSortKey;
domainFromUrl: (url: string) => string;
bookmarkProfilesExpanded: (url: string) => boolean;
}>();
const emit = defineEmits<{
"update:sortKey": [value: BookmarkSortKey];
toggleProfiles: [url: string];
}>();
</script>
<template>
<section class="content-section">
<div class="sort-bar">
<SortDropdown
:model-value="sortKey"
label="Sort by"
:options="[
{ label: 'Name', value: 'title' },
{ label: 'URL', value: 'url' },
]"
@update:model-value="emit('update:sortKey', $event as BookmarkSortKey)"
/>
</div>
<div v-if="bookmarks.length" class="bookmark-list">
<article v-for="bookmark in bookmarks" :key="bookmark.url" class="bookmark-card">
<div class="bookmark-body">
<div class="bookmark-topline">
<h4>{{ bookmark.title }}</h4>
<span class="badge neutral">{{ domainFromUrl(bookmark.url) }}</span>
</div>
<p class="bookmark-url" :title="bookmark.url">{{ bookmark.url }}</p>
<div class="source-disclosure">
<button
class="disclosure-button"
type="button"
@click="emit('toggleProfiles', bookmark.url)"
>
<span>Profiles</span>
<span class="badge neutral">{{ bookmark.profileIds.length }}</span>
</button>
<div
v-if="bookmarkProfilesExpanded(bookmark.url)"
class="disclosure-panel"
>
<span
v-for="profileId in bookmark.profileIds"
:key="`${bookmark.url}-${profileId}`"
class="badge"
>
{{ profileId }}
</span>
</div>
</div>
</div>
</article>
</div>
<div v-else class="empty-card">
<p>No bookmarks were discovered for this browser.</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import type {
ActiveSection,
BookmarkSortKey,
BrowserView,
ExtensionSortKey,
ProfileSortKey,
} from "../../types/browser";
import BookmarksList from "./BookmarksList.vue";
import ExtensionsList from "./ExtensionsList.vue";
import ProfilesList from "./ProfilesList.vue";
defineProps<{
currentBrowser: BrowserView;
activeSection: ActiveSection;
profileSortKey: ProfileSortKey;
extensionSortKey: ExtensionSortKey;
bookmarkSortKey: BookmarkSortKey;
sortedProfiles: BrowserView["profiles"];
sortedExtensions: BrowserView["extensions"];
sortedBookmarks: BrowserView["bookmarks"];
openProfileError: string;
sectionCount: (section: ActiveSection) => number;
isOpeningProfile: (browserId: string, profileId: string) => boolean;
extensionMonogram: (name: string) => string;
extensionProfilesExpanded: (extensionId: string) => boolean;
bookmarkProfilesExpanded: (url: string) => boolean;
domainFromUrl: (url: string) => string;
}>();
const emit = defineEmits<{
"update:activeSection": [value: ActiveSection];
"update:profileSortKey": [value: ProfileSortKey];
"update:extensionSortKey": [value: ExtensionSortKey];
"update:bookmarkSortKey": [value: BookmarkSortKey];
openProfile: [browserId: string, profileId: string];
toggleExtensionProfiles: [extensionId: string];
toggleBookmarkProfiles: [url: string];
}>();
</script>
<template>
<section class="section-tabs">
<button
class="section-tab"
:class="{ active: activeSection === 'profiles' }"
type="button"
@click="emit('update:activeSection', 'profiles')"
>
<span>Profiles</span>
<span class="count-pill">{{ sectionCount("profiles") }}</span>
</button>
<button
class="section-tab"
:class="{ active: activeSection === 'extensions' }"
type="button"
@click="emit('update:activeSection', 'extensions')"
>
<span>Extensions</span>
<span class="count-pill">{{ sectionCount("extensions") }}</span>
</button>
<button
class="section-tab"
:class="{ active: activeSection === 'bookmarks' }"
type="button"
@click="emit('update:activeSection', 'bookmarks')"
>
<span>Bookmarks</span>
<span class="count-pill">{{ sectionCount("bookmarks") }}</span>
</button>
</section>
<div class="content-scroll-area">
<ProfilesList
v-if="activeSection === 'profiles'"
:profiles="sortedProfiles"
:sort-key="profileSortKey"
:open-profile-error="openProfileError"
:browser-id="currentBrowser.browserId"
:is-opening-profile="isOpeningProfile"
@update:sort-key="emit('update:profileSortKey', $event)"
@open-profile="(browserId, profileId) => emit('openProfile', browserId, profileId)"
/>
<ExtensionsList
v-else-if="activeSection === 'extensions'"
:extensions="sortedExtensions"
:sort-key="extensionSortKey"
:extension-monogram="extensionMonogram"
:extension-profiles-expanded="extensionProfilesExpanded"
@update:sort-key="emit('update:extensionSortKey', $event)"
@toggle-profiles="emit('toggleExtensionProfiles', $event)"
/>
<BookmarksList
v-else
:bookmarks="sortedBookmarks"
:sort-key="bookmarkSortKey"
:domain-from-url="domainFromUrl"
:bookmark-profiles-expanded="bookmarkProfilesExpanded"
@update:sort-key="emit('update:bookmarkSortKey', $event)"
@toggle-profiles="emit('toggleBookmarkProfiles', $event)"
/>
</div>
</template>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import SortDropdown from "../SortDropdown.vue";
import type { ExtensionSortKey, ExtensionSummary } from "../../types/browser";
defineProps<{
extensions: ExtensionSummary[];
sortKey: ExtensionSortKey;
extensionMonogram: (name: string) => string;
extensionProfilesExpanded: (extensionId: string) => boolean;
}>();
const emit = defineEmits<{
"update:sortKey": [value: ExtensionSortKey];
toggleProfiles: [extensionId: string];
}>();
</script>
<template>
<section class="content-section">
<div class="sort-bar">
<SortDropdown
:model-value="sortKey"
label="Sort by"
:options="[
{ label: 'Name', value: 'name' },
{ label: 'Extension ID', value: 'id' },
]"
@update:model-value="emit('update:sortKey', $event as ExtensionSortKey)"
/>
</div>
<div v-if="extensions.length" class="stack-list">
<article
v-for="extension in extensions"
:key="extension.id"
class="extension-card"
>
<div class="extension-icon">
<img
v-if="extension.iconDataUrl"
:src="extension.iconDataUrl"
:alt="`${extension.name} icon`"
/>
<span v-else>{{ extensionMonogram(extension.name) }}</span>
</div>
<div class="extension-body">
<div class="extension-topline">
<h4>{{ extension.name }}</h4>
<span v-if="extension.version" class="badge neutral">
v{{ extension.version }}
</span>
</div>
<p class="meta-line">{{ extension.id }}</p>
<div class="source-disclosure">
<button
class="disclosure-button"
type="button"
@click="emit('toggleProfiles', extension.id)"
>
<span>Profiles</span>
<span class="badge neutral">{{ extension.profileIds.length }}</span>
</button>
<div
v-if="extensionProfilesExpanded(extension.id)"
class="disclosure-panel"
>
<span
v-for="profileId in extension.profileIds"
:key="`${extension.id}-${profileId}`"
class="badge"
>
{{ profileId }}
</span>
</div>
</div>
</div>
</article>
</div>
<div v-else class="empty-card">
<p>No extensions were discovered for this browser.</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import SortDropdown from "../SortDropdown.vue";
import type { ProfileSortKey, ProfileSummary } from "../../types/browser";
defineProps<{
profiles: ProfileSummary[];
sortKey: ProfileSortKey;
openProfileError: string;
browserId: string;
isOpeningProfile: (browserId: string, profileId: string) => boolean;
}>();
const emit = defineEmits<{
"update:sortKey": [value: ProfileSortKey];
openProfile: [browserId: string, profileId: string];
}>();
</script>
<template>
<section class="content-section">
<div v-if="openProfileError" class="inline-error">
{{ openProfileError }}
</div>
<div class="sort-bar">
<SortDropdown
:model-value="sortKey"
label="Sort by"
:options="[
{ label: 'Name', value: 'name' },
{ label: 'Email', value: 'email' },
{ label: 'Profile ID', value: 'id' },
]"
@update:model-value="emit('update:sortKey', $event as ProfileSortKey)"
/>
</div>
<div v-if="profiles.length" class="stack-list">
<article v-for="profile in profiles" :key="profile.id" class="profile-card">
<div class="profile-avatar">
<img
v-if="profile.avatarDataUrl"
:src="profile.avatarDataUrl"
:alt="`${profile.name} avatar`"
/>
<span v-else>{{ profile.avatarLabel }}</span>
</div>
<div class="profile-body">
<div class="profile-topline">
<h4>{{ profile.name }}</h4>
<div class="profile-actions">
<button
class="card-action-button"
:disabled="isOpeningProfile(browserId, profile.id)"
type="button"
@click="emit('openProfile', browserId, profile.id)"
>
{{ isOpeningProfile(browserId, profile.id) ? "Opening..." : "Open" }}
</button>
<span class="badge neutral">{{ profile.id }}</span>
</div>
</div>
<p class="profile-email">{{ profile.email || "No email found" }}</p>
</div>
</article>
</div>
<div v-else class="empty-card">
<p>No profile directories were found for this browser.</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { browserIconOptions, browserIconSrc } from "../../utils/icons";
import type { BrowserConfigEntry, CreateCustomBrowserConfigInput } from "../../types/browser";
defineProps<{
configError: string;
configsLoading: boolean;
browserConfigs: BrowserConfigEntry[];
createConfigForm: CreateCustomBrowserConfigInput;
savingConfig: boolean;
configMonogram: (config: BrowserConfigEntry) => string;
isDeletingConfig: (configId: string) => boolean;
}>();
const emit = defineEmits<{
updateName: [value: string];
updateExecutablePath: [value: string];
updateUserDataPath: [value: string];
updateIconKey: [value: string];
pickExecutablePath: [];
pickUserDataPath: [];
createConfig: [];
deleteConfig: [configId: string];
}>();
</script>
<template>
<section class="content-scroll-area">
<section class="content-section">
<div v-if="configError" class="inline-error">
{{ configError }}
</div>
<div class="config-form-card">
<div class="config-form-header">
<h3>Add Custom Browser</h3>
<p>Provide a name, executable path, and Chromium user data path.</p>
</div>
<div class="config-form-layout">
<div class="config-form-fields">
<label class="field-group">
<span>Name</span>
<input
:value="createConfigForm.name"
placeholder="Work Chrome"
@input="emit('updateName', ($event.target as HTMLInputElement).value)"
/>
</label>
<label class="field-group">
<span>Executable Path</span>
<div class="path-input-row">
<input
:value="createConfigForm.executablePath"
placeholder="C:\Program Files\...\chrome.exe"
@input="emit('updateExecutablePath', ($event.target as HTMLInputElement).value)"
/>
<button class="secondary-button" type="button" @click="emit('pickExecutablePath')">
Browse File
</button>
</div>
</label>
<label class="field-group">
<span>User Data Path</span>
<div class="path-input-row">
<input
:value="createConfigForm.userDataPath"
placeholder="C:\Users\...\User Data"
@input="emit('updateUserDataPath', ($event.target as HTMLInputElement).value)"
/>
<button class="secondary-button" type="button" @click="emit('pickUserDataPath')">
Browse Folder
</button>
</div>
</label>
</div>
<div class="config-form-side">
<label class="field-group">
<span>Icon</span>
<div class="icon-option-grid">
<button
v-for="option in browserIconOptions"
:key="option.key"
class="icon-option-button"
:class="{ active: createConfigForm.iconKey === option.key }"
type="button"
@click="emit('updateIconKey', option.key)"
>
<img :src="option.src" :alt="option.label" />
<span>{{ option.label }}</span>
</button>
</div>
</label>
<div class="config-form-actions">
<button
class="primary-button"
type="button"
:disabled="savingConfig"
@click="emit('createConfig')"
>
{{ savingConfig ? "Saving..." : "Add Config" }}
</button>
</div>
</div>
</div>
</div>
<div v-if="configsLoading" class="empty-card">
<p>Loading browser configs...</p>
</div>
<div v-else class="stack-list">
<article
v-for="config in browserConfigs"
:key="config.id"
class="config-card"
>
<div class="config-card-header">
<div class="config-card-lead">
<div class="browser-nav-icon config-icon">
<img
v-if="browserIconSrc(config.iconKey ?? config.browserFamilyId)"
:src="browserIconSrc(config.iconKey ?? config.browserFamilyId) ?? undefined"
:alt="`${config.name} icon`"
/>
<span v-else>{{ configMonogram(config) }}</span>
</div>
<div>
<div class="config-title-row">
<h4>{{ config.name }}</h4>
</div>
</div>
</div>
<button
v-if="config.deletable"
class="danger-button"
type="button"
:disabled="isDeletingConfig(config.id)"
@click="emit('deleteConfig', config.id)"
>
{{ isDeletingConfig(config.id) ? "Deleting..." : "Delete" }}
</button>
</div>
<div class="config-meta">
<div class="config-meta-row">
<span class="config-label">Executable</span>
<p :title="config.executablePath">{{ config.executablePath || "Not resolved" }}</p>
</div>
<div class="config-meta-row">
<span class="config-label">User Data</span>
<p :title="config.userDataPath">{{ config.userDataPath }}</p>
</div>
</div>
</article>
</div>
</section>
</section>
</template>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { browserIconSrc, configurationIconSrc } from "../../utils/icons";
import type { AppPage, BrowserView } from "../../types/browser";
defineProps<{
browsers: BrowserView[];
currentBrowserId: string | null;
page: AppPage;
loading: boolean;
configsLoading: boolean;
browserMonogram: (browserId: string) => string;
}>();
const emit = defineEmits<{
selectBrowser: [browserId: string];
selectConfiguration: [];
refresh: [];
}>();
</script>
<template>
<aside class="sidebar">
<div class="sidebar-toolbar">
<div class="sidebar-title-group">
<h1>Browser Assistant</h1>
<p>Local Chromium profile manager</p>
</div>
</div>
<div v-if="browsers.length" class="browser-nav">
<button
v-for="browser in browsers"
:key="browser.browserId"
class="browser-nav-item"
:class="[browser.browserFamilyId ?? browser.browserId, { active: browser.browserId === currentBrowserId && page === 'browserData' }]"
type="button"
@click="emit('selectBrowser', browser.browserId)"
>
<div class="browser-nav-icon">
<img
v-if="browserIconSrc(browser.iconKey ?? browser.browserFamilyId)"
:src="browserIconSrc(browser.iconKey ?? browser.browserFamilyId) ?? undefined"
:alt="`${browser.browserName} icon`"
/>
<span v-else>{{ browserMonogram(browser.browserId) }}</span>
</div>
<div class="browser-nav-body">
<strong>{{ browser.browserName }}</strong>
<span>{{ browser.dataRoot }}</span>
</div>
</button>
</div>
<div v-else class="sidebar-empty">
<p>No supported Chromium browser data was found yet.</p>
</div>
<button
class="browser-nav-item utility sidebar-utility-nav"
:class="{ active: page === 'configuration' }"
type="button"
@click="emit('selectConfiguration')"
>
<div class="browser-nav-icon config-nav-icon">
<img :src="configurationIconSrc" alt="Configuration icon" />
</div>
<div class="browser-nav-body">
<strong>Configuration</strong>
<span>Manage custom scan sources and paths</span>
</div>
</button>
<button class="refresh-button sidebar-refresh" type="button" @click="emit('refresh')">
{{ loading || configsLoading ? "Refreshing..." : "Refresh" }}
</button>
</aside>
</template>

View File

@@ -2,7 +2,7 @@ import { computed, onMounted, ref, watch } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { sortBookmarks, sortExtensions, sortProfiles } from "./sort"; import { sortBookmarks, sortExtensions, sortProfiles } from "../utils/sort";
import type { import type {
ActiveSection, ActiveSection,
AppPage, AppPage,
@@ -14,9 +14,9 @@ import type {
ExtensionSortKey, ExtensionSortKey,
ProfileSortKey, ProfileSortKey,
ScanResponse, ScanResponse,
} from "./types"; } from "../types/browser";
export function useBrowserAssistant() { export function useBrowserManager() {
const page = ref<AppPage>("browserData"); const page = ref<AppPage>("browserData");
const loading = ref(true); const loading = ref(true);
const error = ref(""); const error = ref("");

View File

@@ -1,954 +1,2 @@
:root { @import "./styles/base.css";
color: #15202b; @import "./styles/app.css";
background:
radial-gradient(circle at top left, rgba(108, 145, 255, 0.16), transparent 32%),
radial-gradient(circle at top right, rgba(16, 185, 129, 0.14), transparent 24%),
linear-gradient(180deg, #f5f8fc 0%, #edf2f7 100%);
font-family:
"Segoe UI Variable Text",
"Segoe UI",
"PingFang SC",
"Microsoft YaHei UI",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--panel: rgba(255, 255, 255, 0.76);
--panel-strong: rgba(255, 255, 255, 0.94);
--panel-border: rgba(148, 163, 184, 0.18);
--shadow: 0 20px 60px rgba(15, 23, 42, 0.08);
--text: #0f172a;
--muted: #526277;
--muted-soft: #7b8aa2;
--badge-bg: rgba(226, 232, 240, 0.7);
--badge-text: #344256;
--accent: #2f6fed;
--accent-soft: rgba(47, 111, 237, 0.12);
--chrome: #2563eb;
--edge: #0891b2;
--brave: #ea580c;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100%;
}
body {
min-height: 100vh;
color: var(--text);
}
button,
input,
textarea,
select {
font: inherit;
}
button {
border: 0;
}
.app-shell {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
height: 100vh;
padding: 14px;
gap: 14px;
overflow: hidden;
}
.sidebar,
.content-panel {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.sidebar {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px 16px;
min-height: 0;
border: 1px solid var(--panel-border);
border-radius: 22px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 252, 0.7));
box-shadow: var(--shadow);
}
.sidebar-toolbar h1,
.state-panel h2,
.profile-topline h4,
.extension-topline h4,
.bookmark-topline h4 {
margin: 0;
font-weight: 600;
letter-spacing: -0.03em;
}
.sidebar-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 2px 2px 8px;
}
.sidebar-title-group {
min-width: 0;
}
.sidebar-toolbar h1 {
font-size: 1.52rem;
line-height: 1.02;
letter-spacing: -0.04em;
}
.sidebar-title-group p {
margin: 6px 0 0;
color: var(--muted);
font-size: 0.84rem;
line-height: 1.35;
}
.state-panel p,
.meta-line,
.profile-email,
.bookmark-url,
.browser-nav-body span,
.sidebar-empty p {
margin: 0;
color: var(--muted);
}
.refresh-button {
flex-shrink: 0;
margin-top: 2px;
padding: 9px 13px;
border-radius: 12px;
background: linear-gradient(135deg, #10213f 0%, #213f75 100%);
color: #fff;
cursor: pointer;
font-size: 0.86rem;
font-weight: 600;
transition:
transform 160ms ease,
box-shadow 160ms ease;
box-shadow: 0 12px 24px rgba(20, 44, 82, 0.18);
}
.refresh-button:hover {
transform: translateY(-1px);
}
.browser-nav {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow: auto;
padding-right: 2px;
}
.browser-nav-item {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 11px;
border-radius: 16px;
text-align: left;
cursor: pointer;
background: rgba(255, 255, 255, 0.54);
border: 1px solid transparent;
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease;
}
.browser-nav-item:hover {
transform: translateY(-1px);
border-color: var(--panel-border);
}
.browser-nav-item.active {
background: var(--accent-soft);
border-color: rgba(47, 111, 237, 0.18);
}
.browser-nav-item.chrome.active {
background: rgba(37, 99, 235, 0.12);
}
.browser-nav-item.edge.active {
background: rgba(8, 145, 178, 0.12);
}
.browser-nav-item.brave.active {
background: rgba(234, 88, 12, 0.12);
}
.browser-nav-item.utility.active {
background: rgba(15, 23, 42, 0.08);
border-color: rgba(15, 23, 42, 0.1);
}
.browser-nav-icon,
.profile-avatar,
.extension-icon {
display: grid;
place-items: center;
flex-shrink: 0;
overflow: hidden;
}
.browser-nav-icon {
width: 42px;
height: 42px;
border-radius: 0;
color: #fff;
font-weight: 700;
font-size: 0.86rem;
letter-spacing: 0.08em;
background: transparent;
overflow: visible;
}
.browser-nav-icon img,
.config-icon img {
display: block;
width: auto;
height: auto;
object-fit: contain;
}
.browser-nav-icon img {
max-width: 38px;
max-height: 38px;
}
.config-icon img {
max-width: 26px;
max-height: 26px;
}
.config-nav-icon {
background: transparent;
}
.sidebar-utility-nav {
margin-top: auto;
}
.browser-nav-body {
min-width: 0;
}
.browser-nav-body strong {
display: block;
color: var(--text);
}
.browser-nav-body span {
display: block;
margin-top: 2px;
font-size: 0.8rem;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-empty {
padding: 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.58);
border: 1px dashed rgba(148, 163, 184, 0.35);
}
.content-panel {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
padding: 4px 0;
overflow: hidden;
}
.section-tabs,
.content-section,
.state-panel {
border: 1px solid var(--panel-border);
border-radius: 22px;
background: var(--panel);
box-shadow: var(--shadow);
}
.content-section {
padding: 16px;
}
.inline-error {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid rgba(239, 68, 68, 0.18);
border-radius: 12px;
background: rgba(254, 242, 242, 0.92);
color: #b42318;
font-size: 0.86rem;
}
.config-form-card,
.config-card {
border-radius: 18px;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: var(--panel-strong);
}
.config-form-header h3,
.config-title-row h4 {
margin: 0;
font-size: 0.94rem;
}
.config-form-header p,
.config-meta-row p {
margin: 6px 0 0;
color: var(--muted);
font-size: 0.82rem;
}
.config-form-grid {
display: none;
}
.config-form-layout {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.95fr);
gap: 14px;
margin-top: 12px;
}
.config-form-fields,
.config-form-side {
display: grid;
gap: 10px;
}
.config-form-side {
align-content: start;
padding: 10px 12px;
border-radius: 16px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.95), rgba(241, 245, 249, 0.8));
border: 1px solid rgba(148, 163, 184, 0.14);
}
.icon-option-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.icon-option-button {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
align-items: center;
column-gap: 8px;
min-height: 0;
padding: 8px 10px;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 12px;
background: rgba(255, 255, 255, 0.9);
color: var(--text);
cursor: pointer;
transition:
border-color 160ms ease,
background 160ms ease,
box-shadow 160ms ease;
}
.icon-option-button img {
width: 22px;
height: 22px;
object-fit: contain;
}
.icon-option-button span {
font-size: 0.75rem;
font-weight: 600;
text-align: left;
line-height: 1.2;
}
.icon-option-button.active {
border-color: rgba(47, 111, 237, 0.3);
background: rgba(232, 240, 255, 0.8);
box-shadow: 0 0 0 3px rgba(47, 111, 237, 0.1);
}
.field-group {
display: grid;
gap: 6px;
}
.path-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.field-group span,
.config-label {
color: var(--muted);
font-size: 0.8rem;
font-weight: 600;
}
.field-group input {
width: 100%;
padding: 9px 11px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 12px;
background: rgba(255, 255, 255, 0.94);
color: var(--text);
outline: none;
}
.field-group input:focus {
border-color: rgba(47, 111, 237, 0.42);
box-shadow: 0 0 0 3px rgba(47, 111, 237, 0.12);
}
.field-span {
grid-column: 1 / -1;
}
.config-form-actions {
display: flex;
justify-content: flex-end;
margin-top: 2px;
}
.primary-button,
.secondary-button,
.danger-button {
padding: 9px 14px;
border-radius: 12px;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
}
.primary-button {
background: linear-gradient(135deg, #10213f 0%, #213f75 100%);
color: #fff;
}
.secondary-button {
border: 1px solid rgba(148, 163, 184, 0.24);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.92));
color: var(--text);
}
.danger-button {
border: 1px solid rgba(239, 68, 68, 0.18);
background: rgba(254, 242, 242, 0.96);
color: #b42318;
}
.primary-button:disabled,
.secondary-button:disabled,
.danger-button:disabled {
cursor: default;
opacity: 0.72;
}
.config-card-header,
.config-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.config-card-lead {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
}
.config-icon {
width: 30px;
height: 30px;
border-radius: 0;
font-size: 0.74rem;
overflow: visible;
}
.config-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 10px;
}
.config-meta-row {
display: grid;
gap: 3px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(248, 250, 252, 0.78);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.config-meta-row p {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sort-bar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.sort-dropdown {
display: inline-flex;
align-items: center;
gap: 8px;
position: relative;
}
.sort-dropdown-label {
color: var(--muted);
font-size: 0.84rem;
}
.sort-dropdown-trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-width: 152px;
padding: 7px 10px;
border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 10px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.92));
color: var(--text);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
cursor: pointer;
transition:
border-color 160ms ease,
box-shadow 160ms ease,
background 160ms ease;
}
.sort-dropdown-trigger:hover {
border-color: rgba(100, 116, 139, 0.36);
}
.sort-dropdown-trigger.open {
border-color: rgba(47, 111, 237, 0.42);
box-shadow:
0 0 0 3px rgba(47, 111, 237, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.sort-dropdown-caret {
width: 10px;
height: 10px;
flex-shrink: 0;
border-right: 1.8px solid #64748b;
border-bottom: 1.8px solid #64748b;
transform: rotate(45deg) translateY(-1px);
transition: transform 160ms ease;
}
.sort-dropdown-trigger.open .sort-dropdown-caret {
transform: rotate(-135deg) translate(-2px, -2px);
}
.sort-dropdown-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 30;
min-width: 100%;
padding: 6px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 20px 40px rgba(15, 23, 42, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.75);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.sort-dropdown-option {
display: block;
width: 100%;
padding: 10px 12px;
border-radius: 10px;
text-align: left;
color: var(--text);
background: transparent;
cursor: pointer;
transition:
background 140ms ease,
color 140ms ease;
}
.sort-dropdown-option:hover {
background: rgba(241, 245, 249, 0.92);
}
.sort-dropdown-option.active {
color: #fff;
background: linear-gradient(135deg, #213f75, #325ca8);
}
.section-tabs {
display: flex;
gap: 10px;
margin-top: 0;
padding: 8px;
flex-shrink: 0;
}
.content-scroll-area {
min-height: 0;
overflow: auto;
padding-right: 2px;
}
.section-tab {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
flex: 1;
padding: 10px 12px;
border-radius: 15px;
color: var(--muted);
background: rgba(255, 255, 255, 0.58);
cursor: pointer;
transition:
background 160ms ease,
color 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.section-tab:hover {
transform: translateY(-1px);
}
.section-tab.active {
color: var(--text);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(232, 240, 255, 0.92));
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
}
.count-pill,
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.82rem;
font-weight: 600;
}
.count-pill {
min-width: 44px;
padding: 6px 10px;
}
.badge {
padding: 6px 10px;
}
.badge.neutral {
background: var(--badge-bg);
color: var(--badge-text);
}
.stack-list,
.bookmark-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.profile-card,
.extension-card,
.bookmark-card,
.empty-card {
border-radius: 18px;
padding: 14px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: var(--panel-strong);
}
.profile-card,
.extension-card,
.bookmark-card {
display: flex;
gap: 12px;
}
.profile-avatar {
width: 52px;
height: 52px;
border-radius: 15px;
background: linear-gradient(135deg, #dbeafe, #eff6ff);
color: #1d4ed8;
font-size: 1.1rem;
font-weight: 700;
}
.profile-avatar img,
.extension-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-body,
.extension-body,
.bookmark-body {
min-width: 0;
flex: 1;
}
.profile-topline,
.extension-topline,
.bookmark-topline {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.profile-topline h4,
.extension-topline h4,
.bookmark-topline h4 {
font-size: 0.96rem;
line-height: 1.35;
}
.profile-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.profile-email,
.meta-line,
.bookmark-url {
margin-top: 6px;
font-size: 0.87rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.extension-icon {
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, #e2e8f0, #f8fafc);
color: #475569;
font-weight: 700;
}
.source-disclosure {
margin-top: 10px;
}
.disclosure-button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: fit-content;
min-width: 120px;
padding: 7px 10px;
border-radius: 12px;
background: rgba(241, 245, 249, 0.9);
color: var(--badge-text);
cursor: pointer;
}
.card-action-button {
padding: 6px 10px;
border-radius: 10px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.92));
border: 1px solid rgba(148, 163, 184, 0.24);
color: var(--text);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition:
border-color 160ms ease,
background 160ms ease,
box-shadow 160ms ease;
}
.card-action-button:hover {
border-color: rgba(100, 116, 139, 0.36);
background: linear-gradient(180deg, rgba(255, 255, 255, 1), rgba(226, 232, 240, 0.9));
}
.card-action-button:disabled {
cursor: default;
opacity: 0.7;
border-color: rgba(148, 163, 184, 0.2);
}
.card-action-button:active {
box-shadow: inset 0 2px 4px rgba(148, 163, 184, 0.18);
}
.disclosure-panel {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.state-panel,
.empty-card {
display: grid;
gap: 8px;
}
.eyebrow {
margin: 0 0 6px;
color: var(--muted-soft);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.state-panel {
min-height: 320px;
place-content: center;
padding: 28px;
}
.sidebar-refresh {
margin-top: 10px;
align-self: stretch;
}
.browser-nav,
.content-scroll-area {
scrollbar-width: thin;
scrollbar-color: rgba(100, 116, 139, 0.42) transparent;
}
.browser-nav::-webkit-scrollbar,
.content-scroll-area::-webkit-scrollbar {
width: 10px;
}
.browser-nav::-webkit-scrollbar-track,
.content-scroll-area::-webkit-scrollbar-track {
background: transparent;
}
.browser-nav::-webkit-scrollbar-thumb,
.content-scroll-area::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 999px;
background: linear-gradient(180deg, rgba(148, 163, 184, 0.72), rgba(100, 116, 139, 0.58));
background-clip: padding-box;
}
.browser-nav::-webkit-scrollbar-thumb:hover,
.content-scroll-area::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(100, 116, 139, 0.82), rgba(71, 85, 105, 0.72));
background-clip: padding-box;
}
.state-panel.error {
background: linear-gradient(180deg, rgba(254, 242, 242, 0.92), rgba(255, 255, 255, 0.86));
}
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: 1fr;
height: auto;
min-height: 100vh;
padding: 12px;
overflow: visible;
}
.content-panel {
padding: 0;
overflow: visible;
}
.browser-nav,
.content-scroll-area {
overflow: visible;
}
}
@media (max-width: 720px) {
.config-form-layout {
grid-template-columns: 1fr;
}
.icon-option-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sort-bar {
justify-content: stretch;
}
.sort-dropdown {
width: 100%;
}
.sort-dropdown-trigger {
min-width: 0;
width: 100%;
}
.profile-card,
.extension-card,
.bookmark-card {
flex-direction: column;
}
.config-meta {
grid-template-columns: 1fr;
}
.profile-avatar,
.extension-icon {
width: 50px;
height: 50px;
}
}

887
src/styles/app.css Normal file
View File

@@ -0,0 +1,887 @@
.app-shell {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
height: 100vh;
padding: 14px;
gap: 14px;
overflow: hidden;
}
.sidebar,
.content-panel {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.sidebar {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px 16px;
min-height: 0;
border: 1px solid var(--panel-border);
border-radius: 22px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 252, 0.7));
box-shadow: var(--shadow);
}
.sidebar-toolbar h1,
.state-panel h2,
.profile-topline h4,
.extension-topline h4,
.bookmark-topline h4 {
margin: 0;
font-weight: 600;
letter-spacing: -0.03em;
}
.sidebar-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 2px 2px 8px;
}
.sidebar-title-group {
min-width: 0;
}
.sidebar-toolbar h1 {
font-size: 1.52rem;
line-height: 1.02;
letter-spacing: -0.04em;
}
.sidebar-title-group p {
margin: 6px 0 0;
color: var(--muted);
font-size: 0.84rem;
line-height: 1.35;
}
.state-panel p,
.meta-line,
.profile-email,
.bookmark-url,
.browser-nav-body span,
.sidebar-empty p {
margin: 0;
color: var(--muted);
}
.refresh-button {
flex-shrink: 0;
margin-top: 2px;
padding: 9px 13px;
border-radius: 12px;
background: linear-gradient(135deg, #10213f 0%, #213f75 100%);
color: #fff;
cursor: pointer;
font-size: 0.86rem;
font-weight: 600;
transition:
transform 160ms ease,
box-shadow 160ms ease;
box-shadow: 0 12px 24px rgba(20, 44, 82, 0.18);
}
.refresh-button:hover {
transform: translateY(-1px);
}
.browser-nav {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow: auto;
padding-right: 2px;
}
.browser-nav-item {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 11px;
border-radius: 16px;
text-align: left;
cursor: pointer;
background: rgba(255, 255, 255, 0.54);
border: 1px solid transparent;
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease;
}
.browser-nav-item:hover {
transform: translateY(-1px);
border-color: var(--panel-border);
}
.browser-nav-item.active {
background: var(--accent-soft);
border-color: rgba(47, 111, 237, 0.18);
}
.browser-nav-item.chrome.active {
background: rgba(37, 99, 235, 0.12);
}
.browser-nav-item.edge.active {
background: rgba(8, 145, 178, 0.12);
}
.browser-nav-item.brave.active {
background: rgba(234, 88, 12, 0.12);
}
.browser-nav-item.utility.active {
background: rgba(15, 23, 42, 0.08);
border-color: rgba(15, 23, 42, 0.1);
}
.browser-nav-icon,
.profile-avatar,
.extension-icon {
display: grid;
place-items: center;
flex-shrink: 0;
overflow: hidden;
}
.browser-nav-icon {
width: 42px;
height: 42px;
border-radius: 0;
color: #fff;
font-weight: 700;
font-size: 0.86rem;
letter-spacing: 0.08em;
background: transparent;
overflow: visible;
}
.browser-nav-icon img,
.config-icon img {
display: block;
width: auto;
height: auto;
object-fit: contain;
}
.browser-nav-icon img {
max-width: 38px;
max-height: 38px;
}
.config-icon img {
max-width: 26px;
max-height: 26px;
}
.config-nav-icon {
background: transparent;
}
.sidebar-utility-nav {
margin-top: auto;
}
.browser-nav-body {
min-width: 0;
}
.browser-nav-body strong {
display: block;
color: var(--text);
}
.browser-nav-body span {
display: block;
margin-top: 2px;
font-size: 0.8rem;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-empty {
padding: 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.58);
border: 1px dashed rgba(148, 163, 184, 0.35);
}
.content-panel {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
padding: 4px 0;
overflow: hidden;
}
.section-tabs,
.content-section,
.state-panel {
border: 1px solid var(--panel-border);
border-radius: 22px;
background: var(--panel);
box-shadow: var(--shadow);
}
.content-section {
padding: 16px;
}
.inline-error {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid rgba(239, 68, 68, 0.18);
border-radius: 12px;
background: rgba(254, 242, 242, 0.92);
color: #b42318;
font-size: 0.86rem;
}
.config-form-card,
.config-card {
border-radius: 18px;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: var(--panel-strong);
}
.config-form-header h3,
.config-title-row h4 {
margin: 0;
font-size: 0.94rem;
}
.config-form-header p,
.config-meta-row p {
margin: 6px 0 0;
color: var(--muted);
font-size: 0.82rem;
}
.config-form-layout {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.95fr);
gap: 14px;
margin-top: 12px;
}
.config-form-fields,
.config-form-side {
display: grid;
gap: 10px;
}
.config-form-side {
align-content: start;
padding: 10px 12px;
border-radius: 16px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.95), rgba(241, 245, 249, 0.8));
border: 1px solid rgba(148, 163, 184, 0.14);
}
.icon-option-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.icon-option-button {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
align-items: center;
column-gap: 8px;
min-height: 0;
padding: 8px 10px;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 12px;
background: rgba(255, 255, 255, 0.9);
color: var(--text);
cursor: pointer;
transition:
border-color 160ms ease,
background 160ms ease,
box-shadow 160ms ease;
}
.icon-option-button img {
width: 22px;
height: 22px;
object-fit: contain;
}
.icon-option-button span {
font-size: 0.75rem;
font-weight: 600;
text-align: left;
line-height: 1.2;
}
.icon-option-button.active {
border-color: rgba(47, 111, 237, 0.3);
background: rgba(232, 240, 255, 0.8);
box-shadow: 0 0 0 3px rgba(47, 111, 237, 0.1);
}
.field-group {
display: grid;
gap: 6px;
}
.path-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.field-group span,
.config-label {
color: var(--muted);
font-size: 0.8rem;
font-weight: 600;
}
.field-group input {
width: 100%;
padding: 9px 11px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 12px;
background: rgba(255, 255, 255, 0.94);
color: var(--text);
outline: none;
}
.field-group input:focus {
border-color: rgba(47, 111, 237, 0.42);
box-shadow: 0 0 0 3px rgba(47, 111, 237, 0.12);
}
.config-form-actions {
display: flex;
justify-content: flex-end;
margin-top: 2px;
}
.primary-button,
.secondary-button,
.danger-button {
padding: 9px 14px;
border-radius: 12px;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
}
.primary-button {
background: linear-gradient(135deg, #10213f 0%, #213f75 100%);
color: #fff;
}
.secondary-button {
border: 1px solid rgba(148, 163, 184, 0.24);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.92));
color: var(--text);
}
.danger-button {
border: 1px solid rgba(239, 68, 68, 0.18);
background: rgba(254, 242, 242, 0.96);
color: #b42318;
}
.primary-button:disabled,
.secondary-button:disabled,
.danger-button:disabled {
cursor: default;
opacity: 0.72;
}
.config-card-header,
.config-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.config-card-lead {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
}
.config-icon {
width: 30px;
height: 30px;
border-radius: 0;
font-size: 0.74rem;
overflow: visible;
}
.config-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 10px;
}
.config-meta-row {
display: grid;
gap: 3px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(248, 250, 252, 0.78);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.config-meta-row p {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sort-bar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.sort-dropdown {
display: inline-flex;
align-items: center;
gap: 8px;
position: relative;
}
.sort-dropdown-label {
color: var(--muted);
font-size: 0.84rem;
}
.sort-dropdown-trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-width: 152px;
padding: 7px 10px;
border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 10px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.92));
color: var(--text);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
cursor: pointer;
transition:
border-color 160ms ease,
box-shadow 160ms ease,
background 160ms ease;
}
.sort-dropdown-trigger:hover {
border-color: rgba(100, 116, 139, 0.36);
}
.sort-dropdown-trigger.open {
border-color: rgba(47, 111, 237, 0.42);
box-shadow:
0 0 0 3px rgba(47, 111, 237, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.sort-dropdown-caret {
width: 10px;
height: 10px;
flex-shrink: 0;
border-right: 1.8px solid #64748b;
border-bottom: 1.8px solid #64748b;
transform: rotate(45deg) translateY(-1px);
transition: transform 160ms ease;
}
.sort-dropdown-trigger.open .sort-dropdown-caret {
transform: rotate(-135deg) translate(-2px, -2px);
}
.sort-dropdown-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 30;
min-width: 100%;
padding: 6px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 20px 40px rgba(15, 23, 42, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.75);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.sort-dropdown-option {
display: block;
width: 100%;
padding: 10px 12px;
border-radius: 10px;
text-align: left;
color: var(--text);
background: transparent;
cursor: pointer;
transition:
background 140ms ease,
color 140ms ease;
}
.sort-dropdown-option:hover {
background: rgba(241, 245, 249, 0.92);
}
.sort-dropdown-option.active {
color: #fff;
background: linear-gradient(135deg, #213f75, #325ca8);
}
.section-tabs {
display: flex;
gap: 10px;
margin-top: 0;
padding: 8px;
flex-shrink: 0;
}
.content-scroll-area {
min-height: 0;
overflow: auto;
padding-right: 2px;
}
.section-tab {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
flex: 1;
padding: 10px 12px;
border-radius: 15px;
color: var(--muted);
background: rgba(255, 255, 255, 0.58);
cursor: pointer;
transition:
background 160ms ease,
color 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.section-tab:hover {
transform: translateY(-1px);
}
.section-tab.active {
color: var(--text);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(232, 240, 255, 0.92));
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
}
.count-pill,
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.82rem;
font-weight: 600;
}
.count-pill {
min-width: 44px;
padding: 6px 10px;
}
.badge {
padding: 6px 10px;
}
.badge.neutral {
background: var(--badge-bg);
color: var(--badge-text);
}
.stack-list,
.bookmark-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.profile-card,
.extension-card,
.bookmark-card,
.empty-card {
border-radius: 18px;
padding: 14px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: var(--panel-strong);
}
.profile-card,
.extension-card,
.bookmark-card {
display: flex;
gap: 12px;
}
.profile-avatar {
width: 52px;
height: 52px;
border-radius: 15px;
background: linear-gradient(135deg, #dbeafe, #eff6ff);
color: #1d4ed8;
font-size: 1.1rem;
font-weight: 700;
}
.profile-avatar img,
.extension-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-body,
.extension-body,
.bookmark-body {
min-width: 0;
flex: 1;
}
.profile-topline,
.extension-topline,
.bookmark-topline {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.profile-topline h4,
.extension-topline h4,
.bookmark-topline h4 {
font-size: 0.96rem;
line-height: 1.35;
}
.profile-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.profile-email,
.meta-line,
.bookmark-url {
margin-top: 6px;
font-size: 0.87rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.extension-icon {
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, #e2e8f0, #f8fafc);
color: #475569;
font-weight: 700;
}
.source-disclosure {
margin-top: 10px;
}
.disclosure-button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: fit-content;
min-width: 120px;
padding: 7px 10px;
border-radius: 12px;
background: rgba(241, 245, 249, 0.9);
color: var(--badge-text);
cursor: pointer;
}
.card-action-button {
padding: 6px 10px;
border-radius: 10px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.92));
border: 1px solid rgba(148, 163, 184, 0.24);
color: var(--text);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition:
border-color 160ms ease,
background 160ms ease,
box-shadow 160ms ease;
}
.card-action-button:hover {
border-color: rgba(100, 116, 139, 0.36);
background: linear-gradient(180deg, rgba(255, 255, 255, 1), rgba(226, 232, 240, 0.9));
}
.card-action-button:disabled {
cursor: default;
opacity: 0.7;
border-color: rgba(148, 163, 184, 0.2);
}
.card-action-button:active {
box-shadow: inset 0 2px 4px rgba(148, 163, 184, 0.18);
}
.disclosure-panel {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.state-panel,
.empty-card {
display: grid;
gap: 8px;
}
.eyebrow {
margin: 0 0 6px;
color: var(--muted-soft);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.state-panel {
min-height: 320px;
place-content: center;
padding: 28px;
}
.sidebar-refresh {
margin-top: 10px;
align-self: stretch;
}
.browser-nav,
.content-scroll-area {
scrollbar-width: thin;
scrollbar-color: rgba(100, 116, 139, 0.42) transparent;
}
.browser-nav::-webkit-scrollbar,
.content-scroll-area::-webkit-scrollbar {
width: 10px;
}
.browser-nav::-webkit-scrollbar-track,
.content-scroll-area::-webkit-scrollbar-track {
background: transparent;
}
.browser-nav::-webkit-scrollbar-thumb,
.content-scroll-area::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 999px;
background: linear-gradient(180deg, rgba(148, 163, 184, 0.72), rgba(100, 116, 139, 0.58));
background-clip: padding-box;
}
.browser-nav::-webkit-scrollbar-thumb:hover,
.content-scroll-area::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(100, 116, 139, 0.82), rgba(71, 85, 105, 0.72));
background-clip: padding-box;
}
.state-panel.error {
background: linear-gradient(180deg, rgba(254, 242, 242, 0.92), rgba(255, 255, 255, 0.86));
}
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: 1fr;
height: auto;
min-height: 100vh;
padding: 12px;
overflow: visible;
}
.content-panel {
padding: 0;
overflow: visible;
}
.browser-nav,
.content-scroll-area {
overflow: visible;
}
}
@media (max-width: 720px) {
.config-form-layout {
grid-template-columns: 1fr;
}
.icon-option-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sort-bar {
justify-content: stretch;
}
.sort-dropdown {
width: 100%;
}
.sort-dropdown-trigger {
min-width: 0;
width: 100%;
}
.profile-card,
.extension-card,
.bookmark-card {
flex-direction: column;
}
.config-meta {
grid-template-columns: 1fr;
}
.profile-avatar,
.extension-icon {
width: 50px;
height: 50px;
}
}

58
src/styles/base.css Normal file
View File

@@ -0,0 +1,58 @@
:root {
color: #15202b;
background:
radial-gradient(circle at top left, rgba(108, 145, 255, 0.16), transparent 32%),
radial-gradient(circle at top right, rgba(16, 185, 129, 0.14), transparent 24%),
linear-gradient(180deg, #f5f8fc 0%, #edf2f7 100%);
font-family:
"Segoe UI Variable Text",
"Segoe UI",
"PingFang SC",
"Microsoft YaHei UI",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--panel: rgba(255, 255, 255, 0.76);
--panel-strong: rgba(255, 255, 255, 0.94);
--panel-border: rgba(148, 163, 184, 0.18);
--shadow: 0 20px 60px rgba(15, 23, 42, 0.08);
--text: #0f172a;
--muted: #526277;
--muted-soft: #7b8aa2;
--badge-bg: rgba(226, 232, 240, 0.7);
--badge-text: #344256;
--accent: #2f6fed;
--accent-soft: rgba(47, 111, 237, 0.12);
--chrome: #2563eb;
--edge: #0891b2;
--brave: #ea580c;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100%;
}
body {
min-height: 100vh;
color: var(--text);
}
button,
input,
textarea,
select {
font: inherit;
}
button {
border: 0;
}

View File

@@ -1,7 +1,7 @@
import braveIcon from "../../assets/brave.png"; import braveIcon from "../assets/brave.png";
import chromeIcon from "../../assets/google-chrome.png"; import chromeIcon from "../assets/google-chrome.png";
import edgeIcon from "../../assets/microsoft-edge.png"; import edgeIcon from "../assets/microsoft-edge.png";
import settingsIcon from "../../assets/settings.png"; import settingsIcon from "../assets/settings.png";
export const browserIconOptions = [ export const browserIconOptions = [
{ key: "chrome", label: "Google Chrome", src: chromeIcon }, { key: "chrome", label: "Google Chrome", src: chromeIcon },

View File

@@ -5,7 +5,7 @@ import type {
ExtensionSummary, ExtensionSummary,
ProfileSortKey, ProfileSortKey,
ProfileSummary, ProfileSummary,
} from "./types"; } from "../types/browser";
export function compareText(left: string, right: string) { export function compareText(left: string, right: string) {
return left.localeCompare(right, undefined, { return left.localeCompare(right, undefined, {