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">
|
||||
<head>
|
||||
<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>
|
||||
<style>
|
||||
body {
|
||||
|
||||
@@ -21,8 +21,24 @@ const navItems = [
|
||||
{ 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() {
|
||||
const [currentUser, setCurrentUser] = useState(null)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,6 +49,11 @@ export default function App() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Close "more" menu on route change
|
||||
useEffect(() => {
|
||||
setMoreOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
const refreshUser = () => {
|
||||
getMe().then((data) => {
|
||||
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 (
|
||||
<div className="flex min-h-screen bg-[#0a0a0a]">
|
||||
{/* Sidebar */}
|
||||
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-[#111] border-r border-[#222] flex flex-col z-50">
|
||||
{/* Desktop Sidebar */}
|
||||
<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 */}
|
||||
<div className="p-6 border-b border-[#222]">
|
||||
<h1 className="text-xl font-bold text-white tracking-tight">
|
||||
@@ -104,8 +129,8 @@ export default function App() {
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="ml-60 flex-1 min-h-screen">
|
||||
<div className="max-w-5xl mx-auto p-6">
|
||||
<main className="md:ml-60 flex-1 min-h-screen pb-20 md:pb-0">
|
||||
<div className="max-w-5xl mx-auto p-4 md:p-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Login onAuth={refreshUser} />} />
|
||||
<Route path="/feed" element={<Feed />} />
|
||||
@@ -119,6 +144,77 @@ export default function App() {
|
||||
</Routes>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -181,3 +277,11 @@ function SettingsIcon({ className }) {
|
||||
</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 (
|
||||
<article className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
|
||||
{/* 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
|
||||
src={author.avatar}
|
||||
alt={author.name || 'User'}
|
||||
@@ -66,7 +66,7 @@ export default function PostCard({ post }) {
|
||||
|
||||
{/* Media */}
|
||||
{media.length > 0 && (
|
||||
<div className="p-4 pt-3 pb-0">
|
||||
<div className="p-3 md:p-4 pt-3 pb-0">
|
||||
<MediaGrid
|
||||
media={media}
|
||||
entityId={post.id}
|
||||
@@ -77,7 +77,7 @@ export default function PostCard({ post }) {
|
||||
|
||||
{/* Collapsible Post Text */}
|
||||
{text && (
|
||||
<div className="px-4 pt-2 pb-3">
|
||||
<div className="px-3 md:px-4 pt-2 pb-3">
|
||||
<button
|
||||
onClick={() => setShowText((v) => !v)}
|
||||
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 (
|
||||
<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">
|
||||
{/* Avatar */}
|
||||
<img
|
||||
src={user.avatar}
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Safe area for iOS bottom bar */
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Transition utilities */
|
||||
.transition-smooth {
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
@@ -108,8 +108,8 @@ export default function Downloads() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-1">Downloads</h1>
|
||||
<div className="mb-4 md:mb-6">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-white mb-1">Downloads</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Manage and monitor media downloads
|
||||
</p>
|
||||
@@ -239,8 +239,8 @@ export default function Downloads() {
|
||||
</div>
|
||||
) : history.length === 0 ? null : (
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
|
||||
{/* Table Header */}
|
||||
<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">
|
||||
{/* Table Header - hidden on mobile */}
|
||||
<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 className="text-right">Files</span>
|
||||
<span className="text-right">Status</span>
|
||||
@@ -253,33 +253,43 @@ export default function Downloads() {
|
||||
return (
|
||||
<div
|
||||
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]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-[#333] flex-shrink-0" />
|
||||
<span className="text-sm text-white truncate">
|
||||
{usernames[uid] ? `@${usernames[uid]}` : `User ${uid}`}
|
||||
{/* 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="w-8 h-8 rounded-full bg-[#333] flex-shrink-0" />
|
||||
<span className="text-sm text-white truncate">
|
||||
{usernames[uid] ? `@${usernames[uid]}` : `User ${uid}`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400 text-right tabular-nums">
|
||||
{item.fileCount || item.file_count || 0}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
<StatusBadge status="complete" />
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 text-right whitespace-nowrap">
|
||||
{formatDate(item.lastDownload || item.last_download || item.completedAt || item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-gray-400 text-right tabular-nums">
|
||||
{item.fileCount || item.file_count || 0}
|
||||
</span>
|
||||
|
||||
<span className="text-right">
|
||||
{/* 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" />
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-gray-500 text-right whitespace-nowrap">
|
||||
{formatDate(
|
||||
item.lastDownload ||
|
||||
item.last_download ||
|
||||
item.completedAt ||
|
||||
item.created_at
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -91,49 +91,51 @@ export default function Duplicates() {
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Duplicate Files</h1>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{total} duplicate group{total !== 1 ? 's' : ''} found
|
||||
{totalSaved > 0 && (
|
||||
<span className="text-gray-500"> · {(totalSaved / (1024 * 1024)).toFixed(1)} MB reclaimable</span>
|
||||
<div className="mb-4 md:mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl font-bold text-white">Duplicate Files</h1>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{total} duplicate group{total !== 1 ? 's' : ''} found
|
||||
{totalSaved > 0 && (
|
||||
<span className="text-gray-500"> · {(totalSaved / (1024 * 1024)).toFixed(1)} MB reclaimable</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
{total > 0 && !cleaning && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const dupeCount = groups.reduce((sum, g) => sum + g.length - 1, 0)
|
||||
if (!confirm(`Delete ${total > LIMIT ? 'all' : dupeCount} duplicate files across ${total} groups?\n\nOne copy of each file will be kept.`)) return
|
||||
setCleaning(true)
|
||||
setCleanResult(null)
|
||||
const result = await cleanDuplicates()
|
||||
setCleaning(false)
|
||||
if (result.error) return
|
||||
setCleanResult(result)
|
||||
setGroups([])
|
||||
setTotal(0)
|
||||
}}
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{total > 0 && !cleaning && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const dupeCount = groups.reduce((sum, g) => sum + g.length - 1, 0)
|
||||
if (!confirm(`Delete ${total > LIMIT ? 'all' : dupeCount} duplicate files across ${total} groups?\n\nOne copy of each file will be kept.`)) return
|
||||
setCleaning(true)
|
||||
setCleanResult(null)
|
||||
const result = await cleanDuplicates()
|
||||
setCleaning(false)
|
||||
if (result.error) return
|
||||
setCleanResult(result)
|
||||
setGroups([])
|
||||
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"
|
||||
>
|
||||
Delete All Duplicates
|
||||
</button>
|
||||
)}
|
||||
{cleaning && (
|
||||
<span className="text-sm text-gray-400 flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4 text-red-400" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Deleting...
|
||||
</span>
|
||||
)}
|
||||
{cleaning && (
|
||||
<span className="text-sm text-gray-400 flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4 text-red-400" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Deleting...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{total > LIMIT && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => fetchGroups(Math.max(0, offset - LIMIT))}
|
||||
disabled={offset === 0 || loading}
|
||||
@@ -189,7 +191,7 @@ export default function Duplicates() {
|
||||
{group[0].type}
|
||||
</span>
|
||||
</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) => {
|
||||
const key = `${file.folder}/${file.filename}`
|
||||
return (
|
||||
|
||||
@@ -81,8 +81,8 @@ export default function Feed() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-1">Feed</h1>
|
||||
<div className="mb-4 md:mb-6">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-white mb-1">Feed</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Recent posts from your subscriptions
|
||||
</p>
|
||||
@@ -97,7 +97,7 @@ export default function Feed() {
|
||||
</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) => (
|
||||
<div key={post.id}>
|
||||
<PostCard post={post} />
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function Gallery() {
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<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">
|
||||
{total} file{total !== 1 ? 's' : ''}{checkedFolders.size === 0 ? ' saved locally' : ''}
|
||||
</p>
|
||||
@@ -187,7 +187,7 @@ export default function Gallery() {
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="relative" ref={filterRef}>
|
||||
<button
|
||||
@@ -350,7 +350,7 @@ export default function Gallery() {
|
||||
</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) => (
|
||||
<div
|
||||
key={`${file.folder}-${file.filename}-${i}`}
|
||||
|
||||
@@ -216,15 +216,15 @@ export default function Login({ onAuth }) {
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Settings</h1>
|
||||
<div className="mb-5 md:mb-8">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-white mb-2">Settings</h1>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Configure your API authentication credentials.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
How to find your credentials
|
||||
</h3>
|
||||
@@ -252,7 +252,7 @@ export default function Login({ onAuth }) {
|
||||
|
||||
{/* Form */}
|
||||
<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) => (
|
||||
<div key={field.key}>
|
||||
<label
|
||||
@@ -329,9 +329,9 @@ export default function Login({ onAuth }) {
|
||||
</form>
|
||||
|
||||
{/* App Settings */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-bold text-white mb-4">App Settings</h2>
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-6">
|
||||
<div className="mt-6 md:mt-8">
|
||||
<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-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-300">Rescan Media Library</p>
|
||||
|
||||
@@ -221,12 +221,12 @@ export default function Scrape() {
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
onClick={() => setTab('forum')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
@@ -293,7 +293,7 @@ export default function Scrape() {
|
||||
/>
|
||||
</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>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Start Page</label>
|
||||
<input
|
||||
@@ -383,7 +383,7 @@ export default function Scrape() {
|
||||
/>
|
||||
</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>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Pages (50 posts each)</label>
|
||||
<input
|
||||
@@ -447,7 +447,7 @@ export default function Scrape() {
|
||||
/>
|
||||
</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>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Max Pages</label>
|
||||
<input
|
||||
|
||||
@@ -89,21 +89,21 @@ export default function Search() {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<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
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
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
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
@@ -148,13 +148,13 @@ export default function Search() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-start gap-3 md:gap-4">
|
||||
{/* Avatar */}
|
||||
<img
|
||||
src={user.avatar}
|
||||
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) => {
|
||||
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 */}
|
||||
<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 */}
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function UserPosts() {
|
||||
{/* Back Button */}
|
||||
<button
|
||||
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
|
||||
className="w-4 h-4"
|
||||
@@ -121,35 +121,36 @@ export default function UserPosts() {
|
||||
|
||||
{/* User Header */}
|
||||
{user && (
|
||||
<div className="flex items-center justify-between bg-[#161616] border border-[#222] rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
className="w-14 h-14 rounded-full object-cover bg-[#1a1a1a]"
|
||||
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>`
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">{decodeHTML(user.name)}</h1>
|
||||
<p className="text-gray-500 text-sm">@{user.username}</p>
|
||||
{(user.postsCount !== undefined || user.mediasCount !== undefined) && (
|
||||
<p className="text-gray-600 text-xs mt-0.5">
|
||||
{user.postsCount !== undefined && `${user.postsCount.toLocaleString()} posts`}
|
||||
{user.postsCount !== undefined && user.mediasCount !== undefined && ' · '}
|
||||
{user.mediasCount !== undefined && `${user.mediasCount.toLocaleString()} media`}
|
||||
</p>
|
||||
)}
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-4 mb-4 md:mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
className="w-11 h-11 md:w-14 md:h-14 rounded-full object-cover bg-[#1a1a1a] flex-shrink-0"
|
||||
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>`
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
{(user.postsCount !== undefined || user.mediasCount !== undefined) && (
|
||||
<p className="text-gray-600 text-xs mt-0.5">
|
||||
{user.postsCount !== undefined && `${user.postsCount.toLocaleString()} posts`}
|
||||
{user.postsCount !== undefined && user.mediasCount !== undefined && ' · '}
|
||||
{user.mediasCount !== undefined && `${user.mediasCount.toLocaleString()} media`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDownloadMenu((v) => !v)}
|
||||
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"
|
||||
>
|
||||
<div className="relative flex-shrink-0 ml-2">
|
||||
<button
|
||||
onClick={() => setShowDownloadMenu((v) => !v)}
|
||||
disabled={downloading}
|
||||
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
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
@@ -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"
|
||||
/>
|
||||
</svg>
|
||||
{downloading ? 'Starting...' : 'Download Media'}
|
||||
<span className="hidden md:inline">{downloading ? 'Starting...' : 'Download Media'}</span>
|
||||
<span className="md:hidden">{downloading ? '...' : ''}</span>
|
||||
<svg
|
||||
className="w-3 h-3 ml-1"
|
||||
className="w-3 h-3 ml-0.5 md:ml-1 hidden md:block"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -185,7 +187,7 @@ export default function UserPosts() {
|
||||
className="fixed inset-0 z-10"
|
||||
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 && (
|
||||
<>
|
||||
<button
|
||||
@@ -225,6 +227,7 @@ export default function UserPosts() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -247,7 +250,7 @@ export default function UserPosts() {
|
||||
</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) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
|
||||
@@ -106,8 +106,8 @@ export default function Users() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-1">Subscriptions</h1>
|
||||
<div className="mb-4 md:mb-6">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-white mb-1">Subscriptions</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{users.length} user{users.length !== 1 ? 's' : ''} found
|
||||
</p>
|
||||
@@ -122,7 +122,7 @@ export default function Users() {
|
||||
</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) => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
|
||||
Reference in New Issue
Block a user