Browse Source

Add SpoolBuddy Inventory page to kiosk UI

  New page at /spoolbuddy/inventory with compact 2-column spool card grid,
  search bar, material filter chips, sort options, and tap-to-open detail
  sheet. Shows Spoolman iframe when Spoolman is enabled. Navigation item
  added between Write and Settings in the bottom bar.
maziggy 2 tháng trước cách đây
mục cha
commit
c834a0bd00

+ 1 - 0
CHANGELOG.md

@@ -19,6 +19,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Install Script Now Upgrades System Packages** — The install script now runs `apt-get upgrade -y` after installing required packages and the WiFi safeguard. This ensures the Pi is fully up to date before SpoolBuddy is deployed, and the WiFi safeguard protects connectivity during the upgrade.
 - **SpoolBuddy Assign-to-AMS Material Mismatch Warnings** — The SpoolBuddy "Assign to AMS" modal now warns when the spool's material or slicer profile doesn't match the target slot's current filament. Shows a confirmation dialog with five warning levels: exact material mismatch, partial material match, profile-only mismatch, and combined material+profile mismatches. Respects the global `disable_filament_warnings` setting. Previously, assigning a spool to an occupied slot proceeded without any validation, matching the behavior already present in the main Assign Spool modal.
 - **Spool Assignment Changes Sync Across Tabs** — Assigning or unassigning a spool now broadcasts a WebSocket event to all connected clients. Other open browser tabs and the SpoolBuddy frontend update automatically without requiring a page reload.
+- **SpoolBuddy Inventory Page** — Added a new Inventory page to the SpoolBuddy kiosk UI, accessible from the bottom navigation bar between Write and Settings. Shows a compact 2-column card grid of all spools with color banners, material pills, brand labels, and remaining-weight bars. Includes a search bar (filters by material, subtype, brand, color, notes), material filter chips, and sort options (Recent, Name, Material, Low Stock). Tapping a spool opens a detail sheet with full weight info, temperature range, cost, tag ID, and notes. When Spoolman is enabled, the page shows the Spoolman UI instead.
 - **SpoolBuddy Auto-Navigate on Tag Scan** — When an NFC tag is detected while the SpoolBuddy UI is on a non-dashboard page (Settings, AMS, Write Tag, etc.), the frontend automatically navigates back to the main dashboard to show the scanned spool. Also wakes the screen if the display was blanked.
 - **SpoolBuddy Swipe to Switch Printers** — Swiping left/right on the SpoolBuddy touchscreen now cycles through online printers instead of triggering browser back/forward navigation. The selected printer updates in the top bar dropdown. Requires at least two online printers; single-printer setups are unaffected.
 - **SpoolBuddy Virtual Keyboard No Longer Overlays Input Fields** — The virtual keyboard now adds temporary scroll padding to the content area when it opens, ensuring the focused input field scrolls above the keyboard instead of being hidden behind it. Fixes text entry on the SpoolBuddy Settings device tab (backend URL, API token fields).

+ 2 - 0
frontend/src/App.tsx

@@ -30,6 +30,7 @@ import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
 import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
 import { SpoolBuddyCalibrationPage } from './pages/spoolbuddy/SpoolBuddyCalibrationPage';
 import { SpoolBuddyWriteTagPage } from './pages/spoolbuddy/SpoolBuddyWriteTagPage';
+import { SpoolBuddyInventoryPage } from './pages/spoolbuddy/SpoolBuddyInventoryPage';
 const queryClient = new QueryClient({
   defaultOptions: {
     queries: {
@@ -125,6 +126,7 @@ function App() {
                   <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
                   <Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
                   <Route path="spoolbuddy/write-tag" element={<SpoolBuddyWriteTagPage />} />
+                  <Route path="spoolbuddy/inventory" element={<SpoolBuddyInventoryPage />} />
                   <Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
                   <Route path="spoolbuddy/calibration" element={<SpoolBuddyCalibrationPage />} />
                 </Route>

+ 10 - 0
frontend/src/components/spoolbuddy/SpoolBuddyBottomNav.tsx

@@ -33,6 +33,16 @@ const navItems = [
       </svg>
     ),
   },
+  {
+    to: '/spoolbuddy/inventory',
+    labelKey: 'spoolbuddy.nav.inventory',
+    fallback: 'Inventory',
+    icon: (
+      <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
+      </svg>
+    ),
+  },
   {
     to: '/spoolbuddy/settings',
     labelKey: 'spoolbuddy.nav.settings',

+ 459 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -0,0 +1,459 @@
+import { useState, useMemo } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Search, X, Filter, Package } from 'lucide-react';
+import { api } from '../../api/client';
+import type { InventorySpool } from '../../api/client';
+import { resolveSpoolColorName } from '../../utils/colors';
+
+type MaterialFilter = string | null;
+type SortKey = 'name' | 'material' | 'remaining' | 'recent';
+
+const MATERIAL_COLORS: Record<string, string> = {
+  PLA: 'bg-green-500/20 text-green-400 border-green-500/30',
+  ABS: 'bg-red-500/20 text-red-400 border-red-500/30',
+  PETG: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
+  TPU: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
+  ASA: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
+  PA: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
+  PC: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
+  PET: 'bg-sky-500/20 text-sky-400 border-sky-500/30',
+  PVA: 'bg-pink-500/20 text-pink-400 border-pink-500/30',
+  HIPS: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
+};
+
+function getMaterialPillClass(material: string): string {
+  const base = material.split('-')[0].split(' ')[0].toUpperCase();
+  return MATERIAL_COLORS[base] || 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30';
+}
+
+function spoolColor(spool: InventorySpool): string {
+  if (spool.rgba) return `#${spool.rgba.substring(0, 6)}`;
+  return '#808080';
+}
+
+function spoolRemaining(spool: InventorySpool): number {
+  return Math.max(0, spool.label_weight - spool.weight_used);
+}
+
+function spoolPct(spool: InventorySpool): number {
+  if (spool.label_weight <= 0) return 0;
+  return Math.max(0, Math.min(100, ((spool.label_weight - spool.weight_used) / spool.label_weight) * 100));
+}
+
+function spoolDisplayName(spool: InventorySpool): string {
+  const parts = [spool.material];
+  if (spool.subtype) parts.push(spool.subtype);
+  return parts.join(' ');
+}
+
+export function SpoolBuddyInventoryPage() {
+  const { t } = useTranslation();
+  const [searchQuery, setSearchQuery] = useState('');
+  const [materialFilter, setMaterialFilter] = useState<MaterialFilter>(null);
+  const [sortKey, setSortKey] = useState<SortKey>('recent');
+  const [showFilters, setShowFilters] = useState(false);
+  const [selectedSpool, setSelectedSpool] = useState<InventorySpool | null>(null);
+
+  // Check if Spoolman is enabled — if so, show iframe
+  const { data: spoolmanSettings } = useQuery({
+    queryKey: ['spoolman-settings'],
+    queryFn: api.getSpoolmanSettings,
+    staleTime: 5 * 60 * 1000,
+  });
+
+  const { data: spools = [], isLoading } = useQuery({
+    queryKey: ['inventory-spools'],
+    queryFn: () => api.getSpools(false),
+    refetchInterval: 30000,
+  });
+
+  // Spoolman iframe mode
+  const spoolmanEnabled = spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url;
+  if (spoolmanEnabled) {
+    return (
+      <div className="h-full flex flex-col">
+        <iframe
+          src={`${spoolmanSettings.spoolman_url.replace(/\/+$/, '')}/spool`}
+          className="flex-1 w-full border-0"
+          title="Spoolman"
+          sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
+        />
+      </div>
+    );
+  }
+
+  // Collect unique materials for filter chips
+  const materials = useMemo(() => {
+    const set = new Set<string>();
+    spools.forEach(s => set.add(s.material));
+    return Array.from(set).sort();
+  }, [spools]);
+
+  // Filter and sort
+  const filteredSpools = useMemo(() => {
+    let list = spools.filter(s => !s.archived_at);
+
+    if (materialFilter) {
+      list = list.filter(s => s.material === materialFilter);
+    }
+
+    if (searchQuery.trim()) {
+      const q = searchQuery.toLowerCase().trim();
+      list = list.filter(s =>
+        s.material.toLowerCase().includes(q) ||
+        (s.subtype && s.subtype.toLowerCase().includes(q)) ||
+        (s.brand && s.brand.toLowerCase().includes(q)) ||
+        (s.color_name && s.color_name.toLowerCase().includes(q)) ||
+        (s.note && s.note.toLowerCase().includes(q))
+      );
+    }
+
+    // Sort
+    list = [...list];
+    switch (sortKey) {
+      case 'name':
+        list.sort((a, b) => spoolDisplayName(a).localeCompare(spoolDisplayName(b)));
+        break;
+      case 'material':
+        list.sort((a, b) => a.material.localeCompare(b.material) || spoolDisplayName(a).localeCompare(spoolDisplayName(b)));
+        break;
+      case 'remaining':
+        list.sort((a, b) => spoolPct(a) - spoolPct(b));
+        break;
+      case 'recent':
+      default:
+        list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
+        break;
+    }
+
+    return list;
+  }, [spools, materialFilter, searchQuery, sortKey]);
+
+  return (
+    <div className="h-full flex flex-col">
+      {/* Search bar */}
+      <div className="px-3 pt-3 pb-2 space-y-2">
+        <div className="flex gap-2">
+          <div className="flex-1 relative">
+            <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
+            <input
+              type="text"
+              value={searchQuery}
+              onChange={e => setSearchQuery(e.target.value)}
+              placeholder={t('spoolbuddy.inventory.searchPlaceholder', 'Search spools...')}
+              className="w-full pl-9 pr-8 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-white/30 focus:outline-none focus:border-bambu-green"
+            />
+            {searchQuery && (
+              <button
+                onClick={() => setSearchQuery('')}
+                className="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60"
+              >
+                <X className="w-4 h-4" />
+              </button>
+            )}
+          </div>
+          <button
+            onClick={() => setShowFilters(!showFilters)}
+            className={`px-3 py-2 rounded-lg border transition-colors ${
+              showFilters || materialFilter
+                ? 'bg-bambu-green/20 border-bambu-green text-bambu-green'
+                : 'bg-bambu-dark-secondary border-bambu-dark-tertiary text-white/50 hover:text-white/70'
+            }`}
+          >
+            <Filter className="w-4 h-4" />
+          </button>
+        </div>
+
+        {/* Filter panel */}
+        {showFilters && (
+          <div className="space-y-2">
+            {/* Material chips */}
+            <div className="flex flex-wrap gap-1.5">
+              <button
+                onClick={() => setMaterialFilter(null)}
+                className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
+                  !materialFilter
+                    ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/40'
+                    : 'bg-bambu-dark-secondary text-white/50 border-bambu-dark-tertiary hover:text-white/70'
+                }`}
+              >
+                {t('spoolbuddy.inventory.all', 'All')}
+              </button>
+              {materials.map(mat => (
+                <button
+                  key={mat}
+                  onClick={() => setMaterialFilter(materialFilter === mat ? null : mat)}
+                  className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
+                    materialFilter === mat
+                      ? getMaterialPillClass(mat)
+                      : 'bg-bambu-dark-secondary text-white/50 border-bambu-dark-tertiary hover:text-white/70'
+                  }`}
+                >
+                  {mat}
+                </button>
+              ))}
+            </div>
+
+            {/* Sort */}
+            <div className="flex items-center gap-2">
+              <span className="text-xs text-white/40">{t('spoolbuddy.inventory.sortBy', 'Sort:')}</span>
+              <div className="flex gap-1">
+                {([
+                  ['recent', t('spoolbuddy.inventory.sortRecent', 'Recent')],
+                  ['name', t('spoolbuddy.inventory.sortName', 'Name')],
+                  ['material', t('spoolbuddy.inventory.sortMaterial', 'Material')],
+                  ['remaining', t('spoolbuddy.inventory.sortRemaining', 'Low Stock')],
+                ] as const).map(([key, label]) => (
+                  <button
+                    key={key}
+                    onClick={() => setSortKey(key)}
+                    className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
+                      sortKey === key
+                        ? 'bg-bambu-green/20 text-bambu-green'
+                        : 'text-white/40 hover:text-white/60'
+                    }`}
+                  >
+                    {label}
+                  </button>
+                ))}
+              </div>
+            </div>
+          </div>
+        )}
+      </div>
+
+      {/* Results count */}
+      <div className="px-3 pb-2 flex items-center justify-between">
+        <span className="text-xs text-white/40">
+          {filteredSpools.length} {filteredSpools.length === 1 ? 'spool' : 'spools'}
+        </span>
+      </div>
+
+      {/* Spool grid */}
+      <div className="flex-1 overflow-y-auto px-3 pb-3">
+        {isLoading ? (
+          <div className="flex items-center justify-center py-16">
+            <div className="w-8 h-8 border-2 border-bambu-green border-t-transparent rounded-full animate-spin" />
+          </div>
+        ) : filteredSpools.length === 0 ? (
+          <div className="flex flex-col items-center justify-center py-16 text-white/30">
+            <Package className="w-12 h-12 mb-3" />
+            <p className="text-sm">
+              {searchQuery || materialFilter
+                ? t('spoolbuddy.inventory.noResults', 'No spools match your filters')
+                : t('spoolbuddy.inventory.empty', 'No spools in inventory')}
+            </p>
+          </div>
+        ) : (
+          <div className="grid grid-cols-2 gap-2">
+            {filteredSpools.map(spool => (
+              <CompactSpoolCard
+                key={spool.id}
+                spool={spool}
+                onClick={() => setSelectedSpool(spool)}
+              />
+            ))}
+          </div>
+        )}
+      </div>
+
+      {/* Detail modal */}
+      {selectedSpool && (
+        <SpoolDetailModal
+          spool={selectedSpool}
+          onClose={() => setSelectedSpool(null)}
+        />
+      )}
+    </div>
+  );
+}
+
+/* Compact spool card for the grid */
+function CompactSpoolCard({ spool, onClick }: { spool: InventorySpool; onClick: () => void }) {
+  const color = spoolColor(spool);
+  const pct = spoolPct(spool);
+  const remaining = spoolRemaining(spool);
+  const colorName = resolveSpoolColorName(spool.color_name, spool.rgba);
+
+  return (
+    <button
+      onClick={onClick}
+      className="bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary hover:border-bambu-green/60 transition-colors text-left overflow-hidden"
+    >
+      {/* Color banner */}
+      <div className="h-8 relative" style={{ backgroundColor: color }}>
+        {colorName && (
+          <span className="absolute inset-0 flex items-center justify-center">
+            <span className="bg-black/50 text-white text-[10px] px-1.5 py-0.5 rounded-full truncate max-w-[90%]">
+              {colorName}
+            </span>
+          </span>
+        )}
+      </div>
+
+      <div className="p-2 space-y-1.5">
+        {/* Material + subtype */}
+        <div className="flex items-center gap-1.5 min-w-0">
+          <span className={`px-1.5 py-0.5 rounded text-[10px] font-semibold border shrink-0 ${getMaterialPillClass(spool.material)}`}>
+            {spool.material}
+          </span>
+          {spool.subtype && (
+            <span className="text-[11px] text-white/50 truncate">{spool.subtype}</span>
+          )}
+        </div>
+
+        {/* Brand */}
+        {spool.brand && (
+          <p className="text-[11px] text-white/40 truncate">{spool.brand}</p>
+        )}
+
+        {/* Remaining bar */}
+        <div className="flex items-center gap-1.5">
+          <div className="flex-1 h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+            <div
+              className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
+              style={{ width: `${Math.min(pct, 100)}%` }}
+            />
+          </div>
+          <span className="text-[10px] text-white/40 min-w-[32px] text-right">{Math.round(remaining)}g</span>
+        </div>
+      </div>
+    </button>
+  );
+}
+
+/* Full detail modal */
+function SpoolDetailModal({ spool, onClose }: { spool: InventorySpool; onClose: () => void }) {
+  const { t } = useTranslation();
+  const color = spoolColor(spool);
+  const pct = spoolPct(spool);
+  const remaining = spoolRemaining(spool);
+  const colorName = resolveSpoolColorName(spool.color_name, spool.rgba);
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-end justify-center" onClick={onClose}>
+      {/* Backdrop */}
+      <div className="absolute inset-0 bg-black/60" />
+
+      {/* Sheet */}
+      <div
+        className="relative w-full max-h-[85vh] bg-bambu-dark rounded-t-2xl overflow-y-auto animate-slide-up"
+        onClick={e => e.stopPropagation()}
+      >
+        {/* Color header */}
+        <div className="h-20 relative" style={{ backgroundColor: color }}>
+          <button
+            onClick={onClose}
+            className="absolute top-3 right-3 bg-black/40 hover:bg-black/60 text-white rounded-full p-1.5 transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+          <div className="absolute bottom-3 left-4">
+            <span className="bg-black/50 text-white text-sm px-2.5 py-1 rounded-full">
+              {colorName || t('spoolbuddy.inventory.unknownColor', 'Unknown Color')}
+            </span>
+          </div>
+        </div>
+
+        <div className="p-4 space-y-4">
+          {/* Title row */}
+          <div className="flex items-start justify-between gap-3">
+            <div>
+              <h2 className="text-lg font-semibold text-white">
+                {spoolDisplayName(spool)}
+              </h2>
+              {spool.brand && (
+                <p className="text-sm text-white/50">{spool.brand}</p>
+              )}
+            </div>
+            <span className="text-xs font-mono text-white/30 bg-bambu-dark-secondary px-2 py-1 rounded">
+              #{spool.id}
+            </span>
+          </div>
+
+          {/* Remaining bar */}
+          <div>
+            <div className="flex justify-between text-xs text-white/50 mb-1.5">
+              <span>{t('spoolbuddy.inventory.remaining', 'Remaining')}</span>
+              <span>{Math.round(pct)}%</span>
+            </div>
+            <div className="flex items-center gap-2">
+              <div className="flex-1 h-3 bg-bambu-dark-secondary rounded-full overflow-hidden">
+                <div
+                  className={`h-full rounded-full transition-all ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
+                  style={{ width: `${Math.min(pct, 100)}%` }}
+                />
+              </div>
+              <span className="text-sm font-medium text-white min-w-[48px] text-right">
+                {Math.round(remaining)}g
+              </span>
+            </div>
+          </div>
+
+          {/* Detail grid */}
+          <div className="grid grid-cols-2 gap-3">
+            <DetailItem
+              label={t('spoolbuddy.inventory.labelWeight', 'Label Weight')}
+              value={`${spool.label_weight}g`}
+            />
+            <DetailItem
+              label={t('spoolbuddy.inventory.weightUsed', 'Used')}
+              value={spool.weight_used > 0 ? `${Math.round(spool.weight_used)}g` : '-'}
+            />
+            <DetailItem
+              label={t('spoolbuddy.inventory.coreWeight', 'Core Weight')}
+              value={spool.core_weight > 0 ? `${spool.core_weight}g` : '-'}
+            />
+            <DetailItem
+              label={t('spoolbuddy.inventory.grossWeight', 'Gross Weight')}
+              value={`${spool.label_weight + spool.core_weight}g`}
+            />
+            {spool.nozzle_temp_min != null && spool.nozzle_temp_max != null && (
+              <DetailItem
+                label={t('spoolbuddy.inventory.nozzleTemp', 'Nozzle Temp')}
+                value={`${spool.nozzle_temp_min}-${spool.nozzle_temp_max}°C`}
+              />
+            )}
+            {spool.cost_per_kg != null && spool.cost_per_kg > 0 && (
+              <DetailItem
+                label={t('spoolbuddy.inventory.costPerKg', 'Cost/kg')}
+                value={`${spool.cost_per_kg.toFixed(2)}/kg`}
+              />
+            )}
+            {spool.last_scale_weight != null && (
+              <DetailItem
+                label={t('spoolbuddy.inventory.lastScaleWeight', 'Scale Weight')}
+                value={`${Math.round(spool.last_scale_weight)}g`}
+              />
+            )}
+            {spool.tag_uid && (
+              <DetailItem
+                label={t('spoolbuddy.inventory.tagId', 'Tag')}
+                value={spool.tag_uid}
+                mono
+              />
+            )}
+          </div>
+
+          {/* Note */}
+          {spool.note && (
+            <div className="bg-bambu-dark-secondary rounded-lg p-3">
+              <p className="text-xs text-white/40 mb-1">{t('spoolbuddy.inventory.note', 'Note')}</p>
+              <p className="text-sm text-white/70">{spool.note}</p>
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function DetailItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+  return (
+    <div className="bg-bambu-dark-secondary rounded-lg px-3 py-2">
+      <p className="text-[10px] text-white/40 uppercase tracking-wide">{label}</p>
+      <p className={`text-sm text-white mt-0.5 truncate ${mono ? 'font-mono text-xs' : ''}`}>{value}</p>
+    </div>
+  );
+}

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-BXZVW--A.css


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-BlzSKlaL.css


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-tcT3So11.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BMRHUkc1.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BXZVW--A.css">
+    <script type="module" crossorigin src="/assets/index-tcT3So11.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BlzSKlaL.css">
   </head>
   <body>
     <div id="root"></div>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác