Skip to content

Commit

Permalink
feat(app/posts): style posts UI after Instagram
Browse files Browse the repository at this point in the history
This patch updates the posts UI to essentially look exactly like
Instagram's with the exception that we are showing item, product, and
look information instead of the comments section.
  • Loading branch information
nicholaschiang committed Nov 24, 2023
1 parent 75c5698 commit 8a6b4f5
Show file tree
Hide file tree
Showing 5 changed files with 158 additions and 84 deletions.
4 changes: 2 additions & 2 deletions app/components/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ export function Dialog({
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Trigger />
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className='fixed inset-0 z-40 bg-white/50 dark:bg-gray-900/50' />
<DialogPrimitive.Overlay className='fixed inset-0 z-40 bg-white/75 dark:bg-gray-900/75' />
<DialogPrimitive.Content
className={cn(
'center fixed z-50 overflow-auto rounded-lg border border-gray-200/50 bg-white shadow-2xl focus:outline-none dark:border-gray-800/50 dark:bg-gray-900 max-h-[calc(100vh-3rem)]',
'center fixed z-50 overflow-auto rounded border border-gray-200/50 bg-white shadow-2xl focus:outline-none dark:border-gray-800/50 dark:bg-gray-950 max-h-[calc(100vh-3rem)] max-w-[calc(100vw-3rem)]',
className,
)}
onOpenAutoFocus={onOpenAutoFocus}
Expand Down
6 changes: 4 additions & 2 deletions app/components/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { buttonVariants } from 'components/ui/button'

import { cn } from 'utils/cn'
import { slug } from 'utils/general'

export function Layout({ className, ...etc }: Partial<PanelGroupProps>) {
return (
Expand Down Expand Up @@ -60,11 +61,12 @@ export function LayoutDivider() {
}

export function LayoutSection({
id,
id: initialId,
header,
children,
className,
}: PropsWithChildren<{ id: string; header: string; className?: string }>) {
}: PropsWithChildren<{ id?: string; header: string; className?: string }>) {
const id = initialId ?? slug(header)
return (
<section
className={cn(
Expand Down
170 changes: 111 additions & 59 deletions app/routes/_header.$username.posts.$postId.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { useLoaderData } from '@remix-run/react'
import { useLoaderData, useNavigate } from '@remix-run/react'
import { type DataFunctionArgs } from '@vercel/remix'
import { MoreHorizontal } from 'lucide-react'
import { type PropsWithChildren } from 'react'
import {
MoreHorizontal,
Bookmark,
Send,
Heart,
MessageCircle,
} from 'lucide-react'
import { type FC } from 'react'

import { Avatar } from 'components/avatar'
import { Dialog } from 'components/dialog'
import { Image } from 'components/image'
import { ImageItem } from 'components/image-item'
import { LayoutSection } from 'components/layout'
import { TimeAgo } from 'components/time-ago'
import { Badge } from 'components/ui/badge'
import { Button } from 'components/ui/button'

Expand Down Expand Up @@ -32,85 +42,127 @@ export async function loader({ params }: DataFunctionArgs) {

export default function PostPage() {
const post = useLoaderData<typeof loader>()
const nav = useNavigate()
return (
<div className='flex'>
<Dialog
open
onOpenChange={() => nav('..', { preventScrollReset: true })}
className='flex w-max'
>
<a
href={post.url}
target='_blank'
rel='noopener noreferrer'
className='aspect-person bg-gray-100 dark:bg-gray-900'
className='aspect-square bg-gray-100 dark:bg-gray-900 grow shrink min-h-[450px] max-w-[800px] max-h-[800px]'
>
<img
<Image
src={post.images[0]?.url}
alt=''
className='w-full h-full object-cover'
/>
</a>
<div>
<header className='flex items-center gap-2 justify-between border-b border-gray-200 dark:border-gray-800'>
<div className='grow shrink-[2] min-w-[405px] max-w-[500px] flex flex-col'>
<header className='flex items-center gap-2 p-4 justify-between border-b flex-none border-gray-200 dark:border-gray-800'>
<div className='flex items-center gap-2'>
<Avatar src={post.author} />
<h1>{post.author.name}</h1>
<div>
<h1 className='text-sm font-medium'>{post.author.username}</h1>
<p className='text-2xs text-gray-500'>{post.author.name}</p>
</div>
</div>
<Button size='icon' variant='ghost'>
<MoreHorizontal className='w-4 h-4' />
</Button>
</header>
<Section header='Items'>
<ol>
{post.items.map((item) => (
<li key={item.id}>
{item.colors.map((color) => (
<Badge>{color.name}</Badge>
))}
{item.styles.map((style) => (
<Badge>{style.name}</Badge>
))}
</li>
))}
</ol>
</Section>
<Section header='Products'>
<ol className='grid grid-cols-2 gap-1'>
{post.products.map((product) => (
<ImageItem
key={product.id}
className='aspect-product'
to={`/products/${product.slug}/variants/${product.variants[0]?.id}`}
image={product.variants[0]?.images[0]?.url}
/>
))}
{post.variants.map((variant) => (
<ImageItem
key={variant.id}
className='aspect-product'
to={`/products/${variant.product.slug}/${variant.id}`}
image={variant.images[0]?.url}
/>
<div className='h-0 grow overflow-y-auto'>
<Items />
<LayoutSection header='Products'>
<ol className='grid grid-cols-2 gap-1'>
{post.products.map((product) => (
<ImageItem
key={product.id}
className='aspect-product'
to={`/products/${product.slug}/variants/${product.variants[0]?.id}`}
image={product.variants[0]?.images[0]?.url}
/>
))}
{post.variants.map((variant) => (
<ImageItem
key={variant.id}
className='aspect-product'
to={`/products/${variant.product.slug}/${variant.id}`}
image={variant.images[0]?.url}
/>
))}
</ol>
</LayoutSection>
<LayoutSection header='Looks'>
<ol className='grid grid-cols-2 gap-1'>
{post.looks.map((look) => (
<ImageItem
key={look.id}
className='aspect-person'
to={`/shows/${look.showId}`}
image={look.images[0]?.url}
/>
))}
</ol>
</LayoutSection>
</div>
<div className='flex-none p-2 border-t border-gray-200 dark:border-gray-800'>
<div className='flex items-center gap-2 justify-between'>
<div className='flex items-center'>
<IconButtonLink url={post.url} icon={Heart} />
<IconButtonLink url={post.url} icon={MessageCircle} />
<IconButtonLink url={post.url} icon={Send} />
</div>
<IconButtonLink url={post.url} icon={Bookmark} />
</div>
<TimeAgo
datetime={post.createdAt}
className='text-3xs text-gray-500 uppercase p-2'
/>
</div>
</div>
</Dialog>
)
}

function Items() {
const post = useLoaderData<typeof loader>()
return (
<LayoutSection header='Items'>
<ol>
{post.items.map((item) => (
<li key={item.id}>
{item.colors.map((color) => (
<Badge>{color.name}</Badge>
))}
</ol>
</Section>
<Section header='Looks'>
<ol className='grid grid-cols-2 gap-1'>
{post.looks.map((look) => (
<ImageItem
key={look.id}
to={`/shows/${look.showId}`}
image={look.images[0]?.url}
/>
{item.styles.map((style) => (
<Badge>{style.name}</Badge>
))}
</ol>
</Section>
</div>
</div>
</li>
))}
</ol>
</LayoutSection>
)
}

function Section({ header, children }: PropsWithChildren<{ header: string }>) {
function IconButtonLink({
url,
icon: Icon,
}: {
url: string
icon: FC<{ className?: string }>
}) {
return (
<section>
<h2>{header}</h2>
{children}
</section>
<a
href={url}
target='_blank'
rel='noopener noreferrer'
className='hover:opacity-50 transition-colors p-2'
>
<Icon className='w-6 h-6' />
</a>
)
}
47 changes: 26 additions & 21 deletions app/routes/_header.$username.posts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useForm } from '@conform-to/react'
import { parse } from '@conform-to/zod'
import {
Form as RemixForm,
Outlet,
useLoaderData,
useActionData,
useNavigation,
Expand Down Expand Up @@ -79,27 +80,31 @@ export default function UserPostsPage() {
const posts = useLoaderData<typeof loader>()
const user = useOptionalUser()
return (
<ol className='grid grid-cols-5 gap-1 relative'>
{posts.map((post) => (
<ImageItem
key={post.id}
className='aspect-product'
to={post.id.toString()}
image={post.images[0]?.url}
/>
))}
{user && (
<NewPostDialog>
<Button
className='absolute bottom-4 right-4 shadow-lg'
size='icon'
variant='outline'
>
<Plus className='w-4 h-4' />
</Button>
</NewPostDialog>
)}
</ol>
<>
<Outlet />
<ol className='grid grid-cols-5 gap-1 relative'>
{posts.map((post) => (
<ImageItem
key={post.id}
className='aspect-square'
to={post.id.toString()}
image={post.images[0]?.url}
preventScrollReset
/>
))}
{user && (
<NewPostDialog>
<Button
className='absolute bottom-4 right-4 shadow-lg'
size='icon'
variant='outline'
>
<Plus className='w-4 h-4' />
</Button>
</NewPostDialog>
)}
</ol>
</>
)
}

Expand Down
15 changes: 15 additions & 0 deletions app/utils/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,21 @@ export function caps(sentence: string): string {
.join(' ')
}

/**
* Derive the brand slug based on the brand name. Ideally, I should just set it
* by default at the database level, but I don't know of a way to express this
* RegExp in the Prisma Schema format (see the migration, though).
*/
export function slug(name: string) {
return name
.trim()
.toLowerCase()
.replace(/[.,/#!$%^&*;:{}=\-_`~()\s]+/g, '-')
.replace(/-$/, '')
.normalize('NFKD')
.replace(/[\u0300-\u036f\u2019'"]/g, '')
}

/**
* Resolve the given path to a fully qualified URL. If no path is provided, this
* will return undefined. If the path is an empty string, this will return the
Expand Down

0 comments on commit 8a6b4f5

Please sign in to comment.