Add mobile-first responsive design with bottom tab navigation
Converts desktop sidebar to hidden on mobile, adds bottom tab bar with 5 primary items and a "More" overflow menu. All pages get responsive spacing, smaller touch targets, and tighter grids on small screens. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,9 @@
|
|||||||
<html lang="en" class="dark">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<title>OFApp</title>
|
<title>OFApp</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -21,8 +21,24 @@ const navItems = [
|
|||||||
{ to: '/', label: 'Settings', icon: SettingsIcon },
|
{ to: '/', label: 'Settings', icon: SettingsIcon },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Bottom bar shows a subset of nav items (most used) to avoid crowding
|
||||||
|
const mobileNavItems = [
|
||||||
|
{ to: '/feed', label: 'Feed', icon: FeedIcon },
|
||||||
|
{ to: '/users', label: 'Users', icon: UsersIcon },
|
||||||
|
{ to: '/gallery', label: 'Gallery', icon: GalleryNavIcon },
|
||||||
|
{ to: '/search', label: 'Search', icon: SearchIcon },
|
||||||
|
{ to: '/more', label: 'More', icon: MoreIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
const moreNavItems = [
|
||||||
|
{ to: '/downloads', label: 'Downloads', icon: DownloadIcon },
|
||||||
|
{ to: '/scrape', label: 'Scrape', icon: ScrapeIcon },
|
||||||
|
{ to: '/', label: 'Settings', icon: SettingsIcon },
|
||||||
|
]
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [currentUser, setCurrentUser] = useState(null)
|
const [currentUser, setCurrentUser] = useState(null)
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,6 +49,11 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Close "more" menu on route change
|
||||||
|
useEffect(() => {
|
||||||
|
setMoreOpen(false)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
const refreshUser = () => {
|
const refreshUser = () => {
|
||||||
getMe().then((data) => {
|
getMe().then((data) => {
|
||||||
if (!data.error) {
|
if (!data.error) {
|
||||||
@@ -41,10 +62,14 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMoreActive = moreNavItems.some((item) =>
|
||||||
|
item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to)
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-[#0a0a0a]">
|
<div className="flex min-h-screen bg-[#0a0a0a]">
|
||||||
{/* Sidebar */}
|
{/* Desktop Sidebar */}
|
||||||
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-[#111] border-r border-[#222] flex flex-col z-50">
|
<aside className="hidden md:flex fixed left-0 top-0 bottom-0 w-60 bg-[#111] border-r border-[#222] flex-col z-50">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="p-6 border-b border-[#222]">
|
<div className="p-6 border-b border-[#222]">
|
||||||
<h1 className="text-xl font-bold text-white tracking-tight">
|
<h1 className="text-xl font-bold text-white tracking-tight">
|
||||||
@@ -104,8 +129,8 @@ export default function App() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="ml-60 flex-1 min-h-screen">
|
<main className="md:ml-60 flex-1 min-h-screen pb-20 md:pb-0">
|
||||||
<div className="max-w-5xl mx-auto p-6">
|
<div className="max-w-5xl mx-auto p-4 md:p-6">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Login onAuth={refreshUser} />} />
|
<Route path="/" element={<Login onAuth={refreshUser} />} />
|
||||||
<Route path="/feed" element={<Feed />} />
|
<Route path="/feed" element={<Feed />} />
|
||||||
@@ -119,6 +144,77 @@ export default function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Mobile Bottom Nav */}
|
||||||
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-[#111] border-t border-[#222] z-50 safe-bottom">
|
||||||
|
<div className="flex items-center justify-around h-14">
|
||||||
|
{mobileNavItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
|
||||||
|
if (item.to === '/more') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key="more"
|
||||||
|
onClick={() => setMoreOpen((v) => !v)}
|
||||||
|
className={`flex flex-col items-center justify-center gap-0.5 w-full h-full transition-colors ${
|
||||||
|
isMoreActive || moreOpen ? 'text-[#0095f6]' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
<span className="text-[10px]">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive =
|
||||||
|
item.to === '/'
|
||||||
|
? location.pathname === '/'
|
||||||
|
: location.pathname.startsWith(item.to)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={`flex flex-col items-center justify-center gap-0.5 w-full h-full transition-colors ${
|
||||||
|
isActive ? 'text-[#0095f6]' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
<span className="text-[10px]">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* More menu popover */}
|
||||||
|
{moreOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setMoreOpen(false)} />
|
||||||
|
<div className="absolute bottom-full left-0 right-0 bg-[#161616] border-t border-[#222] z-50 shadow-xl">
|
||||||
|
{moreNavItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const isActive =
|
||||||
|
item.to === '/'
|
||||||
|
? location.pathname === '/'
|
||||||
|
: location.pathname.startsWith(item.to)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={`flex items-center gap-3 px-5 py-3.5 transition-colors ${
|
||||||
|
isActive ? 'text-[#0095f6] bg-[#0095f6]/5' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
<span className="text-sm font-medium">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -181,3 +277,11 @@ function SettingsIcon({ className }) {
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MoreIcon({ className }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function PostCard({ post }) {
|
|||||||
return (
|
return (
|
||||||
<article className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
|
<article className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
|
||||||
{/* Author Row */}
|
{/* Author Row */}
|
||||||
<div className="flex items-center gap-3 p-4 pb-0">
|
<div className="flex items-center gap-3 p-3 md:p-4 pb-0">
|
||||||
<img
|
<img
|
||||||
src={author.avatar}
|
src={author.avatar}
|
||||||
alt={author.name || 'User'}
|
alt={author.name || 'User'}
|
||||||
@@ -66,7 +66,7 @@ export default function PostCard({ post }) {
|
|||||||
|
|
||||||
{/* Media */}
|
{/* Media */}
|
||||||
{media.length > 0 && (
|
{media.length > 0 && (
|
||||||
<div className="p-4 pt-3 pb-0">
|
<div className="p-3 md:p-4 pt-3 pb-0">
|
||||||
<MediaGrid
|
<MediaGrid
|
||||||
media={media}
|
media={media}
|
||||||
entityId={post.id}
|
entityId={post.id}
|
||||||
@@ -77,7 +77,7 @@ export default function PostCard({ post }) {
|
|||||||
|
|
||||||
{/* Collapsible Post Text */}
|
{/* Collapsible Post Text */}
|
||||||
{text && (
|
{text && (
|
||||||
<div className="px-4 pt-2 pb-3">
|
<div className="px-3 md:px-4 pt-2 pb-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowText((v) => !v)}
|
onClick={() => setShowText((v) => !v)}
|
||||||
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ export default function UserCard({ user, onDownload, downloading }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-4 hover:border-[#333] transition-colors duration-200 hover-lift">
|
<div className="bg-[#161616] border border-[#222] rounded-lg p-3 md:p-4 hover:border-[#333] transition-colors duration-200 hover-lift">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<img
|
<img
|
||||||
src={user.avatar}
|
src={user.avatar}
|
||||||
alt={user.name}
|
alt={user.name}
|
||||||
className="w-16 h-16 rounded-full object-cover bg-[#1a1a1a] flex-shrink-0"
|
className="w-12 h-12 md:w-16 md:h-16 rounded-full object-cover bg-[#1a1a1a] flex-shrink-0"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect fill="%23333" width="64" height="64" rx="32"/><text x="32" y="40" text-anchor="middle" fill="white" font-size="24">${(user.name || '?')[0]}</text></svg>`
|
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect fill="%23333" width="64" height="64" rx="32"/><text x="32" y="40" text-anchor="middle" fill="white" font-size="24">${(user.name || '?')[0]}</text></svg>`
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ body {
|
|||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Safe area for iOS bottom bar */
|
||||||
|
.safe-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
/* Transition utilities */
|
/* Transition utilities */
|
||||||
.transition-smooth {
|
.transition-smooth {
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ export default function Downloads() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-4 md:mb-6">
|
||||||
<h1 className="text-2xl font-bold text-white mb-1">Downloads</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-white mb-1">Downloads</h1>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">
|
||||||
Manage and monitor media downloads
|
Manage and monitor media downloads
|
||||||
</p>
|
</p>
|
||||||
@@ -239,8 +239,8 @@ export default function Downloads() {
|
|||||||
</div>
|
</div>
|
||||||
) : history.length === 0 ? null : (
|
) : history.length === 0 ? null : (
|
||||||
<div className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
|
<div className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
|
||||||
{/* Table Header */}
|
{/* Table Header - hidden on mobile */}
|
||||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 border-b border-[#222] text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
<div className="hidden md:grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 border-b border-[#222] text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||||
<span>User</span>
|
<span>User</span>
|
||||||
<span className="text-right">Files</span>
|
<span className="text-right">Files</span>
|
||||||
<span className="text-right">Status</span>
|
<span className="text-right">Status</span>
|
||||||
@@ -253,34 +253,44 @@ export default function Downloads() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={uid || index}
|
key={uid || index}
|
||||||
className={`grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 items-center ${
|
className={`px-4 py-3 ${
|
||||||
index < history.length - 1 ? 'border-b border-[#1a1a1a]' : ''
|
index < history.length - 1 ? 'border-b border-[#1a1a1a]' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{/* Desktop row */}
|
||||||
|
<div className="hidden md:grid grid-cols-[1fr_auto_auto_auto] gap-4 items-center">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className="w-8 h-8 rounded-full bg-[#333] flex-shrink-0" />
|
<div className="w-8 h-8 rounded-full bg-[#333] flex-shrink-0" />
|
||||||
<span className="text-sm text-white truncate">
|
<span className="text-sm text-white truncate">
|
||||||
{usernames[uid] ? `@${usernames[uid]}` : `User ${uid}`}
|
{usernames[uid] ? `@${usernames[uid]}` : `User ${uid}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-sm text-gray-400 text-right tabular-nums">
|
<span className="text-sm text-gray-400 text-right tabular-nums">
|
||||||
{item.fileCount || item.file_count || 0}
|
{item.fileCount || item.file_count || 0}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="text-right">
|
<span className="text-right">
|
||||||
<StatusBadge status="complete" />
|
<StatusBadge status="complete" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="text-xs text-gray-500 text-right whitespace-nowrap">
|
<span className="text-xs text-gray-500 text-right whitespace-nowrap">
|
||||||
{formatDate(
|
{formatDate(item.lastDownload || item.last_download || item.completedAt || item.created_at)}
|
||||||
item.lastDownload ||
|
|
||||||
item.last_download ||
|
|
||||||
item.completedAt ||
|
|
||||||
item.created_at
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Mobile row */}
|
||||||
|
<div className="md:hidden flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-[#333] flex-shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm text-white truncate block">
|
||||||
|
{usernames[uid] ? `@${usernames[uid]}` : `User ${uid}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{item.fileCount || item.file_count || 0} files
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status="complete" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,9 +91,10 @@ export default function Duplicates() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mb-4 md:mb-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Duplicate Files</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-white">Duplicate Files</h1>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
{total} duplicate group{total !== 1 ? 's' : ''} found
|
{total} duplicate group{total !== 1 ? 's' : ''} found
|
||||||
{totalSaved > 0 && (
|
{totalSaved > 0 && (
|
||||||
@@ -101,7 +102,7 @@ export default function Duplicates() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 md:gap-3">
|
||||||
{total > 0 && !cleaning && (
|
{total > 0 && !cleaning && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -117,7 +118,7 @@ export default function Duplicates() {
|
|||||||
setGroups([])
|
setGroups([])
|
||||||
setTotal(0)
|
setTotal(0)
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 text-sm font-medium rounded-lg transition-colors"
|
className="px-3 md:px-4 py-2 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 text-sm font-medium rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Delete All Duplicates
|
Delete All Duplicates
|
||||||
</button>
|
</button>
|
||||||
@@ -132,8 +133,9 @@ export default function Duplicates() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{total > LIMIT && (
|
{total > LIMIT && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 mt-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => fetchGroups(Math.max(0, offset - LIMIT))}
|
onClick={() => fetchGroups(Math.max(0, offset - LIMIT))}
|
||||||
disabled={offset === 0 || loading}
|
disabled={offset === 0 || loading}
|
||||||
@@ -189,7 +191,7 @@ export default function Duplicates() {
|
|||||||
{group[0].type}
|
{group[0].type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 p-3" style={{ gridTemplateColumns: `repeat(${Math.min(group.length, 4)}, 1fr)` }}>
|
<div className="grid gap-2 md:gap-3 p-2 md:p-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
{group.map((file) => {
|
{group.map((file) => {
|
||||||
const key = `${file.folder}/${file.filename}`
|
const key = `${file.folder}/${file.filename}`
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ export default function Feed() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-4 md:mb-6">
|
||||||
<h1 className="text-2xl font-bold text-white mb-1">Feed</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-white mb-1">Feed</h1>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">
|
||||||
Recent posts from your subscriptions
|
Recent posts from your subscriptions
|
||||||
</p>
|
</p>
|
||||||
@@ -97,7 +97,7 @@ export default function Feed() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<div key={post.id}>
|
<div key={post.id}>
|
||||||
<PostCard post={post} />
|
<PostCard post={post} />
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export default function Gallery() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<h1 className="text-2xl font-bold text-white">Gallery</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-white">Gallery</h1>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">
|
||||||
{total} file{total !== 1 ? 's' : ''}{checkedFolders.size === 0 ? ' saved locally' : ''}
|
{total} file{total !== 1 ? 's' : ''}{checkedFolders.size === 0 ? ' saved locally' : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -187,7 +187,7 @@ export default function Gallery() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-6">
|
<div className="flex flex-wrap items-center gap-2 md:gap-3 mb-4 md:mb-6">
|
||||||
{/* User Filter Popover */}
|
{/* User Filter Popover */}
|
||||||
<div className="relative" ref={filterRef}>
|
<div className="relative" ref={filterRef}>
|
||||||
<button
|
<button
|
||||||
@@ -350,7 +350,7 @@ export default function Gallery() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
<div className="grid grid-cols-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-1 md:gap-2">
|
||||||
{files.map((file, i) => (
|
{files.map((file, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${file.folder}-${file.filename}-${i}`}
|
key={`${file.folder}-${file.filename}-${i}`}
|
||||||
|
|||||||
@@ -216,15 +216,15 @@ export default function Login({ onAuth }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<div className="mb-8">
|
<div className="mb-5 md:mb-8">
|
||||||
<h1 className="text-2xl font-bold text-white mb-2">Settings</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-white mb-2">Settings</h1>
|
||||||
<p className="text-gray-400 text-sm">
|
<p className="text-gray-400 text-sm">
|
||||||
Configure your API authentication credentials.
|
Configure your API authentication credentials.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Instructions */}
|
{/* Instructions */}
|
||||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-4 mb-6">
|
<div className="bg-[#161616] border border-[#222] rounded-lg p-3 md:p-4 mb-4 md:mb-6">
|
||||||
<h3 className="text-sm font-semibold text-gray-300 mb-2">
|
<h3 className="text-sm font-semibold text-gray-300 mb-2">
|
||||||
How to find your credentials
|
How to find your credentials
|
||||||
</h3>
|
</h3>
|
||||||
@@ -252,7 +252,7 @@ export default function Login({ onAuth }) {
|
|||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-6 space-y-5">
|
<div className="bg-[#161616] border border-[#222] rounded-lg p-4 md:p-6 space-y-4 md:space-y-5">
|
||||||
{fields.map((field) => (
|
{fields.map((field) => (
|
||||||
<div key={field.key}>
|
<div key={field.key}>
|
||||||
<label
|
<label
|
||||||
@@ -329,9 +329,9 @@ export default function Login({ onAuth }) {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* App Settings */}
|
{/* App Settings */}
|
||||||
<div className="mt-8">
|
<div className="mt-6 md:mt-8">
|
||||||
<h2 className="text-lg font-bold text-white mb-4">App Settings</h2>
|
<h2 className="text-lg font-bold text-white mb-3 md:mb-4">App Settings</h2>
|
||||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-6">
|
<div className="bg-[#161616] border border-[#222] rounded-lg p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-300">Rescan Media Library</p>
|
<p className="text-sm font-medium text-gray-300">Rescan Media Library</p>
|
||||||
|
|||||||
@@ -221,12 +221,12 @@ export default function Scrape() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-white mb-1">Scrape</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-white mb-1">Scrape</h1>
|
||||||
<p className="text-gray-500 text-sm">Download images from forums, Coomer/Kemono, and gallery sites</p>
|
<p className="text-gray-500 text-sm">Download images from forums, Coomer/Kemono, and gallery sites</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Selector */}
|
{/* Tab Selector */}
|
||||||
<div className="flex gap-1 mb-6 bg-[#161616] p-1 rounded-lg w-fit">
|
<div className="flex gap-1 mb-4 md:mb-6 bg-[#161616] p-1 rounded-lg w-full sm:w-fit">
|
||||||
<button
|
<button
|
||||||
onClick={() => setTab('forum')}
|
onClick={() => setTab('forum')}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||||
@@ -293,7 +293,7 @@ export default function Scrape() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 md:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1.5">Start Page</label>
|
<label className="block text-xs text-gray-500 mb-1.5">Start Page</label>
|
||||||
<input
|
<input
|
||||||
@@ -383,7 +383,7 @@ export default function Scrape() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1.5">Pages (50 posts each)</label>
|
<label className="block text-xs text-gray-500 mb-1.5">Pages (50 posts each)</label>
|
||||||
<input
|
<input
|
||||||
@@ -447,7 +447,7 @@ export default function Scrape() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 md:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1.5">Max Pages</label>
|
<label className="block text-xs text-gray-500 mb-1.5">Max Pages</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -89,21 +89,21 @@ export default function Search() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white mb-6">Search User</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-white mb-4 md:mb-6">Search User</h1>
|
||||||
|
|
||||||
{/* Search Form */}
|
{/* Search Form */}
|
||||||
<form onSubmit={handleSearch} className="flex gap-3 mb-6">
|
<form onSubmit={handleSearch} className="flex gap-2 md:gap-3 mb-4 md:mb-6">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Enter username..."
|
placeholder="Enter username..."
|
||||||
className="flex-1 bg-[#161616] border border-[#333] rounded-lg px-4 py-2.5 text-white text-sm placeholder-gray-500 focus:outline-none focus:border-[#0095f6] transition-colors"
|
className="flex-1 bg-[#161616] border border-[#333] rounded-lg px-3 md:px-4 py-2.5 text-white text-sm placeholder-gray-500 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !query.trim()}
|
disabled={loading || !query.trim()}
|
||||||
className="px-5 py-2.5 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
className="px-4 md:px-5 py-2.5 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors flex-shrink-0"
|
||||||
>
|
>
|
||||||
{loading ? 'Searching...' : 'Search'}
|
{loading ? 'Searching...' : 'Search'}
|
||||||
</button>
|
</button>
|
||||||
@@ -148,13 +148,13 @@ export default function Search() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-4 md:p-6">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-3 md:gap-4">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<img
|
<img
|
||||||
src={user.avatar}
|
src={user.avatar}
|
||||||
alt={user.name}
|
alt={user.name}
|
||||||
className="w-20 h-20 rounded-full object-cover bg-[#1a1a1a] flex-shrink-0"
|
className="w-16 h-16 md:w-20 md:h-20 rounded-full object-cover bg-[#1a1a1a] flex-shrink-0"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><rect fill="%23333" width="80" height="80" rx="40"/><text x="40" y="50" text-anchor="middle" fill="white" font-size="28">${(user.name || '?')[0]}</text></svg>`
|
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><rect fill="%23333" width="80" height="80" rx="40"/><text x="40" y="50" text-anchor="middle" fill="white" font-size="28">${(user.name || '?')[0]}</text></svg>`
|
||||||
}}
|
}}
|
||||||
@@ -215,7 +215,7 @@ export default function Search() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-3 mt-5">
|
<div className="flex flex-wrap items-center gap-2 md:gap-3 mt-4 md:mt-5">
|
||||||
{/* View Posts — always shown */}
|
{/* View Posts — always shown */}
|
||||||
<Link
|
<Link
|
||||||
to={`/users/${user.id}`}
|
to={`/users/${user.id}`}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export default function UserPosts() {
|
|||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/users')}
|
onClick={() => navigate('/users')}
|
||||||
className="flex items-center gap-2 text-gray-400 hover:text-white text-sm mb-6 transition-colors"
|
className="flex items-center gap-2 text-gray-400 hover:text-white text-sm mb-4 md:mb-6 transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
@@ -121,18 +121,19 @@ export default function UserPosts() {
|
|||||||
|
|
||||||
{/* User Header */}
|
{/* User Header */}
|
||||||
{user && (
|
{user && (
|
||||||
<div className="flex items-center justify-between bg-[#161616] border border-[#222] rounded-lg p-4 mb-6">
|
<div className="bg-[#161616] border border-[#222] rounded-lg p-4 mb-4 md:mb-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
|
||||||
<img
|
<img
|
||||||
src={user.avatar}
|
src={user.avatar}
|
||||||
alt={user.name}
|
alt={user.name}
|
||||||
className="w-14 h-14 rounded-full object-cover bg-[#1a1a1a]"
|
className="w-11 h-11 md:w-14 md:h-14 rounded-full object-cover bg-[#1a1a1a] flex-shrink-0"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 56"><rect fill="%23333" width="56" height="56" rx="28"/><text x="28" y="35" text-anchor="middle" fill="white" font-size="20">${(user.name || '?')[0]}</text></svg>`
|
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 56"><rect fill="%23333" width="56" height="56" rx="28"/><text x="28" y="35" text-anchor="middle" fill="white" font-size="20">${(user.name || '?')[0]}</text></svg>`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-lg font-bold text-white">{decodeHTML(user.name)}</h1>
|
<h1 className="text-base md:text-lg font-bold text-white truncate">{decodeHTML(user.name)}</h1>
|
||||||
<p className="text-gray-500 text-sm">@{user.username}</p>
|
<p className="text-gray-500 text-sm">@{user.username}</p>
|
||||||
{(user.postsCount !== undefined || user.mediasCount !== undefined) && (
|
{(user.postsCount !== undefined || user.mediasCount !== undefined) && (
|
||||||
<p className="text-gray-600 text-xs mt-0.5">
|
<p className="text-gray-600 text-xs mt-0.5">
|
||||||
@@ -144,11 +145,11 @@ export default function UserPosts() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative flex-shrink-0 ml-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDownloadMenu((v) => !v)}
|
onClick={() => setShowDownloadMenu((v) => !v)}
|
||||||
disabled={downloading}
|
disabled={downloading}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
className="flex items-center gap-1.5 md:gap-2 px-3 md:px-4 py-2 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
@@ -163,9 +164,10 @@ export default function UserPosts() {
|
|||||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{downloading ? 'Starting...' : 'Download Media'}
|
<span className="hidden md:inline">{downloading ? 'Starting...' : 'Download Media'}</span>
|
||||||
|
<span className="md:hidden">{downloading ? '...' : ''}</span>
|
||||||
<svg
|
<svg
|
||||||
className="w-3 h-3 ml-1"
|
className="w-3 h-3 ml-0.5 md:ml-1 hidden md:block"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -185,7 +187,7 @@ export default function UserPosts() {
|
|||||||
className="fixed inset-0 z-10"
|
className="fixed inset-0 z-10"
|
||||||
onClick={() => setShowDownloadMenu(false)}
|
onClick={() => setShowDownloadMenu(false)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-0 mt-2 w-52 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-20 overflow-hidden">
|
<div className="absolute right-0 mt-2 w-48 md:w-52 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-20 overflow-hidden">
|
||||||
{cursorInfo && (
|
{cursorInfo && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -226,6 +228,7 @@ export default function UserPosts() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Posts */}
|
{/* Posts */}
|
||||||
@@ -247,7 +250,7 @@ export default function UserPosts() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<PostCard key={post.id} post={post} />
|
<PostCard key={post.id} post={post} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ export default function Users() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-4 md:mb-6">
|
||||||
<h1 className="text-2xl font-bold text-white mb-1">Subscriptions</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-white mb-1">Subscriptions</h1>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">
|
||||||
{users.length} user{users.length !== 1 ? 's' : ''} found
|
{users.length} user{users.length !== 1 ? 's' : ''} found
|
||||||
</p>
|
</p>
|
||||||
@@ -122,7 +122,7 @@ export default function Users() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||||
{[...users].sort((a, b) => (a.name || '').localeCompare(b.name || '')).map((user) => (
|
{[...users].sort((a, b) => (a.name || '').localeCompare(b.name || '')).map((user) => (
|
||||||
<UserCard
|
<UserCard
|
||||||
key={user.id}
|
key={user.id}
|
||||||
|
|||||||
Reference in New Issue
Block a user