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:
Trey t
2026-02-16 12:58:48 -06:00
parent faa7dbf4d3
commit 4903b84aef
14 changed files with 263 additions and 137 deletions

View File

@@ -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 {

View File

@@ -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>
)
}

View File

@@ -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"

View File

@@ -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>`
}}

View File

@@ -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;

View File

@@ -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>
)
})}

View File

@@ -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"> &middot; {(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"> &middot; {(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 (

View File

@@ -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} />

View File

@@ -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}`}

View File

@@ -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>

View File

@@ -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

View File

@@ -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}`}

View File

@@ -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} />
))}

View File

@@ -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}