this repo has no description
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: ui improvements

TurtlePaw dd6090cc 72788787

+313 -166
+12
bun.lock
··· 14 14 "class-variance-authority": "^0.7.1", 15 15 "clsx": "^2.1.1", 16 16 "lucide-react": "^0.526.0", 17 + "motion": "^12.23.11", 17 18 "next": "15.4.4", 18 19 "next-themes": "^0.4.6", 19 20 "react": "19.1.0", 20 21 "react-dom": "19.1.0", 22 + "react-masonry-css": "^1.0.16", 21 23 "tailwind-merge": "^3.3.1", 22 24 }, 23 25 "devDependencies": { ··· 553 555 554 556 "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], 555 557 558 + "framer-motion": ["framer-motion@12.23.11", "", { "dependencies": { "motion-dom": "^12.23.9", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-VzNi+exyI3bn7Pzvz1Fjap1VO9gQu8mxrsSsNamMidsZ8AA8W2kQsR+YQOciEUbMtkKAWIbPHPttfn5e9jqqJQ=="], 559 + 556 560 "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 557 561 558 562 "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], ··· 741 745 742 746 "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], 743 747 748 + "motion": ["motion@12.23.11", "", { "dependencies": { "framer-motion": "^12.23.11", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-AHv/2SivIz9fjvND8wwN2LldDTuwkPyTSWecAY/xzB1/2eF7zxvh9JRkf8aF4eGoGsy1e2YKp+CQC5yxcssnEw=="], 749 + 750 + "motion-dom": ["motion-dom@12.23.9", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-6Sv++iWS8XMFCgU1qwKj9l4xuC47Hp4+2jvPfyTXkqDg2tTzSgX6nWKD4kNFXk0k7llO59LZTPuJigza4A2K1A=="], 751 + 752 + "motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], 753 + 744 754 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 745 755 746 756 "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], ··· 808 818 "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], 809 819 810 820 "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], 821 + 822 + "react-masonry-css": ["react-masonry-css@1.0.16", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ=="], 811 823 812 824 "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], 813 825
+3 -2
package.json
··· 3 3 "version": "0.1.0", 4 4 "private": true, 5 5 "scripts": { 6 - "dev": "next dev --turbopack", 7 - "loopback": "next dev -H 127.0.0.1 -p 3000", 6 + "dev": "next dev --turbopack -H 127.0.0.1 -p 3000", 8 7 "build": "next build", 9 8 "start": "next start", 10 9 "lint": "next lint" ··· 20 19 "class-variance-authority": "^0.7.1", 21 20 "clsx": "^2.1.1", 22 21 "lucide-react": "^0.526.0", 22 + "motion": "^12.23.11", 23 23 "next": "15.4.4", 24 24 "next-themes": "^0.4.6", 25 25 "react": "19.1.0", 26 26 "react-dom": "19.1.0", 27 + "react-masonry-css": "^1.0.16", 27 28 "tailwind-merge": "^3.3.1" 28 29 }, 29 30 "devDependencies": {
+7 -7
public/client-metadata.json
··· 1 1 { 2 - "client_id": "https://my-app.com/client-metadata.json", 3 - "client_name": "My App", 4 - "client_uri": "https://my-app.com", 5 - "logo_uri": "https://my-app.com/logo.png", 6 - "tos_uri": "https://my-app.com/tos", 7 - "policy_uri": "https://my-app.com/policy", 2 + "client_id": "https://scribbleboard.pages.dev/client-metadata.json", 3 + "client_name": "Scribbleboard", 4 + "client_uri": "https://scribbleboard.pages.dev", 5 + "logo_uri": "https://scribbleboard.pages.dev/logo.png", 6 + "tos_uri": "https://scribbleboard.pages.dev/tos", 7 + "policy_uri": "https://scribbleboard.pages.dev/policy", 8 8 "redirect_uris": [ 9 - "https://my-app.com/callback" 9 + "https://scribbleboard.pages.dev" 10 10 ], 11 11 "scope": "atproto transition:generic", 12 12 "grant_types": [
+138 -106
src/app/[did]/[uri]/page.tsx
··· 1 1 "use client"; 2 + import LikeCounter from "@/components/LikeCounter"; 2 3 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 3 4 import { Button } from "@/components/ui/button"; 4 5 import { useAuth } from "@/lib/useAuth"; ··· 14 15 Repeat, 15 16 Repeat2, 16 17 } from "lucide-react"; 18 + import { AnimatePresence, motion } from "motion/react"; 17 19 import Image, { ImageProps } from "next/image"; 18 20 import Link from "next/link"; 19 21 import { useParams, useSearchParams } from "next/navigation"; 20 - import { use, useEffect, useState } from "react"; 22 + import { use, useEffect, useMemo, useRef, useState } from "react"; 21 23 22 24 function paramAsString(str: string | string[]): string { 23 25 if (Array.isArray(str)) { ··· 37 39 const [loading, setLoading] = useState(true); 38 40 const [post, setPost] = useState<PostView | null>(null); 39 41 const [error, setError] = useState<Error | null>(null); 42 + const [likeUri, setLikeUri] = useState<string | null>(null); 40 43 const { agent } = useAuth(); 41 44 42 45 useEffect(() => console.log("Agent", agent), [agent]); ··· 78 81 79 82 if (loading) 80 83 return ( 81 - <div className="min-h-screen flex items-center justify-center"> 84 + <div className="min-h-screen flex items-center justify-center px-4"> 82 85 <LoaderCircle className="animate-spin text-black/70 dark:text-white/70 w-8 h-8" /> 83 86 </div> 84 87 ); 85 88 if (error) 86 89 return ( 87 - <div className="min-h-screen flex items-center justify-center"> 88 - <p>Error: {error.message}</p> 90 + <div className="min-h-screen flex items-center justify-center px-4"> 91 + <div className="text-center"> 92 + <p className="text-red-500 dark:text-red-400"> 93 + Error: {error.message} 94 + </p> 95 + </div> 89 96 </div> 90 97 ); 91 98 if (!post) 92 99 return ( 93 - <div className="min-h-screen flex items-center justify-center"> 94 - <p>No post found</p> 100 + <div className="min-h-screen flex items-center justify-center px-4"> 101 + <p className="text-black/70 dark:text-white/70">No post found</p> 95 102 </div> 96 103 ); 97 104 98 105 return ( 99 - <div className="min-h-screen py-4 px-4 sm:py-8 sm:px-6 lg:px-8"> 100 - <div className="max-w-4xl mx-auto"> 101 - <div className="relative rounded-lg overflow-hidden min-h-[80vh] text-black dark:text-white"> 102 - {/* Blurred Background Image - Full Container */} 103 - <BskyImage 104 - embed={post.embed} 105 - fill 106 - className="absolute inset-0 object-cover blur-lg scale-110 z-0 opacity-10" 107 - /> 106 + <div className="py-4 px-4 sm:py-8 sm:px-6 lg:px-8 flex items-center justify-center"> 107 + {/* Container that adapts to image width */} 108 + <div className="w-full max-w-4xl flex justify-center"> 109 + <div className="inline-block"> 110 + <div className="relative rounded-lg overflow-hidden text-black dark:text-white bg-white/10 dark:bg-white/1 border-[1px] border-black/8 dark:border-white/5"> 111 + {/* Blurred Background Image - Full Container */} 112 + <BskyImage 113 + embed={post.embed} 114 + fill 115 + className="absolute inset-0 object-cover blur-3xl scale-110 z-0 opacity-10" 116 + /> 108 117 109 - {/* Foreground Content */} 110 - <div className="relative z-10 bg-black/30 dark:bg-white/10 p-4 sm:p-6 lg:p-8 min-h-[80vh] flex flex-col"> 111 - {/* Centered Image */} 112 - <div className="flex-1 flex items-center justify-center py-4"> 113 - <div className="w-full max-w-2xl"> 118 + {/* Foreground Content */} 119 + <div className="relative z-10 "> 120 + {/* Image Container */} 121 + <div className="p-4 sm:p-6 lg:p-8 pb-0"> 114 122 <BskyImage 115 123 embed={post.embed} 116 124 width={800} 117 - height={800} 125 + height={600} 118 126 style={{ 119 127 objectFit: "contain", 120 - width: "100%", 121 128 height: "auto", 122 - maxHeight: "60vh", 129 + width: "auto", 130 + maxHeight: "70vh", 131 + maxWidth: "90vw", 123 132 }} 124 - className="rounded-lg mb-4" 133 + className="rounded-lg shadow-lg" 125 134 /> 126 135 </div> 127 - </div> 128 136 129 - {/* Author Info */} 130 - <div className="flex items-center mb-4 sm:mb-6"> 131 - <Avatar className="mr-4"> 132 - <AvatarImage src={post.author.avatar} /> 133 - <AvatarFallback> 134 - {post.author.displayName || post.author.handle} 135 - </AvatarFallback> 136 - </Avatar> 137 - <div> 138 - <p className="font-semibold text-base sm:text-lg"> 139 - {post.author.displayName || post.author.handle} 140 - </p> 141 - <p className="text-sm text-black/80 dark:text-white/80"> 142 - @{post.author.handle} 143 - </p> 144 - </div> 145 - </div> 137 + {/* Bottom Content - Author, Text, and Actions */} 138 + <div className="p-4 sm:p-6 lg:p-8"> 139 + {/* Author Info */} 140 + <div className="flex items-center mb-4 sm:mb-6"> 141 + <Avatar className="mr-4"> 142 + <AvatarImage src={post.author.avatar} /> 143 + <AvatarFallback> 144 + {post.author.displayName || post.author.handle} 145 + </AvatarFallback> 146 + </Avatar> 147 + <div> 148 + <p className="font-semibold text-base sm:text-lg"> 149 + {post.author.displayName || post.author.handle} 150 + </p> 151 + <p className="text-sm text-black/80 dark:text-white/80"> 152 + @{post.author.handle} 153 + </p> 154 + </div> 155 + </div> 146 156 147 - {/* Post Text */} 148 - {AppBskyFeedPost.isRecord(post.record) && 149 - typeof post.record.text === "string" && 150 - post.record.text && ( 151 - <p className="text-sm sm:text-base text-black/90 dark:text-white/90 leading-relaxed mb-4 sm:mb-6"> 152 - {post.record.text.length > 280 153 - ? post.record.text.slice(0, 280) + "…" 154 - : post.record.text} 155 - </p> 156 - )} 157 + {/* Post Text */} 158 + {AppBskyFeedPost.isRecord(post.record) && 159 + typeof post.record.text === "string" && 160 + post.record.text && ( 161 + <p className="text-sm sm:text-base text-black/90 dark:text-white/90 leading-relaxed mb-4 sm:mb-6"> 162 + {post.record.text.length > 280 163 + ? post.record.text.slice(0, 280) + "…" 164 + : post.record.text} 165 + </p> 166 + )} 157 167 158 - {/* Bottom Section - Stats and External Link */} 159 - <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4"> 160 - <div className="flex flex-wrap gap-4 sm:gap-6"> 161 - <div className="flex items-center gap-2"> 162 - <Heart 163 - className={clsx( 164 - "w-5 h-5", 165 - post.viewer?.like 166 - ? "fill-red-500 text-red-500" 167 - : "text-black/80 dark:text-white/80" 168 - )} 169 - /> 170 - <span className="text-sm font-medium text-black dark:text-white"> 171 - {post.likeCount || 0} 172 - </span> 173 - </div> 168 + {/* Bottom Section - Stats and External Link */} 169 + <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4"> 170 + <div className="flex flex-wrap gap-4 sm:gap-6"> 171 + <Button 172 + className="flex items-center gap-2 cursor-pointer hover:bg-black/5 dark:hover:bg-white/5" 173 + variant={"ghost"} 174 + disabled={!post.viewer} 175 + onClick={async () => { 176 + if (!agent || !post.viewer) return; 177 + if (likeUri == null) { 178 + const uri = await agent.like(post.uri, post.cid); 179 + setLikeUri(uri.uri); 180 + } else { 181 + agent.deleteLike(likeUri); 182 + } 174 183 175 - <div className="flex items-center gap-2"> 176 - <MessagesSquare className="w-5 h-5 text-black/80 dark:text-white/80" /> 177 - <span className="text-sm font-medium text-black dark:text-white"> 178 - {post.replyCount || 0} 179 - </span> 180 - </div> 184 + //@ts-expect-error ignore this 185 + setPost((prev) => ({ 186 + ...prev, 187 + viewer: { 188 + ...prev!.viewer, 189 + like: !prev!.viewer?.like, 190 + }, 191 + likeCount: 192 + (prev!.likeCount || 0) + 193 + (prev!.viewer?.like ? -1 : 1), 194 + })); 195 + }} 196 + > 197 + <Heart 198 + className={clsx( 199 + "w-5 h-5", 200 + post.viewer?.like 201 + ? "fill-red-500 text-red-500" 202 + : "text-black/80 dark:text-white/80" 203 + )} 204 + /> 205 + <LikeCounter count={post.likeCount || 0} /> 206 + </Button> 181 207 182 - <div className="flex items-center gap-2"> 183 - <Repeat 184 - className={clsx( 185 - "w-5 h-5", 186 - post.viewer?.repost 187 - ? "fill-blue-500 text-blue-500" 188 - : "text-black/80 dark:text-white/80" 189 - )} 190 - /> 191 - <span className="text-sm font-medium text-black dark:text-white"> 192 - {post.repostCount || 0} 193 - </span> 208 + <div className="flex items-center gap-2"> 209 + <MessagesSquare className="w-5 h-5 text-black/80 dark:text-white/80" /> 210 + <span className="text-sm font-medium text-black dark:text-white"> 211 + {post.replyCount || 0} 212 + </span> 213 + </div> 214 + 215 + <div className="flex items-center gap-2"> 216 + <Repeat 217 + className={clsx( 218 + "w-5 h-5", 219 + post.viewer?.repost 220 + ? "fill-blue-500 text-blue-500" 221 + : "text-black/80 dark:text-white/80" 222 + )} 223 + /> 224 + <span className="text-sm font-medium text-black dark:text-white"> 225 + {post.repostCount || 0} 226 + </span> 227 + </div> 228 + </div> 229 + 230 + {/* External Link */} 231 + <Link 232 + href={ 233 + "https://bsky.app/profile/" + 234 + post.author.did + 235 + "/post/" + 236 + post.uri.split("/").pop() 237 + } 238 + target="_blank" 239 + rel="noopener noreferrer" 240 + > 241 + <Button variant="outline" className="cursor-pointer"> 242 + Open in Bluesky 243 + <ExternalLink className="w-4 h-4" /> 244 + </Button> 245 + </Link> 194 246 </div> 195 247 </div> 196 - 197 - {/* External Link */} 198 - <Link 199 - href={ 200 - "https://bsky.app/profile/" + 201 - post.author.did + 202 - "/post/" + 203 - post.uri.split("/").pop() 204 - } 205 - target="_blank" 206 - rel="noopener noreferrer" 207 - > 208 - <Button 209 - variant="outline" 210 - className="cursor-pointer flex items-center gap-2 text-sm font-medium px-4 py-2 dark:bg-white/10 bg-black/10 dark:border-white/20 border-black/15 text-black dark:text-white dark:hover:bg-white/20 hover:bg-black/15" 211 - > 212 - Open in Bluesky 213 - <ExternalLink className="w-4 h-4" /> 214 - </Button> 215 - </Link> 216 248 </div> 217 249 </div> 218 250 </div>
+1 -1
src/app/layout.tsx
··· 37 37 <ProfileProvider> 38 38 <div className="min-h-screen flex flex-col"> 39 39 <Navbar /> 40 - <main className="flex-1 container py-6">{children}</main> 40 + <main className="flex-1 py-6">{children}</main> 41 41 </div> 42 42 </ProfileProvider> 43 43 </AuthProvider>
+88 -47
src/app/page.tsx
··· 11 11 import { useEffect, useRef, useState, useCallback } from "react"; 12 12 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 13 13 import Link from "next/link"; 14 + import Masonry from "react-masonry-css"; 15 + import { motion } from "motion/react"; 14 16 15 17 export default function Home() { 16 18 const [timeline, setTimeline] = useState<AppBskyFeedDefs.FeedViewPost[]>([]); ··· 117 119 }; 118 120 }, [fetchFeed, cursor, loading]); 119 121 122 + const breakpointColumnsObj = { 123 + default: 5, 124 + 1536: 4, 125 + 1280: 3, 126 + 1024: 2, 127 + 768: 1, 128 + }; 129 + 120 130 return ( 121 - <div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20"> 122 - <main className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6 row-start-2"> 123 - {timeline.flatMap((post) => { 124 - if (!AppBskyEmbedImages.isView(post.post.embed)) return; 125 - const images = post.post.embed.images || []; 126 - if (images.length === 0) return []; 127 - const t: string = (post.post.record.text as string) || ""; 128 - const maxLength = 100; 129 - return images.map((image, index) => ( 130 - <Link 131 - href={`/${post.post.author.did}/${post.post.uri 132 - .split("/") 133 - .pop()}?image=${index}`} 134 - key={image.fullsize} 135 - > 136 - <div className="group relative w-[200px] h-[200px] overflow-hidden rounded-xl"> 137 - <Image 138 - src={image.fullsize} 139 - alt={image.alt} 140 - placeholder="blur" 141 - blurDataURL={image.thumb} 142 - fill 143 - style={{ objectFit: "cover" }} 144 - sizes="200px" 145 - /> 146 - <div className="absolute inset-0 bg-black/40 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-3"> 147 - <div className="text-sm mb-1"> 148 - {AppBskyFeedPost.isRecord(post.post.record) && ( 149 - <> 150 - {t.length > maxLength ? t.slice(0, maxLength) + "…" : t} 151 - </> 152 - )} 131 + <div className="items-center justify-items-center"> 132 + <div className="h-5" /> 133 + <main className=""> 134 + <Masonry 135 + breakpointCols={breakpointColumnsObj} 136 + className="flex -mx-2 w-auto" 137 + columnClassName="px-2 space-y-4" 138 + > 139 + {timeline.flatMap((post) => { 140 + if (!AppBskyEmbedImages.isView(post.post.embed)) return; 141 + const images = post.post.embed.images || []; 142 + if (images.length === 0) return []; 143 + const t: string = (post.post.record.text as string) || ""; 144 + const maxLength = 100; 145 + return images.map((image, index) => ( 146 + <Link 147 + href={`/${post.post.author.did}/${post.post.uri 148 + .split("/") 149 + .pop()}?image=${index}`} 150 + key={image.fullsize} 151 + className="block" 152 + > 153 + <motion.div 154 + initial={{ opacity: 0, y: 5 }} 155 + animate={{ opacity: 1, y: 0 }} 156 + transition={{ duration: 0.5, ease: "easeOut" }} 157 + whileTap={{ scale: 0.99 }} 158 + className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900" 159 + > 160 + {/* Blurred background */} 161 + <Image 162 + src={image.fullsize} 163 + alt="" 164 + fill 165 + placeholder="blur" 166 + blurDataURL={image.thumb} 167 + className="object-cover filter blur-xl scale-110 opacity-30" 168 + /> 169 + 170 + {/* Centered foreground image */} 171 + <div className="relative z-10 flex items-center justify-center w-full min-h-[120px]"> 172 + <Image 173 + src={image.fullsize} 174 + alt={image.alt} 175 + placeholder="blur" 176 + blurDataURL={image.thumb} 177 + width={image?.aspectRatio?.width ?? 400} 178 + height={image?.aspectRatio?.height ?? 400} 179 + className="object-contain max-w-full max-h-full rounded-lg" 180 + /> 153 181 </div> 154 - <div className="text-xs flex gap-2"> 155 - <Avatar> 156 - <AvatarImage src={post.post.author.avatar} /> 157 - <AvatarFallback> 158 - {post.post.author.displayName || 159 - post.post.author.handle} 160 - </AvatarFallback> 161 - </Avatar> 162 - {post.post.author.displayName || post.post.author.handle} 182 + 183 + {/* Hover overlay */} 184 + <div className="absolute inset-0 z-20 bg-black/40 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-3"> 185 + <div className="text-sm mb-1"> 186 + {AppBskyFeedPost.isRecord(post.post.record) && ( 187 + <> 188 + {t.length > maxLength 189 + ? t.slice(0, maxLength) + "…" 190 + : t} 191 + </> 192 + )} 193 + </div> 194 + <div className="text-xs flex gap-2"> 195 + <Avatar> 196 + <AvatarImage src={post.post.author.avatar} /> 197 + <AvatarFallback> 198 + {post.post.author.displayName || 199 + post.post.author.handle} 200 + </AvatarFallback> 201 + </Avatar> 202 + {post.post.author.displayName || post.post.author.handle} 203 + </div> 163 204 </div> 164 - </div> 165 - </div> 166 - </Link> 167 - )); 168 - })} 205 + </motion.div> 206 + </Link> 207 + )); 208 + })} 209 + </Masonry> 169 210 <div ref={sentinelRef} className="h-1 col-span-full" /> 170 211 {loading && ( 171 212 <div className="col-span-full flex justify-center text-sm text-black/70 dark:text-white/70"> ··· 174 215 )} 175 216 </main> 176 217 177 - <footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center"></footer> 218 + {/* <footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center"></footer> */} 178 219 </div> 179 220 ); 180 221 }
+61
src/components/LikeCounter.tsx
··· 1 + import { motion, AnimatePresence } from "motion/react"; 2 + import { useState, useEffect } from "react"; 3 + 4 + interface LikeCounterProps { 5 + count: number; 6 + className?: string; 7 + } 8 + 9 + function LikeCounter({ count, className = "" }: LikeCounterProps) { 10 + const [displayCount, setDisplayCount] = useState<number>(count); 11 + const [previousCount, setPreviousCount] = useState<number>(count); 12 + const [direction, setDirection] = useState<"up" | "down">("up"); 13 + 14 + useEffect(() => { 15 + if (count !== displayCount) { 16 + setPreviousCount(displayCount); 17 + setDirection(count > displayCount ? "up" : "down"); 18 + setDisplayCount(count); 19 + } 20 + }, [count, displayCount]); 21 + 22 + return ( 23 + <div 24 + className={`relative inline-block overflow-hidden h-[1.2em] ${className}`} 25 + style={{ 26 + maskImage: 27 + "linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%)", 28 + WebkitMaskImage: 29 + "linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%)", 30 + }} 31 + > 32 + <motion.div 33 + key={`${previousCount}-${displayCount}`} 34 + className="flex flex-col" 35 + initial={{ 36 + y: direction === "up" ? "0%" : "-50%", 37 + }} 38 + animate={{ 39 + y: direction === "up" ? "-50%" : "0%", 40 + }} 41 + transition={{ 42 + type: "spring", 43 + damping: 25, 44 + stiffness: 200, 45 + duration: 0.4, 46 + }} 47 + > 48 + {/* Old number (what we're transitioning FROM) */} 49 + <div className="flex items-center justify-center h-[1.2em]"> 50 + {String(previousCount)} 51 + </div> 52 + {/* New number (what we're transitioning TO) */} 53 + <div className="flex items-center justify-center h-[1.2em]"> 54 + {String(displayCount)} 55 + </div> 56 + </motion.div> 57 + </div> 58 + ); 59 + } 60 + 61 + export default LikeCounter;
+3 -3
src/nav/navbar.tsx
··· 34 34 35 35 return ( 36 36 <header className="border-b border-border bg-background/90 backdrop-blur-[200px] sticky top-0 z-50"> 37 - <div className="container flex items-center justify-between h-16"> 37 + <div className="flex items-center justify-between h-16 px-5"> 38 38 <Link 39 39 href="/" 40 - className="text-xl font-bold pl-5 hover:text-black/70 dark:hover:text-white/70 transition-colors" 40 + className="text-xl font-bold hover:text-black/70 dark:hover:text-white/70 transition-colors" 41 41 > 42 - Scribble 42 + Scribbleboard 43 43 </Link> 44 44 45 45 <nav className="hidden md:flex gap-4">