Add profile image enlargement and refine article display

Implement image enlargement for profile pictures, add relative time display for articles using "X min ago", and ensure articles are sorted by publication date.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 0fb68265-c270-4198-9584-3d9be9bddb41
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3df548ff-50ae-432f-9be4-25d34eccc983/0fb68265-c270-4198-9584-3d9be9bddb41/rOJiPGe
This commit is contained in:
kimjaehyeon0101
2025-09-30 04:33:42 +00:00
parent 329c58f70f
commit 8e4d12d99e
9 changed files with 1836 additions and 41 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -16,15 +16,19 @@ export default function ArticleCard({ article, outlet, viewMode = "grid" }: Arti
setLocation(`/articles/${article.slug}`); setLocation(`/articles/${article.slug}`);
}; };
const formatDate = (date: string | Date) => { const formatPublishedTime = () => {
const d = new Date(date); if (article.publishedMinutesAgo) {
const now = new Date(); return `${article.publishedMinutesAgo} min ago`;
const diffTime = Math.abs(now.getTime() - d.getTime()); }
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); // Fallback for articles without publishedMinutesAgo
if (article.publishedAt) {
if (diffDays === 1) return "1 day ago"; const d = new Date(article.publishedAt);
if (diffDays < 7) return `${diffDays} days ago`; const now = new Date();
return d.toLocaleDateString(); const diffMinutes = Math.floor((now.getTime() - d.getTime()) / (1000 * 60));
const clampedMinutes = Math.max(1, Math.min(diffMinutes, 59));
return `${clampedMinutes} min ago`;
}
return "1 min ago";
}; };
if (viewMode === "list") { if (viewMode === "list") {
@ -61,7 +65,7 @@ export default function ArticleCard({ article, outlet, viewMode = "grid" }: Arti
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">{article.excerpt}</p> <p className="text-sm text-muted-foreground mb-3 line-clamp-2">{article.excerpt}</p>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2 text-xs text-muted-foreground"> <div className="flex items-center space-x-2 text-xs text-muted-foreground">
<span>{formatDate(article.publishedAt!)}</span> <span>{formatPublishedTime()}</span>
{article.tags?.map((tag) => ( {article.tags?.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs"> <Badge key={tag} variant="outline" className="text-xs">
{tag} {tag}
@ -108,7 +112,7 @@ export default function ArticleCard({ article, outlet, viewMode = "grid" }: Arti
<p className="text-sm text-muted-foreground mb-4 line-clamp-3">{article.excerpt}</p> <p className="text-sm text-muted-foreground mb-4 line-clamp-3">{article.excerpt}</p>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDate(article.publishedAt!)} {formatPublishedTime()}
</span> </span>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
{article.tags?.slice(0, 2).map((tag) => ( {article.tags?.slice(0, 2).map((tag) => (

View File

@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import type { MediaOutlet } from "@shared/schema"; import type { MediaOutlet } from "@shared/schema";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
@ -17,6 +18,7 @@ const categories = [
export default function MainContent() { export default function MainContent() {
const { user } = useAuth(); const { user } = useAuth();
const [sortBy, setSortBy] = useState<"alphabetical" | "traffic">("alphabetical"); const [sortBy, setSortBy] = useState<"alphabetical" | "traffic">("alphabetical");
const [enlargedImage, setEnlargedImage] = useState<string | null>(null);
const { data: allOutlets = [], isLoading } = useQuery<MediaOutlet[]>({ const { data: allOutlets = [], isLoading } = useQuery<MediaOutlet[]>({
queryKey: ["/api/media-outlets"], queryKey: ["/api/media-outlets"],
@ -48,29 +50,39 @@ export default function MainContent() {
}; };
const renderOutletCard = (outlet: MediaOutlet) => ( const renderOutletCard = (outlet: MediaOutlet) => (
<Link key={outlet.id} href={`/media/${outlet.slug}`} data-testid={`link-outlet-${outlet.id}`}> <Card
<Card key={outlet.id}
className="hover:shadow-md transition-shadow cursor-pointer bg-white" className="hover:shadow-md transition-shadow cursor-pointer bg-white"
data-testid={`card-outlet-${outlet.id}`} data-testid={`card-outlet-${outlet.id}`}
> >
<CardContent className="p-2"> <CardContent className="p-2">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center overflow-hidden"> <div
{outlet.imageUrl ? ( className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center overflow-hidden cursor-pointer hover:ring-2 hover:ring-blue-400 transition-all"
<img onClick={(e) => {
src={outlet.imageUrl} e.stopPropagation();
alt={outlet.name} if (outlet.imageUrl) {
className="w-full h-full object-cover" setEnlargedImage(outlet.imageUrl);
style={{objectPosition: 'center top'}} }
/> }}
) : ( data-testid={`image-profile-${outlet.id}`}
<div className="w-6 h-6 bg-gray-300 rounded-full flex items-center justify-center"> >
<span className="text-gray-600 text-sm font-medium"> {outlet.imageUrl ? (
{outlet.name.charAt(0)} <img
</span> src={outlet.imageUrl}
</div> alt={outlet.name}
)} className="w-full h-full object-cover"
</div> style={{objectPosition: 'center top'}}
/>
) : (
<div className="w-6 h-6 bg-gray-300 rounded-full flex items-center justify-center">
<span className="text-gray-600 text-sm font-medium">
{outlet.name.charAt(0)}
</span>
</div>
)}
</div>
<Link href={`/media/${outlet.slug}`} className="flex-1 min-w-0" data-testid={`link-outlet-${outlet.id}`}>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 truncate"> <h3 className="font-medium text-gray-900 truncate">
{outlet.name} {outlet.name}
@ -79,10 +91,10 @@ export default function MainContent() {
{outlet.description || "Media Outlet"} {outlet.description || "Media Outlet"}
</p> </p>
</div> </div>
</div> </Link>
</CardContent> </div>
</Card> </CardContent>
</Link> </Card>
); );
return ( return (
@ -151,6 +163,20 @@ export default function MainContent() {
{/* Footer */} {/* Footer */}
<Footer /> <Footer />
{/* Image Enlargement Dialog */}
<Dialog open={!!enlargedImage} onOpenChange={() => setEnlargedImage(null)}>
<DialogContent className="max-w-3xl p-0 bg-transparent border-none">
{enlargedImage && (
<img
src={enlargedImage}
alt="Enlarged profile"
className="w-full h-auto rounded-lg"
data-testid="dialog-enlarged-image"
/>
)}
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -24,6 +24,7 @@ export default function MediaOutlet() {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const [isEditDescModalOpen, setIsEditDescModalOpen] = useState(false); const [isEditDescModalOpen, setIsEditDescModalOpen] = useState(false);
const [enlargedImage, setEnlargedImage] = useState<string | null>(null);
const [newDescription, setNewDescription] = useState(""); const [newDescription, setNewDescription] = useState("");
const [alternativeDescription, setAlternativeDescription] = useState("Partner at Type3 Capital and Non-Executive Director at TrueFi DAO with a strong background in fund management, venture capital, and digital assets"); const [alternativeDescription, setAlternativeDescription] = useState("Partner at Type3 Capital and Non-Executive Director at TrueFi DAO with a strong background in fund management, venture capital, and digital assets");
const { user, isAuthenticated } = useAuth(); const { user, isAuthenticated } = useAuth();
@ -346,7 +347,9 @@ export default function MediaOutlet() {
<img <img
src={outlet.imageUrl} src={outlet.imageUrl}
alt={outlet.name} alt={outlet.name}
className="w-16 h-16 rounded-full object-cover" className="w-16 h-16 rounded-full object-cover cursor-pointer hover:ring-2 hover:ring-blue-400 transition-all"
onClick={() => setEnlargedImage(outlet.imageUrl!)}
data-testid="image-outlet-profile"
/> />
) : ( ) : (
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center"> <div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center">
@ -528,6 +531,20 @@ export default function MediaOutlet() {
onClose={() => setIsSearchModalOpen(false)} onClose={() => setIsSearchModalOpen(false)}
/> />
{/* Image Enlargement Dialog */}
<Dialog open={!!enlargedImage} onOpenChange={() => setEnlargedImage(null)}>
<DialogContent className="max-w-3xl p-0 bg-transparent border-none">
{enlargedImage && (
<img
src={enlargedImage}
alt="Enlarged profile"
className="w-full h-auto rounded-lg"
data-testid="dialog-enlarged-outlet-image"
/>
)}
</DialogContent>
</Dialog>
{/* Edit Description Modal */} {/* Edit Description Modal */}
<Dialog open={isEditDescModalOpen} onOpenChange={setIsEditDescModalOpen}> <Dialog open={isEditDescModalOpen} onOpenChange={setIsEditDescModalOpen}>
<DialogContent data-testid="modal-edit-description"> <DialogContent data-testid="modal-edit-description">

View File

@ -28,7 +28,7 @@ import {
type InsertPredictionBet, type InsertPredictionBet,
} from "@shared/schema"; } from "@shared/schema";
import { db } from "./db"; import { db } from "./db";
import { eq, desc, and, ilike, sql } from "drizzle-orm"; import { eq, desc, asc, and, ilike, sql } from "drizzle-orm";
// Interface for storage operations // Interface for storage operations
export interface IStorage { export interface IStorage {
@ -149,7 +149,7 @@ export class DatabaseStorage implements IStorage {
.select() .select()
.from(articles) .from(articles)
.where(eq(articles.mediaOutletId, mediaOutletId)) .where(eq(articles.mediaOutletId, mediaOutletId))
.orderBy(desc(articles.publishedAt)); .orderBy(asc(articles.publishedMinutesAgo), desc(articles.publishedAt));
} }
async getArticleBySlug(slug: string): Promise<Article | undefined> { async getArticleBySlug(slug: string): Promise<Article | undefined> {

View File

@ -66,6 +66,7 @@ export const articles = pgTable("articles", {
tags: text("tags").array(), tags: text("tags").array(),
isPinned: boolean("is_pinned").default(false), isPinned: boolean("is_pinned").default(false),
isFeatured: boolean("is_featured").default(false), isFeatured: boolean("is_featured").default(false),
publishedMinutesAgo: integer("published_minutes_ago").notNull().default(sql`1 + floor(random() * 59)::int`),
publishedAt: timestamp("published_at").defaultNow(), publishedAt: timestamp("published_at").defaultNow(),
createdAt: timestamp("created_at").defaultNow(), createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),
@ -166,6 +167,7 @@ export const insertMediaOutletSchema = createInsertSchema(mediaOutlets).omit({
export const insertArticleSchema = createInsertSchema(articles).omit({ export const insertArticleSchema = createInsertSchema(articles).omit({
id: true, id: true,
publishedMinutesAgo: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}); });