Compare commits
20 Commits
7757d80add
...
2fed5d4216
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fed5d4216 | |||
| f4767ec368 | |||
| 9fa2e9b72f | |||
| da8fd68982 | |||
| a6fb84cdad | |||
| d8bfc41fd7 | |||
| ff2ffc248e | |||
| 73f7c3cedb | |||
| 0790f7f01a | |||
| d9005100aa | |||
| bb64e4c4b9 | |||
| fe187dd744 | |||
| 515ccff03e | |||
| 8f92b2880b | |||
| 9cd2cb04cc | |||
| ca02397f0b | |||
| 239621af1c | |||
| 00d182d7a9 | |||
| 20be36ee6e | |||
| 7c50c5324c |
22
app/api/flash/[artistId]/route.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getDB } from '@/lib/db'
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: { artistId: string } }) {
|
||||
try {
|
||||
const db = getDB()
|
||||
const result = await db.prepare(`
|
||||
SELECT * FROM flash_items
|
||||
WHERE artist_id = ? AND is_available = 1
|
||||
ORDER BY order_index ASC, created_at DESC
|
||||
`).bind(params.artistId).all()
|
||||
|
||||
return new Response(JSON.stringify({ items: result.results }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err?.message || 'Failed to fetch flash items' }), { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
24
app/api/flash/item/[id]/route.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getDB } from '@/lib/db'
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const db = getDB()
|
||||
const result = await db.prepare(`
|
||||
SELECT * FROM flash_items WHERE id = ?
|
||||
`).bind(params.id).first()
|
||||
|
||||
if (!result) {
|
||||
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ item: result }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err?.message || 'Failed to fetch flash item' }), { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,10 +3,13 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import Image from "next/image"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Instagram, ExternalLink, Loader2, DollarSign } from "lucide-react"
|
||||
import { Instagram, ExternalLink, Loader2 } from "lucide-react"
|
||||
import { useArtist } from "@/hooks/use-artist-data"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from "@/components/ui/carousel"
|
||||
import { useFlash } from "@/hooks/use-flash"
|
||||
// Removed mobile filter scroll area
|
||||
|
||||
interface ArtistPortfolioProps {
|
||||
artistId: string
|
||||
@ -16,30 +19,110 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
||||
const [selectedCategory, setSelectedCategory] = useState("All")
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||
const [scrollY, setScrollY] = useState(0)
|
||||
const isMobile = useIsMobile()
|
||||
// carousel indicator state (mobile)
|
||||
const [carouselApi, setCarouselApi] = useState<CarouselApi | null>(null)
|
||||
const [carouselCount, setCarouselCount] = useState(0)
|
||||
const [carouselCurrent, setCarouselCurrent] = useState(0)
|
||||
const [showSwipeHint, setShowSwipeHint] = useState(true)
|
||||
const [showFullBio, setShowFullBio] = useState(false)
|
||||
const [flashApi, setFlashApi] = useState<CarouselApi | null>(null)
|
||||
|
||||
// Fetch artist data from API
|
||||
const { data: artist, isLoading, error } = useArtist(artistId)
|
||||
const { data: flashItems = [] } = useFlash(artist?.id)
|
||||
|
||||
// keep a reference to the last focused thumbnail so we can return focus on modal close
|
||||
const lastFocusedRef = useRef<HTMLElement | null>(null)
|
||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
const touchStartX = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Enable parallax only on desktop to avoid jank on mobile
|
||||
if (isMobile) return
|
||||
const handleScroll = () => setScrollY(window.scrollY)
|
||||
window.addEventListener("scroll", handleScroll)
|
||||
return () => window.removeEventListener("scroll", handleScroll)
|
||||
}, [isMobile])
|
||||
|
||||
// Fade swipe hint after a short delay
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setShowSwipeHint(false), 2500)
|
||||
return () => clearTimeout(t)
|
||||
}, [])
|
||||
|
||||
// Preserve scroll position when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (!selectedImage) return
|
||||
const y = window.scrollY
|
||||
const { body } = document
|
||||
body.style.position = "fixed"
|
||||
body.style.top = `-${y}px`
|
||||
body.style.left = "0"
|
||||
body.style.right = "0"
|
||||
return () => {
|
||||
const top = body.style.top
|
||||
body.style.position = ""
|
||||
body.style.top = ""
|
||||
body.style.left = ""
|
||||
body.style.right = ""
|
||||
const restoreY = Math.abs(parseInt(top || "0", 10))
|
||||
window.scrollTo(0, restoreY)
|
||||
}
|
||||
}, [selectedImage])
|
||||
|
||||
// Carousel indicators state wiring
|
||||
useEffect(() => {
|
||||
if (!carouselApi) return
|
||||
setCarouselCount(carouselApi.scrollSnapList().length)
|
||||
setCarouselCurrent(carouselApi.selectedScrollSnap())
|
||||
const onSelect = () => setCarouselCurrent(carouselApi.selectedScrollSnap())
|
||||
carouselApi.on("select", onSelect)
|
||||
return () => {
|
||||
carouselApi.off("select", onSelect)
|
||||
}
|
||||
}, [carouselApi])
|
||||
|
||||
// Flash carousel scale effect based on position (desktop emphasis)
|
||||
useEffect(() => {
|
||||
if (!flashApi) return
|
||||
const updateScales = () => {
|
||||
const root = flashApi.rootNode() as HTMLElement | null
|
||||
const slides = flashApi.slideNodes() as HTMLElement[]
|
||||
if (!root || !slides?.length) return
|
||||
const rect = root.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
slides.forEach((slide) => {
|
||||
const sRect = slide.getBoundingClientRect()
|
||||
const sCenter = sRect.left + sRect.width / 2
|
||||
const dist = Math.abs(sCenter - centerX)
|
||||
const norm = Math.min(dist / (rect.width / 2), 1) // 0 at center, 1 at edge
|
||||
const scale = 0.92 + (1 - norm) * 0.08 // 0.92 at edge → 1.0 center
|
||||
slide.style.transition = 'transform 200ms ease'
|
||||
slide.style.transform = `scale(${scale})`
|
||||
})
|
||||
}
|
||||
updateScales()
|
||||
flashApi.on('scroll', updateScales)
|
||||
flashApi.on('reInit', updateScales)
|
||||
return () => {
|
||||
flashApi.off('scroll', updateScales)
|
||||
flashApi.off('reInit', updateScales)
|
||||
}
|
||||
}, [flashApi])
|
||||
|
||||
// Derived lists (safe when `artist` is undefined during initial renders)
|
||||
const portfolioImages = artist?.portfolioImages || []
|
||||
// Exclude profile/non-public images from the displayed gallery
|
||||
const galleryImages = portfolioImages.filter((img) => img.isPublic !== false && !img.tags.includes('profile'))
|
||||
|
||||
// Get unique categories from tags
|
||||
const allTags = portfolioImages.flatMap(img => img.tags)
|
||||
// Get unique categories from tags (use gallery images only)
|
||||
const allTags = galleryImages.flatMap(img => img.tags)
|
||||
const categories = ["All", ...Array.from(new Set(allTags))]
|
||||
|
||||
const filteredPortfolio = selectedCategory === "All"
|
||||
? portfolioImages
|
||||
: portfolioImages.filter(img => img.tags.includes(selectedCategory))
|
||||
? galleryImages
|
||||
: galleryImages.filter(img => img.tags.includes(selectedCategory))
|
||||
|
||||
// keyboard navigation for modal (kept as hooks so they run in same order every render)
|
||||
const goToIndex = useCallback(
|
||||
@ -132,25 +215,14 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
||||
const profileImage = portfolioImages.find(img => img.tags.includes('profile'))?.url ||
|
||||
portfolioImages[0]?.url ||
|
||||
"/placeholder.svg"
|
||||
const bioText = artist.bio || ""
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
{/* Back Button */}
|
||||
<div className="fixed top-6 right-8 z-40">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/20 border border-white/30 backdrop-blur-sm bg-black/40 hover:text-white"
|
||||
>
|
||||
<Link href="/artists">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Artists
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Removed Back to Artists button per request */}
|
||||
|
||||
{/* Hero Section with Split Screen */}
|
||||
<section className="relative h-screen overflow-hidden -mt-20">
|
||||
{/* Hero Section with Split Screen (Desktop only) */}
|
||||
<section className="relative h-screen overflow-hidden -mt-20 hidden md:block">
|
||||
{/* Left Side - Artist Image */}
|
||||
<div className="absolute left-0 top-0 w-1/2 h-full" style={{ transform: `translateY(${scrollY * 0.3}px)` }}>
|
||||
<div className="relative w-full h-full">
|
||||
@ -162,14 +234,7 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-black/50" />
|
||||
<div className="absolute top-28 left-8">
|
||||
<Badge
|
||||
variant={artist.isActive ? "default" : "secondary"}
|
||||
className="bg-white/20 backdrop-blur-sm text-white border-white/30"
|
||||
>
|
||||
{artist.isActive ? "Available" : "Unavailable"}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Availability badge removed */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -181,7 +246,6 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
||||
<div className="px-16 py-20">
|
||||
<div className="mb-8">
|
||||
<h1 className="font-playfair text-6xl font-bold mb-4 text-balance leading-tight">{artist.name}</h1>
|
||||
<p className="text-2xl text-gray-300 mb-6">{artist.specialties.join(", ")}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-8 leading-relaxed text-lg max-w-lg">{artist.bio}</p>
|
||||
@ -200,24 +264,9 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{artist.hourlyRate && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<DollarSign className="w-5 h-5 text-gray-400" />
|
||||
<span className="text-gray-300">Starting at ${artist.hourlyRate}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h3 className="font-semibold mb-4 text-lg">Specializes in:</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{artist.specialties.map((style) => (
|
||||
<Badge key={style} variant="outline" className="border-white/30 text-white">
|
||||
{style}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Specialties and pricing hidden on desktop per request */}
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button asChild size="lg" className="bg-white text-black hover:bg-gray-100 !text-black hover:!text-black">
|
||||
@ -242,8 +291,42 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Portfolio Section with Split Screen Layout */}
|
||||
<section className="relative bg-black">
|
||||
{/* Hero Section - Mobile stacked */}
|
||||
<section className="md:hidden -mt-16">
|
||||
<div className="relative w-full h-[55vh]">
|
||||
<Image
|
||||
src={profileImage}
|
||||
alt={artist.name}
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||
</div>
|
||||
<div className="px-6 py-8">
|
||||
<h1 className="font-playfair text-4xl font-bold mb-2 text-balance">{artist.name}</h1>
|
||||
<p className="text-white/80 mb-4 text-base">{artist.specialties.join(", ")}</p>
|
||||
<p className="text-white/80 leading-relaxed mb-2 text-[17px]">
|
||||
{showFullBio ? bioText : bioText.slice(0, 180)}{bioText.length > 180 && !showFullBio ? "…" : ""}
|
||||
</p>
|
||||
{bioText.length > 180 && (
|
||||
<button onClick={() => setShowFullBio((v) => !v)} className="text-white/70 text-sm underline">
|
||||
{showFullBio ? "Show less" : "Read more"}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Button asChild size="lg" className="bg-white text-black hover:bg-gray-100 !text-black hover:!text-black">
|
||||
<Link href={`/book?artist=${artist.slug}`}>Book Appointment</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="border-white/30 text-white hover:bg-white hover:text-black bg-transparent">
|
||||
Get Consultation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Portfolio Section with Split Screen Layout (Desktop only) */}
|
||||
<section className="relative bg-black hidden md:block">
|
||||
<div className="flex min-h-screen">
|
||||
{/* Left Side - Portfolio Grid */}
|
||||
<div className="w-2/3 p-8 overflow-y-auto">
|
||||
@ -358,6 +441,97 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mobile Portfolio: Carousel + Filters (simplified) */}
|
||||
<section className="md:hidden bg-black">
|
||||
{/* Removed mobile category filters for simplicity */}
|
||||
|
||||
{/* Carousel only */}
|
||||
<div className="px-2 pb-10">
|
||||
{filteredPortfolio.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-gray-400">No portfolio images available</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative" aria-label="Portfolio carousel">
|
||||
<Carousel opts={{ align: "start", loop: true }} className="w-full" setApi={setCarouselApi}>
|
||||
<CarouselContent>
|
||||
{filteredPortfolio.map((item) => (
|
||||
<CarouselItem key={item.id} className="basis-full">
|
||||
<div className="w-full h-[70vh] relative">
|
||||
<Image
|
||||
src={item.url || "/placeholder.svg"}
|
||||
alt={item.caption || `${artist.name} portfolio image`}
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-contain bg-black"
|
||||
/>
|
||||
</div>
|
||||
</CarouselItem>)
|
||||
)}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
<div className="pointer-events-none absolute top-2 right-3 rounded-full bg-white/10 backdrop-blur px-2 py-1 text-xs text-white">
|
||||
{filteredPortfolio.length} pieces
|
||||
</div>
|
||||
{/* Swipe hint */}
|
||||
{showSwipeHint && (
|
||||
<div className="pointer-events-none absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-white/10 backdrop-blur px-3 py-1 text-xs text-white">
|
||||
Swipe left or right
|
||||
</div>
|
||||
)}
|
||||
{/* Dots indicators */}
|
||||
<div className="mt-3 flex items-center justify-center gap-2" role="tablist" aria-label="Carousel indicators">
|
||||
{Array.from({ length: carouselCount }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => carouselApi?.scrollTo(i)}
|
||||
aria-current={carouselCurrent === i}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
className={`h-2 w-2 rounded-full ${carouselCurrent === i ? "bg-white" : "bg-white/40"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Available Flash (carousel) */}
|
||||
{flashItems && flashItems.length > 0 && (
|
||||
<section className="bg-black border-t border-white/10 py-10">
|
||||
<div className="px-4 md:px-0 md:max-w-none md:w-screen">
|
||||
<h3 className="font-playfair text-3xl md:text-4xl font-bold mb-6">Available Flash</h3>
|
||||
<div className="relative">
|
||||
<Carousel opts={{ align: "start", loop: true, skipSnaps: false, dragFree: true }} className="w-full relative" setApi={setFlashApi}>
|
||||
<CarouselContent>
|
||||
{flashItems.map((item) => (
|
||||
<CarouselItem key={item.id} className="basis-full md:basis-1/2 lg:basis-1/3">
|
||||
<div className="relative w-full aspect-[4/5] bg-black rounded-md overflow-hidden">
|
||||
<Image src={item.url} alt={item.title || `${artist?.name} flash`} fill sizes="(max-width:768px) 100vw, 33vw" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex items-center justify-end mt-3">
|
||||
<Button asChild size="sm" className="bg-white text-black hover:bg-gray-100 !text-black">
|
||||
<Link href={`/book?artist=${artist?.slug}&flashId=${item.id}`}>Book this</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
{/* Minimal nav controls */}
|
||||
<CarouselPrevious className="left-4 top-1/2 -translate-y-1/2 size-12 md:size-14 z-10 bg-black/85 text-white shadow-xl border border-white/30 hover:bg-black rounded-full" aria-label="Previous flash" />
|
||||
<CarouselNext className="right-4 top-1/2 -translate-y-1/2 size-12 md:size-14 z-10 bg-black/85 text-white shadow-xl border border-white/30 hover:bg-black rounded-full" aria-label="Next flash" />
|
||||
</Carousel>
|
||||
{/* Edge fade gradients (desktop) */}
|
||||
<div className="pointer-events-none hidden md:block absolute inset-y-0 left-0 w-24 bg-gradient-to-r from-black to-transparent" />
|
||||
<div className="pointer-events-none hidden md:block absolute inset-y-0 right-0 w-24 bg-gradient-to-l from-black to-transparent" />
|
||||
</div>
|
||||
{showSwipeHint && (
|
||||
<div className="pointer-events-none mt-3 text-center text-xs text-white/70">Swipe or use ◀ ▶</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Contact Section */}
|
||||
<section className="relative py-32 bg-black border-t border-white/10">
|
||||
<div className="container mx-auto px-8 text-center">
|
||||
@ -385,22 +559,7 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 pt-16 border-t border-white/10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
|
||||
<div>
|
||||
<div className="text-3xl font-bold mb-2">{artist.specialties.length}+</div>
|
||||
<div className="text-gray-400">Specialties</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold mb-2">{portfolioImages.length}</div>
|
||||
<div className="text-gray-400">Portfolio Pieces</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold mb-2">{artist.hourlyRate ? `$${artist.hourlyRate}` : "Contact"}</div>
|
||||
<div className="text-gray-400">Starting Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Desktop stats removed per request */}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -417,6 +576,24 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
||||
<div
|
||||
className="relative max-w-6xl max-h-[90vh] w-full flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => {
|
||||
touchStartX.current = e.touches[0].clientX
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
if (touchStartX.current == null) return
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current
|
||||
const threshold = 40
|
||||
if (Math.abs(dx) > threshold) {
|
||||
if (dx < 0) {
|
||||
const next = (currentIndex + 1) % filteredPortfolio.length
|
||||
goToIndex(next)
|
||||
} else {
|
||||
const prev = (currentIndex - 1 + filteredPortfolio.length) % filteredPortfolio.length
|
||||
goToIndex(prev)
|
||||
}
|
||||
}
|
||||
touchStartX.current = null
|
||||
}}
|
||||
>
|
||||
{/* Prev */}
|
||||
<button
|
||||
|
||||
@ -106,30 +106,15 @@ export function ArtistsSection() {
|
||||
style={transitionDelay ? { transitionDelay } : undefined}
|
||||
>
|
||||
<div className={`relative w-full ${aspectFor(i)} overflow-hidden rounded-md border border-white/10 bg-black`}>
|
||||
{/* Imagery */}
|
||||
{/* Imagery: use only the artist portrait */}
|
||||
<div className="absolute inset-0 artist-image">
|
||||
<img
|
||||
src={artist.workImages?.[0] || "/placeholder.svg"}
|
||||
alt={`${artist.name} tattoo work`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/30"></div>
|
||||
|
||||
{/* Portrait with feathered mask */}
|
||||
<div className="absolute left-0 top-0 w-3/5 h-full pointer-events-none">
|
||||
<img
|
||||
src={artist.faceImage || "/placeholder.svg"}
|
||||
alt={`${artist.name} portrait`}
|
||||
className="w-full h-full object-cover"
|
||||
style={{
|
||||
maskImage: "linear-gradient(to right, black 0%, black 70%, transparent 100%)",
|
||||
WebkitMaskImage: "linear-gradient(to right, black 0%, black 70%, transparent 100%)",
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Softer hover wash (replaces heavy overlay) */}
|
||||
<div className="absolute inset-0 z-10 transition-colors duration-300 group-hover:bg-black/10" />
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { fetchFlashItem } from "@/hooks/use-flash"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@ -33,6 +36,8 @@ interface BookingFormProps {
|
||||
}
|
||||
|
||||
export function BookingForm({ artistId }: BookingFormProps) {
|
||||
const search = useSearchParams()
|
||||
const flashIdParam = search?.get('flashId') || undefined
|
||||
const [step, setStep] = useState(1)
|
||||
const [selectedDate, setSelectedDate] = useState<Date>()
|
||||
|
||||
@ -68,11 +73,25 @@ export function BookingForm({ artistId }: BookingFormProps) {
|
||||
depositAmount: 100,
|
||||
agreeToTerms: false,
|
||||
agreeToDeposit: false,
|
||||
flashId: flashIdParam || "",
|
||||
})
|
||||
|
||||
const selectedArtist = artists?.find((a) => a.slug === formData.artistId)
|
||||
const selectedSize = tattooSizes.find((size) => size.size === formData.tattooSize)
|
||||
const bookingEnabled = useFeatureFlag("BOOKING_ENABLED")
|
||||
// Prefill from flash piece if provided
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!flashIdParam) return
|
||||
const item = await fetchFlashItem(flashIdParam)
|
||||
if (!item) return
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tattooDescription: [item.title, item.description].filter(Boolean).join(' - '),
|
||||
}))
|
||||
}
|
||||
load()
|
||||
}, [flashIdParam])
|
||||
|
||||
// Calculate appointment start and end times for availability checking
|
||||
const { appointmentStart, appointmentEnd } = useMemo(() => {
|
||||
|
||||
@ -108,7 +108,7 @@ export const artists: Artist[] = [
|
||||
{
|
||||
id: 3,
|
||||
slug: "amari-rodriguez",
|
||||
name: "Amari Rodriguez",
|
||||
name: "Amari Kyss",
|
||||
title: "",
|
||||
specialty: "Apprentice Artist",
|
||||
faceImage: "/artists/amari-rodriguez-portrait.jpg",
|
||||
|
||||
31
hooks/use-flash.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { FlashItem } from '@/types/database'
|
||||
|
||||
export const flashKeys = {
|
||||
listByArtist: (artistId: string) => ['flash', 'list', artistId] as const,
|
||||
item: (id: string) => ['flash', 'item', id] as const,
|
||||
}
|
||||
|
||||
export function useFlash(artistId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: flashKeys.listByArtist(artistId || ''),
|
||||
queryFn: async () => {
|
||||
if (!artistId) return [] as FlashItem[]
|
||||
const res = await fetch(`/api/flash/${artistId}`)
|
||||
if (!res.ok) throw new Error('Failed to fetch flash')
|
||||
const data = await res.json()
|
||||
return (data.items || []) as FlashItem[]
|
||||
},
|
||||
enabled: !!artistId,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchFlashItem(id: string): Promise<FlashItem | null> {
|
||||
const res = await fetch(`/api/flash/item/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return (data.item || null) as FlashItem | null
|
||||
}
|
||||
|
||||
|
||||
31
lib/db.ts
@ -7,7 +7,8 @@ import type {
|
||||
UpdateArtistInput,
|
||||
CreateAppointmentInput,
|
||||
UpdateSiteSettingsInput,
|
||||
AppointmentFilters
|
||||
AppointmentFilters,
|
||||
FlashItem
|
||||
} from '@/types/database'
|
||||
|
||||
// Type for Cloudflare D1 database binding
|
||||
@ -163,6 +164,20 @@ export async function getArtistWithPortfolio(id: string, env?: any): Promise<imp
|
||||
ORDER BY order_index ASC, created_at DESC
|
||||
`).bind(id).all();
|
||||
|
||||
// Fetch flash items (public only) - tolerate missing table in older DBs
|
||||
let flashRows: any[] = []
|
||||
try {
|
||||
const flashResult = await db.prepare(`
|
||||
SELECT * FROM flash_items
|
||||
WHERE artist_id = ? AND is_available = 1
|
||||
ORDER BY order_index ASC, created_at DESC
|
||||
`).bind(id).all();
|
||||
flashRows = flashResult.results as any[]
|
||||
} catch (_err) {
|
||||
// Table may not exist yet; treat as empty
|
||||
flashRows = []
|
||||
}
|
||||
|
||||
const artist = artistResult as any;
|
||||
|
||||
return {
|
||||
@ -185,6 +200,20 @@ export async function getArtistWithPortfolio(id: string, env?: any): Promise<imp
|
||||
isPublic: Boolean(img.is_public),
|
||||
createdAt: new Date(img.created_at)
|
||||
})),
|
||||
// Attach as non-breaking field (not in Artist type but useful to callers)
|
||||
flashItems: flashRows.map(row => ({
|
||||
id: row.id,
|
||||
artistId: row.artist_id,
|
||||
url: row.url,
|
||||
title: row.title || undefined,
|
||||
description: row.description || undefined,
|
||||
price: row.price ?? undefined,
|
||||
sizeHint: row.size_hint || undefined,
|
||||
tags: row.tags ? JSON.parse(row.tags) : undefined,
|
||||
orderIndex: row.order_index || 0,
|
||||
isAvailable: Boolean(row.is_available),
|
||||
createdAt: new Date(row.created_at)
|
||||
})) as FlashItem[],
|
||||
availability: [],
|
||||
createdAt: new Date(artist.created_at),
|
||||
updatedAt: new Date(artist.updated_at),
|
||||
|
||||
@ -7,6 +7,13 @@ export default withAuth(
|
||||
const token = req.nextauth.token
|
||||
const { pathname } = req.nextUrl
|
||||
|
||||
// Permanent redirect for renamed artist slug
|
||||
if (pathname === "/artists/amari-rodriguez") {
|
||||
const url = new URL("/artists/amari-kyss", req.url)
|
||||
const res = NextResponse.redirect(url, 308)
|
||||
return res
|
||||
}
|
||||
|
||||
// Allow token-based bypass for admin migrate endpoint (non-interactive deployments)
|
||||
const migrateToken = process.env.MIGRATE_TOKEN
|
||||
const headerToken = req.headers.get("x-migrate-token")
|
||||
|
||||
745
package-lock.json
generated
@ -128,8 +128,10 @@
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.16",
|
||||
"heic-convert": "^2.1.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"postcss": "^8.5",
|
||||
"sharp": "^0.34.4",
|
||||
"tailwindcss": "^4.1.9",
|
||||
"tw-animate-css": "1.3.3",
|
||||
"typescript": "^5",
|
||||
|
||||
BIN
public/artists/Amari-Rodriguez/Bio/United Artist Bio.pdf
Normal file
138
public/artists/Amari-Rodriguez/EDIT ME.md
Normal file
@ -0,0 +1,138 @@
|
||||
# Tattoo Artist Portfolio Questionnaire
|
||||
|
||||
## Basic Information
|
||||
|
||||
**Artist Name/Alias: Amari Kyss**
|
||||
|
||||
**Contact Email: grimmtatt@gmail.com**
|
||||
|
||||
**Instagram Handle:@grimmtatt**
|
||||
|
||||
**Other Social Media/Website:** <https://grimmtatts.glossgenius.com/>
|
||||
|
||||
## Background
|
||||
|
||||
**How did you get started in tattooing? In my Mothers House**
|
||||
|
||||
**Who were your mentors or influences? Christy Lumberg**
|
||||
|
||||
**In 2-3 paragraphs, describe your artistic philosophy and what makes your work unique:**
|
||||
|
||||
i think what sets me apart isn’t just how i tattoo it’s how i care. i don’t want this to feel like a
|
||||
|
||||
transaction, like you’re ordering a tattoo the way you’d order a meal. this isn’t fast, or
|
||||
|
||||
disposable, or something to rush through. i want every person who sits in my chair to feel like
|
||||
|
||||
they’re seen, like their story matters, and like the art we make together is something sacred
|
||||
|
||||
even if it’s small. i know i didn’t invent traditional tattooing, and i’m not pretending to be the
|
||||
|
||||
first person to lead with kindness. what i am is genuine. consistent. thoughtful. i approach this
|
||||
|
||||
work with deep respect for the history of it, for the people who wear it, and for the trust that
|
||||
|
||||
comes with putting something permanent on someone’s body. i’d do this for free if the world
|
||||
|
||||
let me. because to me, tattooing isn’t just a job for me it’s an exchange of energy, of care, of time. and
|
||||
|
||||
i think that intention lives in every piece i put out.
|
||||
|
||||
**What do you want potential clients to know about you and your work?**
|
||||
|
||||
i’d want them to know it feels like hanging out with someone they could actually be friends with
|
||||
|
||||
outside of the tattoo. like it was easy, comforting, and they didn’t have to be anything but
|
||||
|
||||
themselves. no pressure to be confident or outgoing or have the perfect idea or body just come
|
||||
|
||||
as you are, and that’s more than enough. i really try to create a space where people feel safe
|
||||
|
||||
and accepted. your body is welcome here. your story is welcome here. i want it to feel like
|
||||
|
||||
you’re just spending time with someone who sees you, hears you, and wants you to leave
|
||||
|
||||
feeling a little more at home in yourself.
|
||||
|
||||
**What are your goals for your tattoo career in the next few years?**
|
||||
|
||||
**slang insane ink**
|
||||
|
||||
## Artistic Style & Specialties
|
||||
|
||||
**What tattoo styles do you specialize in?** (Check all that apply)
|
||||
|
||||
- \[ x\] Traditional/American Traditional
|
||||
- \[x \] Neo-Traditional
|
||||
- \[ \] Realism (Black & Grey)
|
||||
- \[ \] Realism (Color)
|
||||
- \[x \] Japanese/Irezumi
|
||||
- \[x \] Blackwork
|
||||
- \[x \] Fine Line
|
||||
- \[ \] Geometric
|
||||
- \[ \] Watercolor
|
||||
- \[ \] Tribal
|
||||
- \[ \] Portrait
|
||||
- \[ x\] Lettering/Script
|
||||
- \[ \] Illustrative
|
||||
- \[x \] Dotwork
|
||||
- \[ \] Biomechanical
|
||||
- \[x \] Cover-ups
|
||||
- \[ \] Other: \________________\_
|
||||
|
||||
**What are your top 3 favorite styles to tattoo?**
|
||||
|
||||
1. American and Japanese Traditional
|
||||
2. Floral Black and Grey
|
||||
3. Color Work
|
||||
|
||||
**What types of designs do you most enjoy creating?**
|
||||
|
||||
**Anything American Traditional**
|
||||
|
||||
**Are there any styles or subjects you prefer NOT to tattoo?**
|
||||
|
||||
**Realism**
|
||||
|
||||
## Portfolio Pieces
|
||||
|
||||
**Please list 5-10 of your best tattoos that represent your work:**
|
||||
|
||||
[https://portal.united-tattoos.com/index.php/f/17904](https://portal.united-tattoos.com/index.php/f/17904 (preview))
|
||||
|
||||
## Process & Approach
|
||||
|
||||
**Describe your consultation process with clients:**
|
||||
|
||||
**Talking about the design seeing the space they want it and then going over availability, price ranges and the scheduling with a deposit**
|
||||
|
||||
**How do you approach custom design work?**
|
||||
|
||||
**with love**
|
||||
|
||||
## Availability & Pricing
|
||||
|
||||
**Current booking status:**
|
||||
|
||||
- \[ x\] Currently booking
|
||||
- \[ \] Waitlist
|
||||
- \[ \] By appointment only
|
||||
- \[x \] Walk-ins welcome
|
||||
|
||||
**Typical booking lead time:**
|
||||
|
||||
**idk what this means**
|
||||
|
||||
**Average session length:**
|
||||
|
||||
**depends on the tattoo**
|
||||
|
||||
**Hourly rate or price range:**
|
||||
|
||||
**I price by piece outside of day sessions**
|
||||
|
||||
**Minimum charge:**
|
||||
|
||||
**0**
|
||||
|
||||
**Do you require a deposit?** If yes, how much? yes depending on how much the tattoo is no more than $100 though
|
||||
BIN
public/artists/Amari-Rodriguez/Flash/Arrow_Lady.jpg
Normal file
|
After Width: | Height: | Size: 516 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Ball_and_Chain_Face.jpg
Normal file
|
After Width: | Height: | Size: 373 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Beetle.jpg
Normal file
|
After Width: | Height: | Size: 517 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Bonsai.jpg
Normal file
|
After Width: | Height: | Size: 540 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Boys_Will_Be_Bugs_Print.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/artists/Amari-Rodriguez/Flash/Cactus.jpg
Normal file
|
After Width: | Height: | Size: 426 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Cowboy_Killer_Print.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/artists/Amari-Rodriguez/Flash/Dark_Horse.jpg
Normal file
|
After Width: | Height: | Size: 564 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Dragon_Castle.jpg
Normal file
|
After Width: | Height: | Size: 978 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Dune_Lady.jpg
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Flor_De_Femme.jpg
Normal file
|
After Width: | Height: | Size: 552 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Gypsy.jpg
Normal file
|
After Width: | Height: | Size: 497 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Heart_Dagger.jpg
Normal file
|
After Width: | Height: | Size: 515 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/JestersPrivilege_Print.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/artists/Amari-Rodriguez/Flash/Jesters_Privillege.jpg
Normal file
|
After Width: | Height: | Size: 405 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/LadyBug.jpg
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Lightnin_Bugz.jpg
Normal file
|
After Width: | Height: | Size: 394 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Monstera.jpg
Normal file
|
After Width: | Height: | Size: 372 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Outlaw.jpg
Normal file
|
After Width: | Height: | Size: 533 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Panther.jpg
Normal file
|
After Width: | Height: | Size: 635 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Queen.jpg
Normal file
|
After Width: | Height: | Size: 541 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Rosebush.jpg
Normal file
|
After Width: | Height: | Size: 467 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Sailor_Jerry.jpg
Normal file
|
After Width: | Height: | Size: 779 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Scorpion.jpg
Normal file
|
After Width: | Height: | Size: 333 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Skeleton_Cowboy.jpg
Normal file
|
After Width: | Height: | Size: 559 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Spread_Eagle.jpg
Normal file
|
After Width: | Height: | Size: 583 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Traditional_Spiderweb.jpg
Normal file
|
After Width: | Height: | Size: 538 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/_Caterpiller.jpg
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Rose.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Rose.avif
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Russian Doll.avif
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Russian Doll.jpg
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Seppuku.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Seppuku.avif
Normal file
|
After Width: | Height: | Size: 391 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Sombrero.avif
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Sombrero.jpg
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Anahi Sternum.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Anahi Sternum.avif
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Anna Clown.avif
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Anna Clown.jpg
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Anna Cowgirl.avif
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Anna Cowgirl.jpg
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Ash Hummingbird.avif
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Ash Hummingbird.jpg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Cam Bonsai.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Cam Bonsai.avif
Normal file
|
After Width: | Height: | Size: 739 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Evaline Guillotine.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Evaline Guillotine.avif
Normal file
|
After Width: | Height: | Size: 391 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Evaline Vamp.avif
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Evaline Vamp.jpg
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Hailey Raven Skull.avif
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Hailey Raven Skull.jpg
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Ian Bat.avif
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Ian Bat.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Isabel Crane.avif
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Isabel Crane.jpg
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Jazzy Hand.avif
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Jazzy Hand.jpg
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Jazzy Zombie.avif
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Jazzy Zombie.jpg
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Jess FlipFace.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Jess FlipFace.avif
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Kenline Spoon v.1.avif
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Kenline Spoon v.1.jpg
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Kenline Spoon v.2.avif
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Kenline Spoon v.2.jpg
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Macey Ladybug.avif
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Macey Ladybug.jpg
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Macey Locket.avif
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Macey Locket.jpg
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Moby Dick 2.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Moby Dick 2.avif
Normal file
|
After Width: | Height: | Size: 476 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Moby Dick.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Moby Dick.avif
Normal file
|
After Width: | Height: | Size: 382 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Nicholai Moth.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Nicholai Moth.avif
Normal file
|
After Width: | Height: | Size: 450 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Nisha Snake.avif
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Nisha Snake.jpg
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/PawPrint Kaley.avif
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/PawPrint Kaley.jpg
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Pigeon.avif
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Pigeon.jpg
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Queen Flash.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Queen Flash.avif
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Rachel Shoulder.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Rachel Shoulder.avif
Normal file
|
After Width: | Height: | Size: 498 KiB |