A: 添加多个设计页面和原型文件
- 添加照片管理器的多个设计版本 - 包含文件夹预览界面 - 添加现代化照片管理器界面 - 包含浅色主题设计变体 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
3cee977417
commit
d9690fbaae
13 files changed
+9515
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user