Browse Source

Merge branch '0.2.0b' into feature/storage_meter

Keybored 3 months ago
parent
commit
8f0abb9bde

+ 12 - 12
frontend/src/components/AMSHistoryModal.tsx

@@ -187,8 +187,8 @@ export function AMSHistoryModal({
         {/* Content */}
         <div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-80px)]">
           {/* Time Range & Mode Selector */}
-          <div className="flex items-center justify-between">
-            <div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: cardBg }}>
+          <div className="flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-3">
+            <div className="inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit" style={{ backgroundColor: cardBg }}>
               <button
                 onClick={() => setMode('humidity')}
                 className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
@@ -211,7 +211,7 @@ export function AMSHistoryModal({
               </button>
             </div>
 
-            <div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: cardBg }}>
+            <div className="inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit" style={{ backgroundColor: cardBg }}>
               {TIME_RANGES.map(range => (
                 <button
                   key={range.value}
@@ -228,10 +228,10 @@ export function AMSHistoryModal({
           </div>
 
           {/* Stats Cards */}
-          <div className="grid grid-cols-4 gap-4">
+          <div className="grid grid-cols-4 gap-4 max-[550px]:grid-cols-2">
             {mode === 'humidity' ? (
               <>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-2" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>
                   <div className="flex items-center gap-2">
                     <p className="text-2xl font-bold" style={{ color: getHumidityColor(currentHumidity) }}>
@@ -240,19 +240,19 @@ export function AMSHistoryModal({
                     <TrendIcon trend={humidityTrend} />
                   </div>
                 </div>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-4" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>
                   <p className="text-2xl font-bold" style={{ color: textPrimary }}>
                     {data?.avg_humidity != null ? `${data.avg_humidity}%` : '—'}
                   </p>
                 </div>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-1" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>
                   <p className="text-2xl font-bold text-green-500">
                     {data?.min_humidity != null ? `${data.min_humidity}%` : '—'}
                   </p>
                 </div>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-3" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>
                   <p className="text-2xl font-bold text-red-500">
                     {data?.max_humidity != null ? `${data.max_humidity}%` : '—'}
@@ -261,7 +261,7 @@ export function AMSHistoryModal({
               </>
             ) : (
               <>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-2" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>
                   <div className="flex items-center gap-2">
                     <p className="text-2xl font-bold" style={{ color: getTempColor(currentTemp) }}>
@@ -270,19 +270,19 @@ export function AMSHistoryModal({
                     <TrendIcon trend={tempTrend} />
                   </div>
                 </div>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-4" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>
                   <p className="text-2xl font-bold" style={{ color: textPrimary }}>
                     {data?.avg_temperature != null ? `${data.avg_temperature}°C` : '—'}
                   </p>
                 </div>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-1" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>
                   <p className="text-2xl font-bold text-blue-500">
                     {data?.min_temperature != null ? `${data.min_temperature}°C` : '—'}
                   </p>
                 </div>
-                <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
+                <div className="rounded-lg p-4 max-[550px]:order-3" style={{ backgroundColor: cardBg }}>
                   <p className="text-xs" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>
                   <p className="text-2xl font-bold text-red-500">
                     {data?.max_temperature != null ? `${data.max_temperature}°C` : '—'}

+ 48 - 15
frontend/src/components/Dashboard.tsx

@@ -32,6 +32,7 @@ interface DashboardProps {
   widgets: DashboardWidget[];
   storageKey: string;
   columns?: number;
+  stackBelow?: number;
   hideControls?: boolean;
   onResetLayout?: () => void;
   renderControls?: (controls: {
@@ -54,6 +55,7 @@ function SortableWidget({
   component,
   isHidden,
   size,
+  columnSpan,
   onToggleVisibility,
   onToggleSize,
 }: {
@@ -62,6 +64,7 @@ function SortableWidget({
   component: ReactNode | ((size: 1 | 2 | 4) => ReactNode);
   isHidden: boolean;
   size: 1 | 2 | 4;
+  columnSpan: number;
   onToggleVisibility: () => void;
   onToggleSize: () => void;
 }) {
@@ -87,7 +90,7 @@ function SortableWidget({
       ref={setNodeRef}
       style={{
         ...style,
-        gridColumn: `span ${size}`,
+        gridColumn: `span ${columnSpan}`,
       }}
       className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden ${
         isDragging ? 'ring-2 ring-bambu-green shadow-lg' : ''
@@ -135,7 +138,7 @@ function SortableWidget({
   );
 }
 
-export function Dashboard({ widgets, storageKey, columns = 4, hideControls = false, onResetLayout, renderControls }: DashboardProps) {
+export function Dashboard({ widgets, storageKey, columns = 4, stackBelow, hideControls = false, onResetLayout, renderControls }: DashboardProps) {
   // Build default sizes from widget definitions
   const getDefaultSizes = () => {
     const sizes: Record<string, 1 | 2 | 4> = {};
@@ -169,6 +172,31 @@ export function Dashboard({ widgets, storageKey, columns = 4, hideControls = fal
   });
 
   const [showHiddenPanel, setShowHiddenPanel] = useState(false);
+  const [isStacked, setIsStacked] = useState(false);
+
+  useEffect(() => {
+    if (!stackBelow) return undefined;
+    const mediaQuery = window.matchMedia(`(max-width: ${stackBelow}px)`);
+    const handleChange = (event: MediaQueryListEvent | MediaQueryList) => {
+      setIsStacked(event.matches);
+    };
+    handleChange(mediaQuery);
+    const onChange = (event: MediaQueryListEvent) => handleChange(event);
+    if (mediaQuery.addEventListener) {
+      mediaQuery.addEventListener('change', onChange);
+    } else {
+      mediaQuery.addListener(onChange);
+    }
+    return () => {
+      if (mediaQuery.removeEventListener) {
+        mediaQuery.removeEventListener('change', onChange);
+      } else {
+        mediaQuery.removeListener(onChange);
+      }
+    };
+  }, [stackBelow]);
+
+  const effectiveColumns = stackBelow && isStacked ? 1 : columns;
 
   // Listen for toggle-hidden-panel event from parent
   useEffect(() => {
@@ -324,21 +352,26 @@ export function Dashboard({ widgets, storageKey, columns = 4, hideControls = fal
           <div
             className="grid gap-6"
             style={{
-              gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
+              gridTemplateColumns: `repeat(${effectiveColumns}, minmax(0, 1fr))`,
             }}
           >
-            {visibleWidgets.map((widget) => (
-              <SortableWidget
-                key={widget.id}
-                id={widget.id}
-                title={widget.title}
-                component={widget.component}
-                isHidden={layout.hidden.includes(widget.id)}
-                size={layout.sizes[widget.id] || 2}
-                onToggleVisibility={() => toggleVisibility(widget.id)}
-                onToggleSize={() => toggleSize(widget.id)}
-              />
-            ))}
+            {visibleWidgets.map((widget) => {
+              const size = layout.sizes[widget.id] || 2;
+              const columnSpan = Math.min(size, effectiveColumns);
+              return (
+                <SortableWidget
+                  key={widget.id}
+                  id={widget.id}
+                  title={widget.title}
+                  component={widget.component}
+                  isHidden={layout.hidden.includes(widget.id)}
+                  size={size}
+                  columnSpan={columnSpan}
+                  onToggleVisibility={() => toggleVisibility(widget.id)}
+                  onToggleSize={() => toggleSize(widget.id)}
+                />
+              );
+            })}
           </div>
         </SortableContext>
       </DndContext>

+ 18 - 12
frontend/src/components/FilamentTrends.tsx

@@ -157,7 +157,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
   return (
     <div className="space-y-6">
       {/* Time Range Selector */}
-      <div className="flex items-center justify-between">
+      <div className="flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-2">
         <h3 className="text-lg font-semibold text-white">Filament Usage Trends</h3>
         <div className="flex gap-1 bg-bambu-dark rounded-lg p-1">
           {(['7d', '30d', '90d', '365d', 'all'] as TimeRange[]).map((range) => (
@@ -177,24 +177,30 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
       </div>
 
       {/* Summary Cards */}
-      <div className="grid grid-cols-3 gap-4">
+      <div className="grid grid-cols-3 gap-4 max-[640px]:grid-cols-1">
         <div className="bg-bambu-dark rounded-lg p-4">
-          <p className="text-sm text-bambu-gray">Period Filament</p>
-          <p className="text-2xl font-bold text-white">{(totalFilament / 1000).toFixed(2)}kg</p>
+          <div className="flex items-center justify-between gap-3">
+            <p className="text-sm text-bambu-gray leading-none">Period Filament</p>
+            <p className="text-2xl font-bold text-white leading-none">{(totalFilament / 1000).toFixed(2)}kg</p>
+          </div>
           <p className="text-xs text-bambu-gray">{totalFilament.toFixed(0)}g total</p>
         </div>
         <div className="bg-bambu-dark rounded-lg p-4">
-          <p className="text-sm text-bambu-gray">Period Cost</p>
-          <p className="text-2xl font-bold text-white">{currency}{totalCost.toFixed(2)}</p>
+          <div className="flex items-center justify-between gap-3">
+            <p className="text-sm text-bambu-gray leading-none">Period Cost</p>
+            <p className="text-2xl font-bold text-white leading-none">{currency}{totalCost.toFixed(2)}</p>
+          </div>
           <p className="text-xs text-bambu-gray">{totalPrints} prints</p>
         </div>
         <div className="bg-bambu-dark rounded-lg p-4">
-          <p className="text-sm text-bambu-gray">Avg per Print</p>
-          <p className="text-2xl font-bold text-white">
-            {totalPrints > 0
-              ? (totalFilament / totalPrints).toFixed(0)
-              : 0}g
-          </p>
+          <div className="flex items-center justify-between gap-3">
+            <p className="text-sm text-bambu-gray leading-none">Avg per Print</p>
+            <p className="text-2xl font-bold text-white leading-none">
+              {totalPrints > 0
+                ? (totalFilament / totalPrints).toFixed(0)
+                : 0}g
+            </p>
+          </div>
           <p className="text-xs text-bambu-gray">
             {currency}{totalPrints > 0 ? (totalCost / totalPrints).toFixed(2) : '0.00'} avg
           </p>

+ 28 - 28
frontend/src/components/Layout.tsx

@@ -8,7 +8,7 @@ import { SwitchbarPopover } from './SwitchbarPopover';
 import { useQuery } from '@tanstack/react-query';
 import { api, supportApi, pendingUploadsApi } from '../api/client';
 import { getIconByName } from './IconPicker';
-import { useIsMobile } from '../hooks/useIsMobile';
+import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { Card, CardHeader, CardContent } from './Card';
@@ -72,7 +72,7 @@ export function Layout() {
   const location = useLocation();
   const { mode, toggleMode } = useTheme();
   const { t } = useTranslation();
-  const isMobile = useIsMobile();
+  const isSidebarCompact = useIsSidebarCompact();
   const { user, authEnabled, logout, hasPermission } = useAuth();
   const { showToast } = useToast();
   const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
@@ -311,12 +311,12 @@ export function Layout() {
     localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
   }, [sidebarExpanded]);
 
-  // Close mobile drawer on navigation
+  // Close compact drawer on navigation
   useEffect(() => {
-    if (isMobile) {
+    if (isSidebarCompact) {
       setMobileDrawerOpen(false);
     }
-  }, [location.pathname, isMobile]);
+  }, [location.pathname, isSidebarCompact]);
 
   // Listen for plate detection warnings (objects on plate, print paused)
   // Only show to users with printers:control permission
@@ -390,8 +390,8 @@ export function Layout() {
 
   return (
     <div className="flex min-h-screen">
-      {/* Mobile Header */}
-      {isMobile && (
+      {/* Compact Header */}
+      {isSidebarCompact && (
         <header className="fixed top-0 left-0 right-0 z-40 h-14 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-4">
           <button
             onClick={() => setMobileDrawerOpen(true)}
@@ -408,8 +408,8 @@ export function Layout() {
         </header>
       )}
 
-      {/* Mobile Drawer Backdrop */}
-      {isMobile && mobileDrawerOpen && (
+      {/* Compact Drawer Backdrop */}
+      {isSidebarCompact && mobileDrawerOpen && (
         <div
           className="fixed inset-0 bg-black/60 z-40 transition-opacity"
           onClick={() => setMobileDrawerOpen(false)}
@@ -419,17 +419,17 @@ export function Layout() {
       {/* Sidebar / Mobile Drawer */}
       <aside
         className={`bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col transition-all duration-300 ${
-          isMobile
+          isSidebarCompact
             ? `fixed inset-y-0 left-0 z-50 w-72 transform ${mobileDrawerOpen ? 'translate-x-0' : '-translate-x-full'}`
             : `fixed inset-y-0 left-0 z-30 ${sidebarExpanded ? 'w-64' : 'w-16'}`
         }`}
       >
         {/* Logo */}
-        <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isMobile || sidebarExpanded ? 'p-4' : 'p-2'}`}>
+        <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isSidebarCompact || sidebarExpanded ? 'p-4' : 'p-2'}`}>
           <img
             src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
             alt="Bambuddy"
-            className={isMobile || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
+            className={isSidebarCompact || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
           />
         </div>
 
@@ -467,10 +467,10 @@ export function Layout() {
                         href={link.url}
                         target="_blank"
                         rel="noopener noreferrer"
-                        className={`flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`}
-                        title={!isMobile && !sidebarExpanded ? link.name : undefined}
+                        className={`flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`}
+                        title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
                       >
-                        {sidebarExpanded && !isMobile && (
+                        {sidebarExpanded && !isSidebarCompact && (
                           <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
                         )}
                         {link.custom_icon ? (
@@ -482,21 +482,21 @@ export function Layout() {
                         ) : (
                           LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
                         )}
-                        {(isMobile || sidebarExpanded) && <span>{link.name}</span>}
+                        {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
                       </a>
                     ) : (
                       <NavLink
                         to={`/external/${link.id}`}
                         className={({ isActive }) =>
-                          `flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
+                          `flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
                             isActive
                               ? 'bg-bambu-green text-white'
                               : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
                           }`
                         }
-                        title={!isMobile && !sidebarExpanded ? link.name : undefined}
+                        title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
                       >
-                        {sidebarExpanded && !isMobile && (
+                        {sidebarExpanded && !isSidebarCompact && (
                           <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
                         )}
                         {link.custom_icon ? (
@@ -508,7 +508,7 @@ export function Layout() {
                         ) : (
                           LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
                         )}
-                        {(isMobile || sidebarExpanded) && <span>{link.name}</span>}
+                        {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
                       </NavLink>
                     )}
                   </li>
@@ -544,15 +544,15 @@ export function Layout() {
                     <NavLink
                       to={to}
                       className={({ isActive }) =>
-                        `flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
+                        `flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
                           isActive
                             ? 'bg-bambu-green text-white'
                             : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
                         }`
                       }
-                      title={!isMobile && !sidebarExpanded ? t(labelKey) : undefined}
+                      title={!isSidebarCompact && !sidebarExpanded ? t(labelKey) : undefined}
                     >
-                      {sidebarExpanded && !isMobile && (
+                      {sidebarExpanded && !isSidebarCompact && (
                         <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
                       )}
                       <div className="relative">
@@ -565,7 +565,7 @@ export function Layout() {
                           </span>
                         )}
                       </div>
-                      {(isMobile || sidebarExpanded) && <span>{t(labelKey)}</span>}
+                      {(isSidebarCompact || sidebarExpanded) && <span>{t(labelKey)}</span>}
                     </NavLink>
                   </li>
                 );
@@ -574,8 +574,8 @@ export function Layout() {
           </ul>
         </nav>
 
-        {/* Collapse toggle - hide on mobile */}
-        {!isMobile && (
+        {/* Collapse toggle - hide on compact sidebar */}
+        {!isSidebarCompact && (
           <button
             onClick={() => setSidebarExpanded(!sidebarExpanded)}
             className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
@@ -591,7 +591,7 @@ export function Layout() {
 
         {/* Footer */}
         <div className="p-2 border-t border-bambu-dark-tertiary">
-          {isMobile || sidebarExpanded ? (
+          {isSidebarCompact || sidebarExpanded ? (
             <div className="flex flex-col gap-2 px-2">
               {/* Top row: icons */}
               <div className="flex items-center justify-center gap-1">
@@ -783,7 +783,7 @@ export function Layout() {
 
       {/* Main content */}
       <main className={`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${
-        isMobile ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
+        isSidebarCompact ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
       }`}>
         {/* Debug logging indicator */}
         {debugLoggingState?.enabled && (

+ 1 - 1
frontend/src/hooks/useIsMobile.ts

@@ -1,6 +1,6 @@
 import { useState, useEffect } from 'react';
 
-const MOBILE_BREAKPOINT = 768; // md breakpoint
+const MOBILE_BREAKPOINT = 768;
 
 export function useIsMobile(): boolean {
   const [isMobile, setIsMobile] = useState(() =>

+ 24 - 0
frontend/src/hooks/useIsSidebarCompact.ts

@@ -0,0 +1,24 @@
+import { useState, useEffect } from 'react';
+
+const SIDEBAR_COMPACT_BREAKPOINT = 1144;
+
+export function useIsSidebarCompact(): boolean {
+  const [isCompact, setIsCompact] = useState(() =>
+    typeof window !== 'undefined' ? window.innerWidth < SIDEBAR_COMPACT_BREAKPOINT : false
+  );
+
+  useEffect(() => {
+    const mediaQuery = window.matchMedia(`(max-width: ${SIDEBAR_COMPACT_BREAKPOINT - 1}px)`);
+
+    const handleChange = (e: MediaQueryListEvent) => {
+      setIsCompact(e.matches);
+    };
+
+    setIsCompact(mediaQuery.matches);
+
+    mediaQuery.addEventListener('change', handleChange);
+    return () => mediaQuery.removeEventListener('change', handleChange);
+  }, []);
+
+  return isCompact;
+}

+ 16 - 0
frontend/src/index.css

@@ -173,6 +173,22 @@
   --border-color: #2a3d30;
 }
 
+/* Printer card control buttons: stack only when they clip */
+.printer-control-buttons-container {
+  container-type: inline-size;
+}
+
+@container (max-width: 220px) {
+  .printer-control-buttons {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .printer-control-buttons > button {
+    width: 100%;
+  }
+}
+
 /* ============================================
    LAYER 2: STYLE EFFECTS
    ============================================ */

+ 3 - 3
frontend/src/pages/MaintenancePage.tsx

@@ -286,9 +286,9 @@ function MaintenanceCard({
 
   return (
     <div className={`rounded-xl border p-4 transition-all ${getBgColor()}`}>
-      <div className="flex items-start gap-3">
+      <div className="flex items-start gap-3 max-[550px]:flex-wrap">
         {/* Icon with status indicator */}
-        <div className={`relative p-2.5 rounded-lg ${
+        <div className={`relative p-2.5 rounded-lg shrink-0 ${
           item.is_due ? 'bg-red-500/20' :
           item.is_warning ? 'bg-amber-500/20' :
           item.enabled ? 'bg-bambu-dark' : 'bg-bambu-dark/50'
@@ -351,7 +351,7 @@ function MaintenanceCard({
         </div>
 
         {/* Actions */}
-        <div className="flex items-center gap-2 shrink-0">
+        <div className="flex items-center gap-2 shrink-0 max-[550px]:w-full max-[550px]:justify-end max-[550px]:mt-1">
           <span title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionUpdate') : undefined}>
             <Toggle
               checked={item.enabled}

+ 22 - 18
frontend/src/pages/PrintersPage.tsx

@@ -1309,7 +1309,7 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
   if (!printers?.length) return null;
 
   return (
-    <div className="flex items-center gap-4 text-sm">
+    <div className="flex flex-wrap items-center gap-4 gap-y-2 text-sm">
       <div className="flex items-center gap-1.5">
         <div className={`w-2 h-2 rounded-full ${counts.idle > 0 ? 'bg-bambu-green' : 'bg-gray-500'}`} />
         <span className="text-bambu-gray">
@@ -1335,17 +1335,21 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
       {nextFinish && (
         <>
           <div className="w-px h-4 bg-bambu-dark-tertiary" />
-          <div className="flex items-center gap-2">
-            <span className="text-bambu-green font-medium">{t('printers.nextAvailable')}:</span>
-            <span className="text-white font-medium">{nextFinish.name}</span>
-            <div className="w-16 bg-bambu-dark-tertiary rounded-full h-1.5">
-              <div
-                className="bg-bambu-green h-1.5 rounded-full transition-all"
-                style={{ width: `${nextFinish.progress}%` }}
-              />
+          <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
+            <div className="flex items-center gap-2">
+              <span className="text-bambu-green font-medium">{t('printers.nextAvailable')}:</span>
+              <span className="text-white font-medium">{nextFinish.name}</span>
+            </div>
+            <div className="flex items-center gap-2 w-full sm:w-auto">
+              <div className="w-full sm:w-16 bg-bambu-dark-tertiary rounded-full h-1.5">
+                <div
+                  className="bg-bambu-green h-1.5 rounded-full transition-all"
+                  style={{ width: `${nextFinish.progress}%` }}
+                />
+              </div>
+              <span className="text-white font-medium">{Math.round(nextFinish.progress)}%</span>
+              <span className="text-bambu-gray">({formatTime(nextFinish.remainingMin * 60)})</span>
             </div>
-            <span className="text-white font-medium">{Math.round(nextFinish.progress)}%</span>
-            <span className="text-bambu-gray">({formatTime(nextFinish.remainingMin * 60)})</span>
           </div>
         </>
       )}
@@ -2622,9 +2626,9 @@ function PrinterCard({
                     <div className="flex-1 h-px bg-bambu-dark-tertiary/30" />
                   </div>
 
-                  <div className="flex items-center justify-between gap-2">
+                  <div className="flex items-center justify-between gap-2 max-[550px]:items-start">
                     {/* Left: Fan Status - always visible, dynamic coloring */}
-                    <div className="flex items-center gap-2">
+                    <div className="flex items-center gap-2 min-w-0 max-[550px]:flex-wrap max-[550px]:items-start max-[550px]:gap-1.5">
                       {/* Part Cooling Fan */}
                       <div
                         className={`flex items-center gap-1 px-1.5 py-1 rounded ${partFan && partFan > 0 ? 'bg-cyan-500/10' : 'bg-bambu-dark'}`}
@@ -2660,7 +2664,7 @@ function PrinterCard({
                     </div>
 
                     {/* Right: Print Control Buttons */}
-                    <div className="flex items-center gap-2">
+                    <div className="flex items-center gap-2 flex-shrink-0 max-[550px]:self-start">
                       {/* Stop button */}
                       <button
                         onClick={() => setShowStopConfirm(true)}
@@ -2746,7 +2750,7 @@ function PrinterCard({
                                 )}
                               </div>
                               {(ams.humidity != null || ams.temp != null) && (
-                                <div className="flex items-center gap-1.5">
+                                <div className="flex items-center gap-1.5 max-[550px]:flex-col max-[550px]:items-start">
                                   {ams.humidity != null && (
                                     <HumidityIndicator
                                       humidity={ams.humidity}
@@ -3100,9 +3104,9 @@ function PrinterCard({
                               )}
                             </div>
                             {/* Row 2: Slot (left) + Stats (right stacked) */}
-                            <div className="flex gap-1.5">
+                            <div className="flex gap-1.5 max-[550px]:flex-col max-[550px]:items-start">
                               {/* Slot wrapper with menu button, dropdown, and loading overlay */}
-                              <div className="relative group flex-1">
+                              <div className="relative group flex-1 max-[550px]:w-full">
                                 {/* Loading overlay during RFID re-read */}
                                 {isHtRefreshing && (
                                   <div className="absolute inset-0 bg-bambu-dark-tertiary/80 rounded flex items-center justify-center z-20">
@@ -3226,7 +3230,7 @@ function PrinterCard({
                               </div>
                               {/* Stats stacked vertically: Temp on top, Humidity below */}
                               {(ams.humidity != null || ams.temp != null) && (
-                                <div className="flex flex-col justify-center gap-1 shrink-0">
+                                <div className="flex flex-col justify-center gap-1 shrink-0 max-[550px]:w-full">
                                   {ams.temp != null && (
                                     <TemperatureIndicator
                                       temp={ams.temp}

+ 9 - 9
frontend/src/pages/ProfilesPage.tsx

@@ -262,7 +262,7 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
             </div>
           )}
 
-          <div className="flex gap-2">
+          <div className="flex gap-2 max-[550px]:flex-wrap max-[550px]:items-center">
             {step === 'code' && (
               <Button type="button" variant="secondary" onClick={() => setStep('email')} className="flex-1">
                 {t('profiles.login.back')}
@@ -1723,7 +1723,7 @@ function CreatePresetModal({
         />
       )}
 
-      <Card className="w-full max-w-6xl max-h-[90vh] flex flex-col">
+      <Card className="w-full max-w-6xl max-h-[90vh] flex flex-col overflow-y-auto">
         <CardContent className="p-0 flex flex-col h-full">
           {/* Header */}
           <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
@@ -1764,7 +1764,7 @@ function CreatePresetModal({
 
           {/* Basic Info */}
           <div className="p-4 border-b border-bambu-dark-tertiary space-y-3">
-            <div className="grid grid-cols-3 gap-4">
+            <div className="grid grid-cols-3 gap-4 max-[640px]:grid-cols-1">
               <div>
                 <label className="block text-sm text-bambu-gray mb-1">{t('common.type')}</label>
                 <select
@@ -1815,7 +1815,7 @@ function CreatePresetModal({
           </div>
 
           {/* Tabs */}
-          <div className="flex border-b border-bambu-dark-tertiary">
+          <div className="flex border-b border-bambu-dark-tertiary max-[640px]:flex-wrap max-[640px]:items-center">
             <button
               onClick={() => setActiveTab('common')}
               className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
@@ -1844,7 +1844,7 @@ function CreatePresetModal({
               JSON
               {jsonError && <AlertCircle className="w-3 h-3 text-red-400" />}
             </button>
-            <div className="flex-1" />
+            <div className="flex-1 max-[640px]:hidden" />
             <button
               onClick={() => {
                 const exportData = {
@@ -1904,7 +1904,7 @@ function CreatePresetModal({
           </div>
 
           {/* Tab Content */}
-          <div className="flex-1 overflow-y-auto p-4">
+          <div className="flex-1 p-4">
             {activeTab === 'common' && (
               <div className="space-y-6">
                 {/* Templates */}
@@ -2010,9 +2010,9 @@ function CreatePresetModal({
                   <h3 className="text-sm font-medium text-white mb-3">{t('profiles.presets.commonSettings')}</h3>
                   <div className="grid grid-cols-2 gap-x-6 gap-y-3">
                     {dynamicFields.slice(0, 10).map(field => (
-                      <div key={field.key} className="flex items-center justify-between gap-4">
+                      <div key={field.key} className="flex items-center justify-between gap-4 max-[640px]:flex-col max-[640px]:items-start">
                         <label className="text-sm text-bambu-gray flex-shrink-0">{field.label}</label>
-                        <div className="w-48">{renderFieldInput(field)}</div>
+                        <div className="w-48 max-[640px]:w-full">{renderFieldInput(field)}</div>
                       </div>
                     ))}
                   </div>
@@ -2460,7 +2460,7 @@ function CloudProfilesView({
             />
           </div>
 
-          <div className="flex gap-2">
+          <div className="flex gap-2 max-[550px]:flex-wrap max-[550px]:items-center">
             <Button
               variant={compareMode ? 'primary' : 'secondary'}
               onClick={() => {

+ 1 - 0
frontend/src/pages/StatsPage.tsx

@@ -760,6 +760,7 @@ export function StatsPage() {
         key={dashboardKey}
         widgets={widgets}
         storageKey="bambusy-dashboard-layout"
+        stackBelow={640}
         hideControls
       />
     </div>