A: 添加多个设计页面和原型文件

- 添加照片管理器的多个设计版本
- 包含文件夹预览界面
- 添加现代化照片管理器界面
- 包含浅色主题设计变体

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jimmyandClaude committed 2025-09-27 19:16:19 +08:00
1 parent 3cee977417
commit d9690fbaae
13 files changed
+9515

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Argus - 文件夹预览</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<style>
:root { --tile: 200px }
.tile { width: var(--tile); }
.tile-h { width: var(--tile); height: calc(var(--tile) * .75) }
.no-select { user-select: none }
</style>
<script>
tailwind.config = { theme:{ extend:{ colors:{ brand:'#3B82F6' }}}}
</script>
</head>
<body class="bg-slate-50 text-slate-900 no-select">
<!-- 顶部 -->
<header class="sticky top-0 z-20 bg-white/80 backdrop-blur border-b border-slate-200">
<div class="mx-auto max-w-[1200px] px-4 py-2 flex items-center gap-2">
<div class="flex items-center gap-2 min-w-[180px]">
<div class="w-8 h-8 rounded bg-brand text-white flex items-center justify-center">A</div>
<div class="font-semibold">Argus</div>
<span class="text-slate-400">/</span>
<div class="text-slate-600">文件夹预览</div>
</div>
<div class="flex items-center gap-1">
<button id="btnBack" class="p-2 rounded hover:bg-slate-100" title="后退 Alt+←"><ion-icon name="chevron-back-outline"></ion-icon></button>
<button id="btnForward" class="p-2 rounded hover:bg-slate-100" title="前进 Alt+→"><ion-icon name="chevron-forward-outline"></ion-icon></button>
<button id="btnUp" class="p-2 rounded hover:bg-slate-100" title="上一级 Backspace"><ion-icon name="arrow-up-outline"></ion-icon></button>
</div>
<nav id="breadcrumb" class="flex-1 text-sm text-slate-600 px-2"></nav>
<div class="relative">
<ion-icon name="search-outline" class="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400"></ion-icon>
<input id="search" placeholder="搜索..." class="pl-8 pr-2 py-1 border rounded text-sm border-slate-200 focus:border-brand focus:outline-none"/>
</div>
<button id="btnTheme" class="p-2 rounded hover:bg-slate-100" title="主题"><ion-icon name="moon-outline"></ion-icon></button>
</div>
</header>
<!-- 工具条 -->
<section class="bg-white border-b border-slate-200">
<div class="mx-auto max-w-[1200px] px-4 py-2 flex items-center gap-3 text-sm">
<div class="flex items-center gap-2">
<span class="text-slate-600">缩略图</span>
<input id="zoom" type="range" min="140" max="320" value="200" class="w-40"/>
</div>
<label class="inline-flex items-center gap-1"><input id="onlyMedia" type="checkbox" checked class="accent-brand">仅媒体</label>
<label class="inline-flex items-center gap-1"><input id="hideOthers" type="checkbox" class="accent-brand">隐藏非媒体</label>
<label class="inline-flex items-center gap-1"><input id="dblUp" type="checkbox" checked class="accent-brand">双击空白返回上级</label>
<div class="ml-auto text-slate-600" id="stats">—</div>
</div>
</section>
<!-- 内容 -->
<main class="mx-auto max-w-[1200px] px-4 py-4">
<div id="grid" class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(var(--tile), 1fr));"></div>
<div id="empty" class="hidden text-center text-slate-500 py-24">此处空空如也</div>
</main>
<!-- 底部操作条 -->
<div id="bar" class="fixed bottom-3 left-1/2 -translate-x-1/2 w-[96%] max-w-[900px] bg-white border border-slate-200 rounded-xl shadow p-2 hidden">
<div class="flex items-center gap-2 text-sm">
<div class="text-slate-600"><span id="sel">0</span> 项已选</div>
<div class="w-px h-4 bg-slate-200"></div>
<button id="actOpen" class="px-3 py-1 rounded bg-brand text-white">打开/预览</button>
<button id="actAlbum" class="px-3 py-1 rounded bg-slate-900 text-white">加入相册</button>
<button id="actTags" class="px-3 py-1 rounded border">编辑标签</button>
<button id="actRename" class="px-3 py-1 rounded border">重命名</button>
<button id="actMove" class="px-3 py-1 rounded border">移动到备份</button>
<button id="actDelete" class="ml-auto px-3 py-1 rounded border border-red-300 text-red-600">删除</button>
</div>
</div>
<!-- 预览 -->
<div id="preview" class="fixed inset-0 bg-black/70 items-center justify-center hidden">
<div class="absolute inset-0" id="previewBg"></div>
<div class="relative max-w-[90vw] max-h-[90vh]">
<img id="pvImg" class="max-w-[90vw] max-h-[90vh] object-contain hidden"/>
<video id="pvVideo" class="max-w-[90vw] max-h-[90vh] hidden" controls></video>
<button id="pvClose" class="absolute top-2 right-2 p-2 rounded-full bg-white"><ion-icon name="close-outline"></ion-icon></button>
</div>
</div>
<script>
// 数据(演示)
const img = (s,w=800,h=600)=>`https://picsum.photos/seed/${encodeURIComponent(s)}/${w}/${h}`
let id=1; const uid=()=> (id++).toString()
const f = (name,children)=>({id:uid(),type:'folder',name,children})
const im= name=>({id:uid(),type:'image',name,src:img(name,1000,750),poster:img(name,400,300),w:1000,h:750})
const vi= name=>({id:uid(),type:'video',name,poster:img(name,960,540)})
const ot= (name,ext,color)=>({id:uid(),type:'other',name,ext,color})
const root = f('首页', [
f('2024-旅拍', [ f('东京', [im('tokyo-1'), im('tokyo-2'), vi('tokyo-v')]), im('street-1'), vi('city-reel') ]),
f('RAW素材', [ ot('说明','txt','#64748B'), im('raw-1'), im('raw-2') ]),
f('导出', [ ot('导出记录','xlsx','#22C55E'), im('export-1') ]),
im('cover-1'), im('cover-2'), vi('reel-1'), ot('安装包','exe','#94A3B8')
])
// 状态
let current = root
let history=[current]; let hi=0
let selection = new Set()
let onlyMedia = true, hideOthers=false, dblUp=true
let keyword = ''
// 工具
const qs = s=>document.querySelector(s)
const grid = ()=> qs('#grid')
const items = ()=> (current.children||[]).filter(it=>{
if(keyword && !it.name.toLowerCase().includes(keyword)) return false
if(onlyMedia && !['image','video','folder'].includes(it.type)) return false
if(hideOthers && it.type==='other') return false
return true
})
const stats = list=>{
const s={all:list.length,folder:0,image:0,video:0,other:0};
list.forEach(i=>s[i.type]++)
qs('#stats').textContent=`共 ${s.all} 项 · 文件夹 ${s.folder} · 照片 ${s.image} · 视频 ${s.video}`
}
function setTile(v){ document.documentElement.style.setProperty('--tile', v+'px') }
// 渲染
function tile(item, idx){
const card = document.createElement('div')
card.className = 'tile border rounded-lg bg-white hover:shadow transition overflow-hidden ' + (selection.has(item.id)?'ring-2 ring-brand border-brand':'border-slate-200')
card.dataset.id=item.id; card.dataset.idx=idx; card.dataset.type=item.type
const box = document.createElement('div'); box.className='tile-h bg-slate-100 relative'; card.appendChild(box)
if(item.type==='folder'){
box.innerHTML = '<div class="absolute left-2 top-2 px-2 py-0.5 text-xs rounded bg-black/40 text-white">文件夹</div>'
const cover = (item.children||[]).find(c=>c.type==='image' || c.type==='video')
if(cover){ const imgEl = document.createElement('img'); imgEl.src = cover.poster || cover.src; imgEl.className='w-full h-full object-cover'; box.appendChild(imgEl) }
} else if(item.type==='image'){
const imgEl = document.createElement('img'); imgEl.src=item.poster||item.src; imgEl.className='w-full h-full object-cover'; box.appendChild(imgEl)
const meta = document.createElement('div'); meta.className='absolute left-2 top-2 px-2 py-0.5 text-xs rounded bg-black/40 text-white'; meta.textContent=`${item.w}×${item.h}`; box.appendChild(meta)
} else if(item.type==='video'){
const imgEl = document.createElement('img'); imgEl.src=item.poster; imgEl.className='w-full h-full object-cover'; box.appendChild(imgEl)
const play = document.createElement('div'); play.className='absolute inset-0 flex items-center justify-center'; play.innerHTML='<div class="w-10 h-10 rounded-full bg-black/50 flex items-center justify-center text-white"><ion-icon name="play-outline"></ion-icon></div>'
box.appendChild(play)
} else {
box.style.background = item.color || '#CBD5E1'
}
const row = document.createElement('div'); row.className='px-2 py-1 text-sm flex items-center gap-2'
row.innerHTML = `<ion-icon name="${item.type==='folder'?'folder-outline':item.type==='image'?'image-outline':item.type==='video'?'videocam-outline':'document-outline'}" class="text-slate-500"></ion-icon><span class="truncate" title="${item.name}">${item.name}</span>`
card.appendChild(row)
return card
}
function render(){
const list = items(); grid().innerHTML=''
if(list.length===0){ qs('#empty').classList.remove('hidden') } else { qs('#empty').classList.add('hidden') }
list.forEach((it, idx)=> grid().appendChild(tile(it, idx)))
// 面包屑
qs('#breadcrumb').textContent = (current===root? '首页' : `首页 / ${current.name}`)
stats(list); updateBar(); updateNav()
}
// 导航
function enterFolder(node){ if(!node||node.type!=='folder') return; current=node; history=history.slice(0,hi+1); history.push(current); hi++; selection.clear(); render() }
function goBack(){ if(hi>0){ hi--; current=history[hi]; selection.clear(); render() } }
function goForward(){ if(hi < history.length-1){ hi++; current=history[hi]; selection.clear(); render() } }
function goUp(){ if(current!==root){ current=root; history=[current]; hi=0; selection.clear(); render() }}
// 选择与操作
function updateBar(){ const n=selection.size; qs('#bar').classList.toggle('hidden', n===0); qs('#sel').textContent=n }
function selectedItems(){ const ids=[...selection]; return items().filter(i=>ids.includes(i.id)) }
function openPreview(){ const s=selectedItems(); if(s.length){ showPreview(s[0]) }}
function showPreview(item){ const p=qs('#preview'),img=qs('#pvImg'),v=qs('#pvVideo'); if(item.type==='image'){ v.pause(); v.classList.add('hidden'); img.src=item.src; img.classList.remove('hidden') } else if(item.type==='video'){ img.classList.add('hidden'); v.poster=item.poster; v.classList.remove('hidden') } p.classList.remove('hidden'); p.classList.add('flex') }
function closePreview(){ const p=qs('#preview'),v=qs('#pvVideo'); v.pause(); p.classList.add('hidden'); p.classList.remove('flex') }
// 事件
grid().addEventListener('click', e=>{
const card = e.target.closest('[data-id]'); if(!card) return
const id = card.dataset.id; const idx = parseInt(card.dataset.idx); const type = card.dataset.type
const list = items(); const item = list[idx]
if(e.detail===2){ if(type==='folder') enterFolder(item); else showPreview(item); return }
if(e.ctrlKey||e.metaKey){ selection.has(id)? selection.delete(id): selection.add(id) } else { selection.clear(); selection.add(id) }
render()
})
document.addEventListener('dblclick', e=>{ if(!qs('#dblUp').checked) return; if(!e.target.closest('#grid [data-id]')) goUp() })
document.addEventListener('keydown', e=>{
if(e.key==='Backspace' && !e.target.matches('input,textarea')){ e.preventDefault(); goUp() }
if(e.altKey && e.key==='ArrowLeft'){ e.preventDefault(); goBack() }
if(e.altKey && e.key==='ArrowRight'){ e.preventDefault(); goForward() }
if((e.ctrlKey||e.metaKey) && e.key.toLowerCase()==='a'){ e.preventDefault(); items().forEach(i=>selection.add(i.id)); render() }
if(e.key==='Escape'){ selection.clear(); render() }
if(e.key==='Enter'){ openPreview() }
if(e.key==='Delete'){ const ids=new Set(selection); current.children=current.children.filter(it=>!ids.has(it.id)); selection.clear(); render() }
})
// 顶部与工具条
qs('#btnBack').onclick=goBack
qs('#btnForward').onclick=goForward
qs('#btnUp').onclick=goUp
qs('#zoom').oninput = e=> setTile(e.target.value)
qs('#onlyMedia').onchange = e=>{ onlyMedia=e.target.checked; render() }
qs('#hideOthers').onchange = e=>{ hideOthers=e.target.checked; render() }
qs('#dblUp').onchange = e=>{ dblUp=e.target.checked }
qs('#search').oninput = e=>{ keyword=(e.target.value||'').toLowerCase().trim(); render() }
// 底部动作
qs('#actOpen').onclick = openPreview
qs('#actAlbum').onclick = ()=> alert('已加入相册(演示)')
qs('#actTags').onclick = ()=> alert('标签已更新(演示)')
qs('#actRename').onclick = ()=> alert('已按 EXIF 重命名(演示)')
qs('#actMove').onclick = ()=> alert('已移动到备份(演示)')
qs('#actDelete').onclick = ()=> { const ids=new Set(selection); current.children=current.children.filter(it=>!ids.has(it.id)); selection.clear(); render() }
// 预览
qs('#pvClose').onclick = closePreview
qs('#previewBg').onclick = closePreview
// 主题
function setTheme(mode){ document.documentElement.classList.toggle('dark', mode==='dark'); document.body.className = mode==='dark'? 'bg-slate-900 text-slate-100' : 'bg-slate-50 text-slate-900'; localStorage.setItem('argus-theme', mode) }
const saved = localStorage.getItem('argus-theme') || (matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'); setTheme(saved)
qs('#btnTheme').onclick = ()=> setTheme(document.documentElement.classList.contains('dark')? 'light':'dark')
// 启动
setTile(200); render()
function updateNav(){ qs('#btnBack').disabled = hi===0; qs('#btnForward').disabled = hi>=history.length-1; qs('#btnUp').disabled = current===root }
</script>
</body>
<style>
.dark .bg-white{ background:#0f172a }
.dark .bg-slate-50{ background:#0b1220 }
.dark .text-slate-900{ color:#E2E8F0 }
.dark .border-slate-200{ border-color:#253048 }
.dark .hover\:bg-slate-100:hover{ background:#172036 }
.dark input{ background:#0b1220; color:#E2E8F0; border-color:#253048 }
.ring-brand{ box-shadow:0 0 0 2px #3B82F6 inset }
.bg-brand{ background:#3B82F6 }
.accent-brand{ accent-color:#3B82F6 }
</style>
</html>
+1485
View File
@@ -0,0 +1,1485 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Argus - 现代照片管理器</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#6366f1',
accent: '#8b5cf6',
surface: '#ffffff',
background: '#fafafa',
border: '#e5e7eb',
muted: '#6b7280',
danger: '#ef4444'
},
fontFamily: {
'sans': ['Inter', 'system-ui', 'sans-serif']
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-up': 'slideUp 0.3s ease-out',
'scale-in': 'scaleIn 0.2s ease-out'
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' }
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' }
},
scaleIn: {
'0%': { transform: 'scale(0.9)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' }
}
}
}
}
}
</script>
<style>
:root {
--tile-size: 140px;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-feature-settings: 'cv03', 'cv04', 'cv11';
}
.glass-effect {
backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.8);
}
.photo-tile {
width: var(--tile-size);
height: var(--tile-size);
}
.folder-tile {
width: var(--tile-size);
height: calc(var(--tile-size) * 0.8);
}
.grid-auto {
grid-template-columns: repeat(auto-fill, minmax(var(--tile-size), 1fr));
gap: 16px;
}
.pane {
min-height: 0;
}
.pane-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.pane-content {
background: #fafafa;
}
.tile-hover {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.tile-hover:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.btn-modern {
@apply px-4 py-2 rounded-xl font-medium transition-all duration-200 transform;
}
.btn-modern:hover {
transform: translateY(-1px);
}
.btn-primary {
@apply bg-gradient-to-r from-primary to-accent text-white shadow-lg;
}
.btn-secondary {
@apply bg-surface border border-border text-muted hover:text-gray-900 hover:border-gray-300;
}
.breadcrumb-item {
@apply px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200;
}
.breadcrumb-item:hover {
@apply bg-white/20 text-white;
}
.breadcrumb-separator {
@apply text-white/60;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
.splitter {
background: #e5e7eb;
cursor: col-resize;
position: relative;
transition: background 0.2s;
}
.splitter:hover {
background: #d1d5db;
}
.splitter::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 4px;
height: 40px;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.splitter.vertical {
cursor: row-resize;
}
.splitter.vertical::after {
width: 40px;
height: 4px;
}
.preview-pane {
background: linear-gradient(145deg, #f8fafc 0%, #f1f5f9 100%);
}
.duplicate-badge {
@apply absolute -top-1 -right-1 w-5 h-5 bg-orange-500 text-white rounded-full flex items-center justify-center text-xs font-bold;
animation: pulse 2s infinite;
}
</style>
</head>
<body class="bg-background text-gray-900 overflow-hidden">
<!-- 现代化顶部工具栏 -->
<header class="glass-effect border-b border-border/50">
<div class="px-6 py-4">
<!-- 主工具栏 -->
<div class="flex items-center justify-between mb-4">
<!-- 左侧:操作按钮 -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<button id="btnCut" class="btn-modern btn-secondary" title="剪切">
<ion-icon name="cut-outline" class="w-4 h-4"></ion-icon>
</button>
<button id="btnCopy" class="btn-modern btn-secondary" title="复制">
<ion-icon name="copy-outline" class="w-4 h-4"></ion-icon>
</button>
<button id="btnPaste" class="btn-modern btn-secondary" title="粘贴">
<ion-icon name="clipboard-outline" class="w-4 h-4"></ion-icon>
</button>
<button id="btnRename" class="btn-modern btn-secondary" title="重命名">
<ion-icon name="create-outline" class="w-4 h-4"></ion-icon>
</button>
<button id="btnDelete" class="btn-modern bg-red-50 border border-red-200 text-red-600 hover:bg-red-100" title="删除">
<ion-icon name="trash-outline" class="w-4 h-4"></ion-icon>
</button>
</div>
<div class="w-px h-8 bg-border"></div>
<div class="flex items-center gap-2">
<button id="btnTags" class="btn-modern btn-secondary" title="编辑标签">
<ion-icon name="pricetag-outline" class="w-4 h-4 mr-2"></ion-icon>
标签
</button>
<button id="btnAlbum" class="btn-modern btn-primary" title="加入相册">
<ion-icon name="albums-outline" class="w-4 h-4 mr-2"></ion-icon>
相册
</button>
<button id="btnBatchEdit" class="btn-modern btn-secondary" title="批量编辑">
<ion-icon name="settings-outline" class="w-4 h-4 mr-2"></ion-icon>
批量编辑
</button>
</div>
</div>
<!-- 右侧:视图控制 -->
<div class="flex items-center gap-4">
<!-- 布局选择 -->
<div class="flex bg-surface border border-border rounded-xl p-1">
<button id="layoutSingle" class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all bg-primary text-white" title="单栏">
<ion-icon name="square-outline" class="w-4 h-4"></ion-icon>
</button>
<button id="layoutDualH" class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all text-muted hover:text-gray-900" title="左右双栏">
<ion-icon name="resize-outline" class="w-4 h-4"></ion-icon>
</button>
<button id="layoutDualV" class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all text-muted hover:text-gray-900" title="上下双栏">
<ion-icon name="reorder-three-outline" class="w-4 h-4"></ion-icon>
</button>
</div>
<!-- 视图模式 -->
<div class="flex bg-surface border border-border rounded-xl p-1">
<button id="viewTiles" class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all bg-primary text-white" title="网格视图">
<ion-icon name="grid-outline" class="w-4 h-4"></ion-icon>
</button>
<button id="viewList" class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all text-muted hover:text-gray-900" title="列表视图">
<ion-icon name="list-outline" class="w-4 h-4"></ion-icon>
</button>
</div>
<!-- 缩略图大小 -->
<div class="flex bg-surface border border-border rounded-xl p-1">
<button class="size-btn px-2 py-1.5 rounded-lg text-sm transition-all text-muted hover:text-gray-900" data-size="100" title="小">
<ion-icon name="apps-outline" class="w-3 h-3"></ion-icon>
</button>
<button class="size-btn px-2 py-1.5 rounded-lg text-sm transition-all bg-primary text-white" data-size="140" title="中">
<ion-icon name="apps-outline" class="w-4 h-4"></ion-icon>
</button>
<button class="size-btn px-2 py-1.5 rounded-lg text-sm transition-all text-muted hover:text-gray-900" data-size="180" title="大">
<ion-icon name="apps-outline" class="w-5 h-5"></ion-icon>
</button>
<button class="size-btn px-2 py-1.5 rounded-lg text-sm transition-all text-muted hover:text-gray-900" data-size="220" title="超大">
<ion-icon name="apps-outline" class="w-6 h-6"></ion-icon>
</button>
</div>
<!-- 预览开关 -->
<label class="flex items-center gap-2 px-3 py-2 bg-surface border border-border rounded-xl cursor-pointer hover:bg-gray-50">
<input id="showPreview" type="checkbox" class="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary">
<span class="text-sm font-medium text-muted">预览</span>
</label>
<!-- 重复项 -->
<button id="btnDuplicates" class="btn-modern bg-orange-50 border border-orange-200 text-orange-700 hover:bg-orange-100" title="重复项">
<ion-icon name="copy-outline" class="w-4 h-4 mr-2"></ion-icon>
重复项
<span id="duplicateCount" class="ml-2 px-2 py-0.5 bg-orange-200 text-orange-800 rounded-full text-xs font-bold">3</span>
</button>
</div>
</div>
<!-- 状态栏 -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-4 text-muted">
<span id="totalStats">0 项</span>
<span id="selectionStats">未选择</span>
</div>
<div class="flex items-center gap-2">
<button id="btnSearch" class="px-3 py-1.5 rounded-lg text-sm bg-surface border border-border hover:bg-gray-50">
<ion-icon name="search-outline" class="w-4 h-4 mr-2"></ion-icon>
搜索
</button>
</div>
</div>
</div>
</header>
<!-- 主工作区 -->
<main class="flex-1 overflow-hidden" style="height: calc(100vh - 180px);">
<div id="workspace" class="h-full grid gap-3 p-3">
<!-- 主面板 -->
<div id="pane1" class="pane flex flex-col rounded-2xl overflow-hidden shadow-lg">
<!-- 面板头部(面包屑) -->
<div id="pane1Header" class="pane-header px-6 py-4 text-white">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex items-center gap-1">
<button id="pane1Back" class="p-1.5 rounded-lg hover:bg-white/20 transition-all" title="后退">
<ion-icon name="chevron-back-outline" class="w-4 h-4"></ion-icon>
</button>
<button id="pane1Forward" class="p-1.5 rounded-lg hover:bg-white/20 transition-all" title="前进">
<ion-icon name="chevron-forward-outline" class="w-4 h-4"></ion-icon>
</button>
<button id="pane1Up" class="p-1.5 rounded-lg hover:bg-white/20 transition-all" title="上一级">
<ion-icon name="arrow-up-outline" class="w-4 h-4"></ion-icon>
</button>
</div>
<div class="w-px h-5 bg-white/30"></div>
<div id="pane1Breadcrumb" class="flex items-center gap-1">
<!-- 动态生成面包屑 -->
</div>
</div>
<div class="text-sm text-white/80" id="pane1Stats">0 项</div>
</div>
</div>
<!-- 面板内容 -->
<div class="flex-1 pane-content overflow-auto p-6">
<!-- 网格视图 -->
<div id="pane1TilesView" class="grid grid-auto">
<!-- 动态生成内容 -->
</div>
<!-- 列表视图 -->
<div id="pane1ListView" class="hidden">
<div class="bg-surface rounded-xl border border-border overflow-hidden">
<!-- 表头 -->
<div class="grid grid-cols-12 gap-4 px-6 py-3 text-xs font-semibold text-muted bg-gray-50 border-b border-border">
<div class="col-span-5">名称</div>
<div class="col-span-2">修改日期</div>
<div class="col-span-2">类型</div>
<div class="col-span-2">大小</div>
<div class="col-span-1">标签</div>
</div>
<!-- 内容 -->
<div id="pane1ListContent">
<!-- 动态生成列表项 -->
</div>
</div>
</div>
<!-- 空状态 -->
<div id="pane1Empty" class="hidden flex flex-col items-center justify-center h-64 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-gray-100 to-gray-200 rounded-full flex items-center justify-center mb-4">
<ion-icon name="image-outline" class="w-8 h-8 text-gray-400"></ion-icon>
</div>
<h3 class="text-lg font-semibold text-gray-700 mb-2">暂无内容</h3>
<p class="text-muted">这个文件夹是空的</p>
</div>
</div>
</div>
<!-- 分割线 -->
<div id="splitter" class="splitter w-2 hidden"></div>
<!-- 第二面板 -->
<div id="pane2" class="pane hidden flex flex-col rounded-2xl overflow-hidden shadow-lg">
<!-- 面板头部(面包屑) -->
<div id="pane2Header" class="pane-header px-6 py-4 text-white">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex items-center gap-1">
<button id="pane2Back" class="p-1.5 rounded-lg hover:bg-white/20 transition-all" title="后退">
<ion-icon name="chevron-back-outline" class="w-4 h-4"></ion-icon>
</button>
<button id="pane2Forward" class="p-1.5 rounded-lg hover:bg-white/20 transition-all" title="前进">
<ion-icon name="chevron-forward-outline" class="w-4 h-4"></ion-icon>
</button>
<button id="pane2Up" class="p-1.5 rounded-lg hover:bg-white/20 transition-all" title="上一级">
<ion-icon name="arrow-up-outline" class="w-4 h-4"></ion-icon>
</button>
</div>
<div class="w-px h-5 bg-white/30"></div>
<div id="pane2Breadcrumb" class="flex items-center gap-1">
<!-- 动态生成面包屑 -->
</div>
</div>
<div class="text-sm text-white/80" id="pane2Stats">0 项</div>
</div>
</div>
<!-- 面板内容 -->
<div class="flex-1 pane-content overflow-auto p-6">
<!-- 网格视图 -->
<div id="pane2TilesView" class="grid grid-auto">
<!-- 动态生成内容 -->
</div>
<!-- 列表视图 -->
<div id="pane2ListView" class="hidden">
<div class="bg-surface rounded-xl border border-border overflow-hidden">
<!-- 表头 -->
<div class="grid grid-cols-12 gap-4 px-6 py-3 text-xs font-semibold text-muted bg-gray-50 border-b border-border">
<div class="col-span-5">名称</div>
<div class="col-span-2">修改日期</div>
<div class="col-span-2">类型</div>
<div class="col-span-2">大小</div>
<div class="col-span-1">标签</div>
</div>
<!-- 内容 -->
<div id="pane2ListContent">
<!-- 动态生成列表项 -->
</div>
</div>
</div>
<!-- 空状态 -->
<div id="pane2Empty" class="hidden flex flex-col items-center justify-center h-64 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-gray-100 to-gray-200 rounded-full flex items-center justify-center mb-4">
<ion-icon name="image-outline" class="w-8 h-8 text-gray-400"></ion-icon>
</div>
<h3 class="text-lg font-semibold text-gray-700 mb-2">暂无内容</h3>
<p class="text-muted">这个文件夹是空的</p>
</div>
</div>
</div>
<!-- 预览面板 -->
<div id="previewPane" class="hidden preview-pane rounded-2xl overflow-hidden shadow-lg">
<div class="p-6 border-b border-border">
<h3 class="font-semibold text-gray-900">预览</h3>
</div>
<div class="flex-1 overflow-auto p-6">
<div id="previewContent">
<div class="flex flex-col items-center justify-center h-64 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center mb-4">
<ion-icon name="eye-outline" class="w-8 h-8 text-primary"></ion-icon>
</div>
<h3 class="text-lg font-semibold text-gray-700 mb-2">选择项目预览</h3>
<p class="text-muted">点击选择照片或视频查看详情</p>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- 全屏预览模态框 -->
<div id="previewModal" class="fixed inset-0 bg-black/90 flex items-center justify-center hidden z-50 animate-fade-in">
<div class="relative max-w-[95vw] max-h-[95vh] animate-scale-in">
<img id="previewImage" class="max-w-[95vw] max-h-[95vh] object-contain hidden rounded-lg">
<video id="previewVideo" class="max-w-[95vw] max-h-[95vh] hidden rounded-lg" controls></video>
<!-- 控制栏 -->
<div class="absolute top-4 right-4 flex items-center gap-2">
<button id="btnFullscreen" class="p-3 rounded-full bg-black/50 text-white hover:bg-black/70 transition-all">
<ion-icon name="expand-outline" class="w-5 h-5"></ion-icon>
</button>
<button id="btnPreviewClose" class="p-3 rounded-full bg-black/50 text-white hover:bg-black/70 transition-all">
<ion-icon name="close-outline" class="w-5 h-5"></ion-icon>
</button>
</div>
<!-- 信息面板 -->
<div id="photoInfo" class="absolute bottom-4 left-4 bg-black/70 text-white rounded-xl p-6 max-w-sm backdrop-blur-sm">
<h4 id="photoName" class="font-semibold mb-3 text-lg">IMG_001.jpg</h4>
<div class="space-y-2 text-sm">
<div id="photoSize">2048 × 1536</div>
<div id="photoDate">2024-01-01 12:00:00</div>
<div id="photoFileSize">2.5 MB</div>
<div id="cameraInfo" class="hidden pt-2 border-t border-white/20">
Canon EOS R5 · f/2.8 · 1/125s · ISO 400
</div>
</div>
</div>
</div>
</div>
<script>
// 应用状态
let state = {
layout: 'single', // single, dual-h, dual-v
viewMode: 'tiles', // tiles, list
showPreview: false,
tileSize: 140,
activePane: 1,
panes: {
1: {
currentFolder: null,
history: [],
historyIndex: 0,
selectedItems: new Set()
},
2: {
currentFolder: null,
history: [],
historyIndex: 0,
selectedItems: new Set()
}
},
clipboard: null
};
// 模拟数据
const img = (name, w=1600, h=1200) => `https://picsum.photos/seed/${encodeURIComponent(name)}/${w}/${h}`;
let itemId = 1;
const uid = () => (itemId++).toString();
const createFolder = (name, children = []) => ({
id: uid(),
type: 'folder',
name,
children,
itemCount: children.length,
date: new Date(2024, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1)
});
const createPhoto = (name, isDuplicate = false) => ({
id: uid(),
type: 'image',
name,
src: img(name),
thumbnail: img(name, 400, 400),
size: Math.floor(Math.random() * 5000000) + 500000,
width: 1600,
height: 1200,
date: new Date(2024, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1),
isDuplicate,
tags: isDuplicate ? ['重复'] : [],
exif: {
camera: 'Canon EOS R5',
lens: 'RF 24-70mm f/2.8L',
fNumber: 'f/2.8',
exposureTime: '1/125s',
iso: 'ISO 400'
}
});
const createVideo = (name) => ({
id: uid(),
type: 'video',
name,
thumbnail: img(name, 400, 400),
size: Math.floor(Math.random() * 50000000) + 5000000,
duration: Math.floor(Math.random() * 300) + 30,
date: new Date(2024, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1),
tags: []
});
const createOtherFile = (name, ext, color) => ({
id: uid(),
type: 'other',
name,
ext,
color,
size: Math.floor(Math.random() * 1000000) + 10000,
date: new Date(),
tags: []
});
// 初始化数据
const rootData = createFolder('我的照片', [
createFolder('2024年旅行', [
createFolder('东京之旅', [
createPhoto('东京塔夜景'),
createPhoto('浅草寺'),
createPhoto('涩谷路口'),
createPhoto('银座购物'),
createPhoto('新宿夜景'),
createVideo('东京街头录像'),
createVideo('地铁延时'),
createPhoto('东京塔夜景', true), // 重复项
]),
createFolder('大阪美食', [
createPhoto('大阪烧'),
createPhoto('章鱼小丸子'),
createPhoto('拉面店'),
createPhoto('道顿堀'),
createVideo('道顿堀夜景'),
createVideo('美食制作')
]),
createFolder('京都古迹', [
createPhoto('清水寺'),
createPhoto('金阁寺'),
createPhoto('竹林小径'),
createPhoto('艺伎街'),
createVideo('庙宇钟声')
]),
createPhoto('机场候机'),
createPhoto('飞机窗景'),
createVideo('飞行延时摄影')
]),
createFolder('工作照片', [
createFolder('会议记录', [
createPhoto('团队合影'),
createPhoto('白板讨论'),
createPhoto('演示现场'),
createVideo('产品演示')
]),
createFolder('办公环境', [
createPhoto('工作台'),
createPhoto('会议室'),
createPhoto('休息区'),
createPhoto('前台')
]),
createPhoto('办公桌'),
createOtherFile('项目文档', 'docx', '#4285f4'),
createOtherFile('演示文稿', 'pptx', '#ff6d01')
]),
createFolder('RAW文件', [
createFolder('人像摄影', [
createPhoto('专业人像01'),
createPhoto('专业人像02'),
createPhoto('环境人像'),
createPhoto('街头抓拍')
]),
createFolder('风景摄影', [
createPhoto('山峦日出'),
createPhoto('海边夕阳'),
createPhoto('城市天际线'),
createPhoto('风景大片')
]),
createOtherFile('lightroom预设', 'lrtemplate', '#31c5f0'),
createOtherFile('photoshop动作', 'atn', '#31a8ff')
]),
createFolder('家庭相册', [
createPhoto('生日聚会'),
createPhoto('家庭聚餐'),
createPhoto('宠物照片'),
createVideo('生活记录'),
createVideo('家庭聚会')
]),
createPhoto('随手拍001'),
createPhoto('随手拍002'),
createPhoto('随手拍001', true), // 重复项
createVideo('生活记录'),
createOtherFile('软件安装包', 'exe', '#666666')
]);
// 初始化状态
state.panes[1].currentFolder = rootData;
state.panes[1].history = [rootData];
state.panes[2].currentFolder = rootData;
state.panes[2].history = [rootData];
// DOM工具
const $ = id => document.getElementById(id);
const $$ = selector => document.querySelectorAll(selector);
// 工具函数
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatDate(date) {
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
}
function getFolderItems(folder) {
if (!folder || !folder.children) return [];
return folder.children;
}
function findItemById(id) {
function search(folder) {
if (!folder.children) return null;
for (const item of folder.children) {
if (item.id === id) return item;
if (item.type === 'folder') {
const found = search(item);
if (found) return found;
}
}
return null;
}
return search(rootData);
}
// 渲染瓦片
function renderTile(item, paneNumber) {
const pane = state.panes[paneNumber];
const tile = document.createElement('div');
tile.className = 'relative group cursor-pointer tile-hover animate-fade-in';
tile.dataset.id = item.id;
tile.dataset.type = item.type;
tile.dataset.pane = paneNumber;
const isSelected = pane.selectedItems.has(item.id);
if (item.type === 'folder') {
tile.innerHTML = `
<div class="folder-tile border-2 border-border rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-100 hover:from-blue-100 hover:to-indigo-200 transition-all duration-300 flex flex-col overflow-hidden ${isSelected ? 'ring-2 ring-primary border-primary' : ''}">
<div class="flex-1 flex items-center justify-center p-6">
<ion-icon name="folder" class="w-12 h-12 text-blue-600"></ion-icon>
</div>
<div class="p-4 bg-white/70 backdrop-blur-sm">
<div class="text-sm font-semibold truncate mb-1" title="${item.name}">${item.name}</div>
<div class="text-xs text-gray-500">${item.itemCount} 项</div>
</div>
</div>
`;
} else if (item.type === 'image') {
tile.innerHTML = `
<div class="photo-tile border-2 border-border rounded-2xl overflow-hidden bg-white transition-all duration-300 ${isSelected ? 'ring-2 ring-primary border-primary' : ''} shadow-sm hover:shadow-lg">
<div class="relative aspect-square">
<img src="${item.thumbnail}" alt="${item.name}" class="w-full h-full object-cover">
${item.isDuplicate ? '<div class="duplicate-badge">!</div>' : ''}
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300"></div>
<div class="absolute bottom-2 left-2 right-2">
<div class="bg-black/70 backdrop-blur-sm text-white text-xs px-2 py-1 rounded truncate">${item.name}</div>
</div>
</div>
</div>
`;
} else if (item.type === 'video') {
tile.innerHTML = `
<div class="photo-tile border-2 border-border rounded-2xl overflow-hidden bg-white transition-all duration-300 ${isSelected ? 'ring-2 ring-primary border-primary' : ''} shadow-sm hover:shadow-lg">
<div class="relative aspect-square">
<img src="${item.thumbnail}" alt="${item.name}" class="w-full h-full object-cover">
<div class="absolute inset-0 bg-black/20 flex items-center justify-center">
<div class="w-12 h-12 bg-black/60 rounded-full flex items-center justify-center backdrop-blur-sm">
<ion-icon name="play" class="w-6 h-6 text-white ml-1"></ion-icon>
</div>
</div>
<div class="absolute top-2 right-2 bg-black/70 backdrop-blur-sm text-white text-xs px-2 py-1 rounded">
${Math.floor(item.duration / 60)}:${(item.duration % 60).toString().padStart(2, '0')}
</div>
<div class="absolute bottom-2 left-2 right-2">
<div class="bg-black/70 backdrop-blur-sm text-white text-xs px-2 py-1 rounded truncate">${item.name}</div>
</div>
</div>
</div>
`;
} else {
tile.innerHTML = `
<div class="photo-tile border-2 border-border rounded-2xl overflow-hidden bg-white transition-all duration-300 opacity-60 shadow-sm">
<div class="relative aspect-square flex flex-col items-center justify-center" style="background: linear-gradient(135deg, ${item.color}22, ${item.color}44)">
<div class="w-8 h-8 rounded-lg flex items-center justify-center mb-2" style="background-color: ${item.color}">
<span class="text-white font-bold text-xs">${item.ext.slice(0, 3).toUpperCase()}</span>
</div>
<div class="text-xs text-gray-600 px-2 text-center truncate">${item.name}</div>
</div>
</div>
`;
}
return tile;
}
// 渲染列表项
function renderListItem(item, paneNumber) {
const pane = state.panes[paneNumber];
const row = document.createElement('div');
row.className = `grid grid-cols-12 gap-4 px-6 py-4 border-b border-border hover:bg-gray-50 cursor-pointer transition-all ${pane.selectedItems.has(item.id) ? 'bg-blue-50 border-blue-200' : ''}`;
row.dataset.id = item.id;
row.dataset.type = item.type;
row.dataset.pane = paneNumber;
const iconName = item.type === 'folder' ? 'folder-outline' :
item.type === 'image' ? 'image-outline' :
item.type === 'video' ? 'videocam-outline' : 'document-outline';
row.innerHTML = `
<div class="col-span-5 flex items-center gap-3 min-w-0">
<ion-icon name="${iconName}" class="w-5 h-5 text-gray-400 flex-shrink-0"></ion-icon>
<span class="truncate font-medium" title="${item.name}">${item.name}</span>
${item.isDuplicate ? '<span class="w-2 h-2 bg-orange-500 rounded-full flex-shrink-0" title="重复项"></span>' : ''}
</div>
<div class="col-span-2 text-sm text-gray-500">${formatDate(item.date)}</div>
<div class="col-span-2 text-sm text-gray-500">${item.type === 'folder' ? '文件夹' : item.type === 'image' ? '图片' : item.type === 'video' ? '视频' : item.ext?.toUpperCase() || '文件'}</div>
<div class="col-span-2 text-sm text-gray-500">${item.type === 'folder' ? `${item.itemCount} 项` : formatFileSize(item.size)}</div>
<div class="col-span-1 text-sm text-gray-500">${item.tags ? item.tags.join(', ') : ''}</div>
`;
return row;
}
// 渲染面包屑
function renderBreadcrumb(paneNumber) {
const pane = state.panes[paneNumber];
const breadcrumb = $(`pane${paneNumber}Breadcrumb`);
breadcrumb.innerHTML = '';
// 构建完整路径
const path = [];
// 获取从根目录到当前文件夹的完整路径
function buildPath(folder, target, currentPath = []) {
currentPath.push(folder);
if (folder === target) {
return [...currentPath];
}
if (folder.children) {
for (const child of folder.children) {
if (child.type === 'folder') {
const result = buildPath(child, target, [...currentPath]);
if (result) return result;
}
}
}
return null;
}
const fullPath = buildPath(rootData, pane.currentFolder) || [rootData];
// 渲染面包屑
fullPath.forEach((folder, index) => {
if (index > 0) {
const separator = document.createElement('span');
separator.innerHTML = '<ion-icon name="chevron-forward-outline" class="w-3 h-3"></ion-icon>';
separator.className = 'breadcrumb-separator';
breadcrumb.appendChild(separator);
}
const crumb = document.createElement('button');
crumb.textContent = folder.name;
crumb.className = 'breadcrumb-item text-white/90 font-medium';
// 如果不是当前文件夹,可以点击跳转
if (index < fullPath.length - 1) {
crumb.classList.add('hover:text-white', 'cursor-pointer');
crumb.onclick = () => {
navigateToFolder(paneNumber, folder);
};
} else {
crumb.classList.add('text-white', 'cursor-default');
}
breadcrumb.appendChild(crumb);
});
}
// 分割线拖拽状态
let dragState = null;
// 初始化分割线拖拽
function initSplitterDrag() {
const splitter = $('splitter');
if (!splitter || splitter.classList.contains('hidden')) return;
splitter.onmousedown = (e) => {
e.preventDefault();
const workspace = $('workspace');
const rect = workspace.getBoundingClientRect();
dragState = {
startX: e.clientX,
startY: e.clientY,
isVertical: splitter.classList.contains('vertical'),
containerRect: rect
};
document.addEventListener('mousemove', handleSplitterDrag);
document.addEventListener('mouseup', stopSplitterDrag);
document.body.style.cursor = dragState.isVertical ? 'row-resize' : 'col-resize';
document.body.style.userSelect = 'none';
};
}
function handleSplitterDrag(e) {
if (!dragState) return;
const workspace = $('workspace');
if (dragState.isVertical) {
// 上下分割
const deltaY = e.clientY - dragState.startY;
const containerHeight = dragState.containerRect.height;
const newTopHeight = Math.max(200, Math.min(containerHeight - 200, containerHeight / 2 + deltaY));
const newBottomHeight = containerHeight - newTopHeight - 8; // 8px for splitter
if (state.showPreview) {
workspace.style.gridTemplateRows = `${newTopHeight}px 8px ${newBottomHeight}px`;
} else {
workspace.style.gridTemplateRows = `${newTopHeight}px 8px ${newBottomHeight}px`;
}
} else {
// 左右分割
const deltaX = e.clientX - dragState.startX;
const containerWidth = dragState.containerRect.width;
const previewWidth = state.showPreview ? 320 : 0;
const availableWidth = containerWidth - previewWidth;
const newLeftWidth = Math.max(300, Math.min(availableWidth - 300, availableWidth / 2 + deltaX));
const newRightWidth = availableWidth - newLeftWidth - 8; // 8px for splitter
if (state.showPreview) {
workspace.style.gridTemplateColumns = `${newLeftWidth}px 8px ${newRightWidth}px 320px`;
} else {
workspace.style.gridTemplateColumns = `${newLeftWidth}px 8px ${newRightWidth}px`;
}
}
}
function stopSplitterDrag() {
dragState = null;
document.removeEventListener('mousemove', handleSplitterDrag);
document.removeEventListener('mouseup', stopSplitterDrag);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
// 布局管理
function updateLayout() {
const workspace = $('workspace');
const pane2 = $('pane2');
const splitter = $('splitter');
const previewPane = $('previewPane');
// 重置
pane2.classList.add('hidden');
splitter.classList.add('hidden');
if (state.showPreview) {
previewPane.classList.remove('hidden');
} else {
previewPane.classList.add('hidden');
}
switch (state.layout) {
case 'single':
workspace.style.gridTemplateColumns = state.showPreview ? '1fr 320px' : '1fr';
workspace.style.gridTemplateRows = '1fr';
splitter.classList.remove('vertical');
break;
case 'dual-h':
pane2.classList.remove('hidden');
splitter.classList.remove('hidden');
splitter.classList.remove('vertical');
// 平分左右空间
workspace.style.gridTemplateColumns = state.showPreview ? '1fr 8px 1fr 320px' : '1fr 8px 1fr';
workspace.style.gridTemplateRows = '1fr';
break;
case 'dual-v':
pane2.classList.remove('hidden');
splitter.classList.remove('hidden');
splitter.classList.add('vertical');
// 平分上下空间
workspace.style.gridTemplateColumns = state.showPreview ? '1fr 320px' : '1fr';
workspace.style.gridTemplateRows = '1fr 8px 1fr';
break;
}
// 重新初始化拖拽
setTimeout(initSplitterDrag, 100);
}
// 渲染面板内容
function renderPane(paneNumber) {
const pane = state.panes[paneNumber];
const items = getFolderItems(pane.currentFolder);
// 渲染面包屑
renderBreadcrumb(paneNumber);
// 更新统计
$(`pane${paneNumber}Stats`).textContent = `${items.length} 项`;
// 更新导航按钮状态
updateNavigationButtons(paneNumber);
if (state.viewMode === 'tiles') {
$(`pane${paneNumber}TilesView`).classList.remove('hidden');
$(`pane${paneNumber}ListView`).classList.add('hidden');
const container = $(`pane${paneNumber}TilesView`);
container.innerHTML = '';
if (items.length === 0) {
$(`pane${paneNumber}Empty`).classList.remove('hidden');
return;
} else {
$(`pane${paneNumber}Empty`).classList.add('hidden');
}
items.forEach(item => {
const tile = renderTile(item, paneNumber);
container.appendChild(tile);
});
} else {
$(`pane${paneNumber}TilesView`).classList.add('hidden');
$(`pane${paneNumber}ListView`).classList.remove('hidden');
const container = $(`pane${paneNumber}ListContent`);
container.innerHTML = '';
if (items.length === 0) {
$(`pane${paneNumber}Empty`).classList.remove('hidden');
return;
} else {
$(`pane${paneNumber}Empty`).classList.add('hidden');
}
items.forEach(item => {
const row = renderListItem(item, paneNumber);
container.appendChild(row);
});
}
}
// 更新全局统计
function updateGlobalStats() {
const activePane = state.panes[state.activePane];
const items = getFolderItems(activePane.currentFolder);
$('totalStats').textContent = `${items.length} 项`;
const selectedCount = activePane.selectedItems.size;
if (selectedCount === 0) {
$('selectionStats').textContent = '未选择';
} else {
$('selectionStats').textContent = `已选择 ${selectedCount} 项`;
}
// 统计重复项
let duplicateCount = 0;
function countDuplicates(folder) {
if (!folder.children) return;
folder.children.forEach(item => {
if (item.isDuplicate) duplicateCount++;
if (item.type === 'folder') countDuplicates(item);
});
}
countDuplicates(rootData);
$('duplicateCount').textContent = duplicateCount;
}
// 导航功能
function navigateToFolder(paneNumber, folder) {
const pane = state.panes[paneNumber];
pane.currentFolder = folder;
pane.history = pane.history.slice(0, pane.historyIndex + 1);
pane.history.push(folder);
pane.historyIndex = pane.history.length - 1;
pane.selectedItems.clear();
renderPane(paneNumber);
updateGlobalStats();
updateNavigationButtons(paneNumber);
}
function goBack(paneNumber) {
const pane = state.panes[paneNumber];
if (pane.historyIndex > 0) {
pane.historyIndex--;
pane.currentFolder = pane.history[pane.historyIndex];
pane.selectedItems.clear();
renderPane(paneNumber);
updateGlobalStats();
updateNavigationButtons(paneNumber);
}
}
function goForward(paneNumber) {
const pane = state.panes[paneNumber];
if (pane.historyIndex < pane.history.length - 1) {
pane.historyIndex++;
pane.currentFolder = pane.history[pane.historyIndex];
pane.selectedItems.clear();
renderPane(paneNumber);
updateGlobalStats();
updateNavigationButtons(paneNumber);
}
}
function goUp(paneNumber) {
const pane = state.panes[paneNumber];
// 找到当前文件夹的父文件夹
function findParent(folder, target, parent = null) {
if (folder === target && parent) {
return parent;
}
if (folder.children) {
for (const child of folder.children) {
if (child.type === 'folder') {
const result = findParent(child, target, folder);
if (result) return result;
}
}
}
return null;
}
const parentFolder = findParent(rootData, pane.currentFolder);
if (parentFolder) {
navigateToFolder(paneNumber, parentFolder);
}
}
function updateNavigationButtons(paneNumber) {
const pane = state.panes[paneNumber];
// 更新后退按钮
const backBtn = $(`pane${paneNumber}Back`);
if (backBtn) {
if (pane.historyIndex > 0) {
backBtn.classList.remove('opacity-50', 'cursor-not-allowed');
backBtn.classList.add('cursor-pointer');
} else {
backBtn.classList.add('opacity-50', 'cursor-not-allowed');
backBtn.classList.remove('cursor-pointer');
}
}
// 更新前进按钮
const forwardBtn = $(`pane${paneNumber}Forward`);
if (forwardBtn) {
if (pane.historyIndex < pane.history.length - 1) {
forwardBtn.classList.remove('opacity-50', 'cursor-not-allowed');
forwardBtn.classList.add('cursor-pointer');
} else {
forwardBtn.classList.add('opacity-50', 'cursor-not-allowed');
forwardBtn.classList.remove('cursor-pointer');
}
}
// 更新上一级按钮
const upBtn = $(`pane${paneNumber}Up`);
if (upBtn) {
// 检查是否在根目录
if (pane.currentFolder === rootData) {
upBtn.classList.add('opacity-50', 'cursor-not-allowed');
upBtn.classList.remove('cursor-pointer');
} else {
upBtn.classList.remove('opacity-50', 'cursor-not-allowed');
upBtn.classList.add('cursor-pointer');
}
}
}
// 设置缩略图大小
function setTileSize(size) {
state.tileSize = size;
document.documentElement.style.setProperty('--tile-size', size + 'px');
// 更新按钮状态
$$('.size-btn').forEach(btn => {
if (parseInt(btn.dataset.size) === size) {
btn.classList.add('bg-primary', 'text-white');
btn.classList.remove('text-muted');
} else {
btn.classList.remove('bg-primary', 'text-white');
btn.classList.add('text-muted');
}
});
}
// 事件处理
function handleItemClick(e) {
const itemElement = e.target.closest('[data-id]');
if (!itemElement) return;
const itemId = itemElement.dataset.id;
const paneNumber = parseInt(itemElement.dataset.pane);
const pane = state.panes[paneNumber];
const item = findItemById(itemId);
if (!item) return;
// 设置活动面板
state.activePane = paneNumber;
if (e.ctrlKey || e.metaKey) {
// 多选
if (pane.selectedItems.has(itemId)) {
pane.selectedItems.delete(itemId);
} else {
pane.selectedItems.add(itemId);
}
} else {
// 单选
pane.selectedItems.clear();
pane.selectedItems.add(itemId);
}
renderPane(paneNumber);
updateGlobalStats();
updatePreviewPane();
}
function handleItemDoubleClick(e) {
e.preventDefault();
e.stopPropagation();
const itemElement = e.target.closest('[data-id]');
if (!itemElement) return;
const itemId = itemElement.dataset.id;
const paneNumber = parseInt(itemElement.dataset.pane);
const item = findItemById(itemId);
if (!item) return;
// 设置活动面板
state.activePane = paneNumber;
if (item.type === 'folder') {
console.log(`导航到文件夹: ${item.name} (面板${paneNumber})`);
navigateToFolder(paneNumber, item);
} else if (item.type === 'image' || item.type === 'video') {
showFullPreview(item);
}
}
// 预览功能
function updatePreviewPane() {
if (!state.showPreview) return;
const activePane = state.panes[state.activePane];
const selectedId = Array.from(activePane.selectedItems)[0];
const item = selectedId ? findItemById(selectedId) : null;
const previewContent = $('previewContent');
if (!item) {
previewContent.innerHTML = `
<div class="flex flex-col items-center justify-center h-64 text-center">
<div class="w-16 h-16 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full flex items-center justify-center mb-4">
<ion-icon name="eye-outline" class="w-8 h-8 text-primary"></ion-icon>
</div>
<h3 class="text-lg font-semibold text-gray-700 mb-2">选择项目预览</h3>
<p class="text-muted">点击选择照片或视频查看详情</p>
</div>
`;
return;
}
if (item.type === 'image') {
previewContent.innerHTML = `
<div class="space-y-4">
<img src="${item.thumbnail}" alt="${item.name}" class="w-full rounded-xl shadow-sm">
<div class="space-y-2">
<h4 class="font-semibold text-gray-900">${item.name}</h4>
<div class="text-sm text-gray-600 space-y-1">
<div>尺寸: ${item.width} × ${item.height}</div>
<div>大小: ${formatFileSize(item.size)}</div>
<div>日期: ${formatDate(item.date)}</div>
${item.exif ? `<div class="pt-2 border-t border-gray-200 text-xs">
<div>${item.exif.camera}</div>
<div>${item.exif.fNumber} · ${item.exif.exposureTime} · ${item.exif.iso}</div>
</div>` : ''}
</div>
</div>
</div>
`;
} else if (item.type === 'video') {
previewContent.innerHTML = `
<div class="space-y-4">
<div class="relative">
<img src="${item.thumbnail}" alt="${item.name}" class="w-full rounded-xl shadow-sm">
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-12 h-12 bg-black/60 rounded-full flex items-center justify-center">
<ion-icon name="play" class="w-6 h-6 text-white ml-1"></ion-icon>
</div>
</div>
</div>
<div class="space-y-2">
<h4 class="font-semibold text-gray-900">${item.name}</h4>
<div class="text-sm text-gray-600 space-y-1">
<div>时长: ${Math.floor(item.duration / 60)}:${(item.duration % 60).toString().padStart(2, '0')}</div>
<div>大小: ${formatFileSize(item.size)}</div>
<div>日期: ${formatDate(item.date)}</div>
</div>
</div>
</div>
`;
} else if (item.type === 'folder') {
previewContent.innerHTML = `
<div class="space-y-4">
<div class="w-full h-40 bg-gradient-to-br from-blue-50 to-indigo-100 rounded-xl flex items-center justify-center">
<ion-icon name="folder" class="w-16 h-16 text-blue-600"></ion-icon>
</div>
<div class="space-y-2">
<h4 class="font-semibold text-gray-900">${item.name}</h4>
<div class="text-sm text-gray-600">
<div>${item.itemCount} 项</div>
</div>
</div>
</div>
`;
}
}
function showFullPreview(item) {
if (item.type === 'image') {
$('previewImage').src = item.src;
$('previewImage').classList.remove('hidden');
$('previewVideo').classList.add('hidden');
} else if (item.type === 'video') {
$('previewVideo').poster = item.thumbnail;
$('previewVideo').classList.remove('hidden');
$('previewImage').classList.add('hidden');
}
// 更新信息
$('photoName').textContent = item.name;
$('photoSize').textContent = item.type === 'image' ? `${item.width} × ${item.height}` : '视频';
$('photoDate').textContent = formatDate(item.date);
$('photoFileSize').textContent = formatFileSize(item.size);
if (item.exif && item.type === 'image') {
$('cameraInfo').textContent = `${item.exif.camera} · ${item.exif.fNumber} · ${item.exif.exposureTime} · ${item.exif.iso}`;
$('cameraInfo').classList.remove('hidden');
} else {
$('cameraInfo').classList.add('hidden');
}
$('previewModal').classList.remove('hidden');
}
function closeFullPreview() {
$('previewModal').classList.add('hidden');
if ($('previewVideo')) {
$('previewVideo').pause();
}
}
// 初始化应用
function initializeApp() {
// 设置初始状态
setTileSize(state.tileSize);
updateLayout();
renderPane(1);
renderPane(2);
updateGlobalStats();
updateNavigationButtons(1);
updateNavigationButtons(2);
// 布局切换
$('layoutSingle').onclick = () => {
state.layout = 'single';
$$('[id^="layout"]').forEach(btn => {
btn.classList.remove('bg-primary', 'text-white');
btn.classList.add('text-muted');
});
$('layoutSingle').classList.add('bg-primary', 'text-white');
$('layoutSingle').classList.remove('text-muted');
updateLayout();
};
$('layoutDualH').onclick = () => {
state.layout = 'dual-h';
$$('[id^="layout"]').forEach(btn => {
btn.classList.remove('bg-primary', 'text-white');
btn.classList.add('text-muted');
});
$('layoutDualH').classList.add('bg-primary', 'text-white');
$('layoutDualH').classList.remove('text-muted');
updateLayout();
};
$('layoutDualV').onclick = () => {
state.layout = 'dual-v';
$$('[id^="layout"]').forEach(btn => {
btn.classList.remove('bg-primary', 'text-white');
btn.classList.add('text-muted');
});
$('layoutDualV').classList.add('bg-primary', 'text-white');
$('layoutDualV').classList.remove('text-muted');
updateLayout();
};
// 视图切换
$('viewTiles').onclick = () => {
state.viewMode = 'tiles';
$('viewTiles').classList.add('bg-primary', 'text-white');
$('viewTiles').classList.remove('text-muted');
$('viewList').classList.remove('bg-primary', 'text-white');
$('viewList').classList.add('text-muted');
renderPane(1);
if (state.layout !== 'single') renderPane(2);
};
$('viewList').onclick = () => {
state.viewMode = 'list';
$('viewList').classList.add('bg-primary', 'text-white');
$('viewList').classList.remove('text-muted');
$('viewTiles').classList.remove('bg-primary', 'text-white');
$('viewTiles').classList.add('text-muted');
renderPane(1);
if (state.layout !== 'single') renderPane(2);
};
// 缩略图大小
$$('.size-btn').forEach(btn => {
btn.onclick = () => {
const size = parseInt(btn.dataset.size);
setTileSize(size);
renderPane(1);
if (state.layout !== 'single') renderPane(2);
};
});
// 预览开关
$('showPreview').onchange = (e) => {
state.showPreview = e.target.checked;
updateLayout();
if (state.showPreview) {
updatePreviewPane();
}
};
// 绑定导航按钮事件
$('pane1Back').onclick = () => goBack(1);
$('pane1Forward').onclick = () => goForward(1);
$('pane1Up').onclick = () => goUp(1);
$('pane2Back').onclick = () => goBack(2);
$('pane2Forward').onclick = () => goForward(2);
$('pane2Up').onclick = () => goUp(2);
// 绑定内容区事件
$('pane1').addEventListener('click', handleItemClick);
$('pane1').addEventListener('dblclick', handleItemDoubleClick);
$('pane2').addEventListener('click', handleItemClick);
$('pane2').addEventListener('dblclick', handleItemDoubleClick);
// 预览模态框
$('btnPreviewClose').onclick = closeFullPreview;
$('previewModal').onclick = (e) => {
if (e.target === $('previewModal')) closeFullPreview();
};
// 文件操作
$('btnCopy').onclick = () => {
const activePane = state.panes[state.activePane];
if (activePane.selectedItems.size === 0) return;
const items = Array.from(activePane.selectedItems).map(id => findItemById(id)).filter(Boolean);
state.clipboard = { action: 'copy', items };
alert(`已复制 ${items.length} 个项目`);
};
$('btnRename').onclick = () => {
const activePane = state.panes[state.activePane];
if (activePane.selectedItems.size !== 1) {
alert('请选择一个项目进行重命名');
return;
}
const item = findItemById(Array.from(activePane.selectedItems)[0]);
const newName = prompt('重命名为:', item.name);
if (newName && newName !== item.name) {
item.name = newName;
renderPane(state.activePane);
updatePreviewPane();
}
};
// 键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea')) return;
if (e.key === 'Escape') {
if (!$('previewModal').classList.contains('hidden')) {
closeFullPreview();
} else {
state.panes[state.activePane].selectedItems.clear();
renderPane(state.activePane);
updateGlobalStats();
updatePreviewPane();
}
}
});
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', initializeApp);
</script>
</body>
</html>
+736
View File
@@ -0,0 +1,736 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Argus - 照片管理器</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: '#3B82F6',
accent: '#10B981'
}
}
}
}
</script>
<style>
:root {
--tile-size: 200px;
--tile-gap: 12px;
}
.photo-tile {
width: var(--tile-size);
height: var(--tile-size);
}
.folder-tile {
width: var(--tile-size);
height: calc(var(--tile-size) * 0.8);
}
.no-select { user-select: none; }
.duplicate-indicator {
animation: pulse 2s infinite;
}
.grid-auto {
grid-template-columns: repeat(auto-fill, minmax(var(--tile-size), 1fr));
gap: var(--tile-gap);
}
</style>
</head>
<body class="bg-slate-50 text-slate-900 no-select">
<!-- 顶部工具栏 -->
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-slate-200">
<div class="px-4 py-3">
<!-- 第一行:主要操作区 -->
<div class="flex items-center justify-between mb-3">
<!-- 左侧:应用标识和导航 -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-brand to-purple-600 text-white flex items-center justify-center font-bold">A</div>
<h1 class="text-lg font-semibold">Argus</h1>
</div>
<div class="h-6 w-px bg-slate-300"></div>
<div class="flex items-center gap-1">
<button id="btnBack" class="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50" title="后退">
<ion-icon name="chevron-back-outline"></ion-icon>
</button>
<button id="btnForward" class="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50" title="前进">
<ion-icon name="chevron-forward-outline"></ion-icon>
</button>
<button id="btnUp" class="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50" title="上一级">
<ion-icon name="arrow-up-outline"></ion-icon>
</button>
</div>
</div>
<!-- 右侧:搜索和设置 -->
<div class="flex items-center gap-3">
<div class="relative">
<ion-icon name="search-outline" class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></ion-icon>
<input id="searchInput" placeholder="搜索照片、视频..."
class="pl-10 pr-4 py-2 w-80 border border-slate-200 rounded-lg focus:border-brand focus:outline-none text-sm"/>
</div>
<button id="btnDuplicates" class="px-3 py-2 rounded-lg bg-orange-50 text-orange-700 border border-orange-200 hover:bg-orange-100 text-sm" title="查看重复项">
<ion-icon name="copy-outline" class="mr-1"></ion-icon>重复项 <span id="duplicateCount" class="ml-1 px-1.5 py-0.5 bg-orange-200 rounded text-xs">0</span>
</button>
<button id="btnSettings" class="p-2 rounded-lg hover:bg-slate-100" title="设置">
<ion-icon name="settings-outline"></ion-icon>
</button>
<button id="btnTheme" class="p-2 rounded-lg hover:bg-slate-100" title="切换主题">
<ion-icon name="moon-outline"></ion-icon>
</button>
</div>
</div>
<!-- 第二行:照片管理工具栏 -->
<div class="flex items-center justify-between">
<!-- 左侧:照片操作 -->
<div class="flex items-center gap-2">
<div class="flex items-center gap-1 bg-slate-100 rounded-lg px-1 py-0.5">
<button id="btnImport" class="px-3 py-1.5 rounded-md text-sm bg-brand text-white">
<ion-icon name="cloud-upload-outline" class="mr-1"></ion-icon>导入照片
</button>
<button id="btnScan" class="px-3 py-1.5 rounded-md text-sm hover:bg-slate-200">
<ion-icon name="scan-outline" class="mr-1"></ion-icon>扫描文件夹
</button>
</div>
<div class="h-6 w-px bg-slate-300 mx-2"></div>
<!-- 选中项操作 -->
<div id="selectionTools" class="flex items-center gap-1 opacity-50">
<span id="selectionCount" class="text-sm text-slate-600 mr-2">未选择</span>
<button id="btnAlbum" class="px-2 py-1.5 rounded-md text-sm hover:bg-accent/10 text-accent" disabled>
<ion-icon name="albums-outline" class="mr-1"></ion-icon>加入相册
</button>
<button id="btnTags" class="px-2 py-1.5 rounded-md text-sm hover:bg-slate-100" disabled>
<ion-icon name="pricetag-outline" class="mr-1"></ion-icon>编辑标签
</button>
<button id="btnBatchEdit" class="px-2 py-1.5 rounded-md text-sm hover:bg-slate-100" disabled>
<ion-icon name="create-outline" class="mr-1"></ion-icon>批量编辑
</button>
<button id="btnMove" class="px-2 py-1.5 rounded-md text-sm hover:bg-slate-100" disabled>
<ion-icon name="folder-outline" class="mr-1"></ion-icon>移动到...
</button>
<button id="btnDelete" class="px-2 py-1.5 rounded-md text-sm hover:bg-red-50 text-red-600" disabled>
<ion-icon name="trash-outline" class="mr-1"></ion-icon>删除
</button>
</div>
</div>
<!-- 右侧:视图控制 -->
<div class="flex items-center gap-3">
<!-- 显示过滤 -->
<div class="flex items-center gap-2 text-sm">
<label class="flex items-center gap-1">
<input id="showPhotos" type="checkbox" checked class="accent-brand">
<span>照片</span>
</label>
<label class="flex items-center gap-1">
<input id="showVideos" type="checkbox" checked class="accent-brand">
<span>视频</span>
</label>
<label class="flex items-center gap-1">
<input id="showFolders" type="checkbox" checked class="accent-brand">
<span>文件夹</span>
</label>
<label class="flex items-center gap-1">
<input id="showOthers" type="checkbox" class="accent-brand">
<span>其他文件</span>
</label>
</div>
<div class="h-6 w-px bg-slate-300"></div>
<!-- 缩略图大小 -->
<div class="flex items-center gap-2">
<span class="text-sm text-slate-600">大小</span>
<input id="tileSize" type="range" min="150" max="300" value="200" class="w-20 accent-brand">
</div>
<!-- 排序方式 -->
<select id="sortBy" class="px-2 py-1 border border-slate-200 rounded text-sm focus:border-brand focus:outline-none">
<option value="name">按名称</option>
<option value="date">按日期</option>
<option value="size">按大小</option>
<option value="type">按类型</option>
</select>
</div>
</div>
</div>
</header>
<!-- 路径导航 -->
<nav class="bg-white border-b border-slate-100 px-4 py-2">
<div id="breadcrumb" class="flex items-center gap-1 text-sm text-slate-600">
<!-- 动态生成面包屑 -->
</div>
</nav>
<!-- 主内容区 -->
<main class="p-4">
<!-- 统计信息 -->
<div id="statsBar" class="mb-4 flex items-center justify-between text-sm text-slate-600">
<div class="flex items-center gap-4">
<span id="totalItems">共 0 项</span>
<span id="photoCount">照片 0</span>
<span id="videoCount">视频 0</span>
<span id="folderCount">文件夹 0</span>
</div>
<div class="flex items-center gap-4">
<span id="lastScan">上次扫描: 从未</span>
<button id="btnQuickScan" class="text-brand hover:underline">快速扫描</button>
</div>
</div>
<!-- 内容网格 -->
<div id="contentGrid" class="grid grid-auto">
<!-- 动态生成内容 -->
</div>
<!-- 空状态 -->
<div id="emptyState" class="hidden text-center py-20">
<div class="text-slate-400 mb-4">
<ion-icon name="image-outline" style="font-size: 4rem;"></ion-icon>
</div>
<h3 class="text-lg font-medium text-slate-600 mb-2">这里还没有照片</h3>
<p class="text-slate-500 mb-4">导入一些照片开始使用 Argus</p>
<button class="px-4 py-2 bg-brand text-white rounded-lg hover:bg-blue-600">
<ion-icon name="cloud-upload-outline" class="mr-2"></ion-icon>导入照片
</button>
</div>
</main>
<!-- 预览模态框 -->
<div id="previewModal" class="fixed inset-0 bg-black/80 flex items-center justify-center hidden z-50">
<div class="relative max-w-[90vw] max-h-[90vh]">
<img id="previewImage" class="max-w-[90vw] max-h-[90vh] object-contain hidden">
<video id="previewVideo" class="max-w-[90vw] max-h-[90vh] hidden" controls></video>
<!-- 预览控制栏 -->
<div class="absolute top-4 right-4 flex items-center gap-2">
<button id="btnFullscreen" class="p-2 rounded-lg bg-black/50 text-white hover:bg-black/70">
<ion-icon name="expand-outline"></ion-icon>
</button>
<button id="btnPreviewClose" class="p-2 rounded-lg bg-black/50 text-white hover:bg-black/70">
<ion-icon name="close-outline"></ion-icon>
</button>
</div>
<!-- 照片信息面板 -->
<div id="photoInfo" class="absolute bottom-4 left-4 bg-black/70 text-white rounded-lg p-4 max-w-sm">
<h4 id="photoName" class="font-medium mb-2">IMG_001.jpg</h4>
<div class="text-sm space-y-1">
<div id="photoSize">2048 × 1536</div>
<div id="photoDate">2024-01-01 12:00:00</div>
<div id="photoFileSize">2.5 MB</div>
<div id="cameraInfo" class="hidden">Canon EOS R5 · f/2.8 · 1/125s · ISO 400</div>
</div>
</div>
</div>
</div>
<script>
// 模拟数据
const img = (name, w=1600, h=1200) => `https://picsum.photos/seed/${encodeURIComponent(name)}/${w}/${h}`;
let itemId = 1;
const uid = () => (itemId++).toString();
// 数据结构
const createFolder = (name, children = []) => ({
id: uid(),
type: 'folder',
name,
children,
path: `/photos/${name}`,
itemCount: children.length
});
const createPhoto = (name, isDuplicate = false) => ({
id: uid(),
type: 'image',
name,
src: img(name),
thumbnail: img(name, 400, 400),
size: Math.floor(Math.random() * 5000000) + 500000, // 0.5-5MB
width: 1600,
height: 1200,
date: new Date(2024, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1),
isDuplicate,
exif: {
camera: 'Canon EOS R5',
lens: 'RF 24-70mm f/2.8L',
fNumber: 'f/2.8',
exposureTime: '1/125s',
iso: 'ISO 400'
}
});
const createVideo = (name) => ({
id: uid(),
type: 'video',
name,
thumbnail: img(name, 400, 400),
size: Math.floor(Math.random() * 50000000) + 5000000, // 5-50MB
duration: Math.floor(Math.random() * 300) + 30, // 30-330s
date: new Date(2024, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1)
});
const createOtherFile = (name, ext, color) => ({
id: uid(),
type: 'other',
name,
ext,
color,
size: Math.floor(Math.random() * 1000000) + 10000,
date: new Date()
});
// 模拟文件系统数据
const rootData = createFolder('我的照片', [
createFolder('2024年旅行', [
createFolder('东京之旅', [
createPhoto('东京塔夜景'),
createPhoto('浅草寺'),
createPhoto('涩谷路口'),
createVideo('东京街头录像'),
createPhoto('东京塔夜景'), // 重复项
]),
createFolder('大阪美食', [
createPhoto('大阪烧'),
createPhoto('章鱼小丸子'),
createVideo('道顿堀夜景')
]),
createPhoto('机场候机'),
createVideo('飞行延时摄影')
]),
createFolder('工作照片', [
createPhoto('会议室'),
createPhoto('办公桌'),
createOtherFile('项目文档', 'docx', '#4285f4'),
createOtherFile('演示文稿', 'pptx', '#ff6d01')
]),
createFolder('RAW文件', [
createPhoto('专业人像01'),
createPhoto('风景大片'),
createOtherFile('lightroom预设', 'lrtemplate', '#31c5f0')
]),
createPhoto('随手拍001'),
createPhoto('随手拍002'),
createPhoto('随手拍001', true), // 重复项
createVideo('生活记录'),
createOtherFile('软件安装包', 'exe', '#666666')
]);
// 状态管理
let currentFolder = rootData;
let history = [rootData];
let historyIndex = 0;
let selectedItems = new Set();
let viewOptions = {
showPhotos: true,
showVideos: true,
showFolders: true,
showOthers: false
};
// DOM 操作工具
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => document.querySelectorAll(selector);
// 文件大小格式化
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// 获取当前显示的项目
function getVisibleItems() {
if (!currentFolder.children) return [];
return currentFolder.children.filter(item => {
if (item.type === 'image' && !viewOptions.showPhotos) return false;
if (item.type === 'video' && !viewOptions.showVideos) return false;
if (item.type === 'folder' && !viewOptions.showFolders) return false;
if (item.type === 'other' && !viewOptions.showOthers) return false;
return true;
});
}
// 渲染面包屑
function renderBreadcrumb() {
const breadcrumb = $('#breadcrumb');
breadcrumb.innerHTML = '';
history.slice(0, historyIndex + 1).forEach((folder, index) => {
if (index > 0) {
const separator = document.createElement('span');
separator.innerHTML = '<ion-icon name="chevron-forward-outline"></ion-icon>';
separator.className = 'text-slate-400';
breadcrumb.appendChild(separator);
}
const crumb = document.createElement('button');
crumb.textContent = folder.name;
crumb.className = 'px-2 py-1 rounded hover:bg-slate-100 ' +
(index === historyIndex ? 'text-slate-900 font-medium' : 'text-slate-600');
if (index < historyIndex) {
crumb.onclick = () => navigateToHistory(index);
}
breadcrumb.appendChild(crumb);
});
}
// 渲染项目瓦片
function renderTile(item) {
const tile = document.createElement('div');
tile.className = 'relative group cursor-pointer';
tile.dataset.id = item.id;
tile.dataset.type = item.type;
if (item.type === 'folder') {
tile.className += ' folder-tile';
tile.innerHTML = `
<div class="folder-tile border-2 border-slate-200 rounded-xl bg-gradient-to-br from-blue-50 to-indigo-100 hover:from-blue-100 hover:to-indigo-200 transition-all duration-200 flex flex-col">
<div class="flex-1 flex items-center justify-center">
<ion-icon name="folder" class="text-4xl text-blue-600"></ion-icon>
</div>
<div class="p-3 border-t bg-white/50">
<div class="font-medium text-sm truncate" title="${item.name}">${item.name}</div>
<div class="text-xs text-slate-600">${item.itemCount} 项</div>
</div>
</div>
`;
} else if (item.type === 'image') {
tile.className += ' photo-tile';
tile.innerHTML = `
<div class="photo-tile border-2 border-slate-200 rounded-xl overflow-hidden bg-white hover:border-brand transition-all duration-200 ${selectedItems.has(item.id) ? 'ring-2 ring-brand border-brand' : ''}">
<div class="relative aspect-square">
<img src="${item.thumbnail}" alt="${item.name}" class="w-full h-full object-cover">
${item.isDuplicate ? '<div class="absolute top-2 right-2 w-3 h-3 bg-orange-500 rounded-full duplicate-indicator" title="重复项"></div>' : ''}
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200"></div>
<!-- 选择框 -->
<div class="absolute top-2 left-2 w-5 h-5 border-2 border-white rounded ${selectedItems.has(item.id) ? 'bg-brand border-brand' : 'bg-black/20'} flex items-center justify-center">
${selectedItems.has(item.id) ? '<ion-icon name="checkmark" class="text-white text-xs"></ion-icon>' : ''}
</div>
</div>
<div class="p-2">
<div class="text-xs font-medium truncate" title="${item.name}">${item.name}</div>
</div>
</div>
`;
} else if (item.type === 'video') {
tile.className += ' photo-tile';
tile.innerHTML = `
<div class="photo-tile border-2 border-slate-200 rounded-xl overflow-hidden bg-white hover:border-brand transition-all duration-200 ${selectedItems.has(item.id) ? 'ring-2 ring-brand border-brand' : ''}">
<div class="relative aspect-square">
<img src="${item.thumbnail}" alt="${item.name}" class="w-full h-full object-cover">
<div class="absolute inset-0 bg-black/20 flex items-center justify-center">
<div class="w-12 h-12 bg-black/60 rounded-full flex items-center justify-center">
<ion-icon name="play" class="text-white text-xl ml-1"></ion-icon>
</div>
</div>
<div class="absolute bottom-2 right-2 px-1.5 py-0.5 bg-black/70 text-white text-xs rounded">
${Math.floor(item.duration / 60)}:${(item.duration % 60).toString().padStart(2, '0')}
</div>
<!-- 选择框 -->
<div class="absolute top-2 left-2 w-5 h-5 border-2 border-white rounded ${selectedItems.has(item.id) ? 'bg-brand border-brand' : 'bg-black/20'} flex items-center justify-center">
${selectedItems.has(item.id) ? '<ion-icon name="checkmark" class="text-white text-xs"></ion-icon>' : ''}
</div>
</div>
<div class="p-2">
<div class="text-xs font-medium truncate" title="${item.name}">${item.name}</div>
</div>
</div>
`;
} else if (item.type === 'other') {
tile.className += ' photo-tile';
tile.innerHTML = `
<div class="photo-tile border-2 border-slate-200 rounded-xl overflow-hidden bg-white hover:border-slate-300 transition-all duration-200 opacity-60">
<div class="relative aspect-square flex items-center justify-center" style="background-color: ${item.color}">
<div class="text-white font-bold text-lg">${item.ext.toUpperCase()}</div>
</div>
<div class="p-2">
<div class="text-xs font-medium truncate" title="${item.name}">${item.name}</div>
</div>
</div>
`;
}
return tile;
}
// 渲染内容网格
function renderContent() {
const items = getVisibleItems();
const grid = $('#contentGrid');
const emptyState = $('#emptyState');
if (items.length === 0) {
grid.style.display = 'none';
emptyState.classList.remove('hidden');
return;
}
grid.style.display = 'grid';
emptyState.classList.add('hidden');
grid.innerHTML = '';
items.forEach(item => {
const tile = renderTile(item);
grid.appendChild(tile);
});
updateStats();
}
// 更新统计信息
function updateStats() {
const items = getVisibleItems();
const photos = items.filter(item => item.type === 'image').length;
const videos = items.filter(item => item.type === 'video').length;
const folders = items.filter(item => item.type === 'folder').length;
$('#totalItems').textContent = `共 ${items.length} 项`;
$('#photoCount').textContent = `照片 ${photos}`;
$('#videoCount').textContent = `视频 ${videos}`;
$('#folderCount').textContent = `文件夹 ${folders}`;
// 统计所有重复项
const allDuplicates = getAllDuplicates();
$('#duplicateCount').textContent = allDuplicates.length;
updateSelectionTools();
}
// 获取所有重复项
function getAllDuplicates() {
const duplicates = [];
function findDuplicates(folder) {
if (!folder.children) return;
folder.children.forEach(item => {
if (item.isDuplicate) duplicates.push(item);
if (item.type === 'folder') findDuplicates(item);
});
}
findDuplicates(rootData);
return duplicates;
}
// 更新选择工具栏
function updateSelectionTools() {
const count = selectedItems.size;
const selectionTools = $('#selectionTools');
const selectionCount = $('#selectionCount');
if (count === 0) {
selectionCount.textContent = '未选择';
selectionTools.className = 'flex items-center gap-1 opacity-50';
$$('#selectionTools button').forEach(btn => btn.disabled = true);
} else {
selectionCount.textContent = `已选择 ${count} 项`;
selectionTools.className = 'flex items-center gap-1 opacity-100';
$$('#selectionTools button').forEach(btn => btn.disabled = false);
}
}
// 导航功能
function navigateToFolder(folder) {
currentFolder = folder;
history = history.slice(0, historyIndex + 1);
history.push(folder);
historyIndex = history.length - 1;
selectedItems.clear();
renderBreadcrumb();
renderContent();
updateNavigationButtons();
}
function navigateToHistory(index) {
historyIndex = index;
currentFolder = history[index];
selectedItems.clear();
renderBreadcrumb();
renderContent();
updateNavigationButtons();
}
function goBack() {
if (historyIndex > 0) {
historyIndex--;
currentFolder = history[historyIndex];
selectedItems.clear();
renderBreadcrumb();
renderContent();
updateNavigationButtons();
}
}
function goForward() {
if (historyIndex < history.length - 1) {
historyIndex++;
currentFolder = history[historyIndex];
selectedItems.clear();
renderBreadcrumb();
renderContent();
updateNavigationButtons();
}
}
function goUp() {
if (historyIndex > 0) {
goBack();
}
}
function updateNavigationButtons() {
$('#btnBack').disabled = historyIndex === 0;
$('#btnForward').disabled = historyIndex === history.length - 1;
$('#btnUp').disabled = historyIndex === 0;
}
// 预览功能
function showPreview(item) {
if (item.type === 'image') {
$('#previewImage').src = item.src;
$('#previewImage').classList.remove('hidden');
$('#previewVideo').classList.add('hidden');
$('#photoName').textContent = item.name;
$('#photoSize').textContent = `${item.width} × ${item.height}`;
$('#photoDate').textContent = item.date.toLocaleString();
$('#photoFileSize').textContent = formatFileSize(item.size);
if (item.exif) {
$('#cameraInfo').textContent = `${item.exif.camera} · ${item.exif.fNumber} · ${item.exif.exposureTime} · ${item.exif.iso}`;
$('#cameraInfo').classList.remove('hidden');
} else {
$('#cameraInfo').classList.add('hidden');
}
$('#previewModal').classList.remove('hidden');
} else if (item.type === 'video') {
// 视频预览逻辑
$('#previewVideo').poster = item.thumbnail;
$('#previewVideo').classList.remove('hidden');
$('#previewImage').classList.add('hidden');
$('#previewModal').classList.remove('hidden');
}
}
function closePreview() {
$('#previewModal').classList.add('hidden');
$('#previewVideo').pause();
}
// 事件监听
document.addEventListener('DOMContentLoaded', function() {
// 导航按钮
$('#btnBack').onclick = goBack;
$('#btnForward').onclick = goForward;
$('#btnUp').onclick = goUp;
// 内容网格点击事件
$('#contentGrid').addEventListener('click', function(e) {
const tile = e.target.closest('[data-id]');
if (!tile) return;
const itemId = tile.dataset.id;
const itemType = tile.dataset.type;
const item = currentFolder.children.find(item => item.id === itemId);
if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd + 点击 = 多选
if (selectedItems.has(itemId)) {
selectedItems.delete(itemId);
} else {
selectedItems.add(itemId);
}
renderContent();
} else if (e.shiftKey) {
// Shift + 点击 = 范围选择
// TODO: 实现范围选择逻辑
} else {
// 普通点击
if (itemType === 'folder') {
navigateToFolder(item);
} else {
selectedItems.clear();
selectedItems.add(itemId);
renderContent();
}
}
});
// 双击事件
$('#contentGrid').addEventListener('dblclick', function(e) {
const tile = e.target.closest('[data-id]');
if (!tile) return;
const itemId = tile.dataset.id;
const item = currentFolder.children.find(item => item.id === itemId);
if (item.type === 'image' || item.type === 'video') {
showPreview(item);
} else if (item.type === 'folder') {
navigateToFolder(item);
}
});
// 预览关闭
$('#btnPreviewClose').onclick = closePreview;
$('#previewModal').addEventListener('click', function(e) {
if (e.target === this) closePreview();
});
// 视图选项
['showPhotos', 'showVideos', 'showFolders', 'showOthers'].forEach(option => {
$(`#${option}`).onchange = function() {
viewOptions[option] = this.checked;
renderContent();
};
});
// 缩略图大小
$('#tileSize').oninput = function() {
document.documentElement.style.setProperty('--tile-size', this.value + 'px');
};
// 初始化
renderBreadcrumb();
renderContent();
updateNavigationButtons();
});
// 键盘快捷键
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
if (!$('#previewModal').classList.contains('hidden')) {
closePreview();
} else {
selectedItems.clear();
renderContent();
}
} else if (e.key === 'Backspace' && !e.target.matches('input')) {
e.preventDefault();
goUp();
} else if ((e.ctrlKey || e.metaKey) && e.key === 'a' && !e.target.matches('input')) {
e.preventDefault();
const items = getVisibleItems();
items.forEach(item => selectedItems.add(item.id));
renderContent();
}
});
</script>
</body>
</html>
@@ -0,0 +1,581 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Argus Photo Manager - Light Design</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
'inter': ['Inter', 'system-ui', 'sans-serif'],
}
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<style>
* {
font-family: 'Inter', system-ui, sans-serif;
}
.pane-splitter {
user-select: none;
}
.pane-splitter.horizontal {
cursor: col-resize;
}
.pane-splitter.vertical {
cursor: row-resize;
}
.pane-splitter:hover {
background-color: #e5e7eb;
}
.pane-splitter.dragging {
background-color: #3b82f6;
}
.breadcrumb-item {
cursor: pointer;
transition: all 0.2s ease;
}
.breadcrumb-item:hover {
background-color: #f3f4f6;
color: #3b82f6;
}
.folder-item, .photo-item {
cursor: pointer;
transition: all 0.2s ease;
}
.folder-item:hover, .photo-item:hover {
background-color: #f9fafb;
transform: translateY(-1px);
}
.folder-item:active, .photo-item:active {
transform: translateY(0);
}
.grid-small {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
.grid-medium {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.grid-large {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.list-view {
display: flex;
flex-direction: column;
gap: 1px;
}
.list-item {
display: flex;
align-items: center;
padding: 8px 12px;
background: white;
border-bottom: 1px solid #f3f4f6;
}
.list-item:hover {
background-color: #f9fafb;
}
</style>
</head>
<body class="bg-gray-50 font-inter">
<div class="h-screen flex flex-col">
<!-- Header -->
<header class="bg-white border-b border-gray-200 px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<h1 class="text-xl font-semibold text-gray-800">Argus Photo Manager</h1>
<div class="flex items-center space-x-2">
<button class="p-2 rounded-lg hover:bg-gray-100 transition-colors" title="Back">
<ion-icon name="arrow-back-outline" class="text-gray-600"></ion-icon>
</button>
<button class="p-2 rounded-lg hover:bg-gray-100 transition-colors" title="Forward">
<ion-icon name="arrow-forward-outline" class="text-gray-600"></ion-icon>
</button>
<button class="p-2 rounded-lg hover:bg-gray-100 transition-colors" title="Up">
<ion-icon name="arrow-up-outline" class="text-gray-600"></ion-icon>
</button>
</div>
</div>
<div class="flex items-center space-x-4">
<!-- View Controls -->
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
<button id="gridViewBtn" class="px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700" onclick="setViewMode('grid')">Grid</button>
<button id="listViewBtn" class="px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700" onclick="setViewMode('list')">List</button>
</div>
<!-- Thumbnail Size -->
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600">Size:</span>
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
<button id="smallBtn" class="p-1 rounded hover:bg-white transition-colors" title="Small" onclick="setThumbnailSize('small')">
<ion-icon name="apps-outline" class="text-sm"></ion-icon>
</button>
<button id="mediumBtn" class="p-1 rounded bg-white shadow-sm transition-colors" title="Medium" onclick="setThumbnailSize('medium')">
<ion-icon name="grid-outline" class="text-sm"></ion-icon>
</button>
<button id="largeBtn" class="p-1 rounded hover:bg-white transition-colors" title="Large" onclick="setThumbnailSize('large')">
<ion-icon name="square-outline" class="text-sm"></ion-icon>
</button>
</div>
</div>
<!-- Layout Toggle -->
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
<button id="horizontalLayoutBtn" class="p-1 rounded bg-white shadow-sm transition-colors" title="Horizontal Layout" onclick="setLayoutMode('horizontal')">
<ion-icon name="resize-outline" class="text-sm"></ion-icon>
</button>
<button id="verticalLayoutBtn" class="p-1 rounded hover:bg-white transition-colors" title="Vertical Layout" onclick="setLayoutMode('vertical')">
<ion-icon name="swap-vertical-outline" class="text-sm"></ion-icon>
</button>
<button id="singleLayoutBtn" class="p-1 rounded hover:bg-white transition-colors" title="Single Pane" onclick="setLayoutMode('single')">
<ion-icon name="square-outline" class="text-sm"></ion-icon>
</button>
</div>
</div>
</div>
<!-- File Operations Toolbar -->
<div class="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
<div class="flex items-center space-x-2">
<button class="flex items-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" title="New Folder">
<ion-icon name="add-outline" class="text-sm"></ion-icon>
<span class="text-sm font-medium">New</span>
</button>
<div class="w-px h-6 bg-gray-300"></div>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Copy">
<ion-icon name="copy-outline" class="text-sm"></ion-icon>
<span class="text-sm">Copy</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Cut">
<ion-icon name="cut-outline" class="text-sm"></ion-icon>
<span class="text-sm">Cut</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Paste" disabled>
<ion-icon name="clipboard-outline" class="text-sm"></ion-icon>
<span class="text-sm">Paste</span>
</button>
<div class="w-px h-6 bg-gray-300"></div>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Rename">
<ion-icon name="create-outline" class="text-sm"></ion-icon>
<span class="text-sm">Rename</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Delete">
<ion-icon name="trash-outline" class="text-sm"></ion-icon>
<span class="text-sm">Delete</span>
</button>
</div>
<div class="flex items-center space-x-2">
<button class="flex items-center space-x-2 px-3 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors" title="Add to Album">
<ion-icon name="albums-outline" class="text-sm"></ion-icon>
<span class="text-sm">Add to Album</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200 transition-colors" title="Edit Tags">
<ion-icon name="pricetag-outline" class="text-sm"></ion-icon>
<span class="text-sm">Tags</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors" title="Batch Edit">
<ion-icon name="layers-outline" class="text-sm"></ion-icon>
<span class="text-sm">Batch Edit</span>
</button>
</div>
</div>
</header>
<!-- Main Content -->
<div id="mainContent" class="flex-1 flex overflow-hidden">
<!-- Left Pane -->
<div id="leftPane" class="flex-1 flex flex-col bg-white border-r border-gray-200">
<!-- Breadcrumb -->
<div id="leftBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
<div class="flex items-center space-x-1 text-sm">
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 p-4 overflow-auto">
<div id="leftContent" class="grid grid-medium gap-4">
<!-- Content will be loaded here -->
</div>
</div>
</div>
<!-- Splitter -->
<div id="splitter" class="w-1 bg-gray-200 pane-splitter horizontal hidden"></div>
<!-- Right Pane (hidden by default) -->
<div id="rightPane" class="flex-1 flex-col bg-white hidden">
<!-- Breadcrumb -->
<div id="rightBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
<div class="flex items-center space-x-1 text-sm">
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 p-4 overflow-auto">
<div id="rightContent" class="grid grid-medium gap-4">
<!-- Content will be loaded here -->
</div>
</div>
</div>
</div>
</div>
<script>
// Sample data structure
const folderStructure = {
'Home': {
type: 'folder',
children: {
'My Photos': {
type: 'folder',
children: {
'Vacation 2023': {
type: 'folder',
children: {
'Beach': {
type: 'folder',
children: {
'photo1.jpg': { type: 'photo', size: '2.4 MB', date: '2023-07-15' },
'photo2.jpg': { type: 'photo', size: '3.1 MB', date: '2023-07-15' },
'photo3.jpg': { type: 'photo', size: '2.8 MB', date: '2023-07-16' }
}
},
'Mountains': {
type: 'folder',
children: {
'sunrise.jpg': { type: 'photo', size: '4.2 MB', date: '2023-07-20' },
'sunset.jpg': { type: 'photo', size: '3.8 MB', date: '2023-07-20' }
}
}
}
},
'Family': {
type: 'folder',
children: {
'birthday.jpg': { type: 'photo', size: '1.9 MB', date: '2023-08-10' },
'dinner.jpg': { type: 'photo', size: '2.2 MB', date: '2023-08-15' }
}
}
}
},
'Work Projects': {
type: 'folder',
children: {
'Screenshots': {
type: 'folder',
children: {
'design1.png': { type: 'photo', size: '1.2 MB', date: '2023-09-01' },
'design2.png': { type: 'photo', size: '1.5 MB', date: '2023-09-02' }
}
}
}
},
'Camera Roll': {
type: 'folder',
children: {
'IMG_001.jpg': { type: 'photo', size: '3.2 MB', date: '2023-09-15' },
'IMG_002.jpg': { type: 'photo', size: '2.9 MB', date: '2023-09-15' },
'IMG_003.jpg': { type: 'photo', size: '3.4 MB', date: '2023-09-16' }
}
}
}
}
};
// Application state
let layoutMode = 'single'; // 'single', 'horizontal', 'vertical'
let viewMode = 'grid'; // 'grid', 'list'
let thumbnailSize = 'medium'; // 'small', 'medium', 'large'
let leftPath = ['Home'];
let rightPath = ['Home'];
let isDragging = false;
// Initialize the application
function init() {
loadContent('left');
updateBreadcrumb('left');
setupSplitter();
}
// Get current folder from path
function getCurrentFolder(path) {
let current = folderStructure;
for (let segment of path) {
if (current[segment] && current[segment].children) {
current = current[segment].children;
} else {
return null;
}
}
return current;
}
// Load content for a pane
function loadContent(pane) {
const path = pane === 'left' ? leftPath : rightPath;
const contentElement = document.getElementById(pane + 'Content');
const currentFolder = getCurrentFolder(path);
if (!currentFolder) {
contentElement.innerHTML = '<p class="text-gray-500">Folder not found</p>';
return;
}
// Sort items: folders first, then photos
const items = Object.entries(currentFolder).sort(([,a], [,b]) => {
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
return 0;
});
if (viewMode === 'list') {
contentElement.className = 'list-view';
let html = '';
items.forEach(([name, item]) => {
if (item.type === 'folder') {
html += `
<div class="list-item folder-item" onclick="navigateToFolder('${pane}', '${name}')">
<ion-icon name="folder-outline" class="text-blue-500 text-lg mr-3"></ion-icon>
<div class="flex-1">
<p class="text-sm font-medium text-gray-700">${name}</p>
<p class="text-xs text-gray-500">Folder</p>
</div>
</div>
`;
} else if (item.type === 'photo') {
html += `
<div class="list-item photo-item">
<ion-icon name="image-outline" class="text-green-500 text-lg mr-3"></ion-icon>
<div class="flex-1">
<p class="text-sm font-medium text-gray-700">${name}</p>
<p class="text-xs text-gray-500">${item.size} • ${item.date}</p>
</div>
</div>
`;
}
});
contentElement.innerHTML = html;
} else {
// Grid view
contentElement.className = `grid grid-${thumbnailSize} gap-4`;
let html = '';
items.forEach(([name, item]) => {
if (item.type === 'folder') {
html += `
<div class="folder-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all" onclick="navigateToFolder('${pane}', '${name}')">
<ion-icon name="folder-outline" class="text-blue-500 text-3xl mb-2"></ion-icon>
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
</div>
`;
} else if (item.type === 'photo') {
html += `
<div class="photo-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all">
<ion-icon name="image-outline" class="text-green-500 text-3xl mb-2"></ion-icon>
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
<p class="text-xs text-gray-500">${item.size}</p>
</div>
`;
}
});
contentElement.innerHTML = html;
}
}
// Navigate to folder
function navigateToFolder(pane, folderName) {
const path = pane === 'left' ? leftPath : rightPath;
path.push(folderName);
loadContent(pane);
updateBreadcrumb(pane);
}
// Update breadcrumb navigation
function updateBreadcrumb(pane) {
const path = pane === 'left' ? leftPath : rightPath;
const breadcrumbElement = document.getElementById(pane + 'Breadcrumb');
let html = '<div class="flex items-center space-x-1 text-sm">';
path.forEach((segment, index) => {
if (index > 0) {
html += '<ion-icon name="chevron-forward-outline" class="text-gray-400 text-xs"></ion-icon>';
}
if (index === path.length - 1) {
html += `<span class="px-2 py-1 rounded bg-blue-100 text-blue-700 font-medium">${segment}</span>`;
} else {
html += `<span class="breadcrumb-item px-2 py-1 rounded text-gray-600 hover:bg-gray-100" onclick="navigateToBreadcrumb('${pane}', ${index})">${segment}</span>`;
}
});
html += '</div>';
breadcrumbElement.innerHTML = html;
}
// Navigate via breadcrumb
function navigateToBreadcrumb(pane, index) {
const path = pane === 'left' ? leftPath : rightPath;
if (pane === 'left') {
leftPath = leftPath.slice(0, index + 1);
} else {
rightPath = rightPath.slice(0, index + 1);
}
loadContent(pane);
updateBreadcrumb(pane);
}
// Set view mode (grid/list)
function setViewMode(mode) {
viewMode = mode;
// Update button states
document.getElementById('gridViewBtn').className = mode === 'grid'
? 'px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700'
: 'px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700';
document.getElementById('listViewBtn').className = mode === 'list'
? 'px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700'
: 'px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700';
// Reload content
loadContent('left');
if (layoutMode !== 'single') {
loadContent('right');
}
}
// Set thumbnail size
function setThumbnailSize(size) {
thumbnailSize = size;
// Update button states
['small', 'medium', 'large'].forEach(s => {
document.getElementById(s + 'Btn').className = s === size
? 'p-1 rounded bg-white shadow-sm transition-colors'
: 'p-1 rounded hover:bg-white transition-colors';
});
// Reload content if in grid mode
if (viewMode === 'grid') {
loadContent('left');
if (layoutMode !== 'single') {
loadContent('right');
}
}
}
// Set layout mode
function setLayoutMode(mode) {
layoutMode = mode;
const rightPane = document.getElementById('rightPane');
const splitter = document.getElementById('splitter');
const mainContent = document.getElementById('mainContent');
// Update button states
['horizontal', 'vertical', 'single'].forEach(m => {
const btn = document.getElementById(m + 'LayoutBtn');
btn.className = m === mode
? 'p-1 rounded bg-white shadow-sm transition-colors'
: 'p-1 rounded hover:bg-white transition-colors';
});
if (mode === 'single') {
rightPane.classList.add('hidden');
rightPane.classList.remove('flex');
splitter.classList.add('hidden');
mainContent.style.flexDirection = '';
mainContent.style.gridTemplateColumns = '';
mainContent.style.gridTemplateRows = '';
} else if (mode === 'horizontal') {
rightPane.classList.remove('hidden');
rightPane.classList.add('flex');
splitter.classList.remove('hidden');
splitter.className = 'w-1 bg-gray-200 pane-splitter horizontal';
mainContent.style.flexDirection = 'row';
mainContent.style.gridTemplateColumns = '1fr 4px 1fr';
mainContent.style.gridTemplateRows = '';
mainContent.style.display = 'grid';
loadContent('right');
updateBreadcrumb('right');
} else if (mode === 'vertical') {
rightPane.classList.remove('hidden');
rightPane.classList.add('flex');
splitter.classList.remove('hidden');
splitter.className = 'h-1 bg-gray-200 pane-splitter vertical';
mainContent.style.flexDirection = 'column';
mainContent.style.gridTemplateRows = '1fr 4px 1fr';
mainContent.style.gridTemplateColumns = '';
mainContent.style.display = 'grid';
loadContent('right');
updateBreadcrumb('right');
}
}
// Setup splitter functionality
function setupSplitter() {
const splitter = document.getElementById('splitter');
const mainContent = document.getElementById('mainContent');
splitter.addEventListener('mousedown', (e) => {
isDragging = true;
splitter.classList.add('dragging');
if (layoutMode === 'horizontal') {
document.body.style.cursor = 'col-resize';
} else if (layoutMode === 'vertical') {
document.body.style.cursor = 'row-resize';
}
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const rect = mainContent.getBoundingClientRect();
if (layoutMode === 'horizontal') {
const x = e.clientX - rect.left;
const percentage = (x / rect.width) * 100;
if (percentage > 20 && percentage < 80) {
mainContent.style.gridTemplateColumns = `${percentage}% 4px 1fr`;
}
} else if (layoutMode === 'vertical') {
const y = e.clientY - rect.top;
const percentage = (y / rect.height) * 100;
if (percentage > 20 && percentage < 80) {
mainContent.style.gridTemplateRows = `${percentage}% 4px 1fr`;
}
}
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
splitter.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
});
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
@@ -0,0 +1,571 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Argus Photo Manager - Light Design</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
'inter': ['Inter', 'system-ui', 'sans-serif'],
}
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<style>
* {
font-family: 'Inter', system-ui, sans-serif;
}
.pane-splitter {
user-select: none;
}
.pane-splitter.horizontal {
cursor: col-resize;
}
.pane-splitter.vertical {
cursor: row-resize;
}
.pane-splitter:hover {
background-color: #e5e7eb;
}
.pane-splitter.dragging {
background-color: #3b82f6;
}
.breadcrumb-item {
cursor: pointer;
transition: all 0.2s ease;
}
.breadcrumb-item:hover {
background-color: #f3f4f6;
color: #3b82f6;
}
.folder-item, .photo-item {
cursor: pointer;
transition: all 0.2s ease;
}
.folder-item:hover, .photo-item:hover {
background-color: #f9fafb;
transform: translateY(-1px);
}
.folder-item:active, .photo-item:active {
transform: translateY(0);
}
.grid-small {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
.grid-medium {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.grid-large {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.list-view {
display: flex;
flex-direction: column;
gap: 1px;
}
.list-item {
display: flex;
align-items: center;
padding: 8px 12px;
background: white;
border-bottom: 1px solid #f3f4f6;
}
.list-item:hover {
background-color: #f9fafb;
}
</style>
</head>
<body class="bg-gray-50 font-inter">
<div class="h-screen flex flex-col">
<!-- Header -->
<header class="bg-white border-b border-gray-200 px-4 py-3">
<!-- File Operations Toolbar -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<button class="flex items-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" title="New Folder">
<ion-icon name="add-outline" class="text-sm"></ion-icon>
<span class="text-sm font-medium">New</span>
</button>
<div class="w-px h-6 bg-gray-300"></div>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Copy">
<ion-icon name="copy-outline" class="text-sm"></ion-icon>
<span class="text-sm">Copy</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Cut">
<ion-icon name="cut-outline" class="text-sm"></ion-icon>
<span class="text-sm">Cut</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Paste" disabled>
<ion-icon name="clipboard-outline" class="text-sm"></ion-icon>
<span class="text-sm">Paste</span>
</button>
<div class="w-px h-6 bg-gray-300"></div>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Rename">
<ion-icon name="create-outline" class="text-sm"></ion-icon>
<span class="text-sm">Rename</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Delete">
<ion-icon name="trash-outline" class="text-sm"></ion-icon>
<span class="text-sm">Delete</span>
</button>
<div class="w-px h-6 bg-gray-300"></div>
<button class="flex items-center space-x-2 px-3 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors" title="Add to Album">
<ion-icon name="albums-outline" class="text-sm"></ion-icon>
<span class="text-sm">Add to Album</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200 transition-colors" title="Edit Tags">
<ion-icon name="pricetag-outline" class="text-sm"></ion-icon>
<span class="text-sm">Tags</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors" title="Batch Edit">
<ion-icon name="layers-outline" class="text-sm"></ion-icon>
<span class="text-sm">Batch Edit</span>
</button>
</div>
<div class="flex items-center space-x-4">
<!-- View Controls -->
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
<button id="gridViewBtn" class="px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700" onclick="setViewMode('grid')">Grid</button>
<button id="listViewBtn" class="px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700" onclick="setViewMode('list')">List</button>
</div>
<!-- Thumbnail Size (only show when Grid is selected) -->
<div id="thumbnailSizeControls" class="flex items-center space-x-2">
<span class="text-sm text-gray-600">Size:</span>
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
<button id="smallBtn" class="p-1 rounded hover:bg-white transition-colors" title="Small" onclick="setThumbnailSize('small')">
<ion-icon name="apps-outline" class="text-sm"></ion-icon>
</button>
<button id="mediumBtn" class="p-1 rounded bg-white shadow-sm transition-colors" title="Medium" onclick="setThumbnailSize('medium')">
<ion-icon name="grid-outline" class="text-sm"></ion-icon>
</button>
<button id="largeBtn" class="p-1 rounded hover:bg-white transition-colors" title="Large" onclick="setThumbnailSize('large')">
<ion-icon name="square-outline" class="text-sm"></ion-icon>
</button>
</div>
</div>
<!-- Layout Toggle -->
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
<button id="horizontalLayoutBtn" class="p-1 rounded bg-white shadow-sm transition-colors" title="Horizontal Layout" onclick="setLayoutMode('horizontal')">
<ion-icon name="resize-outline" class="text-sm"></ion-icon>
</button>
<button id="verticalLayoutBtn" class="p-1 rounded hover:bg-white transition-colors" title="Vertical Layout" onclick="setLayoutMode('vertical')">
<ion-icon name="swap-vertical-outline" class="text-sm"></ion-icon>
</button>
<button id="singleLayoutBtn" class="p-1 rounded hover:bg-white transition-colors" title="Single Pane" onclick="setLayoutMode('single')">
<ion-icon name="square-outline" class="text-sm"></ion-icon>
</button>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<div id="mainContent" class="flex-1 flex overflow-hidden">
<!-- Left Pane -->
<div id="leftPane" class="flex-1 flex flex-col bg-white border-r border-gray-200">
<!-- Breadcrumb -->
<div id="leftBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
<div class="flex items-center space-x-1 text-sm">
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 p-4 overflow-auto">
<div id="leftContent" class="grid grid-medium gap-4">
<!-- Content will be loaded here -->
</div>
</div>
</div>
<!-- Splitter -->
<div id="splitter" class="w-1 bg-gray-200 pane-splitter horizontal hidden"></div>
<!-- Right Pane (hidden by default) -->
<div id="rightPane" class="flex-1 flex-col bg-white hidden">
<!-- Breadcrumb -->
<div id="rightBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
<div class="flex items-center space-x-1 text-sm">
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 p-4 overflow-auto">
<div id="rightContent" class="grid grid-medium gap-4">
<!-- Content will be loaded here -->
</div>
</div>
</div>
</div>
</div>
<script>
// Sample data structure
const folderStructure = {
'Home': {
type: 'folder',
children: {
'My Photos': {
type: 'folder',
children: {
'Vacation 2023': {
type: 'folder',
children: {
'Beach': {
type: 'folder',
children: {
'photo1.jpg': { type: 'photo', size: '2.4 MB', date: '2023-07-15' },
'photo2.jpg': { type: 'photo', size: '3.1 MB', date: '2023-07-15' },
'photo3.jpg': { type: 'photo', size: '2.8 MB', date: '2023-07-16' }
}
},
'Mountains': {
type: 'folder',
children: {
'sunrise.jpg': { type: 'photo', size: '4.2 MB', date: '2023-07-20' },
'sunset.jpg': { type: 'photo', size: '3.8 MB', date: '2023-07-20' }
}
}
}
},
'Family': {
type: 'folder',
children: {
'birthday.jpg': { type: 'photo', size: '1.9 MB', date: '2023-08-10' },
'dinner.jpg': { type: 'photo', size: '2.2 MB', date: '2023-08-15' }
}
}
}
},
'Work Projects': {
type: 'folder',
children: {
'Screenshots': {
type: 'folder',
children: {
'design1.png': { type: 'photo', size: '1.2 MB', date: '2023-09-01' },
'design2.png': { type: 'photo', size: '1.5 MB', date: '2023-09-02' }
}
}
}
},
'Camera Roll': {
type: 'folder',
children: {
'IMG_001.jpg': { type: 'photo', size: '3.2 MB', date: '2023-09-15' },
'IMG_002.jpg': { type: 'photo', size: '2.9 MB', date: '2023-09-15' },
'IMG_003.jpg': { type: 'photo', size: '3.4 MB', date: '2023-09-16' }
}
}
}
}
};
// Application state
let layoutMode = 'single'; // 'single', 'horizontal', 'vertical'
let viewMode = 'grid'; // 'grid', 'list'
let thumbnailSize = 'medium'; // 'small', 'medium', 'large'
let leftPath = ['Home'];
let rightPath = ['Home'];
let isDragging = false;
// Initialize the application
function init() {
loadContent('left');
updateBreadcrumb('left');
setupSplitter();
}
// Get current folder from path
function getCurrentFolder(path) {
let current = folderStructure;
for (let segment of path) {
if (current[segment] && current[segment].children) {
current = current[segment].children;
} else {
return null;
}
}
return current;
}
// Load content for a pane
function loadContent(pane) {
const path = pane === 'left' ? leftPath : rightPath;
const contentElement = document.getElementById(pane + 'Content');
const currentFolder = getCurrentFolder(path);
if (!currentFolder) {
contentElement.innerHTML = '<p class="text-gray-500">Folder not found</p>';
return;
}
// Sort items: folders first, then photos
const items = Object.entries(currentFolder).sort(([,a], [,b]) => {
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
return 0;
});
if (viewMode === 'list') {
contentElement.className = 'list-view';
let html = '';
items.forEach(([name, item]) => {
if (item.type === 'folder') {
html += `
<div class="list-item folder-item" onclick="navigateToFolder('${pane}', '${name}')">
<ion-icon name="folder-outline" class="text-blue-500 text-lg mr-3"></ion-icon>
<div class="flex-1">
<p class="text-sm font-medium text-gray-700">${name}</p>
<p class="text-xs text-gray-500">Folder</p>
</div>
</div>
`;
} else if (item.type === 'photo') {
html += `
<div class="list-item photo-item">
<ion-icon name="image-outline" class="text-green-500 text-lg mr-3"></ion-icon>
<div class="flex-1">
<p class="text-sm font-medium text-gray-700">${name}</p>
<p class="text-xs text-gray-500">${item.size} • ${item.date}</p>
</div>
</div>
`;
}
});
contentElement.innerHTML = html;
} else {
// Grid view
contentElement.className = `grid grid-${thumbnailSize} gap-4`;
let html = '';
items.forEach(([name, item]) => {
if (item.type === 'folder') {
html += `
<div class="folder-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all" onclick="navigateToFolder('${pane}', '${name}')">
<ion-icon name="folder-outline" class="text-blue-500 text-3xl mb-2"></ion-icon>
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
</div>
`;
} else if (item.type === 'photo') {
html += `
<div class="photo-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all">
<ion-icon name="image-outline" class="text-green-500 text-3xl mb-2"></ion-icon>
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
<p class="text-xs text-gray-500">${item.size}</p>
</div>
`;
}
});
contentElement.innerHTML = html;
}
}
// Navigate to folder
function navigateToFolder(pane, folderName) {
const path = pane === 'left' ? leftPath : rightPath;
path.push(folderName);
loadContent(pane);
updateBreadcrumb(pane);
}
// Update breadcrumb navigation
function updateBreadcrumb(pane) {
const path = pane === 'left' ? leftPath : rightPath;
const breadcrumbElement = document.getElementById(pane + 'Breadcrumb');
let html = '<div class="flex items-center space-x-1 text-sm">';
path.forEach((segment, index) => {
if (index > 0) {
html += '<ion-icon name="chevron-forward-outline" class="text-gray-400 text-xs"></ion-icon>';
}
if (index === path.length - 1) {
html += `<span class="px-2 py-1 rounded bg-blue-100 text-blue-700 font-medium">${segment}</span>`;
} else {
html += `<span class="breadcrumb-item px-2 py-1 rounded text-gray-600 hover:bg-gray-100" onclick="navigateToBreadcrumb('${pane}', ${index})">${segment}</span>`;
}
});
html += '</div>';
breadcrumbElement.innerHTML = html;
}
// Navigate via breadcrumb
function navigateToBreadcrumb(pane, index) {
const path = pane === 'left' ? leftPath : rightPath;
if (pane === 'left') {
leftPath = leftPath.slice(0, index + 1);
} else {
rightPath = rightPath.slice(0, index + 1);
}
loadContent(pane);
updateBreadcrumb(pane);
}
// Set view mode (grid/list)
function setViewMode(mode) {
viewMode = mode;
// Update button states
document.getElementById('gridViewBtn').className = mode === 'grid'
? 'px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700'
: 'px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700';
document.getElementById('listViewBtn').className = mode === 'list'
? 'px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700'
: 'px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700';
// Show/hide thumbnail size controls based on view mode
const thumbnailControls = document.getElementById('thumbnailSizeControls');
if (mode === 'grid') {
thumbnailControls.style.display = 'flex';
} else {
thumbnailControls.style.display = 'none';
}
// Reload content
loadContent('left');
if (layoutMode !== 'single') {
loadContent('right');
}
}
// Set thumbnail size
function setThumbnailSize(size) {
thumbnailSize = size;
// Update button states
['small', 'medium', 'large'].forEach(s => {
document.getElementById(s + 'Btn').className = s === size
? 'p-1 rounded bg-white shadow-sm transition-colors'
: 'p-1 rounded hover:bg-white transition-colors';
});
// Reload content if in grid mode
if (viewMode === 'grid') {
loadContent('left');
if (layoutMode !== 'single') {
loadContent('right');
}
}
}
// Set layout mode
function setLayoutMode(mode) {
layoutMode = mode;
const rightPane = document.getElementById('rightPane');
const splitter = document.getElementById('splitter');
const mainContent = document.getElementById('mainContent');
// Update button states
['horizontal', 'vertical', 'single'].forEach(m => {
const btn = document.getElementById(m + 'LayoutBtn');
btn.className = m === mode
? 'p-1 rounded bg-white shadow-sm transition-colors'
: 'p-1 rounded hover:bg-white transition-colors';
});
if (mode === 'single') {
rightPane.classList.add('hidden');
rightPane.classList.remove('flex');
splitter.classList.add('hidden');
mainContent.style.flexDirection = '';
mainContent.style.gridTemplateColumns = '';
mainContent.style.gridTemplateRows = '';
} else if (mode === 'horizontal') {
rightPane.classList.remove('hidden');
rightPane.classList.add('flex');
splitter.classList.remove('hidden');
splitter.className = 'w-1 bg-gray-200 pane-splitter horizontal';
mainContent.style.flexDirection = 'row';
mainContent.style.gridTemplateColumns = '1fr 4px 1fr';
mainContent.style.gridTemplateRows = '';
mainContent.style.display = 'grid';
loadContent('right');
updateBreadcrumb('right');
} else if (mode === 'vertical') {
rightPane.classList.remove('hidden');
rightPane.classList.add('flex');
splitter.classList.remove('hidden');
splitter.className = 'h-1 bg-gray-200 pane-splitter vertical';
mainContent.style.flexDirection = 'column';
mainContent.style.gridTemplateRows = '1fr 4px 1fr';
mainContent.style.gridTemplateColumns = '';
mainContent.style.display = 'grid';
loadContent('right');
updateBreadcrumb('right');
}
}
// Setup splitter functionality
function setupSplitter() {
const splitter = document.getElementById('splitter');
const mainContent = document.getElementById('mainContent');
splitter.addEventListener('mousedown', (e) => {
isDragging = true;
splitter.classList.add('dragging');
if (layoutMode === 'horizontal') {
document.body.style.cursor = 'col-resize';
} else if (layoutMode === 'vertical') {
document.body.style.cursor = 'row-resize';
}
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const rect = mainContent.getBoundingClientRect();
if (layoutMode === 'horizontal') {
const x = e.clientX - rect.left;
const percentage = (x / rect.width) * 100;
if (percentage > 20 && percentage < 80) {
mainContent.style.gridTemplateColumns = `${percentage}% 4px 1fr`;
}
} else if (layoutMode === 'vertical') {
const y = e.clientY - rect.top;
const percentage = (y / rect.height) * 100;
if (percentage > 20 && percentage < 80) {
mainContent.style.gridTemplateRows = `${percentage}% 4px 1fr`;
}
}
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
splitter.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
});
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
@@ -0,0 +1,939 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Argus Photo Manager - Light Design</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
'inter': ['Inter', 'system-ui', 'sans-serif'],
}
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<style>
* {
font-family: 'Inter', system-ui, sans-serif;
}
.pane-splitter {
user-select: none;
}
.pane-splitter.horizontal {
cursor: col-resize;
}
.pane-splitter.vertical {
cursor: row-resize;
}
.pane-splitter:hover {
background-color: #e5e7eb;
}
.pane-splitter.dragging {
background-color: #3b82f6;
}
.breadcrumb-item {
cursor: pointer;
transition: all 0.2s ease;
}
.breadcrumb-item:hover {
background-color: #f3f4f6;
color: #3b82f6;
}
.folder-item, .photo-item {
cursor: pointer;
transition: all 0.2s ease;
}
.folder-item:hover, .photo-item:hover {
background-color: #f9fafb;
transform: translateY(-1px);
}
.folder-item:active, .photo-item:active {
transform: translateY(0);
}
.grid-small {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
.grid-medium {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.grid-large {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.list-view {
display: flex;
flex-direction: column;
gap: 1px;
}
.list-item {
display: flex;
align-items: center;
padding: 8px 12px;
background: white;
border-bottom: 1px solid #f3f4f6;
}
.list-item:hover {
background-color: #f9fafb;
}
.breadcrumb-dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 50;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
min-width: 200px;
max-height: 300px;
overflow-y: auto;
display: none;
}
.breadcrumb-dropdown.show {
display: block;
}
.breadcrumb-dropdown-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
transition: background-color 0.2s;
}
.breadcrumb-dropdown-item:hover {
background-color: #f3f4f6;
}
.breadcrumb-separator {
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
transition: background-color 0.2s;
position: relative;
}
.breadcrumb-separator:hover {
background-color: #f3f4f6;
}
.path-input-container {
position: relative;
}
.path-input {
display: none;
min-width: 300px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.path-input.active {
display: block;
}
.breadcrumb-area {
cursor: pointer;
border-radius: 6px;
padding: 4px 8px;
transition: all 0.2s ease;
min-width: 0;
flex: 1;
}
.breadcrumb-area:hover {
background-color: #f9fafb;
}
.breadcrumb-area.editing {
background-color: white;
border: 1px solid #3b82f6;
}
.path-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 60;
background: white;
border: 1px solid #e5e7eb;
border-top: none;
border-radius: 0 0 8px 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
max-height: 200px;
overflow-y: auto;
display: none;
}
.path-suggestions.show {
display: block;
}
.path-suggestion-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
transition: background-color 0.2s;
font-size: 13px;
}
.path-suggestion-item:hover,
.path-suggestion-item.selected {
background-color: #f3f4f6;
}
.edit-path-hint {
font-size: 11px;
color: #6b7280;
margin-left: 8px;
}
</style>
</head>
<body class="bg-gray-50 font-inter">
<div class="h-screen flex flex-col">
<!-- Header -->
<header class="bg-white border-b border-gray-200 px-4 py-3">
<!-- File Operations Toolbar -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<button class="flex items-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" title="New Folder">
<ion-icon name="add-outline" class="text-sm"></ion-icon>
<span class="text-sm font-medium">New</span>
</button>
<div class="w-px h-6 bg-gray-300"></div>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Copy">
<ion-icon name="copy-outline" class="text-sm"></ion-icon>
<span class="text-sm">Copy</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Cut">
<ion-icon name="cut-outline" class="text-sm"></ion-icon>
<span class="text-sm">Cut</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Paste" disabled>
<ion-icon name="clipboard-outline" class="text-sm"></ion-icon>
<span class="text-sm">Paste</span>
</button>
<div class="w-px h-6 bg-gray-300"></div>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Rename">
<ion-icon name="create-outline" class="text-sm"></ion-icon>
<span class="text-sm">Rename</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Delete">
<ion-icon name="trash-outline" class="text-sm"></ion-icon>
<span class="text-sm">Delete</span>
</button>
<div class="w-px h-6 bg-gray-300"></div>
<button class="flex items-center space-x-2 px-3 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors" title="Add to Album">
<ion-icon name="albums-outline" class="text-sm"></ion-icon>
<span class="text-sm">Add to Album</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200 transition-colors" title="Edit Tags">
<ion-icon name="pricetag-outline" class="text-sm"></ion-icon>
<span class="text-sm">Tags</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors" title="Batch Edit">
<ion-icon name="layers-outline" class="text-sm"></ion-icon>
<span class="text-sm">Batch Edit</span>
</button>
</div>
<div class="flex items-center space-x-4">
<!-- View Controls -->
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
<button id="gridViewBtn" class="px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700" onclick="setViewMode('grid')">Grid</button>
<button id="listViewBtn" class="px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700" onclick="setViewMode('list')">List</button>
</div>
<!-- Thumbnail Size (only show when Grid is selected) -->
<div id="thumbnailSizeControls" class="flex items-center space-x-2">
<span class="text-sm text-gray-600">Size:</span>
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
<button id="smallBtn" class="p-1 rounded hover:bg-white transition-colors" title="Small" onclick="setThumbnailSize('small')">
<ion-icon name="apps-outline" class="text-sm"></ion-icon>
</button>
<button id="mediumBtn" class="p-1 rounded bg-white shadow-sm transition-colors" title="Medium" onclick="setThumbnailSize('medium')">
<ion-icon name="grid-outline" class="text-sm"></ion-icon>
</button>
<button id="largeBtn" class="p-1 rounded hover:bg-white transition-colors" title="Large" onclick="setThumbnailSize('large')">
<ion-icon name="square-outline" class="text-sm"></ion-icon>
</button>
</div>
</div>
<!-- Layout Toggle -->
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
<button id="horizontalLayoutBtn" class="p-1 rounded bg-white shadow-sm transition-colors" title="Horizontal Layout" onclick="setLayoutMode('horizontal')">
<ion-icon name="resize-outline" class="text-sm"></ion-icon>
</button>
<button id="verticalLayoutBtn" class="p-1 rounded hover:bg-white transition-colors" title="Vertical Layout" onclick="setLayoutMode('vertical')">
<ion-icon name="swap-vertical-outline" class="text-sm"></ion-icon>
</button>
<button id="singleLayoutBtn" class="p-1 rounded hover:bg-white transition-colors" title="Single Pane" onclick="setLayoutMode('single')">
<ion-icon name="square-outline" class="text-sm"></ion-icon>
</button>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<div id="mainContent" class="flex-1 flex overflow-hidden">
<!-- Left Pane -->
<div id="leftPane" class="flex-1 flex flex-col bg-white border-r border-gray-200">
<!-- Breadcrumb -->
<div id="leftBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
<div class="flex items-center">
<div class="breadcrumb-area flex items-center space-x-1 text-sm" id="leftBreadcrumbArea" onclick="enterEditMode('left')">
<div id="leftBreadcrumbContent">
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
</div>
<span class="edit-path-hint">Click to edit path</span>
</div>
<div class="path-input-container">
<input type="text" id="leftPathInput" class="path-input px-3 py-2 text-sm border border-gray-300 rounded-lg" placeholder="Enter path..." onblur="exitEditMode('left')" onkeydown="handlePathInputKeydown(event, 'left')" oninput="showPathSuggestions(event, 'left')">
<div class="path-suggestions" id="leftPathSuggestions">
<!-- Suggestions will be populated here -->
</div>
</div>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 p-4 overflow-auto">
<div id="leftContent" class="grid grid-medium gap-4">
<!-- Content will be loaded here -->
</div>
</div>
</div>
<!-- Splitter -->
<div id="splitter" class="w-1 bg-gray-200 pane-splitter horizontal hidden"></div>
<!-- Right Pane (hidden by default) -->
<div id="rightPane" class="flex-1 flex-col bg-white hidden">
<!-- Breadcrumb -->
<div id="rightBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
<div class="flex items-center">
<div class="breadcrumb-area flex items-center space-x-1 text-sm" id="rightBreadcrumbArea" onclick="enterEditMode('right')">
<div id="rightBreadcrumbContent">
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
</div>
<span class="edit-path-hint">Click to edit path</span>
</div>
<div class="path-input-container">
<input type="text" id="rightPathInput" class="path-input px-3 py-2 text-sm border border-gray-300 rounded-lg" placeholder="Enter path..." onblur="exitEditMode('right')" onkeydown="handlePathInputKeydown(event, 'right')" oninput="showPathSuggestions(event, 'right')">
<div class="path-suggestions" id="rightPathSuggestions">
<!-- Suggestions will be populated here -->
</div>
</div>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 p-4 overflow-auto">
<div id="rightContent" class="grid grid-medium gap-4">
<!-- Content will be loaded here -->
</div>
</div>
</div>
</div>
</div>
<script>
// Sample data structure
const folderStructure = {
'Home': {
type: 'folder',
children: {
'My Photos': {
type: 'folder',
children: {
'Vacation 2023': {
type: 'folder',
children: {
'Beach': {
type: 'folder',
children: {
'photo1.jpg': { type: 'photo', size: '2.4 MB', date: '2023-07-15' },
'photo2.jpg': { type: 'photo', size: '3.1 MB', date: '2023-07-15' },
'photo3.jpg': { type: 'photo', size: '2.8 MB', date: '2023-07-16' }
}
},
'Mountains': {
type: 'folder',
children: {
'sunrise.jpg': { type: 'photo', size: '4.2 MB', date: '2023-07-20' },
'sunset.jpg': { type: 'photo', size: '3.8 MB', date: '2023-07-20' }
}
}
}
},
'Family': {
type: 'folder',
children: {
'birthday.jpg': { type: 'photo', size: '1.9 MB', date: '2023-08-10' },
'dinner.jpg': { type: 'photo', size: '2.2 MB', date: '2023-08-15' }
}
}
}
},
'Work Projects': {
type: 'folder',
children: {
'Screenshots': {
type: 'folder',
children: {
'design1.png': { type: 'photo', size: '1.2 MB', date: '2023-09-01' },
'design2.png': { type: 'photo', size: '1.5 MB', date: '2023-09-02' }
}
}
}
},
'Camera Roll': {
type: 'folder',
children: {
'IMG_001.jpg': { type: 'photo', size: '3.2 MB', date: '2023-09-15' },
'IMG_002.jpg': { type: 'photo', size: '2.9 MB', date: '2023-09-15' },
'IMG_003.jpg': { type: 'photo', size: '3.4 MB', date: '2023-09-16' }
}
}
}
}
};
// Application state
let layoutMode = 'single'; // 'single', 'horizontal', 'vertical'
let viewMode = 'grid'; // 'grid', 'list'
let thumbnailSize = 'medium'; // 'small', 'medium', 'large'
let leftPath = ['Home'];
let rightPath = ['Home'];
let isDragging = false;
// Initialize the application
function init() {
loadContent('left');
updateBreadcrumb('left');
setupSplitter();
}
// Get current folder from path
function getCurrentFolder(path) {
let current = folderStructure;
for (let segment of path) {
if (current[segment] && current[segment].children) {
current = current[segment].children;
} else {
return null;
}
}
return current;
}
// Load content for a pane
function loadContent(pane) {
const path = pane === 'left' ? leftPath : rightPath;
const contentElement = document.getElementById(pane + 'Content');
const currentFolder = getCurrentFolder(path);
if (!currentFolder) {
contentElement.innerHTML = '<p class="text-gray-500">Folder not found</p>';
return;
}
// Sort items: folders first, then photos
const items = Object.entries(currentFolder).sort(([,a], [,b]) => {
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
return 0;
});
if (viewMode === 'list') {
contentElement.className = 'list-view';
let html = '';
items.forEach(([name, item]) => {
if (item.type === 'folder') {
html += `
<div class="list-item folder-item" onclick="navigateToFolder('${pane}', '${name}')">
<ion-icon name="folder-outline" class="text-blue-500 text-lg mr-3"></ion-icon>
<div class="flex-1">
<p class="text-sm font-medium text-gray-700">${name}</p>
<p class="text-xs text-gray-500">Folder</p>
</div>
</div>
`;
} else if (item.type === 'photo') {
html += `
<div class="list-item photo-item">
<ion-icon name="image-outline" class="text-green-500 text-lg mr-3"></ion-icon>
<div class="flex-1">
<p class="text-sm font-medium text-gray-700">${name}</p>
<p class="text-xs text-gray-500">${item.size} • ${item.date}</p>
</div>
</div>
`;
}
});
contentElement.innerHTML = html;
} else {
// Grid view
contentElement.className = `grid grid-${thumbnailSize} gap-4`;
let html = '';
items.forEach(([name, item]) => {
if (item.type === 'folder') {
html += `
<div class="folder-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all" onclick="navigateToFolder('${pane}', '${name}')">
<ion-icon name="folder-outline" class="text-blue-500 text-3xl mb-2"></ion-icon>
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
</div>
`;
} else if (item.type === 'photo') {
html += `
<div class="photo-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all">
<ion-icon name="image-outline" class="text-green-500 text-3xl mb-2"></ion-icon>
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
<p class="text-xs text-gray-500">${item.size}</p>
</div>
`;
}
});
contentElement.innerHTML = html;
}
}
// Navigate to folder
function navigateToFolder(pane, folderName) {
const path = pane === 'left' ? leftPath : rightPath;
path.push(folderName);
loadContent(pane);
updateBreadcrumb(pane);
}
// Update breadcrumb navigation
function updateBreadcrumb(pane) {
const path = pane === 'left' ? leftPath : rightPath;
const breadcrumbContentElement = document.getElementById(pane + 'BreadcrumbContent');
let html = '<div class="flex items-center space-x-1">';
path.forEach((segment, index) => {
if (index > 0) {
html += `
<div class="breadcrumb-separator" onclick="showBreadcrumbDropdown('${pane}', ${index - 1})">
<ion-icon name="chevron-forward-outline" class="text-gray-400 text-xs"></ion-icon>
<div class="breadcrumb-dropdown" id="${pane}Dropdown${index - 1}">
<!-- Dropdown content will be populated dynamically -->
</div>
</div>
`;
}
if (index === path.length - 1) {
html += `<span class="px-2 py-1 rounded bg-blue-100 text-blue-700 font-medium">${segment}</span>`;
} else {
html += `<span class="breadcrumb-item px-2 py-1 rounded text-gray-600 hover:bg-gray-100" onclick="navigateToBreadcrumb('${pane}', ${index})">${segment}</span>`;
}
});
html += '</div>';
breadcrumbContentElement.innerHTML = html;
}
// Navigate via breadcrumb
function navigateToBreadcrumb(pane, index) {
const path = pane === 'left' ? leftPath : rightPath;
if (pane === 'left') {
leftPath = leftPath.slice(0, index + 1);
} else {
rightPath = rightPath.slice(0, index + 1);
}
loadContent(pane);
updateBreadcrumb(pane);
}
// Set view mode (grid/list)
function setViewMode(mode) {
viewMode = mode;
// Update button states
document.getElementById('gridViewBtn').className = mode === 'grid'
? 'px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700'
: 'px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700';
document.getElementById('listViewBtn').className = mode === 'list'
? 'px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700'
: 'px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700';
// Show/hide thumbnail size controls based on view mode
const thumbnailControls = document.getElementById('thumbnailSizeControls');
if (mode === 'grid') {
thumbnailControls.style.display = 'flex';
} else {
thumbnailControls.style.display = 'none';
}
// Reload content
loadContent('left');
if (layoutMode !== 'single') {
loadContent('right');
}
}
// Set thumbnail size
function setThumbnailSize(size) {
thumbnailSize = size;
// Update button states
['small', 'medium', 'large'].forEach(s => {
document.getElementById(s + 'Btn').className = s === size
? 'p-1 rounded bg-white shadow-sm transition-colors'
: 'p-1 rounded hover:bg-white transition-colors';
});
// Reload content if in grid mode
if (viewMode === 'grid') {
loadContent('left');
if (layoutMode !== 'single') {
loadContent('right');
}
}
}
// Set layout mode
function setLayoutMode(mode) {
layoutMode = mode;
const rightPane = document.getElementById('rightPane');
const splitter = document.getElementById('splitter');
const mainContent = document.getElementById('mainContent');
// Update button states
['horizontal', 'vertical', 'single'].forEach(m => {
const btn = document.getElementById(m + 'LayoutBtn');
btn.className = m === mode
? 'p-1 rounded bg-white shadow-sm transition-colors'
: 'p-1 rounded hover:bg-white transition-colors';
});
if (mode === 'single') {
rightPane.classList.add('hidden');
rightPane.classList.remove('flex');
splitter.classList.add('hidden');
mainContent.style.flexDirection = '';
mainContent.style.gridTemplateColumns = '';
mainContent.style.gridTemplateRows = '';
} else if (mode === 'horizontal') {
rightPane.classList.remove('hidden');
rightPane.classList.add('flex');
splitter.classList.remove('hidden');
splitter.className = 'w-1 bg-gray-200 pane-splitter horizontal';
mainContent.style.flexDirection = 'row';
mainContent.style.gridTemplateColumns = '1fr 4px 1fr';
mainContent.style.gridTemplateRows = '';
mainContent.style.display = 'grid';
loadContent('right');
updateBreadcrumb('right');
} else if (mode === 'vertical') {
rightPane.classList.remove('hidden');
rightPane.classList.add('flex');
splitter.classList.remove('hidden');
splitter.className = 'h-1 bg-gray-200 pane-splitter vertical';
mainContent.style.flexDirection = 'column';
mainContent.style.gridTemplateRows = '1fr 4px 1fr';
mainContent.style.gridTemplateColumns = '';
mainContent.style.display = 'grid';
loadContent('right');
updateBreadcrumb('right');
}
}
// Setup splitter functionality
function setupSplitter() {
const splitter = document.getElementById('splitter');
const mainContent = document.getElementById('mainContent');
splitter.addEventListener('mousedown', (e) => {
isDragging = true;
splitter.classList.add('dragging');
if (layoutMode === 'horizontal') {
document.body.style.cursor = 'col-resize';
} else if (layoutMode === 'vertical') {
document.body.style.cursor = 'row-resize';
}
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const rect = mainContent.getBoundingClientRect();
if (layoutMode === 'horizontal') {
const x = e.clientX - rect.left;
const percentage = (x / rect.width) * 100;
if (percentage > 20 && percentage < 80) {
mainContent.style.gridTemplateColumns = `${percentage}% 4px 1fr`;
}
} else if (layoutMode === 'vertical') {
const y = e.clientY - rect.top;
const percentage = (y / rect.height) * 100;
if (percentage > 20 && percentage < 80) {
mainContent.style.gridTemplateRows = `${percentage}% 4px 1fr`;
}
}
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
splitter.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
});
}
// Show breadcrumb dropdown
function showBreadcrumbDropdown(pane, index) {
// Hide all other dropdowns
document.querySelectorAll('.breadcrumb-dropdown').forEach(dropdown => {
dropdown.classList.remove('show');
});
const path = pane === 'left' ? leftPath : rightPath;
const dropdownId = `${pane}Dropdown${index}`;
const dropdown = document.getElementById(dropdownId);
if (!dropdown) return;
// Get folders at the specified path
const currentPath = path.slice(0, index + 1);
const currentFolder = getCurrentFolder(currentPath);
if (!currentFolder) return;
// Build dropdown content
let html = '';
Object.entries(currentFolder).forEach(([name, item]) => {
if (item.type === 'folder') {
html += `
<div class="breadcrumb-dropdown-item" onclick="navigateFromBreadcrumbDropdown('${pane}', ${index}, '${name}')">
<ion-icon name="folder-outline" class="text-blue-500 text-sm mr-2"></ion-icon>
<span class="text-sm">${name}</span>
</div>
`;
}
});
if (html === '') {
html = '<div class="breadcrumb-dropdown-item"><span class="text-sm text-gray-500">No folders</span></div>';
}
dropdown.innerHTML = html;
dropdown.classList.add('show');
// Hide dropdown when clicking outside
setTimeout(() => {
document.addEventListener('click', function hideDropdown(e) {
if (!dropdown.contains(e.target) && !e.target.closest('.breadcrumb-separator')) {
dropdown.classList.remove('show');
document.removeEventListener('click', hideDropdown);
}
});
}, 0);
}
// Navigate from breadcrumb dropdown
function navigateFromBreadcrumbDropdown(pane, index, folderName) {
const path = pane === 'left' ? leftPath : rightPath;
const newPath = path.slice(0, index + 1);
newPath.push(folderName);
if (pane === 'left') {
leftPath = newPath;
} else {
rightPath = newPath;
}
loadContent(pane);
updateBreadcrumb(pane);
// Hide dropdown immediately after selection
const dropdown = document.getElementById(`${pane}Dropdown${index}`);
if (dropdown) {
dropdown.classList.remove('show');
}
}
// Enter edit mode (address bar style)
function enterEditMode(pane) {
const breadcrumbArea = document.getElementById(pane + 'BreadcrumbArea');
const inputElement = document.getElementById(pane + 'PathInput');
const path = pane === 'left' ? leftPath : rightPath;
breadcrumbArea.style.display = 'none';
inputElement.classList.add('active');
inputElement.value = path.join('/');
inputElement.focus();
inputElement.select();
}
// Exit edit mode
function exitEditMode(pane) {
const breadcrumbArea = document.getElementById(pane + 'BreadcrumbArea');
const inputElement = document.getElementById(pane + 'PathInput');
const suggestionsElement = document.getElementById(pane + 'PathSuggestions');
breadcrumbArea.style.display = 'flex';
inputElement.classList.remove('active');
suggestionsElement.classList.remove('show');
}
// Handle path input keydown
function handlePathInputKeydown(event, pane) {
const suggestionsElement = document.getElementById(pane + 'PathSuggestions');
const suggestions = suggestionsElement.querySelectorAll('.path-suggestion-item');
if (event.key === 'Enter') {
const inputElement = document.getElementById(pane + 'PathInput');
const pathString = inputElement.value.trim();
if (pathString) {
navigateToPath(pane, pathString);
}
exitEditMode(pane);
} else if (event.key === 'Escape') {
exitEditMode(pane);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
const selected = suggestionsElement.querySelector('.selected');
if (selected && selected.nextElementSibling) {
selected.classList.remove('selected');
selected.nextElementSibling.classList.add('selected');
} else if (suggestions.length > 0) {
suggestions.forEach(s => s.classList.remove('selected'));
suggestions[0].classList.add('selected');
}
} else if (event.key === 'ArrowUp') {
event.preventDefault();
const selected = suggestionsElement.querySelector('.selected');
if (selected && selected.previousElementSibling) {
selected.classList.remove('selected');
selected.previousElementSibling.classList.add('selected');
} else if (suggestions.length > 0) {
suggestions.forEach(s => s.classList.remove('selected'));
suggestions[suggestions.length - 1].classList.add('selected');
}
} else if (event.key === 'Tab' && suggestions.length > 0) {
event.preventDefault();
const selected = suggestionsElement.querySelector('.selected') || suggestions[0];
if (selected) {
document.getElementById(pane + 'PathInput').value = selected.dataset.path;
}
}
}
// Show path suggestions
function showPathSuggestions(event, pane) {
const inputValue = event.target.value.trim();
const suggestionsElement = document.getElementById(pane + 'PathSuggestions');
if (!inputValue) {
suggestionsElement.classList.remove('show');
return;
}
// Get all possible paths
const allPaths = getAllPossiblePaths();
const filteredPaths = allPaths.filter(path =>
path.toLowerCase().includes(inputValue.toLowerCase()) && path !== inputValue
);
if (filteredPaths.length === 0) {
suggestionsElement.classList.remove('show');
return;
}
let html = '';
filteredPaths.slice(0, 8).forEach((path, index) => {
html += `
<div class="path-suggestion-item ${index === 0 ? 'selected' : ''}"
data-path="${path}"
onclick="selectPathSuggestion('${pane}', '${path}')">
<ion-icon name="folder-outline" class="text-blue-500 text-sm mr-2"></ion-icon>
<span>${path}</span>
</div>
`;
});
suggestionsElement.innerHTML = html;
suggestionsElement.classList.add('show');
}
// Get all possible paths
function getAllPossiblePaths() {
const paths = [];
function traverseFolder(currentPath, folder) {
Object.entries(folder).forEach(([name, item]) => {
if (item.type === 'folder') {
const newPath = currentPath ? `${currentPath}/${name}` : name;
paths.push(newPath);
if (item.children) {
traverseFolder(newPath, item.children);
}
}
});
}
traverseFolder('', folderStructure);
return paths.sort();
}
// Select path suggestion
function selectPathSuggestion(pane, path) {
document.getElementById(pane + 'PathInput').value = path;
navigateToPath(pane, path);
exitEditMode(pane);
}
// Navigate to path
function navigateToPath(pane, pathString) {
const pathSegments = pathString.split('/').filter(segment => segment.trim() !== '');
// Always start from Home
if (pathSegments.length === 0 || pathSegments[0] !== 'Home') {
pathSegments.unshift('Home');
}
// Validate path exists
let current = folderStructure;
for (let i = 0; i < pathSegments.length; i++) {
const segment = pathSegments[i];
if (current[segment] && current[segment].children) {
current = current[segment].children;
} else if (i === pathSegments.length - 1 && current[segment]) {
// Last segment can be a file or folder
break;
} else {
// Invalid path
alert('Path not found: ' + pathSegments.slice(0, i + 1).join('/'));
return;
}
}
// Set new path
if (pane === 'left') {
leftPath = pathSegments;
} else {
rightPath = pathSegments;
}
loadContent(pane);
updateBreadcrumb(pane);
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
@@ -0,0 +1,383 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Argus Photo Manager - Light Design</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
'inter': ['Inter', 'system-ui', 'sans-serif'],
}
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<style>
* {
font-family: 'Inter', system-ui, sans-serif;
}
.pane-splitter {
cursor: col-resize;
user-select: none;
}
.pane-splitter:hover {
background-color: #e5e7eb;
}
.pane-splitter.dragging {
background-color: #3b82f6;
}
.breadcrumb-item {
cursor: pointer;
transition: all 0.2s ease;
}
.breadcrumb-item:hover {
background-color: #f3f4f6;
color: #3b82f6;
}
.folder-item, .photo-item {
cursor: pointer;
transition: all 0.2s ease;
}
.folder-item:hover, .photo-item:hover {
background-color: #f9fafb;
transform: translateY(-1px);
}
.folder-item:active, .photo-item:active {
transform: translateY(0);
}
</style>
</head>
<body class="bg-gray-50 font-inter">
<div class="h-screen flex flex-col">
<!-- Header -->
<header class="bg-white border-b border-gray-200 px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<h1 class="text-xl font-semibold text-gray-800">Argus Photo Manager</h1>
<div class="flex items-center space-x-2">
<button class="p-2 rounded-lg hover:bg-gray-100 transition-colors" title="Back">
<ion-icon name="arrow-back-outline" class="text-gray-600"></ion-icon>
</button>
<button class="p-2 rounded-lg hover:bg-gray-100 transition-colors" title="Forward">
<ion-icon name="arrow-forward-outline" class="text-gray-600"></ion-icon>
</button>
<button class="p-2 rounded-lg hover:bg-gray-100 transition-colors" title="Up">
<ion-icon name="arrow-up-outline" class="text-gray-600"></ion-icon>
</button>
</div>
</div>
<div class="flex items-center space-x-4">
<!-- View Controls -->
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
<button class="px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700">Grid</button>
<button class="px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700">List</button>
</div>
<!-- Thumbnail Size -->
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600">Size:</span>
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
<button class="p-1 rounded hover:bg-white transition-colors" title="Small">
<ion-icon name="apps-outline" class="text-sm"></ion-icon>
</button>
<button class="p-1 rounded bg-white shadow-sm transition-colors" title="Medium">
<ion-icon name="grid-outline" class="text-sm"></ion-icon>
</button>
<button class="p-1 rounded hover:bg-white transition-colors" title="Large">
<ion-icon name="square-outline" class="text-sm"></ion-icon>
</button>
</div>
</div>
<!-- Layout Toggle -->
<button id="layoutToggle" class="p-2 rounded-lg hover:bg-gray-100 transition-colors" title="Toggle Dual Pane">
<ion-icon name="resize-outline" class="text-gray-600"></ion-icon>
</button>
</div>
</div>
</header>
<!-- Main Content -->
<div id="mainContent" class="flex-1 flex overflow-hidden">
<!-- Left Pane -->
<div id="leftPane" class="flex-1 flex flex-col bg-white border-r border-gray-200">
<!-- Breadcrumb -->
<div id="leftBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
<div class="flex items-center space-x-1 text-sm">
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 p-4 overflow-auto">
<div id="leftContent" class="grid grid-cols-4 gap-4">
<!-- Content will be loaded here -->
</div>
</div>
</div>
<!-- Splitter -->
<div id="splitter" class="w-1 bg-gray-200 pane-splitter hidden"></div>
<!-- Right Pane (hidden by default) -->
<div id="rightPane" class="flex-1 flex-col bg-white hidden">
<!-- Breadcrumb -->
<div id="rightBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
<div class="flex items-center space-x-1 text-sm">
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 p-4 overflow-auto">
<div id="rightContent" class="grid grid-cols-4 gap-4">
<!-- Content will be loaded here -->
</div>
</div>
</div>
</div>
</div>
<script>
// Sample data structure
const folderStructure = {
'Home': {
type: 'folder',
children: {
'My Photos': {
type: 'folder',
children: {
'Vacation 2023': {
type: 'folder',
children: {
'Beach': {
type: 'folder',
children: {
'photo1.jpg': { type: 'photo', size: '2.4 MB', date: '2023-07-15' },
'photo2.jpg': { type: 'photo', size: '3.1 MB', date: '2023-07-15' },
'photo3.jpg': { type: 'photo', size: '2.8 MB', date: '2023-07-16' }
}
},
'Mountains': {
type: 'folder',
children: {
'sunrise.jpg': { type: 'photo', size: '4.2 MB', date: '2023-07-20' },
'sunset.jpg': { type: 'photo', size: '3.8 MB', date: '2023-07-20' }
}
}
}
},
'Family': {
type: 'folder',
children: {
'birthday.jpg': { type: 'photo', size: '1.9 MB', date: '2023-08-10' },
'dinner.jpg': { type: 'photo', size: '2.2 MB', date: '2023-08-15' }
}
}
}
},
'Work Projects': {
type: 'folder',
children: {
'Screenshots': {
type: 'folder',
children: {
'design1.png': { type: 'photo', size: '1.2 MB', date: '2023-09-01' },
'design2.png': { type: 'photo', size: '1.5 MB', date: '2023-09-02' }
}
}
}
},
'Camera Roll': {
type: 'folder',
children: {
'IMG_001.jpg': { type: 'photo', size: '3.2 MB', date: '2023-09-15' },
'IMG_002.jpg': { type: 'photo', size: '2.9 MB', date: '2023-09-15' },
'IMG_003.jpg': { type: 'photo', size: '3.4 MB', date: '2023-09-16' }
}
}
}
}
};
// Application state
let isDualPane = false;
let leftPath = ['Home'];
let rightPath = ['Home'];
let isDragging = false;
// Initialize the application
function init() {
loadContent('left');
updateBreadcrumb('left');
// Setup event listeners
document.getElementById('layoutToggle').addEventListener('click', toggleDualPane);
setupSplitter();
}
// Get current folder from path
function getCurrentFolder(path) {
let current = folderStructure;
for (let segment of path) {
if (current[segment] && current[segment].children) {
current = current[segment].children;
} else {
return null;
}
}
return current;
}
// Load content for a pane
function loadContent(pane) {
const path = pane === 'left' ? leftPath : rightPath;
const contentElement = document.getElementById(pane + 'Content');
const currentFolder = getCurrentFolder(path);
if (!currentFolder) {
contentElement.innerHTML = '<p class="text-gray-500">Folder not found</p>';
return;
}
let html = '';
// Sort items: folders first, then photos
const items = Object.entries(currentFolder).sort(([,a], [,b]) => {
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
return 0;
});
items.forEach(([name, item]) => {
if (item.type === 'folder') {
html += `
<div class="folder-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all" onclick="navigateToFolder('${pane}', '${name}')">
<ion-icon name="folder-outline" class="text-blue-500 text-3xl mb-2"></ion-icon>
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
</div>
`;
} else if (item.type === 'photo') {
html += `
<div class="photo-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all">
<ion-icon name="image-outline" class="text-green-500 text-3xl mb-2"></ion-icon>
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
<p class="text-xs text-gray-500">${item.size}</p>
</div>
`;
}
});
contentElement.innerHTML = html;
}
// Navigate to folder
function navigateToFolder(pane, folderName) {
const path = pane === 'left' ? leftPath : rightPath;
path.push(folderName);
loadContent(pane);
updateBreadcrumb(pane);
}
// Update breadcrumb navigation
function updateBreadcrumb(pane) {
const path = pane === 'left' ? leftPath : rightPath;
const breadcrumbElement = document.getElementById(pane + 'Breadcrumb');
let html = '<div class="flex items-center space-x-1 text-sm">';
path.forEach((segment, index) => {
if (index > 0) {
html += '<ion-icon name="chevron-forward-outline" class="text-gray-400 text-xs"></ion-icon>';
}
if (index === path.length - 1) {
html += `<span class="px-2 py-1 rounded bg-blue-100 text-blue-700 font-medium">${segment}</span>`;
} else {
html += `<span class="breadcrumb-item px-2 py-1 rounded text-gray-600 hover:bg-gray-100" onclick="navigateToBreadcrumb('${pane}', ${index})">${segment}</span>`;
}
});
html += '</div>';
breadcrumbElement.innerHTML = html;
}
// Navigate via breadcrumb
function navigateToBreadcrumb(pane, index) {
const path = pane === 'left' ? leftPath : rightPath;
if (pane === 'left') {
leftPath = leftPath.slice(0, index + 1);
} else {
rightPath = rightPath.slice(0, index + 1);
}
loadContent(pane);
updateBreadcrumb(pane);
}
// Toggle dual pane mode
function toggleDualPane() {
isDualPane = !isDualPane;
const rightPane = document.getElementById('rightPane');
const splitter = document.getElementById('splitter');
const mainContent = document.getElementById('mainContent');
if (isDualPane) {
rightPane.classList.remove('hidden');
rightPane.classList.add('flex');
splitter.classList.remove('hidden');
loadContent('right');
updateBreadcrumb('right');
} else {
rightPane.classList.add('hidden');
rightPane.classList.remove('flex');
splitter.classList.add('hidden');
}
}
// Setup splitter functionality
function setupSplitter() {
const splitter = document.getElementById('splitter');
const mainContent = document.getElementById('mainContent');
splitter.addEventListener('mousedown', (e) => {
isDragging = true;
splitter.classList.add('dragging');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const rect = mainContent.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = (x / rect.width) * 100;
if (percentage > 20 && percentage < 80) {
mainContent.style.gridTemplateColumns = `${percentage}% 4px 1fr`;
}
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
splitter.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
});
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
+1463
View File
@@ -0,0 +1,1463 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Argus Photo Manager - Light Design</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
'inter': ['Inter', 'system-ui', 'sans-serif'],
}
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<style>
* {
font-family: 'Inter', system-ui, sans-serif;
}
.pane-splitter {
user-select: none;
}
.pane-splitter.horizontal {
cursor: col-resize;
}
.pane-splitter.vertical {
cursor: row-resize;
}
.pane-splitter:hover {
background-color: #e5e7eb;
}
.pane-splitter.dragging {
background-color: #3b82f6;
}
.breadcrumb-item {
cursor: pointer;
transition: all 0.2s ease;
}
.breadcrumb-item:hover {
background-color: #f3f4f6;
color: #3b82f6;
}
.folder-item, .photo-item {
cursor: pointer;
transition: all 0.2s ease;
}
.folder-item:hover, .photo-item:hover {
background-color: #f9fafb;
transform: translateY(-1px);
}
.folder-item:active, .photo-item:active {
transform: translateY(0);
}
.grid-small {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
.grid-medium {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.grid-large {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.list-view {
display: flex;
flex-direction: column;
gap: 1px;
}
.list-item {
display: flex;
align-items: center;
padding: 8px 12px;
background: white;
border-bottom: 1px solid #f3f4f6;
}
.list-item:hover {
background-color: #f9fafb;
}
.breadcrumb-dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 50;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
min-width: 200px;
max-height: 300px;
overflow-y: auto;
display: none;
}
.breadcrumb-dropdown.show {
display: block;
}
.breadcrumb-dropdown-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
transition: background-color 0.2s;
}
.breadcrumb-dropdown-item:hover {
background-color: #f3f4f6;
}
.breadcrumb-separator {
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
transition: background-color 0.2s;
position: relative;
}
.breadcrumb-separator:hover {
background-color: #f3f4f6;
}
.path-input-container {
position: relative;
}
.path-input {
display: none;
min-width: 300px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.path-input.active {
display: block;
}
.breadcrumb-area {
border-radius: 6px;
padding: 4px 8px;
transition: all 0.2s ease;
min-width: 0;
flex: 1;
}
.breadcrumb-area.editing {
background-color: white;
border: 1px solid #3b82f6;
}
.path-edit-trigger {
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.path-edit-trigger:hover {
background-color: #f3f4f6;
border-color: #d1d5db;
}
.path-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 60;
background: white;
border: 1px solid #e5e7eb;
border-top: none;
border-radius: 0 0 8px 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
max-height: 200px;
overflow-y: auto;
display: none;
}
.path-suggestions.show {
display: block;
}
.path-suggestion-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
transition: background-color 0.2s;
font-size: 13px;
}
.path-suggestion-item:hover,
.path-suggestion-item.selected {
background-color: #f3f4f6;
}
.edit-path-hint {
font-size: 11px;
color: #6b7280;
margin-left: 8px;
}
.item-selected {
background-color: #dbeafe !important;
border: 2px solid #3b82f6 !important;
box-shadow: 0 0 0 1px #3b82f6;
}
.item-focused {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.selectable-item {
position: relative;
user-select: none;
}
.selectable-item:focus {
outline: none;
}
.selection-rectangle {
position: absolute;
border: 2px solid #3b82f6;
background-color: rgba(59, 130, 246, 0.1);
pointer-events: none;
z-index: 10;
display: none;
}
.content-area {
position: relative;
}
</style>
</head>
<body class="bg-gray-50 font-inter">
<div class="h-screen flex flex-col">
<!-- Header -->
<header class="bg-white border-b border-gray-200 px-4 py-3">
<!-- File Operations Toolbar -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<button class="flex items-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" title="New Folder">
<ion-icon name="add-outline" class="text-sm"></ion-icon>
<span class="text-sm font-medium">New</span>
</button>
<div class="w-px h-6 bg-gray-300"></div>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Copy">
<ion-icon name="copy-outline" class="text-sm"></ion-icon>
<span class="text-sm">Copy</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Cut">
<ion-icon name="cut-outline" class="text-sm"></ion-icon>
<span class="text-sm">Cut</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Paste" disabled>
<ion-icon name="clipboard-outline" class="text-sm"></ion-icon>
<span class="text-sm">Paste</span>
</button>
<div class="w-px h-6 bg-gray-300"></div>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Rename">
<ion-icon name="create-outline" class="text-sm"></ion-icon>
<span class="text-sm">Rename</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors" title="Delete">
<ion-icon name="trash-outline" class="text-sm"></ion-icon>
<span class="text-sm">Delete</span>
</button>
<div class="w-px h-6 bg-gray-300"></div>
<button class="flex items-center space-x-2 px-3 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors" title="Add to Album">
<ion-icon name="albums-outline" class="text-sm"></ion-icon>
<span class="text-sm">Add to Album</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200 transition-colors" title="Edit Tags">
<ion-icon name="pricetag-outline" class="text-sm"></ion-icon>
<span class="text-sm">Tags</span>
</button>
<button class="flex items-center space-x-2 px-3 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors" title="Batch Edit">
<ion-icon name="layers-outline" class="text-sm"></ion-icon>
<span class="text-sm">Batch Edit</span>
</button>
</div>
<div class="flex items-center space-x-4">
<!-- View Controls -->
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
<button id="gridViewBtn" class="px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700" onclick="setViewMode('grid')">Grid</button>
<button id="listViewBtn" class="px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700" onclick="setViewMode('list')">List</button>
</div>
<!-- Thumbnail Size (only show when Grid is selected) -->
<div id="thumbnailSizeControls" class="flex items-center space-x-2">
<span class="text-sm text-gray-600">Size:</span>
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
<button id="smallBtn" class="p-1 rounded hover:bg-white transition-colors" title="Small" onclick="setThumbnailSize('small')">
<ion-icon name="apps-outline" class="text-sm"></ion-icon>
</button>
<button id="mediumBtn" class="p-1 rounded bg-white shadow-sm transition-colors" title="Medium" onclick="setThumbnailSize('medium')">
<ion-icon name="grid-outline" class="text-sm"></ion-icon>
</button>
<button id="largeBtn" class="p-1 rounded hover:bg-white transition-colors" title="Large" onclick="setThumbnailSize('large')">
<ion-icon name="square-outline" class="text-sm"></ion-icon>
</button>
</div>
</div>
<!-- Layout Toggle -->
<div class="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
<button id="horizontalLayoutBtn" class="p-1 rounded bg-white shadow-sm transition-colors" title="Horizontal Layout" onclick="setLayoutMode('horizontal')">
<ion-icon name="resize-outline" class="text-sm"></ion-icon>
</button>
<button id="verticalLayoutBtn" class="p-1 rounded hover:bg-white transition-colors" title="Vertical Layout" onclick="setLayoutMode('vertical')">
<ion-icon name="swap-vertical-outline" class="text-sm"></ion-icon>
</button>
<button id="singleLayoutBtn" class="p-1 rounded hover:bg-white transition-colors" title="Single Pane" onclick="setLayoutMode('single')">
<ion-icon name="square-outline" class="text-sm"></ion-icon>
</button>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<div id="mainContent" class="flex-1 flex overflow-hidden">
<!-- Left Pane -->
<div id="leftPane" class="flex-1 flex flex-col bg-white border-r border-gray-200">
<!-- Breadcrumb -->
<div id="leftBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
<div class="flex items-center">
<div class="flex items-center space-x-1">
<div class="breadcrumb-area flex items-center space-x-1 text-sm" id="leftBreadcrumbArea">
<div id="leftBreadcrumbContent">
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
</div>
</div>
<div class="path-edit-trigger ml-2" onclick="enterEditMode('left')" title="Edit path">
<ion-icon name="create-outline" class="text-gray-500 text-sm"></ion-icon>
</div>
</div>
<div class="path-input-container">
<input type="text" id="leftPathInput" class="path-input px-3 py-2 text-sm border border-gray-300 rounded-lg" placeholder="Enter path..." onblur="exitEditMode('left')" onkeydown="handlePathInputKeydown(event, 'left')" oninput="showPathSuggestions(event, 'left')">
<div class="path-suggestions" id="leftPathSuggestions">
<!-- Suggestions will be populated here -->
</div>
</div>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 p-4 overflow-auto content-area" id="leftContentArea" onmousedown="startDragSelection(event, 'left')" onmouseup="endDragSelection(event, 'left')" onmousemove="updateDragSelection(event, 'left')" onclick="handleContentAreaClick(event, 'left')">
<div id="leftContent" class="grid grid-medium gap-4">
<!-- Content will be loaded here -->
</div>
<div id="leftSelectionRect" class="selection-rectangle"></div>
</div>
</div>
<!-- Splitter -->
<div id="splitter" class="w-1 bg-gray-200 pane-splitter horizontal hidden"></div>
<!-- Right Pane (hidden by default) -->
<div id="rightPane" class="flex-1 flex-col bg-white hidden">
<!-- Breadcrumb -->
<div id="rightBreadcrumb" class="px-4 py-2 border-b border-gray-100 bg-gray-50">
<div class="flex items-center">
<div class="flex items-center space-x-1">
<div class="breadcrumb-area flex items-center space-x-1 text-sm" id="rightBreadcrumbArea">
<div id="rightBreadcrumbContent">
<span class="breadcrumb-item px-2 py-1 rounded text-blue-600 font-medium">Home</span>
</div>
</div>
<div class="path-edit-trigger ml-2" onclick="enterEditMode('right')" title="Edit path">
<ion-icon name="create-outline" class="text-gray-500 text-sm"></ion-icon>
</div>
</div>
<div class="path-input-container">
<input type="text" id="rightPathInput" class="path-input px-3 py-2 text-sm border border-gray-300 rounded-lg" placeholder="Enter path..." onblur="exitEditMode('right')" onkeydown="handlePathInputKeydown(event, 'right')" oninput="showPathSuggestions(event, 'right')">
<div class="path-suggestions" id="rightPathSuggestions">
<!-- Suggestions will be populated here -->
</div>
</div>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 p-4 overflow-auto content-area" id="rightContentArea" onmousedown="startDragSelection(event, 'right')" onmouseup="endDragSelection(event, 'right')" onmousemove="updateDragSelection(event, 'right')" onclick="handleContentAreaClick(event, 'right')">
<div id="rightContent" class="grid grid-medium gap-4">
<!-- Content will be loaded here -->
</div>
<div id="rightSelectionRect" class="selection-rectangle"></div>
</div>
</div>
</div>
</div>
<script>
// Sample data structure
const folderStructure = {
'Home': {
type: 'folder',
children: {
'My Photos': {
type: 'folder',
children: {
'Vacation 2023': {
type: 'folder',
children: {
'Beach': {
type: 'folder',
children: {
'photo1.jpg': { type: 'photo', size: '2.4 MB', date: '2023-07-15' },
'photo2.jpg': { type: 'photo', size: '3.1 MB', date: '2023-07-15' },
'photo3.jpg': { type: 'photo', size: '2.8 MB', date: '2023-07-16' },
'photo4.jpg': { type: 'photo', size: '4.1 MB', date: '2023-07-16' },
'photo5.jpg': { type: 'photo', size: '3.5 MB', date: '2023-07-17' },
'beach_sunset.jpg': { type: 'photo', size: '5.2 MB', date: '2023-07-17' },
'waves.jpg': { type: 'photo', size: '3.8 MB', date: '2023-07-18' },
'seashells.jpg': { type: 'photo', size: '2.1 MB', date: '2023-07-18' }
}
},
'Mountains': {
type: 'folder',
children: {
'sunrise.jpg': { type: 'photo', size: '4.2 MB', date: '2023-07-20' },
'sunset.jpg': { type: 'photo', size: '3.8 MB', date: '2023-07-20' },
'peak_view.jpg': { type: 'photo', size: '6.1 MB', date: '2023-07-21' },
'hiking_trail.jpg': { type: 'photo', size: '4.5 MB', date: '2023-07-21' },
'mountain_lake.jpg': { type: 'photo', size: '5.3 MB', date: '2023-07-22' },
'forest.jpg': { type: 'photo', size: '4.8 MB', date: '2023-07-22' }
}
},
'City Tour': {
type: 'folder',
children: {
'architecture.jpg': { type: 'photo', size: '3.2 MB', date: '2023-07-25' },
'street_art.jpg': { type: 'photo', size: '2.7 MB', date: '2023-07-25' },
'museum.jpg': { type: 'photo', size: '2.9 MB', date: '2023-07-26' },
'cafe.jpg': { type: 'photo', size: '2.3 MB', date: '2023-07-26' }
}
}
}
},
'Family': {
type: 'folder',
children: {
'Birthday Party': {
type: 'folder',
children: {
'cake.jpg': { type: 'photo', size: '2.1 MB', date: '2023-08-10' },
'group_photo.jpg': { type: 'photo', size: '3.4 MB', date: '2023-08-10' },
'presents.jpg': { type: 'photo', size: '2.8 MB', date: '2023-08-10' }
}
},
'dinner.jpg': { type: 'photo', size: '2.2 MB', date: '2023-08-15' },
'family_portrait.jpg': { type: 'photo', size: '4.1 MB', date: '2023-08-20' },
'kids_playing.jpg': { type: 'photo', size: '3.3 MB', date: '2023-08-22' },
'garden.jpg': { type: 'photo', size: '2.9 MB', date: '2023-08-25' }
}
},
'Events': {
type: 'folder',
children: {
'wedding.jpg': { type: 'photo', size: '5.1 MB', date: '2023-09-05' },
'graduation.jpg': { type: 'photo', size: '3.7 MB', date: '2023-09-10' },
'concert.jpg': { type: 'photo', size: '2.8 MB', date: '2023-09-15' }
}
}
}
},
'Work Projects': {
type: 'folder',
children: {
'Screenshots': {
type: 'folder',
children: {
'design1.png': { type: 'photo', size: '1.2 MB', date: '2023-09-01' },
'design2.png': { type: 'photo', size: '1.5 MB', date: '2023-09-02' },
'mockup.png': { type: 'photo', size: '2.1 MB', date: '2023-09-03' },
'wireframe.png': { type: 'photo', size: '0.8 MB', date: '2023-09-04' },
'prototype.png': { type: 'photo', size: '1.9 MB', date: '2023-09-05' }
}
},
'Presentations': {
type: 'folder',
children: {
'slide1.jpg': { type: 'photo', size: '1.3 MB', date: '2023-09-08' },
'slide2.jpg': { type: 'photo', size: '1.1 MB', date: '2023-09-08' },
'slide3.jpg': { type: 'photo', size: '1.4 MB', date: '2023-09-08' }
}
}
}
},
'Camera Roll': {
type: 'folder',
children: {
'Recent': {
type: 'folder',
children: {
'IMG_010.jpg': { type: 'photo', size: '3.2 MB', date: '2023-09-25' },
'IMG_011.jpg': { type: 'photo', size: '2.9 MB', date: '2023-09-25' },
'IMG_012.jpg': { type: 'photo', size: '3.4 MB', date: '2023-09-26' },
'IMG_013.jpg': { type: 'photo', size: '4.1 MB', date: '2023-09-26' }
}
},
'IMG_001.jpg': { type: 'photo', size: '3.2 MB', date: '2023-09-15' },
'IMG_002.jpg': { type: 'photo', size: '2.9 MB', date: '2023-09-15' },
'IMG_003.jpg': { type: 'photo', size: '3.4 MB', date: '2023-09-16' },
'IMG_004.jpg': { type: 'photo', size: '2.7 MB', date: '2023-09-17' },
'IMG_005.jpg': { type: 'photo', size: '3.8 MB', date: '2023-09-18' },
'IMG_006.jpg': { type: 'photo', size: '4.2 MB', date: '2023-09-19' },
'IMG_007.jpg': { type: 'photo', size: '3.1 MB', date: '2023-09-20' },
'IMG_008.jpg': { type: 'photo', size: '2.8 MB', date: '2023-09-21' },
'IMG_009.jpg': { type: 'photo', size: '3.6 MB', date: '2023-09-22' }
}
},
'Archives': {
type: 'folder',
children: {
'2022': {
type: 'folder',
children: {
'old_photo1.jpg': { type: 'photo', size: '2.1 MB', date: '2022-12-25' },
'old_photo2.jpg': { type: 'photo', size: '1.8 MB', date: '2022-12-31' }
}
},
'2021': {
type: 'folder',
children: {
'vintage1.jpg': { type: 'photo', size: '1.5 MB', date: '2021-06-15' },
'vintage2.jpg': { type: 'photo', size: '1.9 MB', date: '2021-08-20' }
}
}
}
}
}
}
};
// Application state
let layoutMode = 'single'; // 'single', 'horizontal', 'vertical'
let viewMode = 'grid'; // 'grid', 'list'
let thumbnailSize = 'medium'; // 'small', 'medium', 'large'
let leftPath = ['Home'];
let rightPath = ['Home'];
let isDragging = false;
// Selection state
let leftSelectedItems = new Set();
let rightSelectedItems = new Set();
let leftFocusedItem = null;
let rightFocusedItem = null;
// Drag selection state
let isDragSelecting = false;
let dragStartX = 0;
let dragStartY = 0;
let dragCurrentX = 0;
let dragCurrentY = 0;
let dragPane = null;
// Initialize the application
function init() {
loadContent('left');
updateBreadcrumb('left');
setupSplitter();
setupKeyboardNavigation();
}
// Get current folder from path
function getCurrentFolder(path) {
let current = folderStructure;
for (let segment of path) {
if (current[segment] && current[segment].children) {
current = current[segment].children;
} else {
return null;
}
}
return current;
}
// Load content for a pane
function loadContent(pane) {
const path = pane === 'left' ? leftPath : rightPath;
const contentElement = document.getElementById(pane + 'Content');
const currentFolder = getCurrentFolder(path);
// Clear selection when navigating
if (pane === 'left') {
leftSelectedItems.clear();
leftFocusedItem = null;
} else {
rightSelectedItems.clear();
rightFocusedItem = null;
}
if (!currentFolder) {
contentElement.innerHTML = '<p class="text-gray-500">Folder not found</p>';
return;
}
// Sort items: folders first, then photos
const items = Object.entries(currentFolder).sort(([,a], [,b]) => {
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
return 0;
});
if (viewMode === 'list') {
contentElement.className = 'list-view';
let html = '';
items.forEach(([name, item], index) => {
const itemId = `${pane}-item-${index}`;
if (item.type === 'folder') {
html += `
<div class="list-item folder-item selectable-item" data-item-name="${name}" data-item-id="${itemId}" data-pane="${pane}" tabindex="0"
onclick="handleItemClick(event, '${pane}', '${name}', ${index})"
ondblclick="navigateToFolder('${pane}', '${name}')">
<ion-icon name="folder-outline" class="text-blue-500 text-lg mr-3"></ion-icon>
<div class="flex-1">
<p class="text-sm font-medium text-gray-700">${name}</p>
<p class="text-xs text-gray-500">Folder</p>
</div>
</div>
`;
} else if (item.type === 'photo') {
html += `
<div class="list-item photo-item selectable-item" data-item-name="${name}" data-item-id="${itemId}" data-pane="${pane}" tabindex="0"
onclick="handleItemClick(event, '${pane}', '${name}', ${index})">
<ion-icon name="image-outline" class="text-green-500 text-lg mr-3"></ion-icon>
<div class="flex-1">
<p class="text-sm font-medium text-gray-700">${name}</p>
<p class="text-xs text-gray-500">${item.size} • ${item.date}</p>
</div>
</div>
`;
}
});
contentElement.innerHTML = html;
} else {
// Grid view
contentElement.className = `grid grid-${thumbnailSize} gap-4`;
let html = '';
items.forEach(([name, item], index) => {
const itemId = `${pane}-item-${index}`;
if (item.type === 'folder') {
html += `
<div class="folder-item selectable-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all"
data-item-name="${name}" data-item-id="${itemId}" data-pane="${pane}" tabindex="0"
onclick="handleItemClick(event, '${pane}', '${name}', ${index})"
ondblclick="navigateToFolder('${pane}', '${name}')">
<ion-icon name="folder-outline" class="text-blue-500 text-3xl mb-2"></ion-icon>
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
</div>
`;
} else if (item.type === 'photo') {
html += `
<div class="photo-item selectable-item bg-white rounded-lg border border-gray-200 p-4 text-center hover:shadow-md transition-all"
data-item-name="${name}" data-item-id="${itemId}" data-pane="${pane}" tabindex="0"
onclick="handleItemClick(event, '${pane}', '${name}', ${index})">
<ion-icon name="image-outline" class="text-green-500 text-3xl mb-2"></ion-icon>
<p class="text-sm font-medium text-gray-700 truncate">${name}</p>
<p class="text-xs text-gray-500">${item.size}</p>
</div>
`;
}
});
contentElement.innerHTML = html;
}
// Set default selection to first item
if (items.length > 0) {
setFocusedItem(pane, items[0][0], 0);
}
}
// Navigate to folder
function navigateToFolder(pane, folderName) {
const path = pane === 'left' ? leftPath : rightPath;
path.push(folderName);
loadContent(pane);
updateBreadcrumb(pane);
}
// Update breadcrumb navigation
function updateBreadcrumb(pane) {
const path = pane === 'left' ? leftPath : rightPath;
const breadcrumbContentElement = document.getElementById(pane + 'BreadcrumbContent');
let html = '<div class="flex items-center space-x-1">';
path.forEach((segment, index) => {
if (index > 0) {
html += `
<div class="breadcrumb-separator" onclick="showBreadcrumbDropdown('${pane}', ${index - 1})">
<ion-icon name="chevron-forward-outline" class="text-gray-400 text-xs"></ion-icon>
<div class="breadcrumb-dropdown" id="${pane}Dropdown${index - 1}">
<!-- Dropdown content will be populated dynamically -->
</div>
</div>
`;
}
if (index === path.length - 1) {
html += `<span class="px-2 py-1 rounded bg-blue-100 text-blue-700 font-medium">${segment}</span>`;
} else {
html += `<span class="breadcrumb-item px-2 py-1 rounded text-gray-600 hover:bg-gray-100" onclick="navigateToBreadcrumb('${pane}', ${index})">${segment}</span>`;
}
});
html += '</div>';
breadcrumbContentElement.innerHTML = html;
}
// Navigate via breadcrumb
function navigateToBreadcrumb(pane, index) {
const path = pane === 'left' ? leftPath : rightPath;
if (pane === 'left') {
leftPath = leftPath.slice(0, index + 1);
} else {
rightPath = rightPath.slice(0, index + 1);
}
loadContent(pane);
updateBreadcrumb(pane);
}
// Set view mode (grid/list)
function setViewMode(mode) {
viewMode = mode;
// Update button states
document.getElementById('gridViewBtn').className = mode === 'grid'
? 'px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700'
: 'px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700';
document.getElementById('listViewBtn').className = mode === 'list'
? 'px-3 py-1 rounded bg-white shadow-sm text-sm font-medium text-gray-700'
: 'px-3 py-1 rounded text-sm font-medium text-gray-500 hover:text-gray-700';
// Show/hide thumbnail size controls based on view mode
const thumbnailControls = document.getElementById('thumbnailSizeControls');
if (mode === 'grid') {
thumbnailControls.style.display = 'flex';
} else {
thumbnailControls.style.display = 'none';
}
// Reload content
loadContent('left');
if (layoutMode !== 'single') {
loadContent('right');
}
}
// Set thumbnail size
function setThumbnailSize(size) {
thumbnailSize = size;
// Update button states
['small', 'medium', 'large'].forEach(s => {
document.getElementById(s + 'Btn').className = s === size
? 'p-1 rounded bg-white shadow-sm transition-colors'
: 'p-1 rounded hover:bg-white transition-colors';
});
// Reload content if in grid mode
if (viewMode === 'grid') {
loadContent('left');
if (layoutMode !== 'single') {
loadContent('right');
}
}
}
// Set layout mode
function setLayoutMode(mode) {
layoutMode = mode;
const rightPane = document.getElementById('rightPane');
const splitter = document.getElementById('splitter');
const mainContent = document.getElementById('mainContent');
// Update button states
['horizontal', 'vertical', 'single'].forEach(m => {
const btn = document.getElementById(m + 'LayoutBtn');
btn.className = m === mode
? 'p-1 rounded bg-white shadow-sm transition-colors'
: 'p-1 rounded hover:bg-white transition-colors';
});
if (mode === 'single') {
rightPane.classList.add('hidden');
rightPane.classList.remove('flex');
splitter.classList.add('hidden');
mainContent.style.flexDirection = '';
mainContent.style.gridTemplateColumns = '';
mainContent.style.gridTemplateRows = '';
} else if (mode === 'horizontal') {
rightPane.classList.remove('hidden');
rightPane.classList.add('flex');
splitter.classList.remove('hidden');
splitter.className = 'w-1 bg-gray-200 pane-splitter horizontal';
mainContent.style.flexDirection = 'row';
mainContent.style.gridTemplateColumns = '1fr 4px 1fr';
mainContent.style.gridTemplateRows = '';
mainContent.style.display = 'grid';
loadContent('right');
updateBreadcrumb('right');
} else if (mode === 'vertical') {
rightPane.classList.remove('hidden');
rightPane.classList.add('flex');
splitter.classList.remove('hidden');
splitter.className = 'h-1 bg-gray-200 pane-splitter vertical';
mainContent.style.flexDirection = 'column';
mainContent.style.gridTemplateRows = '1fr 4px 1fr';
mainContent.style.gridTemplateColumns = '';
mainContent.style.display = 'grid';
loadContent('right');
updateBreadcrumb('right');
}
}
// Setup splitter functionality
function setupSplitter() {
const splitter = document.getElementById('splitter');
const mainContent = document.getElementById('mainContent');
splitter.addEventListener('mousedown', (e) => {
isDragging = true;
splitter.classList.add('dragging');
if (layoutMode === 'horizontal') {
document.body.style.cursor = 'col-resize';
} else if (layoutMode === 'vertical') {
document.body.style.cursor = 'row-resize';
}
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const rect = mainContent.getBoundingClientRect();
if (layoutMode === 'horizontal') {
const x = e.clientX - rect.left;
const percentage = (x / rect.width) * 100;
if (percentage > 20 && percentage < 80) {
mainContent.style.gridTemplateColumns = `${percentage}% 4px 1fr`;
}
} else if (layoutMode === 'vertical') {
const y = e.clientY - rect.top;
const percentage = (y / rect.height) * 100;
if (percentage > 20 && percentage < 80) {
mainContent.style.gridTemplateRows = `${percentage}% 4px 1fr`;
}
}
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
splitter.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
});
}
// Show breadcrumb dropdown
function showBreadcrumbDropdown(pane, index) {
// Hide all other dropdowns
document.querySelectorAll('.breadcrumb-dropdown').forEach(dropdown => {
dropdown.classList.remove('show');
});
const path = pane === 'left' ? leftPath : rightPath;
const dropdownId = `${pane}Dropdown${index}`;
const dropdown = document.getElementById(dropdownId);
if (!dropdown) return;
// Get folders at the specified path
const currentPath = path.slice(0, index + 1);
const currentFolder = getCurrentFolder(currentPath);
if (!currentFolder) return;
// Build dropdown content
let html = '';
Object.entries(currentFolder).forEach(([name, item]) => {
if (item.type === 'folder') {
html += `
<div class="breadcrumb-dropdown-item" onclick="navigateFromBreadcrumbDropdown('${pane}', ${index}, '${name}')">
<ion-icon name="folder-outline" class="text-blue-500 text-sm mr-2"></ion-icon>
<span class="text-sm">${name}</span>
</div>
`;
}
});
if (html === '') {
html = '<div class="breadcrumb-dropdown-item"><span class="text-sm text-gray-500">No folders</span></div>';
}
dropdown.innerHTML = html;
dropdown.classList.add('show');
// Hide dropdown when clicking outside
setTimeout(() => {
document.addEventListener('click', function hideDropdown(e) {
if (!dropdown.contains(e.target) && !e.target.closest('.breadcrumb-separator')) {
dropdown.classList.remove('show');
document.removeEventListener('click', hideDropdown);
}
});
}, 0);
}
// Navigate from breadcrumb dropdown
function navigateFromBreadcrumbDropdown(pane, index, folderName) {
const path = pane === 'left' ? leftPath : rightPath;
const newPath = path.slice(0, index + 1);
newPath.push(folderName);
if (pane === 'left') {
leftPath = newPath;
} else {
rightPath = newPath;
}
loadContent(pane);
updateBreadcrumb(pane);
// Hide dropdown immediately after selection
const dropdown = document.getElementById(`${pane}Dropdown${index}`);
if (dropdown) {
dropdown.classList.remove('show');
}
}
// Enter edit mode (address bar style)
function enterEditMode(pane) {
const breadcrumbArea = document.getElementById(pane + 'BreadcrumbArea');
const editTrigger = breadcrumbArea.parentElement.querySelector('.path-edit-trigger');
const inputElement = document.getElementById(pane + 'PathInput');
const path = pane === 'left' ? leftPath : rightPath;
breadcrumbArea.style.display = 'none';
editTrigger.style.display = 'none';
inputElement.classList.add('active');
inputElement.value = path.join('/');
inputElement.focus();
inputElement.select();
}
// Exit edit mode
function exitEditMode(pane) {
const breadcrumbArea = document.getElementById(pane + 'BreadcrumbArea');
const editTrigger = breadcrumbArea.parentElement.querySelector('.path-edit-trigger');
const inputElement = document.getElementById(pane + 'PathInput');
const suggestionsElement = document.getElementById(pane + 'PathSuggestions');
breadcrumbArea.style.display = 'flex';
editTrigger.style.display = 'block';
inputElement.classList.remove('active');
suggestionsElement.classList.remove('show');
}
// Handle path input keydown
function handlePathInputKeydown(event, pane) {
const suggestionsElement = document.getElementById(pane + 'PathSuggestions');
const suggestions = suggestionsElement.querySelectorAll('.path-suggestion-item');
if (event.key === 'Enter') {
const inputElement = document.getElementById(pane + 'PathInput');
const pathString = inputElement.value.trim();
if (pathString) {
navigateToPath(pane, pathString);
}
exitEditMode(pane);
} else if (event.key === 'Escape') {
exitEditMode(pane);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
const selected = suggestionsElement.querySelector('.selected');
if (selected && selected.nextElementSibling) {
selected.classList.remove('selected');
selected.nextElementSibling.classList.add('selected');
} else if (suggestions.length > 0) {
suggestions.forEach(s => s.classList.remove('selected'));
suggestions[0].classList.add('selected');
}
} else if (event.key === 'ArrowUp') {
event.preventDefault();
const selected = suggestionsElement.querySelector('.selected');
if (selected && selected.previousElementSibling) {
selected.classList.remove('selected');
selected.previousElementSibling.classList.add('selected');
} else if (suggestions.length > 0) {
suggestions.forEach(s => s.classList.remove('selected'));
suggestions[suggestions.length - 1].classList.add('selected');
}
} else if (event.key === 'Tab' && suggestions.length > 0) {
event.preventDefault();
const selected = suggestionsElement.querySelector('.selected') || suggestions[0];
if (selected) {
document.getElementById(pane + 'PathInput').value = selected.dataset.path;
}
}
}
// Show path suggestions
function showPathSuggestions(event, pane) {
const inputValue = event.target.value.trim();
const suggestionsElement = document.getElementById(pane + 'PathSuggestions');
if (!inputValue) {
suggestionsElement.classList.remove('show');
return;
}
// Get all possible paths
const allPaths = getAllPossiblePaths();
const filteredPaths = allPaths.filter(path =>
path.toLowerCase().includes(inputValue.toLowerCase()) && path !== inputValue
);
if (filteredPaths.length === 0) {
suggestionsElement.classList.remove('show');
return;
}
let html = '';
filteredPaths.slice(0, 8).forEach((path, index) => {
html += `
<div class="path-suggestion-item ${index === 0 ? 'selected' : ''}"
data-path="${path}"
onclick="selectPathSuggestion('${pane}', '${path}')">
<ion-icon name="folder-outline" class="text-blue-500 text-sm mr-2"></ion-icon>
<span>${path}</span>
</div>
`;
});
suggestionsElement.innerHTML = html;
suggestionsElement.classList.add('show');
}
// Get all possible paths
function getAllPossiblePaths() {
const paths = [];
function traverseFolder(currentPath, folder) {
Object.entries(folder).forEach(([name, item]) => {
if (item.type === 'folder') {
const newPath = currentPath ? `${currentPath}/${name}` : name;
paths.push(newPath);
if (item.children) {
traverseFolder(newPath, item.children);
}
}
});
}
traverseFolder('', folderStructure);
return paths.sort();
}
// Select path suggestion
function selectPathSuggestion(pane, path) {
document.getElementById(pane + 'PathInput').value = path;
navigateToPath(pane, path);
exitEditMode(pane);
}
// Navigate to path
function navigateToPath(pane, pathString) {
const pathSegments = pathString.split('/').filter(segment => segment.trim() !== '');
// Always start from Home
if (pathSegments.length === 0 || pathSegments[0] !== 'Home') {
pathSegments.unshift('Home');
}
// Validate path exists
let current = folderStructure;
for (let i = 0; i < pathSegments.length; i++) {
const segment = pathSegments[i];
if (current[segment] && current[segment].children) {
current = current[segment].children;
} else if (i === pathSegments.length - 1 && current[segment]) {
// Last segment can be a file or folder
break;
} else {
// Invalid path
alert('Path not found: ' + pathSegments.slice(0, i + 1).join('/'));
return;
}
}
// Set new path
if (pane === 'left') {
leftPath = pathSegments;
} else {
rightPath = pathSegments;
}
loadContent(pane);
updateBreadcrumb(pane);
}
// Handle item click for selection
function handleItemClick(event, pane, itemName, index) {
event.stopPropagation();
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
if (event.ctrlKey || event.metaKey) {
// Multi-select with Ctrl/Cmd
if (selectedItems.has(itemName)) {
selectedItems.delete(itemName);
removeItemSelection(pane, itemName);
} else {
selectedItems.add(itemName);
addItemSelection(pane, itemName);
}
} else {
// Single select - clear all previous selections first
clearAllSelections(pane);
selectedItems.add(itemName);
addItemSelection(pane, itemName);
}
setFocusedItem(pane, itemName, index);
}
// Set focused item
function setFocusedItem(pane, itemName, index) {
// Clear previous focus
document.querySelectorAll(`[data-pane="${pane}"] .item-focused`).forEach(el => {
el.classList.remove('item-focused');
});
if (pane === 'left') {
leftFocusedItem = { name: itemName, index: index };
} else {
rightFocusedItem = { name: itemName, index: index };
}
// Add focus to new item
const itemElement = document.querySelector(`[data-pane="${pane}"][data-item-name="${itemName}"]`);
if (itemElement) {
itemElement.classList.add('item-focused');
itemElement.focus();
}
}
// Add visual selection to item
function addItemSelection(pane, itemName) {
const itemElement = document.querySelector(`[data-pane="${pane}"][data-item-name="${itemName}"]`);
if (itemElement) {
itemElement.classList.add('item-selected');
}
}
// Remove visual selection from item
function removeItemSelection(pane, itemName) {
const itemElement = document.querySelector(`[data-pane="${pane}"][data-item-name="${itemName}"]`);
if (itemElement) {
itemElement.classList.remove('item-selected');
}
}
// Clear all selections in a pane
function clearAllSelections(pane) {
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
selectedItems.forEach(itemName => {
removeItemSelection(pane, itemName);
});
selectedItems.clear();
}
// Setup keyboard navigation
function setupKeyboardNavigation() {
document.addEventListener('keydown', (event) => {
const activePane = getActivePane();
if (!activePane) return;
const items = getVisibleItems(activePane);
if (items.length === 0) return;
const focusedItem = activePane === 'left' ? leftFocusedItem : rightFocusedItem;
if (!focusedItem) return;
let newIndex = focusedItem.index;
switch (event.key) {
case 'ArrowUp':
event.preventDefault();
if (viewMode === 'list') {
newIndex = Math.max(0, focusedItem.index - 1);
} else {
// Grid view - move up by number of columns
const cols = getGridColumns();
newIndex = Math.max(0, focusedItem.index - cols);
}
break;
case 'ArrowDown':
event.preventDefault();
if (viewMode === 'list') {
newIndex = Math.min(items.length - 1, focusedItem.index + 1);
} else {
// Grid view - move down by number of columns
const cols = getGridColumns();
newIndex = Math.min(items.length - 1, focusedItem.index + cols);
}
break;
case 'ArrowLeft':
if (viewMode === 'grid') {
event.preventDefault();
newIndex = Math.max(0, focusedItem.index - 1);
}
break;
case 'ArrowRight':
if (viewMode === 'grid') {
event.preventDefault();
newIndex = Math.min(items.length - 1, focusedItem.index + 1);
}
break;
case 'Enter':
event.preventDefault();
const currentItem = items[focusedItem.index];
if (currentItem && currentItem.type === 'folder') {
navigateToFolder(activePane, currentItem.name);
}
break;
case ' ':
event.preventDefault();
const selectedItems = activePane === 'left' ? leftSelectedItems : rightSelectedItems;
const currentItemName = items[focusedItem.index].name;
if (selectedItems.has(currentItemName)) {
selectedItems.delete(currentItemName);
removeItemSelection(activePane, currentItemName);
} else {
selectedItems.add(currentItemName);
addItemSelection(activePane, currentItemName);
}
break;
}
if (newIndex !== focusedItem.index && (event.key.startsWith('Arrow') || event.key === 'Enter')) {
const newItem = items[newIndex];
if (newItem) {
setFocusedItem(activePane, newItem.name, newIndex);
// Auto-select on navigation if no items are selected
const selectedItems = activePane === 'left' ? leftSelectedItems : rightSelectedItems;
if (selectedItems.size === 0) {
selectedItems.add(newItem.name);
addItemSelection(activePane, newItem.name);
}
}
}
});
}
// Get currently active pane
function getActivePane() {
const leftPane = document.getElementById('leftPane');
const rightPane = document.getElementById('rightPane');
if (document.activeElement && leftPane.contains(document.activeElement)) {
return 'left';
} else if (document.activeElement && rightPane.contains(document.activeElement)) {
return 'right';
} else {
// Default to left pane
return 'left';
}
}
// Get visible items in current folder
function getVisibleItems(pane) {
const path = pane === 'left' ? leftPath : rightPath;
const currentFolder = getCurrentFolder(path);
if (!currentFolder) return [];
// Sort items: folders first, then photos
const items = Object.entries(currentFolder).sort(([,a], [,b]) => {
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
return 0;
});
return items.map(([name, item], index) => ({ name, type: item.type, index }));
}
// Get number of columns in grid view
function getGridColumns() {
const contentElement = document.getElementById('leftContent');
if (!contentElement) return 4;
const computedStyle = window.getComputedStyle(contentElement);
const gridCols = computedStyle.gridTemplateColumns;
if (gridCols && gridCols !== 'none') {
return gridCols.split(' ').length;
}
// Fallback based on thumbnail size
switch (thumbnailSize) {
case 'small': return 8;
case 'medium': return 6;
case 'large': return 4;
default: return 4;
}
}
// Start drag selection
function startDragSelection(event, pane) {
// Only start drag selection if clicking on empty area (not on an item)
if (event.target.closest('.selectable-item')) {
return;
}
isDragSelecting = true;
dragPane = pane;
const contentArea = document.getElementById(pane + 'ContentArea');
const rect = contentArea.getBoundingClientRect();
dragStartX = event.clientX - rect.left + contentArea.scrollLeft;
dragStartY = event.clientY - rect.top + contentArea.scrollTop;
dragCurrentX = dragStartX;
dragCurrentY = dragStartY;
// Store the initial event for later use
window.dragEvent = event;
// Clear previous selections if not holding Ctrl
if (!event.ctrlKey && !event.metaKey) {
clearAllSelections(pane);
}
event.preventDefault();
}
// Update drag selection
function updateDragSelection(event, pane) {
if (!isDragSelecting || dragPane !== pane) {
return;
}
const contentArea = document.getElementById(pane + 'ContentArea');
const rect = contentArea.getBoundingClientRect();
dragCurrentX = event.clientX - rect.left + contentArea.scrollLeft;
dragCurrentY = event.clientY - rect.top + contentArea.scrollTop;
// Update selection rectangle
updateSelectionRectangle(pane);
// Check which items intersect with selection rectangle
updateItemSelection(pane, event);
}
// End drag selection
function endDragSelection(event, pane) {
if (!isDragSelecting || dragPane !== pane) {
return;
}
// Final selection update to ensure all intersecting items are selected
updateItemSelection(pane, window.dragEvent);
isDragSelecting = false;
dragPane = null;
window.dragEvent = null;
// Hide selection rectangle
const selectionRect = document.getElementById(pane + 'SelectionRect');
selectionRect.style.display = 'none';
}
// Update selection rectangle visual
function updateSelectionRectangle(pane) {
const selectionRect = document.getElementById(pane + 'SelectionRect');
const left = Math.min(dragStartX, dragCurrentX);
const top = Math.min(dragStartY, dragCurrentY);
const width = Math.abs(dragCurrentX - dragStartX);
const height = Math.abs(dragCurrentY - dragStartY);
selectionRect.style.left = left + 'px';
selectionRect.style.top = top + 'px';
selectionRect.style.width = width + 'px';
selectionRect.style.height = height + 'px';
selectionRect.style.display = 'block';
}
// Update item selection based on rectangle intersection
function updateItemSelection(pane, event) {
const contentArea = document.getElementById(pane + 'ContentArea');
const items = contentArea.querySelectorAll('.selectable-item');
const selectionLeft = Math.min(dragStartX, dragCurrentX);
const selectionTop = Math.min(dragStartY, dragCurrentY);
const selectionRight = Math.max(dragStartX, dragCurrentX);
const selectionBottom = Math.max(dragStartY, dragCurrentY);
// Track items that should be selected by drag
const itemsInSelection = new Set();
items.forEach(item => {
const itemRect = item.getBoundingClientRect();
const contentRect = contentArea.getBoundingClientRect();
const itemLeft = itemRect.left - contentRect.left + contentArea.scrollLeft;
const itemTop = itemRect.top - contentRect.top + contentArea.scrollTop;
const itemRight = itemLeft + itemRect.width;
const itemBottom = itemTop + itemRect.height;
// Check if item intersects with selection rectangle
const intersects = !(itemRight < selectionLeft ||
itemLeft > selectionRight ||
itemBottom < selectionTop ||
itemTop > selectionBottom);
const itemName = item.dataset.itemName;
if (intersects) {
itemsInSelection.add(itemName);
}
});
// Update selection based on whether Ctrl is held
const selectedItems = pane === 'left' ? leftSelectedItems : rightSelectedItems;
if (event && (event.ctrlKey || event.metaKey)) {
// Additive selection - only add new items
itemsInSelection.forEach(itemName => {
if (!selectedItems.has(itemName)) {
selectedItems.add(itemName);
addItemSelection(pane, itemName);
}
});
} else {
// Replace selection - clear all and select only intersecting items
clearAllSelections(pane);
itemsInSelection.forEach(itemName => {
selectedItems.add(itemName);
addItemSelection(pane, itemName);
});
}
}
// Add click handler to clear selection when clicking empty area
function handleContentAreaClick(event, pane) {
// Only clear selection if clicking on empty area (not on an item)
if (!event.target.closest('.selectable-item') && !isDragSelecting) {
if (!event.ctrlKey && !event.metaKey) {
clearAllSelections(pane);
}
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
+1079
View File
@@ -0,0 +1,1079 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Argus - 照片管理器</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: '#0078d4',
accent: '#00bcf2',
win: {
bg: '#f3f3f3',
surface: '#ffffff',
border: '#e5e5e5',
text: '#323130',
textSecondary: '#605e5c',
hover: '#f8f8f8',
active: '#e3f2fd'
}
}
}
}
}
</script>
<style>
:root {
--tile-size: 120px;
--tile-gap: 8px;
}
/* Win11 风格 */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
height: 100vh;
overflow: hidden;
}
.win-button {
@apply px-3 py-1.5 text-sm rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-all duration-150;
}
.win-button:hover {
background: rgba(0,0,0,0.03);
}
.win-button.active {
background: #e3f2fd;
border-color: #90caf9;
}
.photo-tile {
width: var(--tile-size);
height: var(--tile-size);
}
.folder-tile {
width: var(--tile-size);
height: calc(var(--tile-size) * 0.85);
}
.grid-tiles {
grid-template-columns: repeat(auto-fill, minmax(var(--tile-size), 1fr));
gap: var(--tile-gap);
}
.no-select {
user-select: none;
}
.duplicate-indicator {
animation: pulse 2s infinite;
}
/* 现代化滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 双栏分割线 */
.pane-splitter {
background: #e5e5e5;
cursor: col-resize;
}
.pane-splitter:hover {
background: #d0d0d0;
}
.pane-splitter.vertical {
cursor: row-resize;
}
/* 现代化按钮组 */
.button-group {
background: #f8f8f8;
border: 1px solid #e5e5e5;
border-radius: 6px;
padding: 2px;
}
.button-group button {
@apply px-2 py-1 text-sm rounded transition-all duration-150;
}
.button-group button.active {
background: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
</style>
</head>
<body class="bg-win-bg text-win-text no-select flex flex-col">
<!-- Win11 风格顶部工具栏 -->
<header class="bg-win-surface border-b border-win-border">
<!-- 第一行:快捷操作栏 -->
<div class="px-4 py-2 flex items-center justify-between">
<!-- 左侧:Win11风格快捷按钮 -->
<div class="flex items-center gap-1">
<!-- 文件操作 -->
<button id="btnCut" class="win-button flex items-center gap-1" title="剪切">
<ion-icon name="cut-outline"></ion-icon>
<span>剪切</span>
</button>
<button id="btnCopy" class="win-button flex items-center gap-1" title="复制">
<ion-icon name="copy-outline"></ion-icon>
<span>复制</span>
</button>
<button id="btnPaste" class="win-button flex items-center gap-1" title="粘贴">
<ion-icon name="clipboard-outline"></ion-icon>
<span>粘贴</span>
</button>
<button id="btnRename" class="win-button flex items-center gap-1" title="重命名">
<ion-icon name="create-outline"></ion-icon>
<span>重命名</span>
</button>
<button id="btnDelete" class="win-button flex items-center gap-1 text-red-600" title="删除">
<ion-icon name="trash-outline"></ion-icon>
<span>删除</span>
</button>
<div class="w-px h-6 bg-win-border mx-2"></div>
<!-- 照片专用操作 -->
<button id="btnTags" class="win-button flex items-center gap-1" title="编辑标签">
<ion-icon name="pricetag-outline"></ion-icon>
<span>编辑标签</span>
</button>
<button id="btnAlbum" class="win-button flex items-center gap-1" title="加入相册">
<ion-icon name="albums-outline"></ion-icon>
<span>加入相册</span>
</button>
<button id="btnBatchEdit" class="win-button flex items-center gap-1" title="批量编辑EXIF">
<ion-icon name="settings-outline"></ion-icon>
<span>批量编辑</span>
</button>
</div>
<!-- 右侧:视图和设置 -->
<div class="flex items-center gap-3">
<!-- 双栏布局控制 -->
<div class="button-group flex">
<button id="layoutSingle" class="active">单栏</button>
<button id="layoutDualH">左右</button>
<button id="layoutDualV">上下</button>
</div>
<!-- 视图切换 -->
<div class="button-group flex">
<button id="viewTiles" class="active" title="平铺视图">
<ion-icon name="grid-outline"></ion-icon>
</button>
<button id="viewList" title="列表视图">
<ion-icon name="list-outline"></ion-icon>
</button>
</div>
<!-- 缩略图大小 -->
<div class="button-group flex">
<button class="size-btn" data-size="80" title="小">
<ion-icon name="square-outline" style="font-size: 12px;"></ion-icon>
</button>
<button class="size-btn active" data-size="120" title="中">
<ion-icon name="square-outline" style="font-size: 16px;"></ion-icon>
</button>
<button class="size-btn" data-size="160" title="大">
<ion-icon name="square-outline" style="font-size: 20px;"></ion-icon>
</button>
<button class="size-btn" data-size="200" title="超大">
<ion-icon name="square-outline" style="font-size: 24px;"></ion-icon>
</button>
</div>
<!-- 信息预览开关 -->
<label class="button-group flex items-center gap-2 px-2">
<input id="showPreviewPane" type="checkbox" class="accent-brand">
<span class="text-sm">预览窗格</span>
</label>
<!-- 更多操作 -->
<div class="flex items-center gap-1">
<button id="btnDuplicates" class="win-button flex items-center gap-1 text-orange-600" title="查看重复项">
<ion-icon name="copy-outline"></ion-icon>
<span>重复项</span>
<span id="duplicateCount" class="px-1.5 py-0.5 bg-orange-100 text-orange-700 rounded text-xs">3</span>
</button>
<button id="btnSearch" class="win-button" title="搜索">
<ion-icon name="search-outline"></ion-icon>
</button>
<button id="btnSettings" class="win-button" title="设置">
<ion-icon name="settings-outline"></ion-icon>
</button>
</div>
</div>
</div>
<!-- 第二行:面包屑导航 -->
<div class="px-4 py-2 bg-win-hover border-t border-win-border">
<div class="flex items-center justify-between">
<!-- 导航按钮 + 面包屑 -->
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<button id="btnBack" class="p-1.5 rounded hover:bg-gray-200 disabled:opacity-50" title="后退">
<ion-icon name="chevron-back-outline"></ion-icon>
</button>
<button id="btnForward" class="p-1.5 rounded hover:bg-gray-200 disabled:opacity-50" title="前进">
<ion-icon name="chevron-forward-outline"></ion-icon>
</button>
<button id="btnUp" class="p-1.5 rounded hover:bg-gray-200 disabled:opacity-50" title="上一级">
<ion-icon name="arrow-up-outline"></ion-icon>
</button>
</div>
<div class="w-px h-5 bg-win-border"></div>
<div id="breadcrumb" class="flex items-center gap-1 text-sm">
<!-- 动态生成面包屑 -->
</div>
</div>
<!-- 搜索框 -->
<div id="searchBox" class="relative hidden">
<ion-icon name="search-outline" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></ion-icon>
<input id="searchInput" placeholder="搜索照片、视频..."
class="pl-10 pr-4 py-2 w-80 border border-win-border rounded focus:border-brand focus:outline-none text-sm"/>
</div>
<!-- 状态信息 -->
<div class="flex items-center gap-4 text-sm text-win-textSecondary">
<span id="selectionInfo">未选择</span>
<span id="totalItems">0 项</span>
</div>
</div>
</div>
</header>
<!-- 主内容区 -->
<main id="mainContent" class="flex-1 overflow-hidden">
<div id="workspaceContainer" class="h-full grid">
<!-- 主工作区 -->
<div id="primaryPane" class="flex flex-col bg-win-surface border border-win-border">
<!-- 内容区 -->
<div class="flex-1 overflow-auto p-3">
<!-- 平铺视图 -->
<div id="tilesView" class="grid grid-tiles gap-2">
<!-- 动态生成瓦片内容 -->
</div>
<!-- 列表视图 -->
<div id="listView" class="hidden">
<!-- 列表表头 -->
<div class="grid grid-cols-12 gap-4 px-3 py-2 text-xs font-medium text-win-textSecondary bg-win-hover border-b border-win-border sticky top-0">
<div class="col-span-5">名称</div>
<div class="col-span-2">修改日期</div>
<div class="col-span-2">类型</div>
<div class="col-span-2">大小</div>
<div class="col-span-1">标签</div>
</div>
<!-- 列表内容 -->
<div id="listContent">
<!-- 动态生成列表项 -->
</div>
</div>
<!-- 空状态 -->
<div id="emptyState" class="hidden flex flex-col items-center justify-center h-full text-center">
<div class="text-gray-300 mb-4">
<ion-icon name="image-outline" style="font-size: 4rem;"></ion-icon>
</div>
<h3 class="text-lg font-medium text-win-text mb-2">这里还没有照片</h3>
<p class="text-win-textSecondary mb-6">导入一些照片开始使用 Argus</p>
<button class="px-4 py-2 bg-brand text-white rounded hover:bg-blue-600 flex items-center gap-2">
<ion-icon name="cloud-upload-outline"></ion-icon>
导入照片
</button>
</div>
</div>
</div>
<!-- 分割线(仅双栏时显示) -->
<div id="splitter" class="pane-splitter w-2 hidden"></div>
<!-- 第二栏(仅双栏时显示) -->
<div id="secondaryPane" class="hidden flex flex-col bg-win-surface border border-win-border">
<div class="flex-1 overflow-auto p-3">
<div id="secondaryContent" class="grid grid-tiles gap-2">
<!-- 第二栏内容 -->
</div>
</div>
</div>
<!-- 预览窗格(可选) -->
<div id="previewPane" class="hidden flex flex-col bg-win-surface border-l border-win-border w-80">
<div class="p-3 border-b border-win-border">
<h3 class="font-medium text-sm">预览</h3>
</div>
<div class="flex-1 overflow-auto p-3">
<div id="previewContent">
<div class="text-center text-win-textSecondary py-8">
<ion-icon name="image-outline" style="font-size: 3rem;" class="block mb-2"></ion-icon>
<p>选择项目查看预览</p>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- 预览模态框 -->
<div id="previewModal" class="fixed inset-0 bg-black/80 flex items-center justify-center hidden z-50">
<div class="relative max-w-[90vw] max-h-[90vh]">
<img id="previewImage" class="max-w-[90vw] max-h-[90vh] object-contain hidden">
<video id="previewVideo" class="max-w-[90vw] max-h-[90vh] hidden" controls></video>
<!-- 预览控制栏 -->
<div class="absolute top-4 right-4 flex items-center gap-2">
<button id="btnFullscreen" class="p-2 rounded-lg bg-black/50 text-white hover:bg-black/70">
<ion-icon name="expand-outline"></ion-icon>
</button>
<button id="btnPreviewClose" class="p-2 rounded-lg bg-black/50 text-white hover:bg-black/70">
<ion-icon name="close-outline"></ion-icon>
</button>
</div>
<!-- 照片信息面板 -->
<div id="photoInfo" class="absolute bottom-4 left-4 bg-black/70 text-white rounded-lg p-4 max-w-sm">
<h4 id="photoName" class="font-medium mb-2">IMG_001.jpg</h4>
<div class="text-sm space-y-1">
<div id="photoSize">2048 × 1536</div>
<div id="photoDate">2024-01-01 12:00:00</div>
<div id="photoFileSize">2.5 MB</div>
<div id="cameraInfo" class="hidden">Canon EOS R5 · f/2.8 · 1/125s · ISO 400</div>
</div>
</div>
</div>
</div>
<script>
// 应用状态
let state = {
currentFolder: null,
history: [],
historyIndex: 0,
selectedItems: new Set(),
layout: 'single', // single, dual-h, dual-v
viewMode: 'tiles', // tiles, list
showPreview: false,
tileSize: 120,
clipboard: null // { action: 'cut'|'copy', items: [] }
};
// 模拟数据生成
const img = (name, w=1600, h=1200) => `https://picsum.photos/seed/${encodeURIComponent(name)}/${w}/${h}`;
let itemId = 1;
const uid = () => (itemId++).toString();
const createFolder = (name, children = []) => ({
id: uid(),
type: 'folder',
name,
children,
path: `/photos/${name}`,
itemCount: children.length,
date: new Date(2024, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1)
});
const createPhoto = (name, isDuplicate = false) => ({
id: uid(),
type: 'image',
name,
src: img(name),
thumbnail: img(name, 400, 400),
size: Math.floor(Math.random() * 5000000) + 500000,
width: 1600,
height: 1200,
date: new Date(2024, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1),
isDuplicate,
tags: isDuplicate ? ['重复'] : [],
exif: {
camera: 'Canon EOS R5',
lens: 'RF 24-70mm f/2.8L',
fNumber: 'f/2.8',
exposureTime: '1/125s',
iso: 'ISO 400'
}
});
const createVideo = (name) => ({
id: uid(),
type: 'video',
name,
thumbnail: img(name, 400, 400),
size: Math.floor(Math.random() * 50000000) + 5000000,
duration: Math.floor(Math.random() * 300) + 30,
date: new Date(2024, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1),
tags: []
});
const createOtherFile = (name, ext, color) => ({
id: uid(),
type: 'other',
name,
ext,
color,
size: Math.floor(Math.random() * 1000000) + 10000,
date: new Date(),
tags: []
});
// 初始化数据
const rootData = createFolder('我的照片', [
createFolder('2024年旅行', [
createFolder('东京之旅', [
createPhoto('东京塔夜景'),
createPhoto('浅草寺'),
createPhoto('涩谷路口'),
createVideo('东京街头录像'),
createPhoto('东京塔夜景', true), // 重复项
]),
createFolder('大阪美食', [
createPhoto('大阪烧'),
createPhoto('章鱼小丸子'),
createVideo('道顿堀夜景')
]),
createPhoto('机场候机'),
createVideo('飞行延时摄影')
]),
createFolder('工作照片', [
createPhoto('会议室'),
createPhoto('办公桌'),
createOtherFile('项目文档', 'docx', '#4285f4'),
createOtherFile('演示文稿', 'pptx', '#ff6d01')
]),
createFolder('RAW文件', [
createPhoto('专业人像01'),
createPhoto('风景大片'),
createOtherFile('lightroom预设', 'lrtemplate', '#31c5f0')
]),
createPhoto('随手拍001'),
createPhoto('随手拍002'),
createPhoto('随手拍001', true), // 重复项
createVideo('生活记录'),
createOtherFile('软件安装包', 'exe', '#666666')
]);
// 初始化状态
state.currentFolder = rootData;
state.history = [rootData];
// DOM工具
const $ = id => document.getElementById(id);
const $$ = selector => document.querySelectorAll(selector);
// 工具函数
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatDate(date) {
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
}
// 获取当前文件夹内容
function getFolderItems(folder) {
if (!folder || !folder.children) return [];
return folder.children;
}
// 渲染瓦片项目
function renderTile(item) {
const tile = document.createElement('div');
tile.className = 'relative group cursor-pointer';
tile.dataset.id = item.id;
tile.dataset.type = item.type;
const isSelected = state.selectedItems.has(item.id);
if (item.type === 'folder') {
tile.innerHTML = `
<div class="folder-tile border border-win-border rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 hover:from-blue-100 hover:to-blue-200 transition-all duration-200 flex flex-col ${isSelected ? 'ring-2 ring-brand' : ''}">
<div class="flex-1 flex items-center justify-center p-4">
<ion-icon name="folder" class="text-3xl text-blue-600"></ion-icon>
</div>
<div class="p-2 bg-white/70 border-t">
<div class="text-xs font-medium truncate" title="${item.name}">${item.name}</div>
<div class="text-xs text-win-textSecondary">${item.itemCount} 项</div>
</div>
</div>
`;
} else if (item.type === 'image') {
tile.innerHTML = `
<div class="photo-tile border border-win-border rounded-lg overflow-hidden bg-white hover:border-brand transition-all duration-200 ${isSelected ? 'ring-2 ring-brand' : ''}">
<div class="relative aspect-square">
<img src="${item.thumbnail}" alt="${item.name}" class="w-full h-full object-cover">
${item.isDuplicate ? '<div class="absolute top-1 right-1 w-2 h-2 bg-orange-500 rounded-full duplicate-indicator" title="重复项"></div>' : ''}
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200"></div>
</div>
<div class="p-1">
<div class="text-xs truncate" title="${item.name}">${item.name}</div>
</div>
</div>
`;
} else if (item.type === 'video') {
tile.innerHTML = `
<div class="photo-tile border border-win-border rounded-lg overflow-hidden bg-white hover:border-brand transition-all duration-200 ${isSelected ? 'ring-2 ring-brand' : ''}">
<div class="relative aspect-square">
<img src="${item.thumbnail}" alt="${item.name}" class="w-full h-full object-cover">
<div class="absolute inset-0 bg-black/20 flex items-center justify-center">
<div class="w-8 h-8 bg-black/60 rounded-full flex items-center justify-center">
<ion-icon name="play" class="text-white text-sm ml-0.5"></ion-icon>
</div>
</div>
<div class="absolute bottom-1 right-1 px-1 py-0.5 bg-black/70 text-white text-xs rounded">
${Math.floor(item.duration / 60)}:${(item.duration % 60).toString().padStart(2, '0')}
</div>
</div>
<div class="p-1">
<div class="text-xs truncate" title="${item.name}">${item.name}</div>
</div>
</div>
`;
} else {
tile.innerHTML = `
<div class="photo-tile border border-win-border rounded-lg overflow-hidden bg-white hover:border-gray-300 transition-all duration-200 opacity-60">
<div class="relative aspect-square flex items-center justify-center" style="background-color: ${item.color}">
<div class="text-white font-bold text-sm">${item.ext.toUpperCase()}</div>
</div>
<div class="p-1">
<div class="text-xs truncate" title="${item.name}">${item.name}</div>
</div>
</div>
`;
}
return tile;
}
// 渲染列表项目
function renderListItem(item) {
const row = document.createElement('div');
row.className = `grid grid-cols-12 gap-4 px-3 py-2 border-b border-win-border hover:bg-win-hover cursor-pointer ${state.selectedItems.has(item.id) ? 'bg-win-active' : ''}`;
row.dataset.id = item.id;
row.dataset.type = item.type;
const iconName = item.type === 'folder' ? 'folder-outline' :
item.type === 'image' ? 'image-outline' :
item.type === 'video' ? 'videocam-outline' : 'document-outline';
row.innerHTML = `
<div class="col-span-5 flex items-center gap-2 min-w-0">
<ion-icon name="${iconName}" class="text-win-textSecondary flex-shrink-0"></ion-icon>
<span class="truncate" title="${item.name}">${item.name}</span>
${item.isDuplicate ? '<span class="w-2 h-2 bg-orange-500 rounded-full flex-shrink-0" title="重复项"></span>' : ''}
</div>
<div class="col-span-2 text-sm text-win-textSecondary">${formatDate(item.date)}</div>
<div class="col-span-2 text-sm text-win-textSecondary">${item.type === 'folder' ? '文件夹' : item.type === 'image' ? '图片' : item.type === 'video' ? '视频' : item.ext?.toUpperCase() || '文件'}</div>
<div class="col-span-2 text-sm text-win-textSecondary">${item.type === 'folder' ? `${item.itemCount} 项` : formatFileSize(item.size)}</div>
<div class="col-span-1 text-sm text-win-textSecondary">${item.tags ? item.tags.join(', ') : ''}</div>
`;
return row;
}
// 渲染面包屑
function renderBreadcrumb() {
const breadcrumb = $('breadcrumb');
breadcrumb.innerHTML = '';
state.history.slice(0, state.historyIndex + 1).forEach((folder, index) => {
if (index > 0) {
const separator = document.createElement('span');
separator.innerHTML = '<ion-icon name="chevron-forward-outline"></ion-icon>';
separator.className = 'text-win-textSecondary text-xs';
breadcrumb.appendChild(separator);
}
const crumb = document.createElement('button');
crumb.textContent = folder.name;
crumb.className = `px-2 py-1 rounded text-sm hover:bg-gray-200 ${index === state.historyIndex ? 'text-win-text font-medium' : 'text-win-textSecondary'}`;
if (index < state.historyIndex) {
crumb.onclick = () => navigateToHistory(index);
}
breadcrumb.appendChild(crumb);
});
}
// 布局管理
function updateLayout() {
const container = $('workspaceContainer');
const splitter = $('splitter');
const secondaryPane = $('secondaryPane');
const previewPane = $('previewPane');
// 重置所有面板
splitter.classList.add('hidden');
secondaryPane.classList.add('hidden');
if (state.showPreview) {
previewPane.classList.remove('hidden');
} else {
previewPane.classList.add('hidden');
}
switch (state.layout) {
case 'single':
container.style.gridTemplateColumns = state.showPreview ? '1fr 320px' : '1fr';
container.style.gridTemplateRows = '1fr';
break;
case 'dual-h':
splitter.classList.remove('hidden');
secondaryPane.classList.remove('hidden');
splitter.classList.remove('vertical');
container.style.gridTemplateColumns = state.showPreview ? '1fr 6px 1fr 320px' : '1fr 6px 1fr';
container.style.gridTemplateRows = '1fr';
break;
case 'dual-v':
splitter.classList.remove('hidden');
secondaryPane.classList.remove('hidden');
splitter.classList.add('vertical');
container.style.gridTemplateColumns = state.showPreview ? '1fr 320px' : '1fr';
container.style.gridTemplateRows = '1fr 6px 1fr';
break;
}
}
// 渲染内容
function renderContent() {
const items = getFolderItems(state.currentFolder);
if (state.viewMode === 'tiles') {
$('tilesView').classList.remove('hidden');
$('listView').classList.add('hidden');
const tilesContainer = $('tilesView');
tilesContainer.innerHTML = '';
if (items.length === 0) {
$('emptyState').classList.remove('hidden');
return;
} else {
$('emptyState').classList.add('hidden');
}
items.forEach(item => {
const tile = renderTile(item);
tilesContainer.appendChild(tile);
});
} else {
$('tilesView').classList.add('hidden');
$('listView').classList.remove('hidden');
const listContainer = $('listContent');
listContainer.innerHTML = '';
if (items.length === 0) {
$('emptyState').classList.remove('hidden');
return;
} else {
$('emptyState').classList.add('hidden');
}
items.forEach(item => {
const row = renderListItem(item);
listContainer.appendChild(row);
});
}
updateStats();
updateNavigationButtons();
}
// 更新统计信息
function updateStats() {
const items = getFolderItems(state.currentFolder);
$('totalItems').textContent = `${items.length} 项`;
const selectedCount = state.selectedItems.size;
if (selectedCount === 0) {
$('selectionInfo').textContent = '未选择';
} else {
$('selectionInfo').textContent = `已选择 ${selectedCount} 项`;
}
// 统计重复项
let duplicateCount = 0;
function countDuplicates(folder) {
if (!folder.children) return;
folder.children.forEach(item => {
if (item.isDuplicate) duplicateCount++;
if (item.type === 'folder') countDuplicates(item);
});
}
countDuplicates(rootData);
$('duplicateCount').textContent = duplicateCount;
}
// 导航功能
function navigateToFolder(folder) {
state.currentFolder = folder;
state.history = state.history.slice(0, state.historyIndex + 1);
state.history.push(folder);
state.historyIndex = state.history.length - 1;
state.selectedItems.clear();
renderBreadcrumb();
renderContent();
}
function navigateToHistory(index) {
state.historyIndex = index;
state.currentFolder = state.history[index];
state.selectedItems.clear();
renderBreadcrumb();
renderContent();
}
function goBack() {
if (state.historyIndex > 0) {
state.historyIndex--;
state.currentFolder = state.history[state.historyIndex];
state.selectedItems.clear();
renderBreadcrumb();
renderContent();
}
}
function goForward() {
if (state.historyIndex < state.history.length - 1) {
state.historyIndex++;
state.currentFolder = state.history[state.historyIndex];
state.selectedItems.clear();
renderBreadcrumb();
renderContent();
}
}
function goUp() {
if (state.historyIndex > 0) {
goBack();
}
}
function updateNavigationButtons() {
$('btnBack').disabled = state.historyIndex === 0;
$('btnForward').disabled = state.historyIndex === state.history.length - 1;
$('btnUp').disabled = state.historyIndex === 0;
}
// 设置缩略图大小
function setTileSize(size) {
state.tileSize = size;
document.documentElement.style.setProperty('--tile-size', size + 'px');
// 更新按钮状态
$$('.size-btn').forEach(btn => btn.classList.remove('active'));
$$(`[data-size="${size}"]`).forEach(btn => btn.classList.add('active'));
}
// 事件处理
function handleItemClick(e) {
const itemElement = e.target.closest('[data-id]');
if (!itemElement) return;
const itemId = itemElement.dataset.id;
const item = findItemById(itemId);
if (!item) return;
if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd + 点击 = 多选
if (state.selectedItems.has(itemId)) {
state.selectedItems.delete(itemId);
} else {
state.selectedItems.add(itemId);
}
renderContent();
} else {
// 普通点击 = 单选
state.selectedItems.clear();
state.selectedItems.add(itemId);
renderContent();
}
}
function handleItemDoubleClick(e) {
const itemElement = e.target.closest('[data-id]');
if (!itemElement) return;
const itemId = itemElement.dataset.id;
const item = findItemById(itemId);
if (!item) return;
if (item.type === 'folder') {
navigateToFolder(item);
} else if (item.type === 'image' || item.type === 'video') {
showPreview(item);
}
}
function findItemById(id) {
function search(folder) {
if (!folder.children) return null;
for (const item of folder.children) {
if (item.id === id) return item;
if (item.type === 'folder') {
const found = search(item);
if (found) return found;
}
}
return null;
}
return search(rootData);
}
// 预览功能
function showPreview(item) {
if (item.type === 'image') {
$('previewImage').src = item.src;
$('previewImage').classList.remove('hidden');
$('previewVideo').classList.add('hidden');
updatePreviewInfo(item);
$('previewModal').classList.remove('hidden');
} else if (item.type === 'video') {
$('previewVideo').poster = item.thumbnail;
$('previewVideo').classList.remove('hidden');
$('previewImage').classList.add('hidden');
updatePreviewInfo(item);
$('previewModal').classList.remove('hidden');
}
}
function updatePreviewInfo(item) {
$('photoName').textContent = item.name;
$('photoSize').textContent = item.type === 'image' ? `${item.width} × ${item.height}` : '视频';
$('photoDate').textContent = formatDate(item.date);
$('photoFileSize').textContent = formatFileSize(item.size);
if (item.exif && item.type === 'image') {
$('cameraInfo').textContent = `${item.exif.camera} · ${item.exif.fNumber} · ${item.exif.exposureTime} · ${item.exif.iso}`;
$('cameraInfo').classList.remove('hidden');
} else {
$('cameraInfo').classList.add('hidden');
}
}
function closePreview() {
$('previewModal').classList.add('hidden');
if ($('previewVideo')) {
$('previewVideo').pause();
}
}
// 初始化应用
function initializeApp() {
// 设置初始状态
setTileSize(state.tileSize);
updateLayout();
renderBreadcrumb();
renderContent();
// 绑定导航事件
$('btnBack').onclick = goBack;
$('btnForward').onclick = goForward;
$('btnUp').onclick = goUp;
// 绑定布局切换事件
$('layoutSingle').onclick = () => {
state.layout = 'single';
$$('[id^="layout"]').forEach(btn => btn.classList.remove('active'));
$('layoutSingle').classList.add('active');
updateLayout();
};
$('layoutDualH').onclick = () => {
state.layout = 'dual-h';
$$('[id^="layout"]').forEach(btn => btn.classList.remove('active'));
$('layoutDualH').classList.add('active');
updateLayout();
};
$('layoutDualV').onclick = () => {
state.layout = 'dual-v';
$$('[id^="layout"]').forEach(btn => btn.classList.remove('active'));
$('layoutDualV').classList.add('active');
updateLayout();
};
// 绑定视图切换事件
$('viewTiles').onclick = () => {
state.viewMode = 'tiles';
$('viewTiles').classList.add('active');
$('viewList').classList.remove('active');
renderContent();
};
$('viewList').onclick = () => {
state.viewMode = 'list';
$('viewList').classList.add('active');
$('viewTiles').classList.remove('active');
renderContent();
};
// 绑定缩略图大小事件
$$('.size-btn').forEach(btn => {
btn.onclick = () => {
const size = parseInt(btn.dataset.size);
setTileSize(size);
};
});
// 绑定预览窗格切换
$('showPreviewPane').onchange = (e) => {
state.showPreview = e.target.checked;
updateLayout();
};
// 绑定搜索功能
$('btnSearch').onclick = () => {
const searchBox = $('searchBox');
if (searchBox.classList.contains('hidden')) {
searchBox.classList.remove('hidden');
$('searchInput').focus();
} else {
searchBox.classList.add('hidden');
}
};
// 绑定内容区点击事件
$('primaryPane').addEventListener('click', handleItemClick);
$('primaryPane').addEventListener('dblclick', handleItemDoubleClick);
// 绑定预览关闭事件
$('btnPreviewClose').onclick = closePreview;
$('previewModal').onclick = (e) => {
if (e.target === $('previewModal')) {
closePreview();
}
};
// 绑定文件操作按钮
$('btnCut').onclick = () => {
if (state.selectedItems.size === 0) return;
const items = Array.from(state.selectedItems).map(id => findItemById(id)).filter(Boolean);
state.clipboard = { action: 'cut', items };
alert('已剪切选中项目');
};
$('btnCopy').onclick = () => {
if (state.selectedItems.size === 0) return;
const items = Array.from(state.selectedItems).map(id => findItemById(id)).filter(Boolean);
state.clipboard = { action: 'copy', items };
alert('已复制选中项目');
};
$('btnPaste').onclick = () => {
if (!state.clipboard) return;
alert(`已${state.clipboard.action === 'cut' ? '移动' : '复制'} ${state.clipboard.items.length} 个项目`);
};
$('btnRename').onclick = () => {
if (state.selectedItems.size !== 1) {
alert('请选择一个项目进行重命名');
return;
}
const item = findItemById(Array.from(state.selectedItems)[0]);
const newName = prompt('重命名为:', item.name);
if (newName && newName !== item.name) {
item.name = newName;
renderContent();
}
};
$('btnDelete').onclick = () => {
if (state.selectedItems.size === 0) return;
if (confirm(`确定要删除选中的 ${state.selectedItems.size} 个项目吗?`)) {
alert('删除功能演示');
state.selectedItems.clear();
renderContent();
}
};
$('btnTags').onclick = () => {
if (state.selectedItems.size === 0) return;
alert('编辑标签功能演示');
};
$('btnAlbum').onclick = () => {
if (state.selectedItems.size === 0) return;
alert('加入相册功能演示');
};
$('btnBatchEdit').onclick = () => {
if (state.selectedItems.size === 0) return;
alert('批量编辑EXIF功能演示');
};
$('btnDuplicates').onclick = () => {
alert('查看重复项功能演示');
};
// 绑定键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea')) return;
if (e.key === 'Escape') {
if (!$('previewModal').classList.contains('hidden')) {
closePreview();
} else {
state.selectedItems.clear();
renderContent();
}
} else if (e.key === 'Backspace') {
e.preventDefault();
goUp();
} else if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
e.preventDefault();
const items = getFolderItems(state.currentFolder);
items.forEach(item => state.selectedItems.add(item.id));
renderContent();
} else if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault();
goBack();
} else if (e.altKey && e.key === 'ArrowRight') {
e.preventDefault();
goForward();
}
});
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', initializeApp);
</script>
</body>
</html>
+1399
View File
@@ -0,0 +1,1399 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Argus - 照片管理器</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: '#0078d4',
accent: '#00bcf2',
win: {
bg: '#f3f3f3',
surface: '#ffffff',
border: '#e5e5e5',
text: '#323130',
textSecondary: '#605e5c',
hover: '#f8f8f8',
active: '#e3f2fd'
}
}
}
}
}
</script>
<style>
:root {
--tile-size: 120px;
--tile-gap: 8px;
}
/* Win11 风格 */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.win-button {
@apply px-3 py-1.5 text-sm rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 transition-all duration-150;
}
.win-button:hover {
background: rgba(0,0,0,0.03);
}
.win-button.active {
background: #e3f2fd;
border-color: #90caf9;
}
.photo-tile {
width: var(--tile-size);
height: var(--tile-size);
}
.folder-tile {
width: var(--tile-size);
height: calc(var(--tile-size) * 0.85);
}
.grid-tiles {
grid-template-columns: repeat(auto-fill, minmax(var(--tile-size), 1fr));
gap: var(--tile-gap);
}
.no-select {
user-select: none;
}
.duplicate-indicator {
animation: pulse 2s infinite;
}
/* 现代化滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 双栏分割线 */
.pane-splitter {
background: #e5e5e5;
cursor: col-resize;
}
.pane-splitter:hover {
background: #d0d0d0;
}
.pane-splitter.vertical {
cursor: row-resize;
}
/* 现代化按钮组 */
.button-group {
background: #f8f8f8;
border: 1px solid #e5e5e5;
border-radius: 6px;
padding: 2px;
}
.button-group button {
@apply px-2 py-1 text-sm rounded transition-all duration-150;
}
.button-group button.active {
background: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
</style>
</head>
<body class="bg-win-bg text-win-text no-select">
<!-- Win11 风格顶部工具栏 -->
<header class="bg-win-surface border-b border-win-border">
<!-- 第一行:快捷操作栏 -->
<div class="px-4 py-2 flex items-center justify-between">
<!-- 左侧:Win11风格快捷按钮 -->
<div class="flex items-center gap-1">
<!-- 文件操作 -->
<button id="btnCut" class="win-button flex items-center gap-1" title="剪切">
<ion-icon name="cut-outline"></ion-icon>
<span>剪切</span>
</button>
<button id="btnCopy" class="win-button flex items-center gap-1" title="复制">
<ion-icon name="copy-outline"></ion-icon>
<span>复制</span>
</button>
<button id="btnPaste" class="win-button flex items-center gap-1" title="粘贴">
<ion-icon name="clipboard-outline"></ion-icon>
<span>粘贴</span>
</button>
<button id="btnRename" class="win-button flex items-center gap-1" title="重命名">
<ion-icon name="create-outline"></ion-icon>
<span>重命名</span>
</button>
<button id="btnDelete" class="win-button flex items-center gap-1 text-red-600" title="删除">
<ion-icon name="trash-outline"></ion-icon>
<span>删除</span>
</button>
<div class="w-px h-6 bg-win-border mx-2"></div>
<!-- 照片专用操作 -->
<button id="btnTags" class="win-button flex items-center gap-1" title="编辑标签">
<ion-icon name="pricetag-outline"></ion-icon>
<span>编辑标签</span>
</button>
<button id="btnAlbum" class="win-button flex items-center gap-1" title="加入相册">
<ion-icon name="albums-outline"></ion-icon>
<span>加入相册</span>
</button>
<button id="btnBatchEdit" class="win-button flex items-center gap-1" title="批量编辑EXIF">
<ion-icon name="settings-outline"></ion-icon>
<span>批量编辑</span>
</button>
</div>
<!-- 右侧:视图和设置 -->
<div class="flex items-center gap-3">
<!-- 双栏布局控制 -->
<div class="button-group flex">
<button id="layoutSingle" class="active">单栏</button>
<button id="layoutDualH">左右</button>
<button id="layoutDualV">上下</button>
</div>
<!-- 视图切换 -->
<div class="button-group flex">
<button id="viewTiles" class="active" title="平铺视图">
<ion-icon name="grid-outline"></ion-icon>
</button>
<button id="viewList" title="列表视图">
<ion-icon name="list-outline"></ion-icon>
</button>
</div>
<!-- 缩略图大小 -->
<div class="button-group flex">
<button class="size-btn" data-size="80" title="小">
<ion-icon name="square-outline" style="font-size: 12px;"></ion-icon>
</button>
<button class="size-btn active" data-size="120" title="中">
<ion-icon name="square-outline" style="font-size: 16px;"></ion-icon>
</button>
<button class="size-btn" data-size="160" title="大">
<ion-icon name="square-outline" style="font-size: 20px;"></ion-icon>
</button>
<button class="size-btn" data-size="200" title="超大">
<ion-icon name="square-outline" style="font-size: 24px;"></ion-icon>
</button>
</div>
<!-- 信息预览开关 -->
<label class="button-group flex items-center gap-2 px-2">
<input id="showPreviewPane" type="checkbox" class="accent-brand">
<span class="text-sm">预览窗格</span>
</label>
<!-- 更多操作 -->
<div class="flex items-center gap-1">
<button id="btnDuplicates" class="win-button flex items-center gap-1 text-orange-600" title="查看重复项">
<ion-icon name="copy-outline"></ion-icon>
<span>重复项</span>
<span id="duplicateCount" class="px-1.5 py-0.5 bg-orange-100 text-orange-700 rounded text-xs">3</span>
</button>
<button id="btnSearch" class="win-button" title="搜索">
<ion-icon name="search-outline"></ion-icon>
</button>
<button id="btnSettings" class="win-button" title="设置">
<ion-icon name="settings-outline"></ion-icon>
</button>
</div>
</div>
</div>
<!-- 第二行:面包屑导航 -->
<div class="px-4 py-2 bg-win-hover border-t border-win-border">
<div class="flex items-center justify-between">
<!-- 导航按钮 + 面包屑 -->
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<button id="btnBack" class="p-1.5 rounded hover:bg-gray-200 disabled:opacity-50" title="后退">
<ion-icon name="chevron-back-outline"></ion-icon>
</button>
<button id="btnForward" class="p-1.5 rounded hover:bg-gray-200 disabled:opacity-50" title="前进">
<ion-icon name="chevron-forward-outline"></ion-icon>
</button>
<button id="btnUp" class="p-1.5 rounded hover:bg-gray-200 disabled:opacity-50" title="上一级">
<ion-icon name="arrow-up-outline"></ion-icon>
</button>
</div>
<div class="w-px h-5 bg-win-border"></div>
<div id="breadcrumb" class="flex items-center gap-1 text-sm">
<!-- 动态生成面包屑 -->
</div>
</div>
<!-- 搜索框 -->
<div id="searchBox" class="relative hidden">
<ion-icon name="search-outline" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></ion-icon>
<input id="searchInput" placeholder="搜索照片、视频..."
class="pl-10 pr-4 py-2 w-80 border border-win-border rounded focus:border-brand focus:outline-none text-sm"/>
</div>
<!-- 状态信息 -->
<div class="flex items-center gap-4 text-sm text-win-textSecondary">
<span id="selectionInfo">未选择</span>
<span id="totalItems">0 项</span>
</div>
</div>
</div>
</header>
<!-- 主内容区 -->
<main id="mainContent" class="flex-1 overflow-hidden">
<div id="workspaceContainer" class="h-full grid">
<!-- 主工作区 -->
<div id="primaryPane" class="flex flex-col bg-win-surface border border-win-border">
<!-- 内容区 -->
<div class="flex-1 overflow-auto p-3">
<!-- 平铺视图 -->
<div id="tilesView" class="grid grid-tiles gap-2">
<!-- 动态生成瓦片内容 -->
</div>
<!-- 列表视图 -->
<div id="listView" class="hidden">
<!-- 列表表头 -->
<div class="grid grid-cols-12 gap-4 px-3 py-2 text-xs font-medium text-win-textSecondary bg-win-hover border-b border-win-border sticky top-0">
<div class="col-span-5">名称</div>
<div class="col-span-2">修改日期</div>
<div class="col-span-2">类型</div>
<div class="col-span-2">大小</div>
<div class="col-span-1">标签</div>
</div>
<!-- 列表内容 -->
<div id="listContent">
<!-- 动态生成列表项 -->
</div>
</div>
<!-- 空状态 -->
<div id="emptyState" class="hidden flex flex-col items-center justify-center h-full text-center">
<div class="text-gray-300 mb-4">
<ion-icon name="image-outline" style="font-size: 4rem;"></ion-icon>
</div>
<h3 class="text-lg font-medium text-win-text mb-2">这里还没有照片</h3>
<p class="text-win-textSecondary mb-6">导入一些照片开始使用 Argus</p>
<button class="px-4 py-2 bg-brand text-white rounded hover:bg-blue-600 flex items-center gap-2">
<ion-icon name="cloud-upload-outline"></ion-icon>
导入照片
</button>
</div>
</div>
</div>
<!-- 分割线(仅双栏时显示) -->
<div id="splitter" class="pane-splitter w-2 hidden"></div>
<!-- 第二栏(仅双栏时显示) -->
<div id="secondaryPane" class="hidden flex flex-col bg-win-surface border border-win-border">
<div class="flex-1 overflow-auto p-3">
<div id="secondaryContent" class="grid grid-tiles gap-2">
<!-- 第二栏内容 -->
</div>
</div>
</div>
<!-- 预览窗格(可选) -->
<div id="previewPane" class="hidden flex flex-col bg-win-surface border-l border-win-border w-80">
<div class="p-3 border-b border-win-border">
<h3 class="font-medium text-sm">预览</h3>
</div>
<div class="flex-1 overflow-auto p-3">
<div id="previewContent">
<div class="text-center text-win-textSecondary py-8">
<ion-icon name="image-outline" style="font-size: 3rem;" class="block mb-2"></ion-icon>
<p>选择项目查看预览</p>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- 预览模态框 -->
<div id="previewModal" class="fixed inset-0 bg-black/80 flex items-center justify-center hidden z-50">
<div class="relative max-w-[90vw] max-h-[90vh]">
<img id="previewImage" class="max-w-[90vw] max-h-[90vh] object-contain hidden">
<video id="previewVideo" class="max-w-[90vw] max-h-[90vh] hidden" controls></video>
<!-- 预览控制栏 -->
<div class="absolute top-4 right-4 flex items-center gap-2">
<button id="btnFullscreen" class="p-2 rounded-lg bg-black/50 text-white hover:bg-black/70">
<ion-icon name="expand-outline"></ion-icon>
</button>
<button id="btnPreviewClose" class="p-2 rounded-lg bg-black/50 text-white hover:bg-black/70">
<ion-icon name="close-outline"></ion-icon>
</button>
</div>
<!-- 照片信息面板 -->
<div id="photoInfo" class="absolute bottom-4 left-4 bg-black/70 text-white rounded-lg p-4 max-w-sm">
<h4 id="photoName" class="font-medium mb-2">IMG_001.jpg</h4>
<div class="text-sm space-y-1">
<div id="photoSize">2048 × 1536</div>
<div id="photoDate">2024-01-01 12:00:00</div>
<div id="photoFileSize">2.5 MB</div>
<div id="cameraInfo" class="hidden">Canon EOS R5 · f/2.8 · 1/125s · ISO 400</div>
</div>
</div>
</div>
</div>
<script>
// 应用状态
let state = {
currentFolder: null,
history: [],
historyIndex: 0,
selectedItems: new Set(),
layout: 'single', // single, dual-h, dual-v
viewMode: 'tiles', // tiles, list
showPreview: false,
tileSize: 120,
clipboard: null, // { action: 'cut'|'copy', items: [] }
panes: {
primary: null,
secondary: null
}
};
// 模拟数据生成
const img = (name, w=1600, h=1200) => `https://picsum.photos/seed/${encodeURIComponent(name)}/${w}/${h}`;
let itemId = 1;
const uid = () => (itemId++).toString();
const createFolder = (name, children = []) => ({
id: uid(),
type: 'folder',
name,
children,
path: `/photos/${name}`,
itemCount: children.length,
date: new Date(2024, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1)
});
const createPhoto = (name, isDuplicate = false) => ({
id: uid(),
type: 'image',
name,
src: img(name),
thumbnail: img(name, 400, 400),
size: Math.floor(Math.random() * 5000000) + 500000,
width: 1600,
height: 1200,
date: new Date(2024, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1),
isDuplicate,
tags: isDuplicate ? ['重复'] : [],
exif: {
camera: 'Canon EOS R5',
lens: 'RF 24-70mm f/2.8L',
fNumber: 'f/2.8',
exposureTime: '1/125s',
iso: 'ISO 400'
}
});
const createVideo = (name) => ({
id: uid(),
type: 'video',
name,
thumbnail: img(name, 400, 400),
size: Math.floor(Math.random() * 50000000) + 5000000,
duration: Math.floor(Math.random() * 300) + 30,
date: new Date(2024, Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1),
tags: []
});
const createOtherFile = (name, ext, color) => ({
id: uid(),
type: 'other',
name,
ext,
color,
size: Math.floor(Math.random() * 1000000) + 10000,
date: new Date(),
tags: []
});
// 初始化数据
const rootData = createFolder('我的照片', [
createFolder('2024年旅行', [
createFolder('东京之旅', [
createPhoto('东京塔夜景'),
createPhoto('浅草寺'),
createPhoto('涩谷路口'),
createVideo('东京街头录像'),
createPhoto('东京塔夜景', true), // 重复项
]),
createFolder('大阪美食', [
createPhoto('大阪烧'),
createPhoto('章鱼小丸子'),
createVideo('道顿堀夜景')
]),
createPhoto('机场候机'),
createVideo('飞行延时摄影')
]),
createFolder('工作照片', [
createPhoto('会议室'),
createPhoto('办公桌'),
createOtherFile('项目文档', 'docx', '#4285f4'),
createOtherFile('演示文稿', 'pptx', '#ff6d01')
]),
createFolder('RAW文件', [
createPhoto('专业人像01'),
createPhoto('风景大片'),
createOtherFile('lightroom预设', 'lrtemplate', '#31c5f0')
]),
createPhoto('随手拍001'),
createPhoto('随手拍002'),
createPhoto('随手拍001', true), // 重复项
createVideo('生活记录'),
createOtherFile('软件安装包', 'exe', '#666666')
]);
// 初始化状态
state.currentFolder = rootData;
state.history = [rootData];
state.panes.primary = rootData;
// DOM工具
const $ = id => document.getElementById(id);
const $$ = selector => document.querySelectorAll(selector);
// 工具函数
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatDate(date) {
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
}
// 获取当前文件夹内容
function getFolderItems(folder) {
if (!folder || !folder.children) return [];
return folder.children;
}
// 渲染瓦片项目
function renderTile(item) {
const tile = document.createElement('div');
tile.className = 'relative group cursor-pointer';
tile.dataset.id = item.id;
tile.dataset.type = item.type;
const isSelected = state.selectedItems.has(item.id);
if (item.type === 'folder') {
tile.innerHTML = `
<div class="folder-tile border border-win-border rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 hover:from-blue-100 hover:to-blue-200 transition-all duration-200 flex flex-col ${isSelected ? 'ring-2 ring-brand' : ''}">
<div class="flex-1 flex items-center justify-center p-4">
<ion-icon name="folder" class="text-3xl text-blue-600"></ion-icon>
</div>
<div class="p-2 bg-white/70 border-t">
<div class="text-xs font-medium truncate" title="${item.name}">${item.name}</div>
<div class="text-xs text-win-textSecondary">${item.itemCount} 项</div>
</div>
</div>
`;
} else if (item.type === 'image') {
tile.innerHTML = `
<div class="photo-tile border border-win-border rounded-lg overflow-hidden bg-white hover:border-brand transition-all duration-200 ${isSelected ? 'ring-2 ring-brand' : ''}">
<div class="relative aspect-square">
<img src="${item.thumbnail}" alt="${item.name}" class="w-full h-full object-cover">
${item.isDuplicate ? '<div class="absolute top-1 right-1 w-2 h-2 bg-orange-500 rounded-full duplicate-indicator" title="重复项"></div>' : ''}
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200"></div>
</div>
<div class="p-1">
<div class="text-xs truncate" title="${item.name}">${item.name}</div>
</div>
</div>
`;
} else if (item.type === 'video') {
tile.innerHTML = `
<div class="photo-tile border border-win-border rounded-lg overflow-hidden bg-white hover:border-brand transition-all duration-200 ${isSelected ? 'ring-2 ring-brand' : ''}">
<div class="relative aspect-square">
<img src="${item.thumbnail}" alt="${item.name}" class="w-full h-full object-cover">
<div class="absolute inset-0 bg-black/20 flex items-center justify-center">
<div class="w-8 h-8 bg-black/60 rounded-full flex items-center justify-center">
<ion-icon name="play" class="text-white text-sm ml-0.5"></ion-icon>
</div>
</div>
<div class="absolute bottom-1 right-1 px-1 py-0.5 bg-black/70 text-white text-xs rounded">
${Math.floor(item.duration / 60)}:${(item.duration % 60).toString().padStart(2, '0')}
</div>
</div>
<div class="p-1">
<div class="text-xs truncate" title="${item.name}">${item.name}</div>
</div>
</div>
`;
} else {
tile.innerHTML = `
<div class="photo-tile border border-win-border rounded-lg overflow-hidden bg-white hover:border-gray-300 transition-all duration-200 opacity-60">
<div class="relative aspect-square flex items-center justify-center" style="background-color: ${item.color}">
<div class="text-white font-bold text-sm">${item.ext.toUpperCase()}</div>
</div>
<div class="p-1">
<div class="text-xs truncate" title="${item.name}">${item.name}</div>
</div>
</div>
`;
}
return tile;
}
// 渲染列表项目
function renderListItem(item) {
const row = document.createElement('div');
row.className = `grid grid-cols-12 gap-4 px-3 py-2 border-b border-win-border hover:bg-win-hover cursor-pointer ${state.selectedItems.has(item.id) ? 'bg-win-active' : ''}`;
row.dataset.id = item.id;
row.dataset.type = item.type;
const iconName = item.type === 'folder' ? 'folder-outline' :
item.type === 'image' ? 'image-outline' :
item.type === 'video' ? 'videocam-outline' : 'document-outline';
row.innerHTML = `
<div class="col-span-5 flex items-center gap-2 min-w-0">
<ion-icon name="${iconName}" class="text-win-textSecondary flex-shrink-0"></ion-icon>
<span class="truncate" title="${item.name}">${item.name}</span>
${item.isDuplicate ? '<span class="w-2 h-2 bg-orange-500 rounded-full flex-shrink-0" title="重复项"></span>' : ''}
</div>
<div class="col-span-2 text-sm text-win-textSecondary">${formatDate(item.date)}</div>
<div class="col-span-2 text-sm text-win-textSecondary">${item.type === 'folder' ? '文件夹' : item.type === 'image' ? '图片' : item.type === 'video' ? '视频' : item.ext?.toUpperCase() || '文件'}</div>
<div class="col-span-2 text-sm text-win-textSecondary">${item.type === 'folder' ? `${item.itemCount} 项` : formatFileSize(item.size)}</div>
<div class="col-span-1 text-sm text-win-textSecondary">${item.tags ? item.tags.join(', ') : ''}</div>
`;
return row;
}
// 渲染面包屑
function renderBreadcrumb() {
const breadcrumb = $('breadcrumb');
breadcrumb.innerHTML = '';
state.history.slice(0, state.historyIndex + 1).forEach((folder, index) => {
if (index > 0) {
const separator = document.createElement('span');
separator.innerHTML = '<ion-icon name="chevron-forward-outline"></ion-icon>';
separator.className = 'text-win-textSecondary text-xs';
breadcrumb.appendChild(separator);
}
const crumb = document.createElement('button');
crumb.textContent = folder.name;
crumb.className = `px-2 py-1 rounded text-sm hover:bg-gray-200 ${index === state.historyIndex ? 'text-win-text font-medium' : 'text-win-textSecondary'}`;
if (index < state.historyIndex) {
crumb.onclick = () => navigateToHistory(index);
}
breadcrumb.appendChild(crumb);
});
}
// 布局管理
function updateLayout() {
const container = $('workspaceContainer');
const splitter = $('splitter');
const secondaryPane = $('secondaryPane');
const previewPane = $('previewPane');
// 重置所有面板
splitter.classList.add('hidden');
secondaryPane.classList.add('hidden');
if (state.showPreview) {
previewPane.classList.remove('hidden');
} else {
previewPane.classList.add('hidden');
}
switch (state.layout) {
case 'single':
container.style.gridTemplateColumns = state.showPreview ? '1fr 320px' : '1fr';
container.style.gridTemplateRows = '1fr';
break;
case 'dual-h':
splitter.classList.remove('hidden');
secondaryPane.classList.remove('hidden');
splitter.classList.remove('vertical');
container.style.gridTemplateColumns = state.showPreview ? '1fr 6px 1fr 320px' : '1fr 6px 1fr';
container.style.gridTemplateRows = '1fr';
break;
case 'dual-v':
splitter.classList.remove('hidden');
secondaryPane.classList.remove('hidden');
splitter.classList.add('vertical');
container.style.gridTemplateColumns = state.showPreview ? '1fr 320px' : '1fr';
container.style.gridTemplateRows = '1fr 6px 1fr';
break;
}
}
// 渲染内容
function renderContent() {
const items = getFolderItems(state.currentFolder);
if (state.viewMode === 'tiles') {
$('tilesView').classList.remove('hidden');
$('listView').classList.add('hidden');
const tilesContainer = $('tilesView');
tilesContainer.innerHTML = '';
if (items.length === 0) {
$('emptyState').classList.remove('hidden');
return;
} else {
$('emptyState').classList.add('hidden');
}
items.forEach(item => {
const tile = renderTile(item);
tilesContainer.appendChild(tile);
});
} else {
$('tilesView').classList.add('hidden');
$('listView').classList.remove('hidden');
const listContainer = $('listContent');
listContainer.innerHTML = '';
if (items.length === 0) {
$('emptyState').classList.remove('hidden');
return;
} else {
$('emptyState').classList.add('hidden');
}
items.forEach(item => {
const row = renderListItem(item);
listContainer.appendChild(row);
});
}
updateStats();
updateNavigationButtons();
}
// 更新统计信息
function updateStats() {
const items = getFolderItems(state.currentFolder);
$('totalItems').textContent = `${items.length} 项`;
const selectedCount = state.selectedItems.size;
if (selectedCount === 0) {
$('selectionInfo').textContent = '未选择';
} else {
$('selectionInfo').textContent = `已选择 ${selectedCount} 项`;
}
// 统计重复项
let duplicateCount = 0;
function countDuplicates(folder) {
if (!folder.children) return;
folder.children.forEach(item => {
if (item.isDuplicate) duplicateCount++;
if (item.type === 'folder') countDuplicates(item);
});
}
countDuplicates(rootData);
$('duplicateCount').textContent = duplicateCount;
}
// 导航功能
function navigateToFolder(folder) {
state.currentFolder = folder;
state.history = state.history.slice(0, state.historyIndex + 1);
state.history.push(folder);
state.historyIndex = state.history.length - 1;
state.selectedItems.clear();
renderBreadcrumb();
renderContent();
}
function navigateToHistory(index) {
state.historyIndex = index;
state.currentFolder = state.history[index];
state.selectedItems.clear();
renderBreadcrumb();
renderContent();
}
function goBack() {
if (state.historyIndex > 0) {
state.historyIndex--;
state.currentFolder = state.history[state.historyIndex];
state.selectedItems.clear();
renderBreadcrumb();
renderContent();
}
}
function goForward() {
if (state.historyIndex < state.history.length - 1) {
state.historyIndex++;
state.currentFolder = state.history[state.historyIndex];
state.selectedItems.clear();
renderBreadcrumb();
renderContent();
}
}
function goUp() {
if (state.historyIndex > 0) {
goBack();
}
}
function updateNavigationButtons() {
$('btnBack').disabled = state.historyIndex === 0;
$('btnForward').disabled = state.historyIndex === state.history.length - 1;
$('btnUp').disabled = state.historyIndex === 0;
}
// 设置缩略图大小
function setTileSize(size) {
state.tileSize = size;
document.documentElement.style.setProperty('--tile-size', size + 'px');
// 更新按钮状态
$$('.size-btn').forEach(btn => btn.classList.remove('active'));
$$(`[data-size="${size}"]`).forEach(btn => btn.classList.add('active'));
}
// 事件处理
function handleItemClick(e) {
const itemElement = e.target.closest('[data-id]');
if (!itemElement) return;
const itemId = itemElement.dataset.id;
const item = findItemById(itemId);
if (!item) return;
if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd + 点击 = 多选
if (state.selectedItems.has(itemId)) {
state.selectedItems.delete(itemId);
} else {
state.selectedItems.add(itemId);
}
renderContent();
} else {
// 普通点击 = 单选
state.selectedItems.clear();
state.selectedItems.add(itemId);
renderContent();
}
}
function handleItemDoubleClick(e) {
const itemElement = e.target.closest('[data-id]');
if (!itemElement) return;
const itemId = itemElement.dataset.id;
const item = findItemById(itemId);
if (!item) return;
if (item.type === 'folder') {
navigateToFolder(item);
} else if (item.type === 'image' || item.type === 'video') {
showPreview(item);
}
}
function findItemById(id) {
function search(folder) {
if (!folder.children) return null;
for (const item of folder.children) {
if (item.id === id) return item;
if (item.type === 'folder') {
const found = search(item);
if (found) return found;
}
}
return null;
}
return search(rootData);
}
// 预览功能
function showPreview(item) {
if (item.type === 'image') {
$('previewImage').src = item.src;
$('previewImage').classList.remove('hidden');
$('previewVideo').classList.add('hidden');
updatePreviewInfo(item);
$('previewModal').classList.remove('hidden');
} else if (item.type === 'video') {
$('previewVideo').poster = item.thumbnail;
$('previewVideo').classList.remove('hidden');
$('previewImage').classList.add('hidden');
updatePreviewInfo(item);
$('previewModal').classList.remove('hidden');
}
}
function updatePreviewInfo(item) {
$('photoName').textContent = item.name;
$('photoSize').textContent = item.type === 'image' ? `${item.width} × ${item.height}` : '视频';
$('photoDate').textContent = formatDate(item.date);
$('photoFileSize').textContent = formatFileSize(item.size);
if (item.exif && item.type === 'image') {
$('cameraInfo').textContent = `${item.exif.camera} · ${item.exif.fNumber} · ${item.exif.exposureTime} · ${item.exif.iso}`;
$('cameraInfo').classList.remove('hidden');
} else {
$('cameraInfo').classList.add('hidden');
}
}
function closePreview() {
$('previewModal').classList.add('hidden');
if ($('previewVideo')) {
$('previewVideo').pause();
}
}
// 初始化应用
function initializeApp() {
// 设置初始状态
setTileSize(state.tileSize);
updateLayout();
renderBreadcrumb();
renderContent();
// 绑定导航事件
$('btnBack').onclick = goBack;
$('btnForward').onclick = goForward;
$('btnUp').onclick = goUp;
// 绑定布局切换事件
$('layoutSingle').onclick = () => {
state.layout = 'single';
$$('[id^="layout"]').forEach(btn => btn.classList.remove('active'));
$('layoutSingle').classList.add('active');
updateLayout();
};
$('layoutDualH').onclick = () => {
state.layout = 'dual-h';
$$('[id^="layout"]').forEach(btn => btn.classList.remove('active'));
$('layoutDualH').classList.add('active');
updateLayout();
};
$('layoutDualV').onclick = () => {
state.layout = 'dual-v';
$$('[id^="layout"]').forEach(btn => btn.classList.remove('active'));
$('layoutDualV').classList.add('active');
updateLayout();
};
// 绑定视图切换事件
$('viewTiles').onclick = () => {
state.viewMode = 'tiles';
$('viewTiles').classList.add('active');
$('viewList').classList.remove('active');
renderContent();
};
$('viewList').onclick = () => {
state.viewMode = 'list';
$('viewList').classList.add('active');
$('viewTiles').classList.remove('active');
renderContent();
};
// 绑定缩略图大小事件
$$('.size-btn').forEach(btn => {
btn.onclick = () => {
const size = parseInt(btn.dataset.size);
setTileSize(size);
};
});
// 绑定预览窗格切换
$('showPreviewPane').onchange = (e) => {
state.showPreview = e.target.checked;
updateLayout();
};
// 绑定搜索功能
$('btnSearch').onclick = () => {
const searchBox = $('searchBox');
if (searchBox.classList.contains('hidden')) {
searchBox.classList.remove('hidden');
$('searchInput').focus();
} else {
searchBox.classList.add('hidden');
}
};
// 绑定内容区点击事件
$('primaryPane').addEventListener('click', handleItemClick);
$('primaryPane').addEventListener('dblclick', handleItemDoubleClick);
// 绑定预览关闭事件
$('btnPreviewClose').onclick = closePreview;
$('previewModal').onclick = (e) => {
if (e.target === $('previewModal')) {
closePreview();
}
};
// 绑定文件操作按钮
$('btnCut').onclick = () => {
if (state.selectedItems.size === 0) return;
const items = Array.from(state.selectedItems).map(id => findItemById(id)).filter(Boolean);
state.clipboard = { action: 'cut', items };
alert('已剪切选中项目');
};
$('btnCopy').onclick = () => {
if (state.selectedItems.size === 0) return;
const items = Array.from(state.selectedItems).map(id => findItemById(id)).filter(Boolean);
state.clipboard = { action: 'copy', items };
alert('已复制选中项目');
};
$('btnPaste').onclick = () => {
if (!state.clipboard) return;
alert(`已${state.clipboard.action === 'cut' ? '移动' : '复制'} ${state.clipboard.items.length} 个项目`);
};
$('btnRename').onclick = () => {
if (state.selectedItems.size !== 1) {
alert('请选择一个项目进行重命名');
return;
}
const item = findItemById(Array.from(state.selectedItems)[0]);
const newName = prompt('重命名为:', item.name);
if (newName && newName !== item.name) {
item.name = newName;
renderContent();
}
};
$('btnDelete').onclick = () => {
if (state.selectedItems.size === 0) return;
if (confirm(`确定要删除选中的 ${state.selectedItems.size} 个项目吗?`)) {
alert('删除功能演示');
state.selectedItems.clear();
renderContent();
}
};
$('btnTags').onclick = () => {
if (state.selectedItems.size === 0) return;
alert('编辑标签功能演示');
};
$('btnAlbum').onclick = () => {
if (state.selectedItems.size === 0) return;
alert('加入相册功能演示');
};
$('btnBatchEdit').onclick = () => {
if (state.selectedItems.size === 0) return;
alert('批量编辑EXIF功能演示');
};
$('btnDuplicates').onclick = () => {
alert('查看重复项功能演示');
};
// 绑定键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea')) return;
if (e.key === 'Escape') {
if (!$('previewModal').classList.contains('hidden')) {
closePreview();
} else {
state.selectedItems.clear();
renderContent();
}
} else if (e.key === 'Backspace') {
e.preventDefault();
goUp();
} else if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
e.preventDefault();
const items = getFolderItems(state.currentFolder);
items.forEach(item => state.selectedItems.add(item.id));
renderContent();
} else if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault();
goBack();
} else if (e.altKey && e.key === 'ArrowRight') {
e.preventDefault();
goForward();
}
});
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', initializeApp);
</script>
</body>
</html>
${item.isDuplicate ? '<div class="absolute top-2 right-2 w-3 h-3 bg-orange-500 rounded-full duplicate-indicator" title="重复项"></div>' : ''}
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200"></div>
<!-- 选择框 -->
<div class="absolute top-2 left-2 w-5 h-5 border-2 border-white rounded ${selectedItems.has(item.id) ? 'bg-brand border-brand' : 'bg-black/20'} flex items-center justify-center">
${selectedItems.has(item.id) ? '<ion-icon name="checkmark" class="text-white text-xs"></ion-icon>' : ''}
</div>
</div>
<div class="p-2">
<div class="text-xs font-medium truncate" title="${item.name}">${item.name}</div>
</div>
</div>
`;
} else if (item.type === 'video') {
tile.className += ' photo-tile';
tile.innerHTML = `
<div class="photo-tile border-2 border-slate-200 rounded-xl overflow-hidden bg-white hover:border-brand transition-all duration-200 ${selectedItems.has(item.id) ? 'ring-2 ring-brand border-brand' : ''}">
<div class="relative aspect-square">
<img src="${item.thumbnail}" alt="${item.name}" class="w-full h-full object-cover">
<div class="absolute inset-0 bg-black/20 flex items-center justify-center">
<div class="w-12 h-12 bg-black/60 rounded-full flex items-center justify-center">
<ion-icon name="play" class="text-white text-xl ml-1"></ion-icon>
</div>
</div>
<div class="absolute bottom-2 right-2 px-1.5 py-0.5 bg-black/70 text-white text-xs rounded">
${Math.floor(item.duration / 60)}:${(item.duration % 60).toString().padStart(2, '0')}
</div>
<!-- 选择框 -->
<div class="absolute top-2 left-2 w-5 h-5 border-2 border-white rounded ${selectedItems.has(item.id) ? 'bg-brand border-brand' : 'bg-black/20'} flex items-center justify-center">
${selectedItems.has(item.id) ? '<ion-icon name="checkmark" class="text-white text-xs"></ion-icon>' : ''}
</div>
</div>
<div class="p-2">
<div class="text-xs font-medium truncate" title="${item.name}">${item.name}</div>
</div>
</div>
`;
} else if (item.type === 'other') {
tile.className += ' photo-tile';
tile.innerHTML = `
<div class="photo-tile border-2 border-slate-200 rounded-xl overflow-hidden bg-white hover:border-slate-300 transition-all duration-200 opacity-60">
<div class="relative aspect-square flex items-center justify-center" style="background-color: ${item.color}">
<div class="text-white font-bold text-lg">${item.ext.toUpperCase()}</div>
</div>
<div class="p-2">
<div class="text-xs font-medium truncate" title="${item.name}">${item.name}</div>
</div>
</div>
`;
}
return tile;
}
// 渲染内容网格
function renderContent() {
const items = getVisibleItems();
const grid = $('#contentGrid');
const emptyState = $('#emptyState');
if (items.length === 0) {
grid.style.display = 'none';
emptyState.classList.remove('hidden');
return;
}
grid.style.display = 'grid';
emptyState.classList.add('hidden');
grid.innerHTML = '';
items.forEach(item => {
const tile = renderTile(item);
grid.appendChild(tile);
});
updateStats();
}
// 更新统计信息
function updateStats() {
const items = getVisibleItems();
const photos = items.filter(item => item.type === 'image').length;
const videos = items.filter(item => item.type === 'video').length;
const folders = items.filter(item => item.type === 'folder').length;
$('#totalItems').textContent = `共 ${items.length} 项`;
$('#photoCount').textContent = `照片 ${photos}`;
$('#videoCount').textContent = `视频 ${videos}`;
$('#folderCount').textContent = `文件夹 ${folders}`;
// 统计所有重复项
const allDuplicates = getAllDuplicates();
$('#duplicateCount').textContent = allDuplicates.length;
updateSelectionTools();
}
// 获取所有重复项
function getAllDuplicates() {
const duplicates = [];
function findDuplicates(folder) {
if (!folder.children) return;
folder.children.forEach(item => {
if (item.isDuplicate) duplicates.push(item);
if (item.type === 'folder') findDuplicates(item);
});
}
findDuplicates(rootData);
return duplicates;
}
// 更新选择工具栏
function updateSelectionTools() {
const count = selectedItems.size;
const selectionTools = $('#selectionTools');
const selectionCount = $('#selectionCount');
if (count === 0) {
selectionCount.textContent = '未选择';
selectionTools.className = 'flex items-center gap-1 opacity-50';
$$('#selectionTools button').forEach(btn => btn.disabled = true);
} else {
selectionCount.textContent = `已选择 ${count} 项`;
selectionTools.className = 'flex items-center gap-1 opacity-100';
$$('#selectionTools button').forEach(btn => btn.disabled = false);
}
}
// 导航功能
function navigateToFolder(folder) {
currentFolder = folder;
history = history.slice(0, historyIndex + 1);
history.push(folder);
historyIndex = history.length - 1;
selectedItems.clear();
renderBreadcrumb();
renderContent();
updateNavigationButtons();
}
function navigateToHistory(index) {
historyIndex = index;
currentFolder = history[index];
selectedItems.clear();
renderBreadcrumb();
renderContent();
updateNavigationButtons();
}
function goBack() {
if (historyIndex > 0) {
historyIndex--;
currentFolder = history[historyIndex];
selectedItems.clear();
renderBreadcrumb();
renderContent();
updateNavigationButtons();
}
}
function goForward() {
if (historyIndex < history.length - 1) {
historyIndex++;
currentFolder = history[historyIndex];
selectedItems.clear();
renderBreadcrumb();
renderContent();
updateNavigationButtons();
}
}
function goUp() {
if (historyIndex > 0) {
goBack();
}
}
function updateNavigationButtons() {
$('#btnBack').disabled = historyIndex === 0;
$('#btnForward').disabled = historyIndex === history.length - 1;
$('#btnUp').disabled = historyIndex === 0;
}
// 预览功能
function showPreview(item) {
if (item.type === 'image') {
$('#previewImage').src = item.src;
$('#previewImage').classList.remove('hidden');
$('#previewVideo').classList.add('hidden');
$('#photoName').textContent = item.name;
$('#photoSize').textContent = `${item.width} × ${item.height}`;
$('#photoDate').textContent = item.date.toLocaleString();
$('#photoFileSize').textContent = formatFileSize(item.size);
if (item.exif) {
$('#cameraInfo').textContent = `${item.exif.camera} · ${item.exif.fNumber} · ${item.exif.exposureTime} · ${item.exif.iso}`;
$('#cameraInfo').classList.remove('hidden');
} else {
$('#cameraInfo').classList.add('hidden');
}
$('#previewModal').classList.remove('hidden');
} else if (item.type === 'video') {
// 视频预览逻辑
$('#previewVideo').poster = item.thumbnail;
$('#previewVideo').classList.remove('hidden');
$('#previewImage').classList.add('hidden');
$('#previewModal').classList.remove('hidden');
}
}
function closePreview() {
$('#previewModal').classList.add('hidden');
$('#previewVideo').pause();
}
// 事件监听
document.addEventListener('DOMContentLoaded', function() {
// 导航按钮
$('#btnBack').onclick = goBack;
$('#btnForward').onclick = goForward;
$('#btnUp').onclick = goUp;
// 内容网格点击事件
$('#contentGrid').addEventListener('click', function(e) {
const tile = e.target.closest('[data-id]');
if (!tile) return;
const itemId = tile.dataset.id;
const itemType = tile.dataset.type;
const item = currentFolder.children.find(item => item.id === itemId);
if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd + 点击 = 多选
if (selectedItems.has(itemId)) {
selectedItems.delete(itemId);
} else {
selectedItems.add(itemId);
}
renderContent();
} else if (e.shiftKey) {
// Shift + 点击 = 范围选择
// TODO: 实现范围选择逻辑
} else {
// 普通点击
if (itemType === 'folder') {
navigateToFolder(item);
} else {
selectedItems.clear();
selectedItems.add(itemId);
renderContent();
}
}
});
// 双击事件
$('#contentGrid').addEventListener('dblclick', function(e) {
const tile = e.target.closest('[data-id]');
if (!tile) return;
const itemId = tile.dataset.id;
const item = currentFolder.children.find(item => item.id === itemId);
if (item.type === 'image' || item.type === 'video') {
showPreview(item);
} else if (item.type === 'folder') {
navigateToFolder(item);
}
});
// 预览关闭
$('#btnPreviewClose').onclick = closePreview;
$('#previewModal').addEventListener('click', function(e) {
if (e.target === this) closePreview();
});
// 视图选项
['showPhotos', 'showVideos', 'showFolders', 'showOthers'].forEach(option => {
$(`#${option}`).onchange = function() {
viewOptions[option] = this.checked;
renderContent();
};
});
// 缩略图大小
$('#tileSize').oninput = function() {
document.documentElement.style.setProperty('--tile-size', this.value + 'px');
};
// 初始化
renderBreadcrumb();
renderContent();
updateNavigationButtons();
});
// 键盘快捷键
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
if (!$('#previewModal').classList.contains('hidden')) {
closePreview();
} else {
selectedItems.clear();
renderContent();
}
} else if (e.key === 'Backspace' && !e.target.matches('input')) {
e.preventDefault();
goUp();
} else if ((e.ctrlKey || e.metaKey) && e.key === 'a' && !e.target.matches('input')) {
e.preventDefault();
const items = getVisibleItems();
items.forEach(item => selectedItems.add(item.id));
renderContent();
}
});
</script>
</body>
</html>
+189
View File
@@ -0,0 +1,189 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Argus - 文件夹管理(Win11风格原型)</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<script>tailwind.config={theme:{extend:{colors:{brand:'#3B82F6'}}}}</script>
<style>
:root { --tile: 220px }
.tile { width: var(--tile) }
.tile-h { width: var(--tile); height: calc(var(--tile)*.75) }
.pane-active { outline: 2px solid rgba(59,130,246,.6); outline-offset:-2px }
.no-select { user-select:none }
</style>
</head>
<body class="bg-slate-50 text-slate-900 no-select">
<!-- 第一行:命令栏 -->
<div class="sticky top-0 z-30 bg-white/80 backdrop-blur border-b border-slate-200">
<div class="mx-auto max-w-[1400px] px-3 py-2 flex items-center gap-2">
<div class="flex items-center gap-1">
<button id="cmdNew" class="px-2 py-1 rounded hover:bg-slate-100" title="新建文件夹"><ion-icon name="folder-open-outline"></ion-icon></button>
<div class="w-px h-5 bg-slate-200 mx-1"></div>
<button id="cmdCut" class="px-2 py-1 rounded hover:bg-slate-100" title="剪切"><ion-icon name="cut-outline"></ion-icon></button>
<button id="cmdCopy" class="px-2 py-1 rounded hover:bg-slate-100" title="复制"><ion-icon name="copy-outline"></ion-icon></button>
<button id="cmdPaste" class="px-2 py-1 rounded hover:bg-slate-100" title="粘贴"><ion-icon name="clipboard-outline"></ion-icon></button>
<button id="cmdRename" class="px-2 py-1 rounded hover:bg-slate-100" title="重命名"><ion-icon name="create-outline"></ion-icon></button>
<button id="cmdDel" class="px-2 py-1 rounded hover:bg-slate-100 text-red-600" title="删除"><ion-icon name="trash-outline"></ion-icon></button>
<div class="w-px h-5 bg-slate-200 mx-1"></div>
<button id="cmdAlbum" class="px-2 py-1 rounded hover:bg-slate-100" title="加入相册"><ion-icon name="albums-outline"></ion-icon></button>
<button id="cmdTags" class="px-2 py-1 rounded hover:bg-slate-100" title="编辑标签"><ion-icon name="pricetag-outline"></ion-icon></button>
</div>
<div class="flex items-center gap-2 mx-auto">
<div class="flex items-center gap-1 bg-slate-100 rounded-full px-1 py-0.5">
<button id="viewTiles" class="px-3 py-1 rounded-full text-sm bg-white shadow">平铺</button>
<button id="viewList" class="px-3 py-1 rounded-full text-sm">列表</button>
</div>
<div class="flex items-center gap-1 bg-slate-100 rounded-full px-1 py-0.5" title="缩略图大小">
<button class="sizeBtn px-2 py-1 rounded-full text-sm" data-size="160">小</button>
<button class="sizeBtn px-2 py-1 rounded-full text-sm bg-white shadow" data-size="220">中</button>
<button class="sizeBtn px-2 py-1 rounded-full text-sm" data-size="280">大</button>
<button class="sizeBtn px-2 py-1 rounded-full text-sm" data-size="340">超大</button>
</div>
<div class="flex items-center gap-1 bg-slate-100 rounded-full px-1 py-0.5" title="布局">
<button id="laySingle" class="px-3 py-1 rounded-full text-sm bg-white shadow">单栏</button>
<button id="layH" class="px-3 py-1 rounded-full text-sm">左右</button>
<button id="layV" class="px-3 py-1 rounded-full text-sm">上下</button>
</div>
<label class="inline-flex items-center gap-1 text-sm text-slate-700"><input id="togglePreview" type="checkbox" class="accent-[--brand]">预览窗格</label>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<ion-icon name="search-outline" class="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400"></ion-icon>
<input id="search" placeholder="搜索(活动面板)" class="pl-8 pr-2 py-1 border rounded text-sm border-slate-200 focus:border-brand focus:outline-none w-64"/>
</div>
<button id="btnTheme" class="p-2 rounded hover:bg-slate-100" title="主题"><ion-icon name="moon-outline"></ion-icon></button>
</div>
</div>
<div class="mx-auto max-w-[1400px] px-3 pb-2 flex items-center gap-2 text-sm text-slate-600">
<button id="navBack" class="p-1.5 rounded hover:bg-slate-100" title="后退"><ion-icon name="chevron-back-outline"></ion-icon></button>
<button id="navForward" class="p-1.5 rounded hover:bg-slate-100" title="前进"><ion-icon name="chevron-forward-outline"></ion-icon></button>
<button id="navUp" class="p-1.5 rounded hover:bg-slate-100" title="上一级"><ion-icon name="arrow-up-outline"></ion-icon></button>
<div id="breadcrumb" class="flex items-center gap-1 overflow-hidden"></div>
<div class="ml-auto text-slate-500" id="stats">—</div>
</div>
</div>
<!-- 布局容器 -->
<main class="mx-auto max-w-[1400px] px-3 py-3">
<div id="layout" class="grid gap-3" style="grid-template-columns: 1fr"></div>
</main>
<!-- 预览大图 -->
<div id="previewModal" class="fixed inset-0 bg-black/70 items-center justify-center hidden">
<div class="absolute inset-0" id="previewBg"></div>
<div class="relative max-w-[90vw] max-h-[90vh]">
<img id="pvImg" class="max-w-[90vw] max-h-[90vh] object-contain hidden"/>
<video id="pvVideo" class="max-w-[90vw] max-h-[90vh] hidden" controls></video>
<button id="pvClose" class="absolute top-2 right-2 p-2 rounded-full bg-white"><ion-icon name="close-outline"></ion-icon></button>
</div>
</div>
<script>
// 数据(演示)
const img=(s,w=1000,h=750)=>`https://picsum.photos/seed/${encodeURIComponent(s)}/${w}/${h}`
let gid=1; const uid=()=> (gid++).toString()
const f=(name,ch=[])=>({id:uid(),type:'folder',name,children:ch})
const im=name=>({id:uid(),type:'image',name,src:img(name),poster:img(name,400,300),w:1000,h:750,date:new Date()})
const vi=name=>({id:uid(),type:'video',name,poster:img(name,960,540),date:new Date()})
const ot=(name,ext,color)=>({id:uid(),type:'other',name,ext,color,date:new Date()})
const root=f('首页',[f('旅拍',[f('东京',[im('tokyo-1'),im('tokyo-2'),vi('tokyo-v')]),f('北海道',[im('hok-1'),im('hok-2')]),im('street-1')]),f('RAW',[ot('readme','txt','#64748B'),im('raw-1')]),im('cover-1'),vi('reel-1'),ot('安装包','exe','#94A3B8')])
// Pane 状态
const mk=()=>({current:root,history:[root],hi:0,selection:new Set(),view:'tiles',keyword:''})
const panes=[mk(),mk()]; let active=0; let mode='single'; let preview=false
const qs=s=>document.querySelector(s)
const layout=()=>qs('#layout')
function setTile(v){ document.documentElement.style.setProperty('--tile', v+'px') }
// FS 工具
function visit(n,fn){ fn(n); if(n.children) n.children.forEach(c=>visit(c,fn)) }
function findById(id){ let r=null; visit(root,x=>{if(x.id===id) r=x}); return r }
function parentOf(id){ let p=null; (function rec(n){ if(!n.children) return; for(const c of n.children){ if(c.id===id){ p=n; return } rec(c) } })(root); return p }
function removeNode(id){ const p=parentOf(id); if(p) p.children=p.children.filter(c=>c.id!==id) }
function cloneNode(n){ if(n.type==='folder') return {id:uid(),type:'folder',name:n.name+' - 副本',children:(n.children||[]).map(cloneNode)}; return {...n,id:uid(),name:n.name+' - 副本'} }
let clip=null // {op:'cut'|'copy',ids:[]}
// 过滤与统计
function items(p){ let a=(p.current.children||[]); if(p.keyword) a=a.filter(i=>i.name.toLowerCase().includes(p.keyword)); return a }
function stat(p){ const l=items(p), s={all:l.length,folder:0,image:0,video:0,other:0}; l.forEach(i=>s[i.type]++); return `共 ${s.all} 项 · 文件夹 ${s.folder} · 照片 ${s.image} · 视频 ${s.video}` }
// 面包屑
function pathTo(node){ const path=[]; let ok=false; (function dfs(n){ path.push(n); if(n===node){ ok=true; return } if(n.children) for(const c of n.children){ if(dfs(c)) return } path.pop() })(root); return ok? path:[root] }
function renderCrumb(){ const p=panes[active], bc=qs('#breadcrumb'); bc.innerHTML=''; pathTo(p.current).forEach((n,i,arr)=>{ const b=document.createElement('button'); b.className='px-2 py-1 rounded hover:bg-slate-100 truncate max-w-[240px]'; b.textContent=n.name; b.title=n.name; b.onclick=()=>{ if(i===arr.length-1) return; navTo(active,n) }; bc.appendChild(b); if(i<arr.length-1){ const s=document.createElement('span'); s.textContent='/'; s.className='text-slate-400'; bc.appendChild(s) } }) }
// 视图:平铺
function tile(p, item, idx){ const sel=p.selection.has(item.id); const c=document.createElement('div'); c.className='tile border rounded-lg bg-white hover:shadow transition overflow-hidden '+(sel?'ring-2 ring-brand border-brand':'border-slate-200'); c.dataset.id=item.id; c.dataset.idx=idx; c.dataset.type=item.type; const box=document.createElement('div'); box.className='tile-h bg-slate-100 relative'; c.appendChild(box); if(item.type==='folder'){ const cover=(item.children||[]).find(x=>x.type==='image'||x.type==='video'); if(cover){ const i=document.createElement('img'); i.src=cover.poster||cover.src; i.className='w-full h-full object-cover'; box.appendChild(i) } const b=document.createElement('div'); b.className='absolute left-2 top-2 px-2 py-0.5 text-xs rounded bg-black/40 text-white'; b.textContent='文件夹'; box.appendChild(b) } else if(item.type==='image'){ const i=document.createElement('img'); i.src=item.poster||item.src; i.className='w-full h-full object-cover'; box.appendChild(i); const m=document.createElement('div'); m.className='absolute left-2 top-2 px-2 py-0.5 text-xs rounded bg-black/40 text-white'; m.textContent=`${item.w}×${item.h}`; box.appendChild(m) } else if(item.type==='video'){ const i=document.createElement('img'); i.src=item.poster; i.className='w-full h-full object-cover'; box.appendChild(i); const pbtn=document.createElement('div'); pbtn.className='absolute inset-0 flex items-center justify-center'; pbtn.innerHTML='<div class="w-10 h-10 rounded-full bg-black/50 flex items-center justify-center text-white"><ion-icon name="play-outline"></ion-icon></div>'; box.appendChild(pbtn) } else { box.style.background=item.color||'#CBD5E1' } const row=document.createElement('div'); row.className='px-2 py-1 text-sm flex items-center gap-2'; row.innerHTML=`<ion-icon name="${item.type==='folder'?'folder-outline':item.type==='image'?'image-outline':item.type==='video'?'videocam-outline':'document-outline'}" class="text-slate-500"></ion-icon><span class="truncate" title="${item.name}">${item.name}</span>`; c.appendChild(row); return c }
// 视图:列表
function listRow(p,item,idx){ const sel=p.selection.has(item.id); const r=document.createElement('div'); r.className='grid grid-cols-12 items-center px-3 py-2 border-b '+(sel?'bg-blue-50 border-brand/40':'hover:bg-slate-50'); r.dataset.id=item.id; r.dataset.idx=idx; r.dataset.type=item.type; const n=document.createElement('div'); n.className='col-span-6 flex items-center gap-2'; n.innerHTML=`<ion-icon name="${item.type==='folder'?'folder-outline':item.type==='image'?'image-outline':item.type==='video'?'videocam-outline':'document-outline'}" class="text-slate-500"></ion-icon><span class="truncate" title="${item.name}">${item.name}</span>`; const t=document.createElement('div'); t.className='col-span-2 text-slate-500 text-sm'; t.textContent=item.type; const s=document.createElement('div'); s.className='col-span-2 text-slate-500 text-sm'; s.textContent=item.type==='image'? `${item.w}×${item.h}`:'--'; const d=document.createElement('div'); d.className='col-span-2 text-slate-500 text-sm text-right'; d.textContent=(item.date||new Date()).toLocaleDateString(); r.append(n,t,s,d); return r }
function renderPane(p, mount){ const wrap=document.createElement('div'); wrap.className='border border-slate-200 rounded-xl bg-white overflow-hidden'; wrap.onclick=()=>{ active=(mount.id==='paneB')?1:0; renderAll() }; if(p.view==='tiles'){ const cont=document.createElement('div'); cont.className='p-3 grid gap-3'; cont.style.gridTemplateColumns='repeat(auto-fill, minmax(var(--tile), 1fr))'; items(p).forEach((it,i)=> cont.appendChild(tile(p,it,i))); wrap.appendChild(cont); mount.replaceChildren(wrap); cont.addEventListener('click', e=>onClick(e,p)); cont.addEventListener('dblclick', e=>onDbl(e,p)) } else { const head=document.createElement('div'); head.className='grid grid-cols-12 px-3 py-2 text-xs text-slate-500 border-b bg-slate-50'; head.innerHTML='<div class="col-span-6">名称</div><div class="col-span-2">类型</div><div class="col-span-2">尺寸/大小</div><div class="col-span-2 text-right">日期</div>'; const cont=document.createElement('div'); cont.className='divide-y'; items(p).forEach((it,i)=> cont.appendChild(listRow(p,it,i))); wrap.append(head,cont); mount.replaceChildren(wrap); cont.addEventListener('click', e=>onClick(e,p)); cont.addEventListener('dblclick', e=>onDbl(e,p)) } if((mount.id==='paneA'&&active===0)||(mount.id==='paneB'&&active===1)) wrap.classList.add('pane-active') }
function onClick(e,p){ const el=e.target.closest('[data-id]'); if(!el) return; const id=el.dataset.id; const idx=parseInt(el.dataset.idx); const it=items(p)[idx]; if(e.ctrlKey||e.metaKey){ p.selection.has(id)? p.selection.delete(id): p.selection.add(id) } else { p.selection.clear(); p.selection.add(id) } renderAll() }
function onDbl(e,p){ const el=e.target.closest('[data-id]'); if(!el) return; const it=findById(el.dataset.id); if(!it) return; if(it.type==='folder') enter(p,it); else openPreview(it) }
// 导航
function enter(p,node){ p.current=node; p.history=p.history.slice(0,p.hi+1); p.history.push(p.current); p.hi++; p.selection.clear(); renderAll() }
function back(){ const p=panes[active]; if(p.hi>0){ p.hi--; p.current=p.history[p.hi]; p.selection.clear(); renderAll() } }
function forward(){ const p=panes[active]; if(p.hi<p.history.length-1){ p.hi++; p.current=p.history[p.hi]; p.selection.clear(); renderAll() } }
function up(){ const p=panes[active]; if(p.current!==root){ p.current=root; p.history=[root]; p.hi=0; p.selection.clear(); renderAll() } }
function navTo(i,node){ const p=panes[i]; p.current=node; p.history=[root,node]; p.hi=1; p.selection.clear(); renderAll() }
// 预览窗格与大预览
function selectedOne(){ const p=panes[active]; const id=[...p.selection][0]; return id? findById(id):null }
function renderPreview(mount){ const it=selectedOne(); const box=document.createElement('div'); box.className='border border-slate-200 rounded-xl bg-white p-3'; if(!it){ box.innerHTML='<div class="text-slate-500 text-sm">未选择项目</div>' } else { box.innerHTML = it.type==='image'? `<img src="${it.poster||it.src}" class="w-full rounded mb-3"/><div class="text-sm">${it.name}</div><div class="text-xs text-slate-500">${it.w}×${it.h}</div>` : it.type==='video'? `<img src="${it.poster}" class="w-full rounded mb-3"/><div class="text-sm">${it.name}</div><div class="text-xs text-slate-500">视频</div>` : it.type==='folder'? `<div class="text-sm">文件夹 · ${it.name}</div>` : `<div class='w-full h-40 rounded' style='background:${it.color||'#CBD5E1'}'></div><div class='text-sm mt-2'>${it.name}</div>` } mount.replaceChildren(box) }
function openPreview(it){ const m=qs('#previewModal'),i=qs('#pvImg'),v=qs('#pvVideo'); if(it.type==='image'){ v.pause(); v.classList.add('hidden'); i.src=it.src; i.classList.remove('hidden') } else if(it.type==='video'){ i.classList.add('hidden'); v.poster=it.poster; v.classList.remove('hidden') } m.classList.remove('hidden'); m.classList.add('flex') }
function closePreview(){ const m=qs('#previewModal'),v=qs('#pvVideo'); v.pause(); m.classList.add('hidden'); m.classList.remove('flex') }
// 命令栏
function sel(){ return [...panes[active].selection].map(id=>findById(id)).filter(Boolean) }
qs('#cmdNew').onclick = ()=>{ const p=panes[active]; const n='新建文件夹 '+((p.current.children||[]).filter(x=>x.type==='folder').length+1); p.current.children.push(f(n,[])); renderAll() }
qs('#cmdCut').onclick = ()=>{ const a=sel(); if(!a.length) return alert('请选择项目'); clip={op:'cut', ids:a.map(x=>x.id)} }
qs('#cmdCopy').onclick = ()=>{ const a=sel(); if(!a.length) return alert('请选择项目'); clip={op:'copy', ids:a.map(x=>x.id)} }
qs('#cmdPaste').onclick = ()=>{ if(!clip) return; const p=panes[active]; if(clip.op==='cut'){ clip.ids.forEach(id=>{ const n=findById(id); if(n){ removeNode(id); p.current.children.push(n) } }); clip=null } else { clip.ids.forEach(id=>{ const n=findById(id); if(n) p.current.children.push(cloneNode(n)) }) } renderAll() }
qs('#cmdRename').onclick = ()=>{ const p=panes[active]; const id=[...p.selection][0]; if(!id) return alert('请选择 1 项'); const n=findById(id); const name=prompt('重命名为:', n.name); if(name){ n.name=name; renderAll() } }
qs('#cmdDel').onclick = ()=>{ const p=panes[active]; if(!p.selection.size) return; if(!confirm('确定删除所选项目(演示)?')) return; [...p.selection].forEach(id=>removeNode(id)); p.selection.clear(); renderAll() }
qs('#cmdAlbum').onclick = ()=> alert('已加入相册(演示)')
qs('#cmdTags').onclick = ()=> alert('标签已更新(演示)')
// 视图/布局/预览/搜索
qs('#viewTiles').onclick=()=>{ panes[active].view='tiles'; qs('#viewTiles').classList.add('bg-white','shadow'); qs('#viewList').classList.remove('bg-white','shadow'); renderAll() }
qs('#viewList').onclick=()=>{ panes[active].view='list'; qs('#viewList').classList.add('bg-white','shadow'); qs('#viewTiles').classList.remove('bg-white','shadow'); renderAll() }
document.querySelectorAll('.sizeBtn').forEach(b=> b.onclick=()=>{ document.querySelectorAll('.sizeBtn').forEach(x=>x.classList.remove('bg-white','shadow')); b.classList.add('bg-white','shadow'); setTile(parseInt(b.dataset.size)); renderAll() })
qs('#laySingle').onclick=()=>{ mode='single'; setLay('laySingle') }
qs('#layH').onclick=()=>{ mode='two-h'; setLay('layH') }
qs('#layV').onclick=()=>{ mode='two-v'; setLay('layV') }
function setLay(id){ ['laySingle','layH','layV'].forEach(k=>qs('#'+k).classList.remove('bg-white','shadow')); qs('#'+id).classList.add('bg-white','shadow'); renderAll() }
qs('#togglePreview').onchange=e=>{ preview=e.target.checked; renderAll() }
qs('#search').oninput=e=>{ panes[active].keyword=(e.target.value||'').toLowerCase().trim(); renderAll() }
// 导航按钮与快捷键
qs('#navBack').onclick=()=>back(); qs('#navForward').onclick=()=>forward(); qs('#navUp').onclick=()=>up()
document.addEventListener('keydown', e=>{ const p=panes[active]; if(e.key==='Backspace' && !e.target.matches('input,textarea')){ e.preventDefault(); up() } if(e.altKey&&e.key==='ArrowLeft'){ e.preventDefault(); back() } if(e.altKey&&e.key==='ArrowRight'){ e.preventDefault(); forward() } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){ e.preventDefault(); items(p).forEach(i=>p.selection.add(i.id)); renderAll() } if(e.key==='Delete'){ e.preventDefault(); qs('#cmdDel').click() } })
// 预览大图
qs('#pvClose').onclick=()=>closePreview(); qs('#previewBg').onclick=()=>closePreview()
// 主题
function theme(m){ document.documentElement.classList.toggle('dark', m==='dark'); document.body.className = m==='dark' ? 'bg-slate-900 text-slate-100' : 'bg-slate-50 text-slate-900'; localStorage.setItem('argus-theme', m) }
const saved = localStorage.getItem('argus-theme') || (matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'); theme(saved)
qs('#btnTheme').onclick=()=> theme(document.documentElement.classList.contains('dark')? 'light':'dark')
// 渲染入口
function renderAll(){ qs('#stats').textContent=stat(panes[active]); renderCrumb(); const lay=layout(); lay.innerHTML=''; let cols='1fr'; if(mode==='two-h') cols = preview? '1fr 1fr 320px' : '1fr 1fr'; if(mode==='single') cols = preview? '1fr 320px' : '1fr'; if(mode==='two-v') cols = preview? '1fr 320px' : '1fr'; lay.style.gridTemplateColumns=cols; if(mode==='single'||mode==='two-h'){ const a=document.createElement('div'); a.id='paneA'; renderPane(panes[0],a); lay.appendChild(a); if(mode==='two-h'){ const b=document.createElement('div'); b.id='paneB'; renderPane(panes[1],b); lay.appendChild(b) } if(preview){ const pv=document.createElement('aside'); pv.id='pv'; renderPreview(pv); lay.appendChild(pv) } } else { lay.style.gridTemplateColumns = preview? '1fr 320px':'1fr'; const col=document.createElement('div'); col.className='grid gap-3'; col.style.gridTemplateRows='1fr 1fr'; const a=document.createElement('div'); a.id='paneA'; renderPane(panes[0],a); const b=document.createElement('div'); b.id='paneB'; renderPane(panes[1],b); col.appendChild(a); col.appendChild(b); lay.appendChild(col); if(preview){ const pv=document.createElement('aside'); pv.id='pv'; renderPreview(pv); lay.appendChild(pv) } } const p=panes[active]; qs('#navBack').disabled=p.hi===0; qs('#navForward').disabled=p.hi>=p.history.length-1; qs('#navUp').disabled=p.current===root }
setTile(220); renderAll()
</script>
</body>
<style>
.dark .bg-white{ background:#0f172a }
.dark .bg-slate-50{ background:#0b1220 }
.dark .text-slate-900{ color:#E2E8F0 }
.dark .border-slate-200{ border-color:#253048 }
.dark .hover\:bg-slate-100:hover{ background:#172036 }
.accent-\[--brand\]{ accent-color:#3B82F6 }
</style>
</html>
@@ -0,0 +1,226 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Argus - 文件夹预览(双栏 + 面包屑 + 可拖动分割)</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<script>tailwind.config={theme:{extend:{colors:{brand:'#3B82F6'}}}}</script>
<style>
:root { --tile: 220px }
.tile { width: var(--tile) }
.tile-h { width: var(--tile); height: calc(var(--tile) * .75) }
.pane-active { outline: 2px solid rgba(59,130,246,.6); outline-offset: -2px }
.splitter { background: transparent; position: relative; }
.splitter:hover::after { content:''; position:absolute; inset:0; background: rgba(59,130,246,.2) }
.splitter-vert { width: 6px; cursor: col-resize }
.splitter-horz { height: 6px; cursor: row-resize }
.no-select { user-select: none }
</style>
</head>
<body class="bg-slate-50 text-slate-900 no-select">
<!-- 顶部:命令栏(第1行) -->
<div class="sticky top-0 z-30 bg-white/80 backdrop-blur border-b border-slate-200">
<div class="mx-auto max-w-[1400px] px-3 py-2 flex items-center gap-2">
<!-- 快捷命令(左侧) -->
<div class="flex items-center gap-1">
<button id="cmdCut" class="px-2 py-1 rounded hover:bg-slate-100" title="剪切"><ion-icon name="cut-outline"></ion-icon></button>
<button id="cmdCopy" class="px-2 py-1 rounded hover:bg-slate-100" title="复制"><ion-icon name="copy-outline"></ion-icon></button>
<button id="cmdPaste" class="px-2 py-1 rounded hover:bg-slate-100" title="粘贴"><ion-icon name="clipboard-outline"></ion-icon></button>
<button id="cmdRename" class="px-2 py-1 rounded hover:bg-slate-100" title="重命名"><ion-icon name="create-outline"></ion-icon></button>
<button id="cmdDel" class="px-2 py-1 rounded hover:bg-slate-100 text-red-600" title="删除"><ion-icon name="trash-outline"></ion-icon></button>
<div class="w-px h-5 bg-slate-200 mx-1"></div>
<button id="cmdAlbum" class="px-2 py-1 rounded hover:bg-slate-100" title="加入相册"><ion-icon name="albums-outline"></ion-icon></button>
<button id="cmdTags" class="px-2 py-1 rounded hover:bg-slate-100" title="编辑标签"><ion-icon name="pricetag-outline"></ion-icon></button>
</div>
<!-- 中部:视图/大小/布局/预览开关 -->
<div class="flex items-center gap-2 mx-auto">
<div class="flex items-center gap-1 bg-slate-100 rounded-full px-1 py-0.5">
<button id="viewTiles" class="px-3 py-1 rounded-full text-sm bg-white shadow">平铺</button>
<button id="viewList" class="px-3 py-1 rounded-full text-sm">列表</button>
</div>
<div class="flex items-center gap-1 bg-slate-100 rounded-full px-1 py-0.5" title="缩略图大小">
<button class="sizeBtn px-2 py-1 rounded-full text-sm" data-size="160">小</button>
<button class="sizeBtn px-2 py-1 rounded-full text-sm bg-white shadow" data-size="220">中</button>
<button class="sizeBtn px-2 py-1 rounded-full text-sm" data-size="280">大</button>
<button class="sizeBtn px-2 py-1 rounded-full text-sm" data-size="340">超大</button>
</div>
<div class="flex items-center gap-1 bg-slate-100 rounded-full px-1 py-0.5" title="布局">
<button id="btnSingle" class="px-3 py-1 rounded-full text-sm bg-white shadow">单栏</button>
<button id="btnH" class="px-3 py-1 rounded-full text-sm">左右</button>
<button id="btnV" class="px-3 py-1 rounded-full text-sm">上下</button>
</div>
<label class="inline-flex items-center gap-1 text-sm text-slate-700"><input id="togglePreview" type="checkbox">预览窗格</label>
</div>
<!-- 右侧:搜索/主题 -->
<div class="flex items-center gap-2">
<div class="relative">
<ion-icon name="search-outline" class="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400"></ion-icon>
<input id="search" placeholder="搜索(活动面板)" class="pl-8 pr-2 py-1 border rounded text-sm border-slate-200 focus:border-brand focus:outline-none w-64"/>
</div>
<button id="btnTheme" class="p-2 rounded hover:bg-slate-100" title="主题"><ion-icon name="moon-outline"></ion-icon></button>
</div>
</div>
<!-- 第二行:面包屑(活动面板) -->
<div class="mx-auto max-w-[1400px] px-3 pb-2 flex items-center gap-2 text-sm text-slate-600">
<button id="navBack" class="p-1.5 rounded hover:bg-slate-100" title="后退"><ion-icon name="chevron-back-outline"></ion-icon></button>
<button id="navForward" class="p-1.5 rounded hover:bg-slate-100" title="前进"><ion-icon name="chevron-forward-outline"></ion-icon></button>
<button id="navUp" class="p-1.5 rounded hover:bg-slate-100" title="上一级"><ion-icon name="arrow-up-outline"></ion-icon></button>
<div id="breadcrumb" class="flex items-center gap-1 overflow-hidden"></div>
<div class="ml-auto text-slate-500" id="stats">—</div>
</div>
</div>
<!-- 工作区:单栏/双栏(左右/上下) + 预览窗格可选 -->
<main class="mx-auto max-w-[1400px] px-3 py-3">
<div id="work" class="grid gap-2" style="grid-template-columns: 1fr; height: calc(100vh - 112px);"></div>
</main>
<!-- 大图预览 -->
<div id="previewModal" class="fixed inset-0 bg-black/70 items-center justify-center hidden">
<div class="absolute inset-0" id="previewBg"></div>
<div class="relative max-w-[90vw] max-h-[90vh]">
<img id="pvImg" class="max-w-[90vw] max-h-[90vh] object-contain hidden"/>
<video id="pvVideo" class="max-w-[90vw] max-h-[90vh] hidden" controls></video>
<button id="pvClose" class="absolute top-2 right-2 p-2 rounded-full bg-white"><ion-icon name="close-outline"></ion-icon></button>
</div>
</div>
<script>
// 数据(演示)
const img=(s,w=1000,h=750)=>`https://picsum.photos/seed/${encodeURIComponent(s)}/${w}/${h}`
let gid=1; const uid=()=> (gid++).toString()
const f=(name,ch=[])=>({id:uid(),type:'folder',name,children:ch})
const im=name=>({id:uid(),type:'image',name,src:img(name),poster:img(name,400,300),w:1000,h:750,date:new Date()})
const vi=name=>({id:uid(),type:'video',name,poster:img(name,960,540),date:new Date()})
const ot=(name,ext,color)=>({id:uid(),type:'other',name,ext,color,date:new Date()})
const root=f('首页',[f('旅拍',[f('东京',[im('tokyo-1'),im('tokyo-2'),vi('tokyo-v')]),f('北海道',[im('hok-1'),im('hok-2')]),im('street-1')]),f('RAW',[ot('readme','txt','#64748B'),im('raw-1')]),im('cover-1'),vi('reel-1'),ot('安装包','exe','#94A3B8')])
// Pane 状态
const mk=()=>({current:root,history:[root],hi:0,selection:new Set(),view:'tiles',keyword:''})
const panes=[mk(),mk()]
let active=0, layoutMode='single', previewOn=false, tileSize=220
const qs=s=>document.querySelector(s)
function setTile(v){ tileSize=v; document.documentElement.style.setProperty('--tile', v+'px') }
// FS 工具
function visit(n,fn){ fn(n); if(n.children) n.children.forEach(c=>visit(c,fn)) }
function findById(id){ let r=null; visit(root,x=>{ if(x.id===id) r=x }); return r }
function items(p){ let a=(p.current.children||[]); if(p.keyword) a=a.filter(i=>i.name.toLowerCase().includes(p.keyword)); return a }
// 面包屑(活动面板)
function pathTo(node){ const path=[]; let ok=false; (function dfs(n){ path.push(n); if(n===node){ ok=true; return } if(n.children) for(const c of n.children){ if(dfs(c)) return } path.pop() })(root); return ok? path:[root] }
function renderCrumb(){ const p=panes[active], bc=qs('#breadcrumb'); bc.innerHTML=''; const path=pathTo(p.current); path.forEach((n,i,arr)=>{ const b=document.createElement('button'); b.className='px-2 py-1 rounded hover:bg-slate-100 truncate max-w-[240px]'; b.textContent=n.name; b.title=n.name; b.onclick=()=>{ if(i===arr.length-1) return; p.current=n; p.history=[root,n]; p.hi=1; p.selection.clear(); renderAll() }; bc.appendChild(b); if(i<arr.length-1){ const s=document.createElement('span'); s.textContent='/'; s.className='text-slate-400'; bc.appendChild(s) } }) }
// 视图:平铺
function tile(p, item, idx){ const sel=p.selection.has(item.id); const c=document.createElement('div'); c.className='tile border rounded-lg bg-white hover:shadow transition overflow-hidden '+(sel?'ring-2 ring-brand border-brand':'border-slate-200'); c.dataset.id=item.id; c.dataset.idx=idx; c.dataset.type=item.type; const box=document.createElement('div'); box.className='tile-h bg-slate-100 relative'; c.appendChild(box); if(item.type==='folder'){ const cover=(item.children||[]).find(x=>x.type==='image'||x.type==='video'); if(cover){ const i=document.createElement('img'); i.src=cover.poster||cover.src; i.className='w-full h-full object-cover'; box.appendChild(i) } const b=document.createElement('div'); b.className='absolute left-2 top-2 px-2 py-0.5 text-xs rounded bg-black/40 text-white'; b.textContent='文件夹'; box.appendChild(b) } else if(item.type==='image'){ const i=document.createElement('img'); i.src=item.poster||item.src; i.className='w-full h-full object-cover'; box.appendChild(i); const m=document.createElement('div'); m.className='absolute left-2 top-2 px-2 py-0.5 text-xs rounded bg-black/40 text-white'; m.textContent=`${item.w}×${item.h}`; box.appendChild(m) } else if(item.type==='video'){ const i=document.createElement('img'); i.src=item.poster; i.className='w-full h-full object-cover'; box.appendChild(i); const pbtn=document.createElement('div'); pbtn.className='absolute inset-0 flex items-center justify-center'; pbtn.innerHTML='<div class="w-10 h-10 rounded-full bg-black/50 flex items-center justify-center text-white"><ion-icon name="play-outline"></ion-icon></div>'; box.appendChild(pbtn) } else { box.style.background=item.color||'#CBD5E1' } const row=document.createElement('div'); row.className='px-2 py-1 text-sm flex items-center gap-2'; row.innerHTML=`<ion-icon name="${item.type==='folder'?'folder-outline':item.type==='image'?'image-outline':item.type==='video'?'videocam-outline':'document-outline'}" class="text-slate-500"></ion-icon><span class="truncate" title="${item.name}">${item.name}</span>`; c.appendChild(row); return c }
// 视图:列表
function listRow(p,item,idx){ const sel=p.selection.has(item.id); const r=document.createElement('div'); r.className='grid grid-cols-12 items-center px-3 py-2 border-b '+(sel?'bg-blue-50 border-brand/40':'hover:bg-slate-50'); r.dataset.id=item.id; r.dataset.idx=idx; r.dataset.type=item.type; const n=document.createElement('div'); n.className='col-span-6 flex items-center gap-2'; n.innerHTML=`<ion-icon name="${item.type==='folder'?'folder-outline':item.type==='image'?'image-outline':item.type==='video'?'videocam-outline':'document-outline'}" class="text-slate-500"></ion-icon><span class="truncate" title="${item.name}">${item.name}</span>`; const t=document.createElement('div'); t.className='col-span-2 text-slate-500 text-sm'; t.textContent=item.type; const s=document.createElement('div'); s.className='col-span-2 text-slate-500 text-sm'; s.textContent=item.type==='image'? `${item.w}×${item.h}`:'--'; const d=document.createElement('div'); d.className='col-span-2 text-slate-500 text-sm text-right'; d.textContent=(item.date||new Date()).toLocaleDateString(); r.append(n,t,s,d); return r }
function renderPane(p, mount){ const wrap=document.createElement('div'); wrap.className='border border-slate-200 rounded-xl bg-white overflow-hidden'; wrap.onclick=()=>{ active=(mount.id==='paneB')?1:0; renderAll() }; if(p.view==='tiles'){ const cont=document.createElement('div'); cont.className='p-3 grid gap-3'; cont.style.gridTemplateColumns='repeat(auto-fill, minmax(var(--tile), 1fr))'; items(p).forEach((it,i)=> cont.appendChild(tile(p,it,i))); wrap.appendChild(cont); mount.replaceChildren(wrap); cont.addEventListener('click', e=>onClick(e,p)); cont.addEventListener('dblclick', e=>onDbl(e,p)) } else { const head=document.createElement('div'); head.className='grid grid-cols-12 px-3 py-2 text-xs text-slate-500 border-b bg-slate-50'; head.innerHTML='<div class="col-span-6">名称</div><div class="col-span-2">类型</div><div class="col-span-2">尺寸/大小</div><div class="col-span-2 text-right">日期</div>'; const cont=document.createElement('div'); cont.className='divide-y'; items(p).forEach((it,i)=> cont.appendChild(listRow(p,it,i))); wrap.append(head,cont); mount.replaceChildren(wrap); cont.addEventListener('click', e=>onClick(e,p)); cont.addEventListener('dblclick', e=>onDbl(e,p)) } if((mount.id==='paneA'&&active===0)||(mount.id==='paneB'&&active===1)) wrap.classList.add('pane-active') }
// 点击/双击
function onClick(e,p){ const el=e.target.closest('[data-id]'); if(!el) return; const id=el.dataset.id; const idx=parseInt(el.dataset.idx); const it=items(p)[idx]; if(e.ctrlKey||e.metaKey){ p.selection.has(id)? p.selection.delete(id): p.selection.add(id) } else { p.selection.clear(); p.selection.add(id) } renderAll() }
function onDbl(e,p){ const el=e.target.closest('[data-id]'); if(!el) return; const it=findById(el.dataset.id); if(!it) return; if(it.type==='folder') enter(p,it); else openPreview(it) }
// 导航
function enter(p,node){ p.current=node; p.history=p.history.slice(0,p.hi+1); p.history.push(p.current); p.hi++; p.selection.clear(); renderAll() }
function back(){ const p=panes[active]; if(p.hi>0){ p.hi--; p.current=p.history[p.hi]; p.selection.clear(); renderAll() } }
function forward(){ const p=panes[active]; if(p.hi<p.history.length-1){ p.hi++; p.current=p.history[p.hi]; p.selection.clear(); renderAll() } }
function up(){ const p=panes[active]; if(p.current!==root){ p.current=root; p.history=[root]; p.hi=0; p.selection.clear(); renderAll() } }
function navTo(i,node){ const p=panes[i]; p.current=node; p.history=[root,node]; p.hi=1; p.selection.clear(); renderAll() }
// 预览窗格与大预览
function selectedOne(){ const p=panes[active]; const id=[...p.selection][0]; return id? findById(id):null }
function renderPreviewPane(mount){ const it=selectedOne(); const box=document.createElement('div'); box.className='border border-slate-200 rounded-xl bg-white p-3'; if(!it){ box.innerHTML='<div class="text-slate-500 text-sm">未选择项目</div>' } else { box.innerHTML = it.type==='image'? `<img src="${it.poster||it.src}" class="w-full rounded mb-3"/><div class="text-sm">${it.name}</div><div class="text-xs text-slate-500">${it.w}×${it.h}</div>` : it.type==='video'? `<img src="${it.poster}" class="w-full rounded mb-3"/><div class="text-sm">${it.name}</div><div class="text-xs text-slate-500">视频</div>` : it.type==='folder'? `<div class="text-sm">文件夹 · ${it.name}</div>` : `<div class='w-full h-40 rounded' style='background:${it.color||'#CBD5E1'}'></div><div class='text-sm mt-2'>${it.name}</div>` } mount.replaceChildren(box) }
function openPreview(it){ const m=qs('#previewModal'),i=qs('#pvImg'),v=qs('#pvVideo'); if(it.type==='image'){ v.pause(); v.classList.add('hidden'); i.src=it.src; i.classList.remove('hidden') } else if(it.type==='video'){ i.classList.add('hidden'); v.poster=it.poster; v.classList.remove('hidden') } m.classList.remove('hidden'); m.classList.add('flex') }
function closePreview(){ const m=qs('#previewModal'),v=qs('#pvVideo'); v.pause(); m.classList.add('hidden'); m.classList.remove('flex') }
// 分割条拖动
let dragState=null
function initHResize(container){ const splitter=container.querySelector('.splitter-vert'); if(!splitter) return; splitter.onmousedown=(e)=>{ e.preventDefault(); const rect=container.getBoundingClientRect(); const total=rect.width; dragState={type:'h', startX:e.clientX, total, container, left:container._left || total/2 } ; document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag, {once:true}) } }
function initVResize(container){ const splitter=container.querySelector('.splitter-horz'); if(!splitter) return; splitter.onmousedown=(e)=>{ e.preventDefault(); const rect=container.getBoundingClientRect(); const total=rect.height; dragState={type:'v', startY:e.clientY, total, container, top:container._top || total/2 } ; document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag, {once:true}) } }
function onDrag(e){ if(!dragState) return; if(dragState.type==='h'){ const dx=e.clientX - dragState.startX; let left=dragState.left + dx; const min=240, max=dragState.total - 240; left=Math.max(min, Math.min(max, left)); const right=dragState.total - left - 6; dragState.container.style.gridTemplateColumns = dragState.container._withPreview? `${left}px 6px ${Math.max(240,right-340-6)}px 6px 340px` : `${left}px 6px ${right}px`; dragState.container._left = left } else { const dy=e.clientY - dragState.startY; let top=dragState.top + dy; const min=180, max=dragState.total - 180; top=Math.max(min, Math.min(max, top)); const bottom=dragState.total - top - 6; dragState.container.style.gridTemplateRows = `${top}px 6px ${bottom}px`; dragState.container._top = top } }
function stopDrag(){ document.removeEventListener('mousemove', onDrag); dragState=null }
// 统计
function statsText(p){ const l=items(p); const s={all:l.length,folder:0,image:0,video:0,other:0}; l.forEach(i=>s[i.type]++); return `共 ${s.all} 项 · 文件夹 ${s.folder} · 照片 ${s.image} · 视频 ${s.video}` }
// 渲染布局
function renderAll(){
// 顶部状态
qs('#stats').textContent = statsText(panes[active])
renderCrumb()
const work=qs('#work'); work.innerHTML=''
// 计算列 / 行
if(layoutMode==='single'){
work.style.gridTemplateRows = '1fr'
work.style.gridTemplateColumns = previewOn? '1fr 340px' : '1fr'
const a=document.createElement('div'); a.id='paneA'; renderPane(panes[0], a); work.appendChild(a)
if(previewOn){ const pv=document.createElement('aside'); renderPreviewPane(pv); work.appendChild(pv) }
} else if(layoutMode==='h'){
if(previewOn){ work.style.gridTemplateColumns = '1fr 6px 1fr 6px 340px'; work._withPreview=true } else { work.style.gridTemplateColumns = '1fr 6px 1fr'; work._withPreview=false }
work.style.gridTemplateRows = '1fr'
const a=document.createElement('div'); a.id='paneA'; const b=document.createElement('div'); b.id='paneB'; const split=document.createElement('div'); split.className='splitter splitter-vert rounded'; renderPane(panes[0], a); renderPane(panes[1], b); work.append(a, split, b); if(previewOn){ const s2=document.createElement('div'); s2.style.width='6px'; work.appendChild(s2); const pv=document.createElement('aside'); renderPreviewPane(pv); work.appendChild(pv) } initHResize(work)
} else { // two-v
work.style.gridTemplateColumns = previewOn? '1fr 340px' : '1fr'
work.style.gridTemplateRows = '1fr 6px 1fr'
const col=document.createElement('div'); col.className='grid gap-2'; col.style.gridTemplateRows='1fr 6px 1fr'
const a=document.createElement('div'); a.id='paneA'; const b=document.createElement('div'); b.id='paneB'; const split=document.createElement('div'); split.className='splitter splitter-horz rounded'; renderPane(panes[0], a); renderPane(panes[1], b); col.append(a, split, b); if(previewOn){ const pv=document.createElement('aside'); renderPreviewPane(pv); work.append(col, pv) } else { work.append(col) } initVResize(col)
}
// 导航按钮状态
const p=panes[active]; qs('#navBack').disabled=p.hi===0; qs('#navForward').disabled=p.hi>=p.history.length-1; qs('#navUp').disabled=p.current===root
}
// 命令栏(活动面板)
let clip=null
function sel(){ return [...panes[active].selection].map(id=>findById(id)).filter(Boolean) }
qs('#cmdCut').onclick = ()=>{ const a=sel(); if(!a.length) return alert('请选择'); clip={op:'cut', ids:a.map(x=>x.id)} }
qs('#cmdCopy').onclick = ()=>{ const a=sel(); if(!a.length) return alert('请选择'); clip={op:'copy', ids:a.map(x=>x.id)} }
qs('#cmdPaste').onclick = ()=>{ if(!clip) return; const p=panes[active]; if(clip.op==='cut'){ clip.ids.forEach(id=>{ const n=findById(id); if(n){ removeNode(id); (p.current.children ||= []).push(n) } }); clip=null } else { clip.ids.forEach(id=>{ const n=findById(id); if(n){ (p.current.children ||= []).push(cloneNode(n)) } }) } renderAll() }
qs('#cmdRename').onclick = ()=>{ const p=panes[active]; const id=[...p.selection][0]; if(!id) return alert('请选择 1 项'); const n=findById(id); const name=prompt('重命名为:', n.name); if(name){ n.name=name; renderAll() } }
qs('#cmdDel').onclick = ()=>{ const p=panes[active]; if(!p.selection.size) return; if(!confirm('确定删除所选项目(演示)?')) return; [...p.selection].forEach(id=>removeNode(id)); p.selection.clear(); renderAll() }
qs('#cmdAlbum').onclick = ()=> alert('已加入相册(演示)')
qs('#cmdTags').onclick = ()=> alert('标签已更新(演示)')
// 复制与删除辅助
function parentOf(id){ let p=null; (function rec(n){ if(!n.children) return; for(const c of n.children){ if(c.id===id){ p=n; return } rec(c) } })(root); return p }
function removeNode(id){ const p=parentOf(id); if(p){ p.children=p.children.filter(c=>c.id!==id) } }
function cloneNode(n){ if(n.type==='folder') return {id:uid(),type:'folder',name:n.name+' - 副本',children:(n.children||[]).map(cloneNode)}; return {...n,id:uid(),name:n.name+' - 副本'} }
// 事件绑定:视图/布局/预览/搜索
qs('#viewTiles').onclick=()=>{ panes[active].view='tiles'; qs('#viewTiles').classList.add('bg-white','shadow'); qs('#viewList').classList.remove('bg-white','shadow'); renderAll() }
qs('#viewList').onclick=()=>{ panes[active].view='list'; qs('#viewList').classList.add('bg-white','shadow'); qs('#viewTiles').classList.remove('bg-white','shadow'); renderAll() }
document.querySelectorAll('.sizeBtn').forEach(b=> b.onclick=()=>{ document.querySelectorAll('.sizeBtn').forEach(x=>x.classList.remove('bg-white','shadow')); b.classList.add('bg-white','shadow'); setTile(parseInt(b.dataset.size)); renderAll() })
qs('#btnSingle').onclick=()=>{ layoutMode='single'; setLay('btnSingle') }
qs('#btnH').onclick=()=>{ layoutMode='h'; setLay('btnH') }
qs('#btnV').onclick=()=>{ layoutMode='v'; setLay('btnV') }
function setLay(id){ ['btnSingle','btnH','btnV'].forEach(k=>qs('#'+k).classList.remove('bg-white','shadow')); qs('#'+id).classList.add('bg-white','shadow'); renderAll() }
qs('#togglePreview').onchange=e=>{ previewOn=e.target.checked; renderAll() }
qs('#search').oninput = e=>{ panes[active].keyword=(e.target.value||'').toLowerCase().trim(); renderAll() }
// 导航按钮与快捷键
qs('#navBack').onclick=()=>back(); qs('#navForward').onclick=()=>forward(); qs('#navUp').onclick=()=>up()
document.addEventListener('keydown', e=>{ const p=panes[active]; if(e.key==='Backspace' && !e.target.matches('input,textarea')){ e.preventDefault(); up() } if(e.altKey&&e.key==='ArrowLeft'){ e.preventDefault(); back() } if(e.altKey&&e.key==='ArrowRight'){ e.preventDefault(); forward() } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){ e.preventDefault(); items(p).forEach(i=>p.selection.add(i.id)); renderAll() } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='c'){ e.preventDefault(); qs('#cmdCopy').click() } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='x'){ e.preventDefault(); qs('#cmdCut').click() } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='v'){ e.preventDefault(); qs('#cmdPaste').click() } if(e.key==='Delete'){ e.preventDefault(); qs('#cmdDel').click() } if(e.key==='Enter'){ const it=selectedOne(); if(it){ if(it.type==='folder') enter(p,it); else openPreview(it) } } })
// 主题
function theme(m){ document.documentElement.classList.toggle('dark', m==='dark'); document.body.className = m==='dark' ? 'bg-slate-900 text-slate-100' : 'bg-slate-50 text-slate-900'; localStorage.setItem('argus-theme', m) }
const saved = localStorage.getItem('argus-theme') || (matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'); theme(saved)
qs('#btnTheme').onclick=()=> theme(document.documentElement.classList.contains('dark')? 'light':'dark')
// 启动
setTile(220); renderAll()
</script>
</body>
<style>
.dark .bg-white{ background:#0f172a }
.dark .bg-slate-50{ background:#0b1220 }
.dark .text-slate-900{ color:#E2E8F0 }
.dark .border-slate-200{ border-color:#253048 }
.dark .hover\:bg-slate-100:hover{ background:#172036 }
</style>
</html>
@@ -0,0 +1,229 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Argus - 文件夹预览(双栏 + 面包屑 + 可拖动分割)</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<script>tailwind.config={theme:{extend:{colors:{brand:'#3B82F6'}}}}</script>
<style>
:root { --tile: 220px }
.tile { width: var(--tile) }
.tile-h { width: var(--tile); height: calc(var(--tile) * .75) }
.pane-active { outline: 2px solid rgba(59,130,246,.6); outline-offset: -2px }
.splitter { background: transparent; position: relative; }
.splitter:hover::after { content:''; position:absolute; inset:0; background: rgba(59,130,246,.2) }
.splitter-vert { width: 6px; cursor: col-resize }
.splitter-horz { height: 6px; cursor: row-resize }
.no-select { user-select: none }
</style>
</head>
<body class="bg-slate-50 text-slate-900 no-select">
<!-- 顶部:命令栏(第1行) -->
<div class="sticky top-0 z-30 bg-white/80 backdrop-blur border-b border-slate-200">
<div class="mx-auto max-w-[1400px] px-3 py-2 flex items-center gap-2">
<!-- 快捷命令(左侧) -->
<div class="flex items-center gap-1">
<button id="cmdCut" class="px-2 py-1 rounded hover:bg-slate-100" title="剪切"><ion-icon name="cut-outline"></ion-icon></button>
<button id="cmdCopy" class="px-2 py-1 rounded hover:bg-slate-100" title="复制"><ion-icon name="copy-outline"></ion-icon></button>
<button id="cmdPaste" class="px-2 py-1 rounded hover:bg-slate-100" title="粘贴"><ion-icon name="clipboard-outline"></ion-icon></button>
<button id="cmdRename" class="px-2 py-1 rounded hover:bg-slate-100" title="重命名"><ion-icon name="create-outline"></ion-icon></button>
<button id="cmdDel" class="px-2 py-1 rounded hover:bg-slate-100 text-red-600" title="删除"><ion-icon name="trash-outline"></ion-icon></button>
<div class="w-px h-5 bg-slate-200 mx-1"></div>
<button id="cmdAlbum" class="px-2 py-1 rounded hover:bg-slate-100" title="加入相册"><ion-icon name="albums-outline"></ion-icon></button>
<button id="cmdTags" class="px-2 py-1 rounded hover:bg-slate-100" title="编辑标签"><ion-icon name="pricetag-outline"></ion-icon></button>
</div>
<!-- 中部:视图/大小/布局/预览开关 -->
<div class="flex items-center gap-2 mx-auto">
<div class="flex items-center gap-1 bg-slate-100 rounded-full px-1 py-0.5">
<button id="viewTiles" class="px-3 py-1 rounded-full text-sm bg-white shadow">平铺</button>
<button id="viewList" class="px-3 py-1 rounded-full text-sm">列表</button>
</div>
<div class="flex items-center gap-1 bg-slate-100 rounded-full px-1 py-0.5" title="缩略图大小">
<button class="sizeBtn px-2 py-1 rounded-full text-sm" data-size="160">小</button>
<button class="sizeBtn px-2 py-1 rounded-full text-sm bg-white shadow" data-size="220">中</button>
<button class="sizeBtn px-2 py-1 rounded-full text-sm" data-size="280">大</button>
<button class="sizeBtn px-2 py-1 rounded-full text-sm" data-size="340">超大</button>
</div>
<div class="flex items-center gap-1 bg-slate-100 rounded-full px-1 py-0.5" title="布局">
<button id="btnSingle" class="px-3 py-1 rounded-full text-sm bg-white shadow">单栏</button>
<button id="btnH" class="px-3 py-1 rounded-full text-sm">左右</button>
<button id="btnV" class="px-3 py-1 rounded-full text-sm">上下</button>
</div>
<label class="inline-flex items-center gap-1 text-sm text-slate-700"><input id="togglePreview" type="checkbox">预览窗格</label>
</div>
<!-- 右侧:搜索/主题 -->
<div class="flex items-center gap-2">
<div class="relative">
<ion-icon name="search-outline" class="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400"></ion-icon>
<input id="search" placeholder="搜索(活动面板)" class="pl-8 pr-2 py-1 border rounded text-sm border-slate-200 focus:border-brand focus:outline-none w-64"/>
</div>
<button id="btnTheme" class="p-2 rounded hover:bg-slate-100" title="主题"><ion-icon name="moon-outline"></ion-icon></button>
</div>
</div>
<!-- 第二行:面包屑(活动面板) -->
<div class="mx-auto max-w-[1400px] px-3 pb-2 flex items-center gap-2 text-sm text-slate-600">
<button id="navBack" class="p-1.5 rounded hover:bg-slate-100" title="后退"><ion-icon name="chevron-back-outline"></ion-icon></button>
<button id="navForward" class="p-1.5 rounded hover:bg-slate-100" title="前进"><ion-icon name="chevron-forward-outline"></ion-icon></button>
<button id="navUp" class="p-1.5 rounded hover:bg-slate-100" title="上一级"><ion-icon name="arrow-up-outline"></ion-icon></button>
<div id="breadcrumb" class="flex items-center gap-1 overflow-hidden"></div>
<div class="ml-auto text-slate-500" id="stats">—</div>
</div>
</div>
<!-- 工作区:单栏/双栏(左右/上下) + 预览窗格可选 -->
<main class="mx-auto max-w-[1400px] px-3 py-3">
<div id="work" class="grid gap-2" style="grid-template-columns: 1fr; height: calc(100vh - 112px);"></div>
</main>
<!-- 大图预览 -->
<div id="previewModal" class="fixed inset-0 bg-black/70 items-center justify-center hidden">
<div class="absolute inset-0" id="previewBg"></div>
<div class="relative max-w-[90vw] max-h-[90vh]">
<img id="pvImg" class="max-w-[90vw] max-h-[90vh] object-contain hidden"/>
<video id="pvVideo" class="max-w-[90vw] max-h-[90vh] hidden" controls></video>
<button id="pvClose" class="absolute top-2 right-2 p-2 rounded-full bg-white"><ion-icon name="close-outline"></ion-icon></button>
</div>
</div>
<script>
// 数据(演示)
const img=(s,w=1000,h=750)=>`https://picsum.photos/seed/${encodeURIComponent(s)}/${w}/${h}`
let gid=1; const uid=()=> (gid++).toString()
const f=(name,ch=[])=>({id:uid(),type:'folder',name,children:ch})
const im=name=>({id:uid(),type:'image',name,src:img(name),poster:img(name,400,300),w:1000,h:750,date:new Date()})
const vi=name=>({id:uid(),type:'video',name,poster:img(name,960,540),date:new Date()})
const ot=(name,ext,color)=>({id:uid(),type:'other',name,ext,color,date:new Date()})
const root=f('首页',[f('旅拍',[f('东京',[im('tokyo-1'),im('tokyo-2'),vi('tokyo-v')]),f('北海道',[im('hok-1'),im('hok-2')]),im('street-1')]),f('RAW',[ot('readme','txt','#64748B'),im('raw-1')]),im('cover-1'),vi('reel-1'),ot('安装包','exe','#94A3B8')])
// Pane 状态
const mk=()=>({current:root,history:[root],hi:0,selection:new Set(),view:'tiles',keyword:''})
const panes=[mk(),mk()]
let active=0, layoutMode='single', previewOn=false, tileSize=220
const qs=s=>document.querySelector(s)
function setTile(v){ tileSize=v; document.documentElement.style.setProperty('--tile', v+'px') }
// FS 工具
function visit(n,fn){ fn(n); if(n.children) n.children.forEach(c=>visit(c,fn)) }
function findById(id){ let r=null; visit(root,x=>{ if(x.id===id) r=x }); return r }
function items(p){ let a=(p.current.children||[]); if(p.keyword) a=a.filter(i=>i.name.toLowerCase().includes(p.keyword)); return a }
// 面包屑(活动面板)
function pathTo(node){ const path=[]; let ok=false; (function dfs(n){ path.push(n); if(n===node){ ok=true; return } if(n.children) for(const c of n.children){ if(dfs(c)) return } path.pop() })(root); return ok? path:[root] }
function renderCrumb(){ const p=panes[active], bc=qs('#breadcrumb'); bc.innerHTML=''; const path=pathTo(p.current); path.forEach((n,i,arr)=>{ const b=document.createElement('button'); b.className='px-2 py-1 rounded hover:bg-slate-100 truncate max-w-[240px]'; b.textContent=n.name; b.title=n.name; b.onclick=()=>{ if(i===arr.length-1) return; navTo(active, n) }; bc.appendChild(b); if(i<arr.length-1){ const s=document.createElement('span'); s.textContent='/'; s.className='text-slate-400'; bc.appendChild(s) } }) }
// 每个面板自身的面包屑(用于双栏各自跳转)
function paneCrumb(p, idx){ const bar=document.createElement('div'); bar.className='flex items-center gap-1 text-sm text-slate-600 px-2 py-1.5 border-b bg-slate-50'; const path=pathTo(p.current); path.forEach((n,i,arr)=>{ const b=document.createElement('button'); b.className='px-2 py-1 rounded hover:bg-slate-100 truncate max-w-[240px]'; b.textContent=n.name; b.title=n.name; b.onclick=()=>{ if(i===arr.length-1) return; navTo(idx, n) }; bar.appendChild(b); if(i<arr.length-1){ const s=document.createElement('span'); s.textContent='/'; s.className='text-slate-400'; bar.appendChild(s) } }); return bar }
// 视图:平铺
function tile(p, item, idx){ const sel=p.selection.has(item.id); const c=document.createElement('div'); c.className='tile border rounded-lg bg-white hover:shadow transition overflow-hidden '+(sel?'ring-2 ring-brand border-brand':'border-slate-200'); c.dataset.id=item.id; c.dataset.idx=idx; c.dataset.type=item.type; const box=document.createElement('div'); box.className='tile-h bg-slate-100 relative'; c.appendChild(box); if(item.type==='folder'){ const cover=(item.children||[]).find(x=>x.type==='image'||x.type==='video'); if(cover){ const i=document.createElement('img'); i.src=cover.poster||cover.src; i.className='w-full h-full object-cover'; box.appendChild(i) } const b=document.createElement('div'); b.className='absolute left-2 top-2 px-2 py-0.5 text-xs rounded bg-black/40 text-white'; b.textContent='文件夹'; box.appendChild(b) } else if(item.type==='image'){ const i=document.createElement('img'); i.src=item.poster||item.src; i.className='w-full h-full object-cover'; box.appendChild(i); const m=document.createElement('div'); m.className='absolute left-2 top-2 px-2 py-0.5 text-xs rounded bg-black/40 text-white'; m.textContent=`${item.w}×${item.h}`; box.appendChild(m) } else if(item.type==='video'){ const i=document.createElement('img'); i.src=item.poster; i.className='w-full h-full object-cover'; box.appendChild(i); const pbtn=document.createElement('div'); pbtn.className='absolute inset-0 flex items-center justify-center'; pbtn.innerHTML='<div class="w-10 h-10 rounded-full bg-black/50 flex items-center justify-center text-white"><ion-icon name="play-outline"></ion-icon></div>'; box.appendChild(pbtn) } else { box.style.background=item.color||'#CBD5E1' } const row=document.createElement('div'); row.className='px-2 py-1 text-sm flex items-center gap-2'; row.innerHTML=`<ion-icon name="${item.type==='folder'?'folder-outline':item.type==='image'?'image-outline':item.type==='video'?'videocam-outline':'document-outline'}" class="text-slate-500"></ion-icon><span class="truncate" title="${item.name}">${item.name}</span>`; c.appendChild(row); return c }
// 视图:列表
function listRow(p,item,idx){ const sel=p.selection.has(item.id); const r=document.createElement('div'); r.className='grid grid-cols-12 items-center px-3 py-2 border-b '+(sel?'bg-blue-50 border-brand/40':'hover:bg-slate-50'); r.dataset.id=item.id; r.dataset.idx=idx; r.dataset.type=item.type; const n=document.createElement('div'); n.className='col-span-6 flex items-center gap-2'; n.innerHTML=`<ion-icon name="${item.type==='folder'?'folder-outline':item.type==='image'?'image-outline':item.type==='video'?'videocam-outline':'document-outline'}" class="text-slate-500"></ion-icon><span class="truncate" title="${item.name}">${item.name}</span>`; const t=document.createElement('div'); t.className='col-span-2 text-slate-500 text-sm'; t.textContent=item.type; const s=document.createElement('div'); s.className='col-span-2 text-slate-500 text-sm'; s.textContent=item.type==='image'? `${item.w}×${item.h}`:'--'; const d=document.createElement('div'); d.className='col-span-2 text-slate-500 text-sm text-right'; d.textContent=(item.date||new Date()).toLocaleDateString(); r.append(n,t,s,d); return r }
function renderPane(p, mount, idx){ const wrap=document.createElement('div'); wrap.className='border border-slate-200 rounded-xl bg-white overflow-hidden flex flex-col'; wrap.onclick=()=>{ active=idx; renderAll() }; wrap.appendChild(paneCrumb(p, idx)); if(p.view==='tiles'){ const cont=document.createElement('div'); cont.className='p-3 grid gap-3 flex-1 overflow-auto'; cont.style.gridTemplateColumns='repeat(auto-fill, minmax(var(--tile), 1fr))'; items(p).forEach((it,i)=> cont.appendChild(tile(p,it,i))); wrap.appendChild(cont); mount.replaceChildren(wrap); cont.addEventListener('click', e=>onClick(e,p)); cont.addEventListener('dblclick', e=>onDbl(e,p)) } else { const head=document.createElement('div'); head.className='grid grid-cols-12 px-3 py-2 text-xs text-slate-500 border-b bg-slate-50'; head.innerHTML='<div class="col-span-6">名称</div><div class="col-span-2">类型</div><div class="col-span-2">尺寸/大小</div><div class="col-span-2 text-right">日期</div>'; const cont=document.createElement('div'); cont.className='divide-y flex-1 overflow-auto'; items(p).forEach((it,i)=> cont.appendChild(listRow(p,it,i))); wrap.append(head,cont); mount.replaceChildren(wrap); cont.addEventListener('click', e=>onClick(e,p)); cont.addEventListener('dblclick', e=>onDbl(e,p)) } if((idx===0&&active===0)||(idx===1&&active===1)) wrap.classList.add('pane-active') }
// 点击/双击
function onClick(e,p){ const el=e.target.closest('[data-id]'); if(!el) return; const id=el.dataset.id; const idx=parseInt(el.dataset.idx); const it=items(p)[idx]; if(e.ctrlKey||e.metaKey){ p.selection.has(id)? p.selection.delete(id): p.selection.add(id) } else { p.selection.clear(); p.selection.add(id) } renderAll() }
function onDbl(e,p){ const el=e.target.closest('[data-id]'); if(!el) return; const it=findById(el.dataset.id); if(!it) return; if(it.type==='folder') enter(p,it); else openPreview(it) }
// 导航
function enter(p,node){ p.current=node; p.history=p.history.slice(0,p.hi+1); p.history.push(p.current); p.hi++; p.selection.clear(); renderAll() }
function back(){ const p=panes[active]; if(p.hi>0){ p.hi--; p.current=p.history[p.hi]; p.selection.clear(); renderAll() } }
function forward(){ const p=panes[active]; if(p.hi<p.history.length-1){ p.hi++; p.current=p.history[p.hi]; p.selection.clear(); renderAll() } }
function up(){ const p=panes[active]; if(p.current!==root){ p.current=root; p.history=[root]; p.hi=0; p.selection.clear(); renderAll() } }
function navTo(i,node){ const p=panes[i]; p.current=node; p.history=[root,node]; p.hi=1; p.selection.clear(); renderAll() }
// 预览窗格与大预览
function selectedOne(){ const p=panes[active]; const id=[...p.selection][0]; return id? findById(id):null }
function renderPreviewPane(mount){ const it=selectedOne(); const box=document.createElement('div'); box.className='border border-slate-200 rounded-xl bg-white p-3'; if(!it){ box.innerHTML='<div class="text-slate-500 text-sm">未选择项目</div>' } else { box.innerHTML = it.type==='image'? `<img src="${it.poster||it.src}" class="w-full rounded mb-3"/><div class="text-sm">${it.name}</div><div class="text-xs text-slate-500">${it.w}×${it.h}</div>` : it.type==='video'? `<img src="${it.poster}" class="w-full rounded mb-3"/><div class="text-sm">${it.name}</div><div class="text-xs text-slate-500">视频</div>` : it.type==='folder'? `<div class="text-sm">文件夹 · ${it.name}</div>` : `<div class='w-full h-40 rounded' style='background:${it.color||'#CBD5E1'}'></div><div class='text-sm mt-2'>${it.name}</div>` } mount.replaceChildren(box) }
function openPreview(it){ const m=qs('#previewModal'),i=qs('#pvImg'),v=qs('#pvVideo'); if(it.type==='image'){ v.pause(); v.classList.add('hidden'); i.src=it.src; i.classList.remove('hidden') } else if(it.type==='video'){ i.classList.add('hidden'); v.poster=it.poster; v.classList.remove('hidden') } m.classList.remove('hidden'); m.classList.add('flex') }
function closePreview(){ const m=qs('#previewModal'),v=qs('#pvVideo'); v.pause(); m.classList.add('hidden'); m.classList.remove('flex') }
// 分割条拖动
let dragState=null
function initHResize(container){ const splitter=container.querySelector('.splitter-vert'); if(!splitter) return; splitter.onmousedown=(e)=>{ e.preventDefault(); const rect=container.getBoundingClientRect(); const total=rect.width; dragState={type:'h', startX:e.clientX, total, container, left:container._left || total/2 } ; document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag, {once:true}) } }
function initVResize(container){ const splitter=container.querySelector('.splitter-horz'); if(!splitter) return; splitter.onmousedown=(e)=>{ e.preventDefault(); const rect=container.getBoundingClientRect(); const total=rect.height; dragState={type:'v', startY:e.clientY, total, container, top:container._top || total/2 } ; document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag, {once:true}) } }
function onDrag(e){ if(!dragState) return; if(dragState.type==='h'){ const dx=e.clientX - dragState.startX; let left=dragState.left + dx; const min=240, max=dragState.total - 240; left=Math.max(min, Math.min(max, left)); const right=dragState.total - left - 6; dragState.container.style.gridTemplateColumns = dragState.container._withPreview? `${left}px 6px ${Math.max(240,right-340-6)}px 6px 340px` : `${left}px 6px ${right}px`; dragState.container._left = left } else { const dy=e.clientY - dragState.startY; let top=dragState.top + dy; const min=180, max=dragState.total - 180; top=Math.max(min, Math.min(max, top)); const bottom=dragState.total - top - 6; dragState.container.style.gridTemplateRows = `${top}px 6px ${bottom}px`; dragState.container._top = top } }
function stopDrag(){ document.removeEventListener('mousemove', onDrag); dragState=null }
// 统计
function statsText(p){ const l=items(p); const s={all:l.length,folder:0,image:0,video:0,other:0}; l.forEach(i=>s[i.type]++); return `共 ${s.all} 项 · 文件夹 ${s.folder} · 照片 ${s.image} · 视频 ${s.video}` }
// 渲染布局
function renderAll(){
// 顶部状态
qs('#stats').textContent = statsText(panes[active])
renderCrumb()
const work=qs('#work'); work.innerHTML=''
// 计算列 / 行
if(layoutMode==='single'){
work.style.gridTemplateRows = '1fr'
work.style.gridTemplateColumns = previewOn? '1fr 340px' : '1fr'
const a=document.createElement('div'); a.id='paneA'; renderPane(panes[0], a, 0); work.appendChild(a)
if(previewOn){ const pv=document.createElement('aside'); renderPreviewPane(pv); work.appendChild(pv) }
} else if(layoutMode==='h'){
if(previewOn){ work.style.gridTemplateColumns = '1fr 6px 1fr 6px 340px'; work._withPreview=true } else { work.style.gridTemplateColumns = '1fr 6px 1fr'; work._withPreview=false }
work.style.gridTemplateRows = '1fr'
const a=document.createElement('div'); a.id='paneA'; const b=document.createElement('div'); b.id='paneB'; const split=document.createElement('div'); split.className='splitter splitter-vert rounded'; renderPane(panes[0], a, 0); renderPane(panes[1], b, 1); work.append(a, split, b); if(previewOn){ const s2=document.createElement('div'); s2.style.width='6px'; work.appendChild(s2); const pv=document.createElement('aside'); renderPreviewPane(pv); work.appendChild(pv) } initHResize(work)
} else { // two-v
work.style.gridTemplateColumns = previewOn? '1fr 340px' : '1fr'
work.style.gridTemplateRows = '1fr 6px 1fr'
const col=document.createElement('div'); col.className='grid gap-2'; col.style.gridTemplateRows='1fr 6px 1fr'
const a=document.createElement('div'); a.id='paneA'; const b=document.createElement('div'); b.id='paneB'; const split=document.createElement('div'); split.className='splitter splitter-horz rounded'; renderPane(panes[0], a, 0); renderPane(panes[1], b, 1); col.append(a, split, b); if(previewOn){ const pv=document.createElement('aside'); renderPreviewPane(pv); work.append(col, pv) } else { work.append(col) } initVResize(col)
}
// 导航按钮状态
const p=panes[active]; qs('#navBack').disabled=p.hi===0; qs('#navForward').disabled=p.hi>=p.history.length-1; qs('#navUp').disabled=p.current===root
}
// 命令栏(活动面板)
let clip=null
function sel(){ return [...panes[active].selection].map(id=>findById(id)).filter(Boolean) }
qs('#cmdCut').onclick = ()=>{ const a=sel(); if(!a.length) return alert('请选择'); clip={op:'cut', ids:a.map(x=>x.id)} }
qs('#cmdCopy').onclick = ()=>{ const a=sel(); if(!a.length) return alert('请选择'); clip={op:'copy', ids:a.map(x=>x.id)} }
qs('#cmdPaste').onclick = ()=>{ if(!clip) return; const p=panes[active]; if(clip.op==='cut'){ clip.ids.forEach(id=>{ const n=findById(id); if(n){ removeNode(id); (p.current.children ||= []).push(n) } }); clip=null } else { clip.ids.forEach(id=>{ const n=findById(id); if(n){ (p.current.children ||= []).push(cloneNode(n)) } }) } renderAll() }
qs('#cmdRename').onclick = ()=>{ const p=panes[active]; const id=[...p.selection][0]; if(!id) return alert('请选择 1 项'); const n=findById(id); const name=prompt('重命名为:', n.name); if(name){ n.name=name; renderAll() } }
qs('#cmdDel').onclick = ()=>{ const p=panes[active]; if(!p.selection.size) return; if(!confirm('确定删除所选项目(演示)?')) return; [...p.selection].forEach(id=>removeNode(id)); p.selection.clear(); renderAll() }
qs('#cmdAlbum').onclick = ()=> alert('已加入相册(演示)')
qs('#cmdTags').onclick = ()=> alert('标签已更新(演示)')
// 复制与删除辅助
function parentOf(id){ let p=null; (function rec(n){ if(!n.children) return; for(const c of n.children){ if(c.id===id){ p=n; return } rec(c) } })(root); return p }
function removeNode(id){ const p=parentOf(id); if(p){ p.children=p.children.filter(c=>c.id!==id) } }
function cloneNode(n){ if(n.type==='folder') return {id:uid(),type:'folder',name:n.name+' - 副本',children:(n.children||[]).map(cloneNode)}; return {...n,id:uid(),name:n.name+' - 副本'} }
// 事件绑定:视图/布局/预览/搜索
qs('#viewTiles').onclick=()=>{ panes[active].view='tiles'; qs('#viewTiles').classList.add('bg-white','shadow'); qs('#viewList').classList.remove('bg-white','shadow'); renderAll() }
qs('#viewList').onclick=()=>{ panes[active].view='list'; qs('#viewList').classList.add('bg-white','shadow'); qs('#viewTiles').classList.remove('bg-white','shadow'); renderAll() }
document.querySelectorAll('.sizeBtn').forEach(b=> b.onclick=()=>{ document.querySelectorAll('.sizeBtn').forEach(x=>x.classList.remove('bg-white','shadow')); b.classList.add('bg-white','shadow'); setTile(parseInt(b.dataset.size)); renderAll() })
qs('#btnSingle').onclick=()=>{ layoutMode='single'; setLay('btnSingle') }
qs('#btnH').onclick=()=>{ layoutMode='h'; setLay('btnH') }
qs('#btnV').onclick=()=>{ layoutMode='v'; setLay('btnV') }
function setLay(id){ ['btnSingle','btnH','btnV'].forEach(k=>qs('#'+k).classList.remove('bg-white','shadow')); qs('#'+id).classList.add('bg-white','shadow'); renderAll() }
qs('#togglePreview').onchange=e=>{ previewOn=e.target.checked; renderAll() }
qs('#search').oninput = e=>{ panes[active].keyword=(e.target.value||'').toLowerCase().trim(); renderAll() }
// 导航按钮与快捷键
qs('#navBack').onclick=()=>back(); qs('#navForward').onclick=()=>forward(); qs('#navUp').onclick=()=>up()
document.addEventListener('keydown', e=>{ const p=panes[active]; if(e.key==='Backspace' && !e.target.matches('input,textarea')){ e.preventDefault(); up() } if(e.altKey&&e.key==='ArrowLeft'){ e.preventDefault(); back() } if(e.altKey&&e.key==='ArrowRight'){ e.preventDefault(); forward() } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){ e.preventDefault(); items(p).forEach(i=>p.selection.add(i.id)); renderAll() } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='c'){ e.preventDefault(); qs('#cmdCopy').click() } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='x'){ e.preventDefault(); qs('#cmdCut').click() } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='v'){ e.preventDefault(); qs('#cmdPaste').click() } if(e.key==='Delete'){ e.preventDefault(); qs('#cmdDel').click() } if(e.key==='Enter'){ const it=selectedOne(); if(it){ if(it.type==='folder') enter(p,it); else openPreview(it) } } })
// 主题
function theme(m){ document.documentElement.classList.toggle('dark', m==='dark'); document.body.className = m==='dark' ? 'bg-slate-900 text-slate-100' : 'bg-slate-50 text-slate-900'; localStorage.setItem('argus-theme', m) }
const saved = localStorage.getItem('argus-theme') || (matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'); theme(saved)
qs('#btnTheme').onclick=()=> theme(document.documentElement.classList.contains('dark')? 'light':'dark')
// 启动
setTile(220); renderAll()
</script>
</body>
<style>
.dark .bg-white{ background:#0f172a }
.dark .bg-slate-50{ background:#0b1220 }
.dark .text-slate-900{ color:#E2E8F0 }
.dark .border-slate-200{ border-color:#253048 }
.dark .hover\:bg-slate-100:hover{ background:#172036 }
</style>
</html>