css refactor

This commit is contained in:
2026-02-19 18:24:36 +03:00
parent 4de5b22d57
commit fdcbe32dfa
45 changed files with 1798 additions and 1857 deletions

View File

@@ -6,19 +6,21 @@
* @LAYER: UI
* @RELATION: DEPENDS_ON -> page store
* @INVARIANT: Always shows current page path
*
*
* @UX_STATE: Idle -> Breadcrumbs showing current path
* @UX_FEEDBACK: Hover on breadcrumb shows clickable state
* @UX_RECOVERY: Click breadcrumb to navigate
*/
import { page } from '$app/stores';
import { t, _ } from '$lib/i18n';
import { page } from "$app/stores";
import { t, _ } from "$lib/i18n";
export let maxVisible = 3;
let { maxVisible = 3 } = $props();
// Breadcrumb items derived from current path
$: breadcrumbItems = getBreadcrumbs($page?.url?.pathname || '/', maxVisible);
let breadcrumbItems = $derived(
getBreadcrumbs($page?.url?.pathname || "/", maxVisible),
);
/**
* Generate breadcrumb items from path
@@ -26,47 +28,31 @@
* @returns {Array} Array of breadcrumb items
*/
function getBreadcrumbs(pathname, maxVisible = 3) {
const segments = pathname.split('/').filter(Boolean);
const allItems = [
{ label: 'Home', path: '/' }
];
const segments = pathname.split("/").filter(Boolean);
const allItems = [{ label: "Home", path: "/" }];
let currentPath = '';
let currentPath = "";
segments.forEach((segment, index) => {
currentPath += `/${segment}`;
// Convert segment to readable label
const label = formatBreadcrumbLabel(segment);
allItems.push({
label,
path: currentPath,
isLast: index === segments.length - 1
isLast: index === segments.length - 1,
});
});
// Handle truncation if too many items
// If we have more than maxVisible items, we truncate the middle ones
// Always show Home (first) and Current (last)
if (allItems.length > maxVisible) {
const firstItem = allItems[0];
const lastItem = allItems[allItems.length - 1];
// Calculate how many items we can show in the middle
// We reserve 1 for first, 1 for last, and 1 for ellipsis
// But ellipsis isn't a real item in terms of logic, it just replaces hidden ones
// Actually, let's keep it simple: First ... [Last - (maxVisible - 2) .. Last]
const itemsToShow = [];
itemsToShow.push(firstItem);
itemsToShow.push({ isEllipsis: true });
// Add the last (maxVisible - 2) items
// e.g. if maxVisible is 3, we show Start ... End
// if maxVisible is 4, we show Start ... SecondLast End
const startFromIndex = allItems.length - (maxVisible - 1);
for(let i = startFromIndex; i < allItems.length; i++) {
itemsToShow.push(allItems[i]);
}
return itemsToShow;
const firstItem = allItems[0];
const itemsToShow = [];
itemsToShow.push(firstItem);
itemsToShow.push({ isEllipsis: true });
const startFromIndex = allItems.length - (maxVisible - 1);
for (let i = startFromIndex; i < allItems.length; i++) {
itemsToShow.push(allItems[i]);
}
return itemsToShow;
}
return allItems;
@@ -78,63 +64,46 @@
* @returns {string} Formatted label
*/
function formatBreadcrumbLabel(segment) {
// Handle special cases
const specialCases = {
'dashboards': 'nav.dashboard',
'datasets': 'nav.tools_mapper',
'storage': 'nav.tools_storage',
'admin': 'nav.admin',
'settings': 'nav.settings',
'git': 'nav.git'
};
dashboards: "nav.dashboard",
datasets: "nav.tools_mapper",
storage: "nav.tools_storage",
admin: "nav.admin",
settings: "nav.settings",
git: "nav.git",
};
if (specialCases[segment]) {
return _(specialCases[segment]) || segment;
}
// Default: capitalize and replace hyphens with spaces
return segment
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
</script>
<style>
.breadcrumbs {
@apply flex items-center space-x-2 text-sm text-gray-600;
}
.breadcrumb-item {
@apply flex items-center;
}
.breadcrumb-link {
@apply hover:text-blue-600 hover:underline cursor-pointer transition-colors;
}
.breadcrumb-current {
@apply text-gray-900 font-medium;
}
.breadcrumb-separator {
@apply text-gray-400;
}
</style>
<nav class="breadcrumbs" aria-label="Breadcrumb navigation">
<nav
class="flex items-center space-x-2 text-sm text-gray-600"
aria-label="Breadcrumb navigation"
>
{#each breadcrumbItems as item, index}
<div class="breadcrumb-item">
<div class="flex items-center">
{#if item.isEllipsis}
<span class="breadcrumb-separator">...</span>
<span class="text-gray-400">...</span>
{:else if item.isLast}
<span class="breadcrumb-current">{item.label}</span>
<span class="text-gray-900 font-medium">{item.label}</span>
{:else}
<a href={item.path} class="breadcrumb-link">{item.label}</a>
<a
href={item.path}
class="hover:text-primary hover:underline cursor-pointer transition-colors"
>{item.label}</a
>
{/if}
</div>
{#if index < breadcrumbItems.length - 1}
<span class="breadcrumb-separator">/</span>
<span class="text-gray-400">/</span>
{/if}
{/each}
</nav>

View File

@@ -30,7 +30,7 @@
{
id: "dashboards",
label: $t.nav?.dashboards || "DASHBOARDS",
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z", // Grid icon
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
path: "/dashboards",
subItems: [
{ label: $t.nav?.overview || "Overview", path: "/dashboards" },
@@ -39,7 +39,7 @@
{
id: "datasets",
label: $t.nav?.datasets || "DATASETS",
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z", // List icon
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z",
path: "/datasets",
subItems: [
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
@@ -48,7 +48,7 @@
{
id: "storage",
label: $t.nav?.storage || "STORAGE",
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z", // Folder icon
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
path: "/storage",
subItems: [
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" },
@@ -61,7 +61,7 @@
{
id: "admin",
label: $t.nav?.admin || "ADMIN",
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z", // User icon
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z",
path: "/admin",
subItems: [
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" },
@@ -75,7 +75,7 @@
let activeCategory = "dashboards";
let activeItem = "/dashboards";
let isMobileOpen = false;
let expandedCategories = new Set(["dashboards"]); // Track expanded categories
let expandedCategories = new Set(["dashboards"]);
// Subscribe to sidebar store
$: if ($sidebarStore) {
@@ -90,7 +90,7 @@
{
id: "dashboards",
label: $t.nav?.dashboards || "DASHBOARDS",
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z", // Grid icon
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
path: "/dashboards",
subItems: [
{ label: $t.nav?.overview || "Overview", path: "/dashboards" },
@@ -99,7 +99,7 @@
{
id: "datasets",
label: $t.nav?.datasets || "DATASETS",
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z", // List icon
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z",
path: "/datasets",
subItems: [
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
@@ -108,7 +108,7 @@
{
id: "storage",
label: $t.nav?.storage || "STORAGE",
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z", // Folder icon
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
path: "/storage",
subItems: [
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" },
@@ -121,7 +121,7 @@
{
id: "admin",
label: $t.nav?.admin || "ADMIN",
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z", // User icon
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z",
path: "/admin",
subItems: [
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" },
@@ -133,7 +133,6 @@
// Update active item when page changes
$: if ($page && $page.url.pathname !== activeItem) {
// Find matching category
const matched = categories.find((cat) =>
$page.url.pathname.startsWith(cat.path),
);
@@ -143,7 +142,6 @@
}
}
// Handle click on sidebar item
function handleItemClick(category) {
console.log(`[Sidebar][Action] Clicked category ${category.id}`);
setActiveItem(category.id, category.path);
@@ -153,7 +151,6 @@
}
}
// Handle click on category header to toggle expansion
function handleCategoryToggle(categoryId, event) {
event.stopPropagation();
@@ -173,28 +170,24 @@
} else {
expandedCategories.add(categoryId);
}
expandedCategories = expandedCategories; // Trigger reactivity
expandedCategories = expandedCategories;
}
// Handle click on sub-item
function handleSubItemClick(categoryId, path) {
console.log(`[Sidebar][Action] Clicked sub-item ${path}`);
setActiveItem(categoryId, path);
closeMobile();
// Force navigation if it's a link
if (browser) {
window.location.href = path;
}
}
// Handle toggle button click
function handleToggleClick(event) {
event.stopPropagation();
console.log("[Sidebar][Action] Toggle sidebar");
toggleSidebar();
}
// Handle mobile overlay click
function handleOverlayClick() {
console.log("[Sidebar][Action] Close mobile overlay");
closeMobile();
@@ -209,7 +202,7 @@
<!-- Mobile overlay (only on mobile) -->
{#if isMobileOpen}
<div
class="mobile-overlay"
class="fixed inset-0 bg-black/50 z-20 md:hidden"
on:click={handleOverlayClick}
on:keydown={(e) => e.key === "Escape" && handleOverlayClick()}
role="presentation"
@@ -218,12 +211,18 @@
<!-- Sidebar -->
<div
class="sidebar {isExpanded ? 'expanded' : 'collapsed'} {isMobileOpen
? 'mobile'
: 'mobile-hidden'}"
class="bg-white border-r border-gray-200 flex flex-col h-screen fixed left-0 top-0 z-30 transition-[width] duration-200 ease-in-out
{isExpanded ? 'w-sidebar' : 'w-sidebar-collapsed'}
{isMobileOpen
? 'translate-x-0 w-sidebar'
: '-translate-x-full md:translate-x-0'}"
>
<!-- Header (simplified, toggle moved to footer) -->
<div class="sidebar-header {isExpanded ? '' : 'collapsed'}">
<!-- Header -->
<div
class="flex items-center p-4 border-b border-gray-200 {isExpanded
? 'justify-between'
: 'justify-center'}"
>
{#if isExpanded}
<span class="font-semibold text-gray-800">Menu</span>
{:else}
@@ -232,13 +231,14 @@
</div>
<!-- Navigation items -->
<nav class="nav-section">
<nav class="flex-1 overflow-y-auto py-2">
{#each categories as category}
<div class="category">
<div>
<!-- Category Header -->
<div
class="category-header {activeCategory === category.id
? 'active'
class="flex items-center justify-between px-4 py-3 cursor-pointer transition-colors hover:bg-gray-100
{activeCategory === category.id
? 'bg-primary-light text-primary md:border-r-2 md:border-primary'
: ''}"
on:click={(e) => handleCategoryToggle(category.id, e)}
on:keydown={(e) =>
@@ -251,7 +251,7 @@
>
<div class="flex items-center">
<svg
class="nav-icon"
class="w-5 h-5 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@@ -261,13 +261,17 @@
<path d={category.icon} />
</svg>
{#if isExpanded}
<span class="nav-label">{category.label}</span>
<span class="ml-3 text-sm font-medium truncate"
>{category.label}</span
>
{/if}
</div>
{#if isExpanded}
<svg
class="category-toggle {expandedCategories.has(category.id)
? 'expanded'
class="text-gray-400 transition-transform duration-200 {expandedCategories.has(
category.id,
)
? 'rotate-180'
: ''}"
xmlns="http://www.w3.org/2000/svg"
width="16"
@@ -284,10 +288,13 @@
<!-- Sub Items (only when expanded) -->
{#if isExpanded && expandedCategories.has(category.id)}
<div class="sub-items">
<div class="bg-gray-50 overflow-hidden transition-all duration-200">
{#each category.subItems as subItem}
<div
class="sub-item {activeItem === subItem.path ? 'active' : ''}"
class="flex items-center px-4 py-2 pl-12 cursor-pointer transition-colors text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900
{activeItem === subItem.path
? 'bg-primary-light text-primary'
: ''}"
on:click={() => handleSubItemClick(category.id, subItem.path)}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
@@ -306,8 +313,11 @@
<!-- Footer with Collapse button -->
{#if isExpanded}
<div class="sidebar-footer">
<button class="collapse-btn" on:click={handleToggleClick}>
<div class="border-t border-gray-200 p-4">
<button
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
on:click={handleToggleClick}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@@ -324,8 +334,12 @@
</button>
</div>
{:else}
<div class="sidebar-footer">
<button class="collapse-btn" on:click={handleToggleClick} aria-label="Expand sidebar">
<div class="border-t border-gray-200 p-4">
<button
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
on:click={handleToggleClick}
aria-label="Expand sidebar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@@ -337,101 +351,10 @@
>
<path d="M9 18l6-6-6-6" />
</svg>
<span class="collapse-btn-text">Expand</span>
<span class="ml-2">Expand</span>
</button>
</div>
{/if}
</div>
<!-- [/DEF:Sidebar:Component] -->
<style>
.sidebar {
@apply bg-white border-r border-gray-200 flex flex-col h-screen fixed left-0 top-0 z-30;
transition: width 0.2s ease-in-out;
}
.sidebar.expanded {
width: 240px;
}
.sidebar.collapsed {
width: 64px;
}
.sidebar.mobile {
@apply translate-x-0;
width: 240px;
}
.sidebar.mobile-hidden {
@apply -translate-x-full md:translate-x-0;
}
.sidebar-header {
@apply flex items-center justify-between p-4 border-b border-gray-200;
}
.sidebar-header.collapsed {
@apply justify-center;
}
.nav-icon {
@apply w-5 h-5 flex-shrink-0;
}
.nav-label {
@apply ml-3 text-sm font-medium truncate;
}
.category-header {
@apply flex items-center justify-between px-4 py-3 cursor-pointer transition-colors hover:bg-gray-100;
}
.category-header.active {
@apply bg-blue-50 text-blue-600 md:border-r-2 md:border-blue-600;
}
.category-toggle {
@apply text-gray-400 transition-transform duration-200;
}
.category-toggle.expanded {
@apply rotate-180;
}
.sub-items {
@apply bg-gray-50 overflow-hidden transition-all duration-200;
}
.sub-item {
@apply flex items-center px-4 py-2 pl-12 cursor-pointer transition-colors text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900;
}
.sub-item.active {
@apply bg-blue-50 text-blue-600;
}
.sidebar-footer {
@apply border-t border-gray-200 p-4;
}
.collapse-btn {
@apply flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors;
}
.collapse-btn-text {
@apply ml-2;
}
/* Mobile overlay */
.mobile-overlay {
@apply fixed inset-0 bg-black bg-opacity-50 z-20;
}
@media (min-width: 768px) {
.mobile-overlay {
@apply hidden;
}
}
</style>

View File

@@ -31,54 +31,38 @@
let showUserMenu = false;
let isSearchFocused = false;
// Subscribe to sidebar store for responsive layout
$: isExpanded = $sidebarStore?.isExpanded ?? true;
// Subscribe to activity store
$: activeCount = $activityStore?.activeCount || 0;
$: recentTasks = $activityStore?.recentTasks || [];
// Get user from auth store
$: user = $auth?.user || null;
// Toggle user menu
function toggleUserMenu(event) {
event.stopPropagation();
showUserMenu = !showUserMenu;
console.log(`[TopNavbar][Action] Toggle user menu: ${showUserMenu}`);
}
// Close user menu
function closeUserMenu() {
showUserMenu = false;
}
// Handle logout
function handleLogout() {
console.log("[TopNavbar][Action] Logout");
auth.logout();
closeUserMenu();
// Navigate to login
window.location.href = "/login";
}
// Handle activity indicator click - open Task Drawer with most recent task
function handleActivityClick() {
console.log("[TopNavbar][Action] Activity indicator clicked");
// Open drawer with the most recent running task, or list mode
const runningTask = recentTasks.find((t) => t.status === "RUNNING");
if (runningTask) {
openDrawerForTask(runningTask.taskId);
} else if (recentTasks.length > 0) {
openDrawerForTask(recentTasks[recentTasks.length - 1].taskId);
} else {
// No tracked tasks — open in list mode to show recent tasks from API
openDrawer();
}
dispatch("activityClick");
}
// Handle search focus
function handleSearchFocus() {
isSearchFocused = true;
}
@@ -87,34 +71,31 @@
isSearchFocused = false;
}
// Close dropdowns when clicking outside
function handleDocumentClick(event) {
if (!event.target.closest(".user-menu-container")) {
closeUserMenu();
}
}
// Listen for document clicks
if (typeof document !== "undefined") {
document.addEventListener("click", handleDocumentClick);
}
// Handle hamburger menu click for mobile
function handleHamburgerClick(event) {
event.stopPropagation();
console.log("[TopNavbar][Action] Toggle mobile sidebar");
toggleMobileSidebar();
}
</script>
<nav
class="navbar {isExpanded ? 'with-sidebar' : 'with-collapsed-sidebar'} mobile"
class="bg-white border-b border-gray-200 fixed top-0 right-0 left-0 h-16 flex items-center justify-between px-4 z-40
{isExpanded ? 'md:left-[240px]' : 'md:left-16'}"
>
<!-- Left section: Hamburger (mobile) + Logo -->
<div class="flex items-center gap-2">
<!-- Hamburger Menu (mobile only) -->
<button
class="hamburger-btn"
class="p-2 rounded-lg hover:bg-gray-100 text-gray-600 md:hidden"
on:click={handleHamburgerClick}
aria-label="Toggle menu"
>
@@ -134,9 +115,12 @@
</button>
<!-- Logo/Brand -->
<a href="/" class="logo-link">
<a
href="/"
class="flex items-center text-xl font-bold text-gray-800 hover:text-primary transition-colors"
>
<svg
class="logo-icon"
class="w-8 h-8 mr-2 text-primary"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
@@ -148,10 +132,11 @@
</div>
<!-- Search placeholder (non-functional for now) -->
<div class="search-container">
<div class="flex-1 max-w-xl mx-4 hidden md:block">
<input
type="text"
class="search-input {isSearchFocused ? 'focused' : ''}"
class="w-full px-4 py-2 bg-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-ring transition-all
{isSearchFocused ? 'bg-white border border-primary-ring' : ''}"
placeholder={$t.common.search || "Search..."}
on:focus={handleSearchFocus}
on:blur={handleSearchBlur}
@@ -159,10 +144,10 @@
</div>
<!-- Nav Actions -->
<div class="nav-actions">
<div class="flex items-center space-x-4">
<!-- Activity Indicator -->
<div
class="activity-indicator"
class="relative cursor-pointer p-2 rounded-lg hover:bg-gray-100 transition-colors"
on:click={handleActivityClick}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && handleActivityClick()}
@@ -171,7 +156,7 @@
aria-label="Activity"
>
<svg
class="activity-icon"
class="w-6 h-6 text-gray-600"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@@ -183,14 +168,17 @@
/>
</svg>
{#if activeCount > 0}
<span class="activity-badge">{activeCount}</span>
<span
class="absolute -top-1 -right-1 bg-destructive text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
>{activeCount}</span
>
{/if}
</div>
<!-- User Menu -->
<div class="user-menu-container">
<div class="user-menu-container relative">
<div
class="user-avatar"
class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center cursor-pointer hover:bg-primary-hover transition-colors"
on:click={toggleUserMenu}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && toggleUserMenu(e)}
@@ -208,13 +196,17 @@
</div>
<!-- User Dropdown -->
<div class="user-dropdown {showUserMenu ? '' : 'hidden'}">
<div class="dropdown-item">
<div
class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50 {showUserMenu
? ''
: 'hidden'}"
>
<div class="px-4 py-2 text-sm text-gray-700">
<strong>{user?.username || "User"}</strong>
</div>
<div class="dropdown-divider"></div>
<div class="border-t border-gray-200 my-1"></div>
<div
class="dropdown-item"
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
on:click={() => {
window.location.href = "/settings";
}}
@@ -227,7 +219,7 @@
{$t.nav?.settings || "Settings"}
</div>
<div
class="dropdown-item danger"
class="px-4 py-2 text-sm text-destructive hover:bg-destructive-light cursor-pointer"
on:click={handleLogout}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") && handleLogout()}
@@ -242,96 +234,3 @@
</nav>
<!-- [/DEF:TopNavbar:Component] -->
<style>
.navbar {
@apply bg-white border-b border-gray-200 fixed top-0 right-0 left-0 h-16 flex items-center justify-between px-4 z-40;
}
.navbar.with-sidebar {
@apply md:left-64;
}
.navbar.with-collapsed-sidebar {
@apply md:left-16;
}
.navbar.mobile {
@apply left-0;
}
.logo-link {
@apply flex items-center text-xl font-bold text-gray-800 hover:text-blue-600 transition-colors;
}
.logo-icon {
@apply w-8 h-8 mr-2 text-blue-600;
}
.search-container {
@apply flex-1 max-w-xl mx-4;
}
.search-input {
@apply w-full px-4 py-2 bg-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all;
}
.search-input.focused {
@apply bg-white border border-blue-500;
}
.nav-actions {
@apply flex items-center space-x-4;
}
.hamburger-btn {
@apply p-2 rounded-lg hover:bg-gray-100 text-gray-600 md:hidden;
}
.activity-indicator {
@apply relative cursor-pointer p-2 rounded-lg hover:bg-gray-100 transition-colors;
}
.activity-badge {
@apply absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center;
}
.activity-icon {
@apply w-6 h-6 text-gray-600;
}
.user-menu-container {
@apply relative;
}
.user-avatar {
@apply w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center cursor-pointer hover:bg-blue-700 transition-colors;
}
.user-dropdown {
@apply absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50;
}
.user-dropdown.hidden {
display: none;
}
.dropdown-item {
@apply px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer;
}
.dropdown-item.danger {
@apply text-red-600 hover:bg-red-50;
}
.dropdown-divider {
@apply border-t border-gray-200 my-1;
}
/* Mobile responsive */
@media (max-width: 768px) {
.search-container {
display: none;
}
}
</style>

View File

@@ -9,45 +9,51 @@
@INVARIANT: Supports accessible labels and keyboard navigation.
-->
<script lang="ts">
<script>
// [SECTION: IMPORTS]
import { cn } from '$lib/utils.js';
// [/SECTION: IMPORTS]
// [SECTION: PROPS]
/**
* @purpose Define component interface and default values.
* @purpose Define component interface and default values (Svelte 5 Runes).
*/
export let variant: 'primary' | 'secondary' | 'danger' | 'ghost' = 'primary';
export let size: 'sm' | 'md' | 'lg' = 'md';
export let isLoading: boolean = false;
export let disabled: boolean = false;
export let type: 'button' | 'submit' | 'reset' = 'button';
let className: string = "";
export { className as class };
let {
variant = 'primary',
size = 'md',
isLoading = false,
disabled = false,
type = 'button',
class: className = '',
children,
onclick,
...rest
} = $props();
// [/SECTION: PROPS]
const baseStyles = "inline-flex items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 rounded-md";
const baseStyles = 'inline-flex items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 rounded-md';
const variants = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-500",
danger: "bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500",
ghost: "bg-transparent hover:bg-gray-100 text-gray-700 focus-visible:ring-gray-500"
primary: 'bg-primary text-white hover:bg-primary-hover focus-visible:ring-primary-ring',
secondary: 'bg-secondary text-secondary-text hover:bg-secondary-hover focus-visible:ring-secondary-ring',
danger: 'bg-destructive text-white hover:bg-destructive-hover focus-visible:ring-destructive-ring',
ghost: 'bg-transparent hover:bg-ghost-hover text-ghost-text focus-visible:ring-ghost-ring',
};
const sizes = {
sm: "h-8 px-3 text-xs",
md: "h-10 px-4 py-2 text-sm",
lg: "h-12 px-6 text-base"
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 py-2 text-sm',
lg: 'h-12 px-6 text-base',
};
</script>
<!-- [SECTION: TEMPLATE] -->
<button
{type}
class="{baseStyles} {variants[variant]} {sizes[size]} {className}"
class={cn(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || isLoading}
on:click
{onclick}
{...rest}
>
{#if isLoading}
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -55,7 +61,7 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{/if}
<slot />
{@render children?.()}
</button>
<!-- [/SECTION: TEMPLATE] -->

View File

@@ -6,31 +6,46 @@
@LAYER: Atom
-->
<script lang="ts">
<script>
// [SECTION: IMPORTS]
import { cn } from "$lib/utils.js";
// [/SECTION: IMPORTS]
// [SECTION: PROPS]
export let title: string = "";
export let padding: 'none' | 'sm' | 'md' | 'lg' = 'md';
let {
title = "",
padding = "md",
class: className = "",
children,
...rest
} = $props();
// [/SECTION: PROPS]
const paddings = {
none: "p-0",
sm: "p-3",
md: "p-6",
lg: "p-8"
lg: "p-8",
};
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="rounded-lg border border-gray-200 bg-white text-gray-950 shadow-sm">
<div
class={cn(
"rounded-lg border border-gray-200 bg-white text-gray-950 shadow-sm",
className,
)}
{...rest}
>
{#if title}
<div class="flex flex-col space-y-1.5 p-6 border-b border-gray-100">
<h3 class="text-lg font-semibold leading-none tracking-tight">{title}</h3>
</div>
{/if}
<div class="{paddings[padding]}">
<slot />
<div class={paddings[padding]}>
{@render children?.()}
</div>
</div>
<!-- [/SECTION: TEMPLATE] -->
<!-- [/DEF:Card:Component] -->
<!-- [/DEF:Card:Component] -->

View File

@@ -8,14 +8,22 @@
@INVARIANT: Consistent spacing and focus states.
-->
<script lang="ts">
<script>
// [SECTION: IMPORTS]
import { cn } from "$lib/utils.js";
// [/SECTION: IMPORTS]
// [SECTION: PROPS]
export let label: string = "";
export let value: string = "";
export let placeholder: string = "";
export let error: string = "";
export let disabled: boolean = false;
export let type: 'text' | 'password' | 'email' | 'number' = 'text';
let {
label = "",
value = $bindable(""),
placeholder = "",
error = "",
disabled = false,
type = "text",
class: className = "",
...rest
} = $props();
// [/SECTION: PROPS]
let id = "input-" + Math.random().toString(36).substr(2, 9);
@@ -28,20 +36,25 @@
{label}
</label>
{/if}
<input
{id}
{type}
{placeholder}
{disabled}
bind:value
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {error ? 'border-red-500' : ''}"
class={cn(
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
error ? "border-destructive" : "",
className,
)}
{...rest}
/>
{#if error}
<span class="text-xs text-red-500">{error}</span>
<span class="text-xs text-destructive">{error}</span>
{/if}
</div>
<!-- [/SECTION: TEMPLATE] -->
<!-- [/DEF:Input:Component] -->
<!-- [/DEF:Input:Component] -->

View File

@@ -7,25 +7,22 @@
@RELATION: BINDS_TO -> i18n.locale
-->
<script lang="ts">
<script>
// [SECTION: IMPORTS]
import { locale } from '$lib/i18n';
import Select from './Select.svelte';
import { locale } from "$lib/i18n";
import Select from "./Select.svelte";
// [/SECTION: IMPORTS]
const options = [
{ value: 'ru', label: 'Русский' },
{ value: 'en', label: 'English' }
{ value: "ru", label: "Русский" },
{ value: "en", label: "English" },
];
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="w-32">
<Select
bind:value={$locale}
{options}
/>
<Select bind:value={$locale} {options} />
</div>
<!-- [/SECTION: TEMPLATE] -->
<!-- [/DEF:LanguageSwitcher:Component] -->
<!-- [/DEF:LanguageSwitcher:Component] -->

View File

@@ -6,22 +6,35 @@
@LAYER: Atom
-->
<script lang="ts">
<script>
// [SECTION: IMPORTS]
import { cn } from "$lib/utils.js";
// [/SECTION: IMPORTS]
// [SECTION: PROPS]
export let title: string = "";
let {
title = "",
class: className = "",
subtitle,
actions,
...rest
} = $props();
// [/SECTION: PROPS]
</script>
<!-- [SECTION: TEMPLATE] -->
<header class="flex items-center justify-between mb-8">
<header
class={cn("flex items-center justify-between mb-8", className)}
{...rest}
>
<div class="space-y-1">
<h1 class="text-3xl font-bold tracking-tight text-gray-900">{title}</h1>
<slot name="subtitle" />
{@render subtitle?.()}
</div>
<div class="flex items-center gap-4">
<slot name="actions" />
{@render actions?.()}
</div>
</header>
<!-- [/SECTION: TEMPLATE] -->
<!-- [/DEF:PageHeader:Component] -->
<!-- [/DEF:PageHeader:Component] -->

View File

@@ -6,12 +6,20 @@
@LAYER: Atom
-->
<script lang="ts">
<script>
// [SECTION: IMPORTS]
import { cn } from "$lib/utils.js";
// [/SECTION: IMPORTS]
// [SECTION: PROPS]
export let label: string = "";
export let value: string | number = "";
export let options: Array<{ value: string | number, label: string }> = [];
export let disabled: boolean = false;
let {
label = "",
value = $bindable(""),
options = [],
disabled = false,
class: className = "",
...rest
} = $props();
// [/SECTION: PROPS]
let id = "select-" + Math.random().toString(36).substr(2, 9);
@@ -29,7 +37,11 @@
{id}
{disabled}
bind:value
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
class={cn(
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...rest}
>
{#each options as option}
<option value={option.value}>{option.label}</option>
@@ -38,4 +50,4 @@
</div>
<!-- [/SECTION: TEMPLATE] -->
<!-- [/DEF:Select:Component] -->
<!-- [/DEF:Select:Component] -->

View File

@@ -0,0 +1,8 @@
/**
* Merges class names into a single string.
* @param {...(string | undefined | null | false)} inputs
* @returns {string}
*/
export function cn(...inputs) {
return inputs.filter(Boolean).join(" ");
}