Initial commit: DrinkTracker full-stack app

Next.js 14 drink collection tracker with AI-powered search,
menu scanning, ratings, wishlist, sharing, and CSV backup/restore.

Features:
- Auth (credentials + OAuth ready)
- Drink collection with ratings and reviews
- AI search via Claude/OpenAI with search history
- Menu photo scanning with AI extraction
- Wishlist / Try Later system
- Public sharing via slug URLs
- CSV backup and restore (merge/replace modes)
- Docker Compose for Postgres + MinIO + dev server

Security: docker-compose files use env var interpolation
instead of hardcoded secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JP Scott
2026-03-01 12:27:08 -07:00
commit 969bc9347a
115 changed files with 19397 additions and 0 deletions

68
src/hooks/use-backup.ts Normal file
View File

@@ -0,0 +1,68 @@
"use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import type { RestoreSummary } from "@/lib/backup"
interface RestoreResponse {
success: boolean
summary: RestoreSummary
}
export function useExportBackup() {
return useMutation({
mutationFn: async () => {
const res = await fetch("/api/settings/backup")
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || "Failed to export backup")
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const disposition = res.headers.get("Content-Disposition")
const filename =
disposition?.match(/filename="(.+)"/)?.[1] ||
`drinktracker-backup-${new Date().toISOString().split("T")[0]}.csv`
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
},
})
}
export function useRestoreBackup() {
const queryClient = useQueryClient()
return useMutation<RestoreResponse, Error, { file: File; mode: string }>({
mutationFn: async ({ file, mode }) => {
const formData = new FormData()
formData.append("file", file)
formData.append("mode", mode)
const res = await fetch("/api/settings/restore", {
method: "POST",
body: formData,
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || "Failed to restore backup")
}
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drinks"] })
queryClient.invalidateQueries({ queryKey: ["drink"] })
queryClient.invalidateQueries({ queryKey: ["wishlist"] })
queryClient.invalidateQueries({ queryKey: ["preferences"] })
queryClient.invalidateQueries({ queryKey: ["shared-lists"] })
queryClient.invalidateQueries({ queryKey: ["search-history"] })
},
})
}

137
src/hooks/use-drinks.ts Normal file
View File

@@ -0,0 +1,137 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import type { DrinkCreate, DrinkUpdate } from "@/lib/validators"
export interface DrinkFilters {
search?: string
type?: string
sort?: string
page?: number
limit?: number
}
export interface DrinkListItem {
id: string
userId: string
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType: string | null
brewery: string | null
region: string | null
abv: number | null
description: string | null
imageUrl: string | null
createdAt: string
updatedAt: string
avgRating: number | null
ratingCount: number
}
export interface DrinkRating {
id: string
userId: string
drinkId: string
score: number
notes: string | null
wouldReorder: boolean
location: string | null
createdAt: string
updatedAt: string
}
export interface DrinkDetail extends DrinkListItem {
ratings: DrinkRating[]
}
export interface DrinksResponse {
drinks: DrinkListItem[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}
async function fetchWithError(url: string, options?: RequestInit) {
const res = await fetch(url, options)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `Request failed with status ${res.status}`)
}
return res.json()
}
export function useDrinks(filters: DrinkFilters = {}) {
const params = new URLSearchParams()
if (filters.search) params.set("search", filters.search)
if (filters.type && filters.type !== "ALL") params.set("type", filters.type)
if (filters.sort) params.set("sort", filters.sort)
if (filters.page) params.set("page", String(filters.page))
if (filters.limit) params.set("limit", String(filters.limit))
const queryString = params.toString()
const url = `/api/drinks${queryString ? `?${queryString}` : ""}`
return useQuery<DrinksResponse>({
queryKey: ["drinks", filters],
queryFn: () => fetchWithError(url),
})
}
export function useDrink(id: string | undefined) {
return useQuery<DrinkDetail>({
queryKey: ["drink", id],
queryFn: () => fetchWithError(`/api/drinks/${id}`),
enabled: !!id,
})
}
export function useCreateDrink() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: DrinkCreate) =>
fetchWithError("/api/drinks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drinks"] })
},
})
}
export function useUpdateDrink(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: DrinkUpdate) =>
fetchWithError(`/api/drinks/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drinks"] })
queryClient.invalidateQueries({ queryKey: ["drink", id] })
},
})
}
export function useDeleteDrink(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () =>
fetchWithError(`/api/drinks/${id}`, {
method: "DELETE",
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drinks"] })
queryClient.removeQueries({ queryKey: ["drink", id] })
},
})
}

151
src/hooks/use-ratings.ts Normal file
View File

@@ -0,0 +1,151 @@
"use client"
import {
useQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query"
import type { RatingCreate, RatingUpdate } from "@/lib/validators"
interface RatingDrink {
id: string
name: string
type: string
subType: string | null
brewery: string | null
imageUrl: string | null
}
export interface Rating {
id: string
userId: string
drinkId: string
score: number
notes: string | null
wouldReorder: boolean
location: string | null
createdAt: string
updatedAt: string
drink: RatingDrink
}
interface RatingsResponse {
ratings: Rating[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}
interface UseRatingsOptions {
drinkId?: string
page?: number
limit?: number
sort?: "recent" | "score-high" | "score-low"
}
async function fetchRatings(options: UseRatingsOptions): Promise<RatingsResponse> {
const params = new URLSearchParams()
if (options.drinkId) params.set("drinkId", options.drinkId)
if (options.page) params.set("page", String(options.page))
if (options.limit) params.set("limit", String(options.limit))
if (options.sort) params.set("sort", options.sort)
const res = await fetch(`/api/ratings?${params.toString()}`)
if (!res.ok) {
const error = await res.json().catch(() => ({ error: "Request failed" }))
throw new Error(error.error || "Failed to fetch ratings")
}
return res.json()
}
export function useRatings(options: UseRatingsOptions = {}) {
return useQuery({
queryKey: ["ratings", options],
queryFn: () => fetchRatings(options),
})
}
export function useRating(id: string) {
return useQuery({
queryKey: ["ratings", id],
queryFn: async () => {
const res = await fetch(`/api/ratings/${id}`)
if (!res.ok) {
const error = await res.json().catch(() => ({ error: "Request failed" }))
throw new Error(error.error || "Failed to fetch rating")
}
return res.json() as Promise<Rating>
},
enabled: !!id,
})
}
export function useCreateRating() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: RatingCreate) => {
const res = await fetch("/api/ratings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
if (!res.ok) {
const error = await res.json().catch(() => ({ error: "Request failed" }))
throw new Error(error.error || "Failed to create rating")
}
return res.json() as Promise<Rating>
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ratings"] })
queryClient.invalidateQueries({ queryKey: ["drinks"] })
},
})
}
export function useUpdateRating(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: RatingUpdate) => {
const res = await fetch(`/api/ratings/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
if (!res.ok) {
const error = await res.json().catch(() => ({ error: "Request failed" }))
throw new Error(error.error || "Failed to update rating")
}
return res.json() as Promise<Rating>
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ratings"] })
queryClient.invalidateQueries({ queryKey: ["drinks"] })
},
})
}
export function useDeleteRating(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => {
const res = await fetch(`/api/ratings/${id}`, {
method: "DELETE",
})
if (!res.ok) {
const error = await res.json().catch(() => ({ error: "Request failed" }))
throw new Error(error.error || "Failed to delete rating")
}
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ratings"] })
queryClient.invalidateQueries({ queryKey: ["drinks"] })
},
})
}

109
src/hooks/use-scan.ts Normal file
View File

@@ -0,0 +1,109 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
interface ScanResult {
id: string
status: "UPLOADING" | "PROCESSING" | "COMPLETED" | "FAILED"
imageUrl: string
errorMessage?: string
items: Array<{
id: string
name: string
type: string
subType?: string
brewery?: string
abv?: number
price?: string
description?: string
matchedDrinkId?: string
userRating?: number
aiRecommended: boolean
aiReason?: string
matchedDrink?: {
id: string
name: string
ratings: Array<{ score: number }>
}
}>
}
export function useScan(id: string | undefined) {
return useQuery<ScanResult>({
queryKey: ["scan", id],
queryFn: async () => {
const res = await fetch(`/api/scan/${id}`)
if (!res.ok) throw new Error("Failed to fetch scan")
return res.json()
},
enabled: !!id,
refetchInterval: (query) => {
const data = query.state.data
if (data?.status === "PROCESSING" || data?.status === "UPLOADING") {
return 2000 // Poll every 2s while processing
}
return false
},
})
}
export function useScans() {
return useQuery({
queryKey: ["scans"],
queryFn: async () => {
const res = await fetch("/api/scan")
if (!res.ok) throw new Error("Failed to fetch scans")
return res.json()
},
})
}
export function useCreateScan() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (file: File) => {
const formData = new FormData()
formData.append("file", file)
const res = await fetch("/api/scan", {
method: "POST",
body: formData,
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || "Failed to create scan")
}
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["scans"] })
},
})
}
export function useAddDrinkFromScan() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (item: {
name: string
type: string
subType?: string
brewery?: string
abv?: number
description?: string
}) => {
const res = await fetch("/api/drinks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(item),
})
if (!res.ok) throw new Error("Failed to add drink")
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drinks"] })
queryClient.invalidateQueries({ queryKey: ["scans"] })
},
})
}

View File

@@ -0,0 +1,35 @@
"use client"
import { useQuery } from "@tanstack/react-query"
export interface SearchHistoryDrink {
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType?: string
brewery?: string
abv?: number
description?: string
}
export interface SearchHistoryItem {
id: string
query: string
provider: string
results: { drinks: SearchHistoryDrink[] }
createdAt: string
}
interface SearchHistoryResponse {
searches: SearchHistoryItem[]
}
export function useSearchHistory() {
return useQuery<SearchHistoryResponse>({
queryKey: ["search-history"],
queryFn: async () => {
const res = await fetch("/api/ai/search/history")
if (!res.ok) throw new Error("Failed to load search history")
return res.json()
},
})
}

76
src/hooks/use-wishlist.ts Normal file
View File

@@ -0,0 +1,76 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import type { WishlistCreate } from "@/lib/validators"
export interface WishlistItem {
id: string
userId: string
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType: string | null
brewery: string | null
abv: number | null
description: string | null
notes: string | null
source: string | null
createdAt: string
updatedAt: string
}
async function fetchWithError(url: string, options?: RequestInit) {
const res = await fetch(url, options)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `Request failed with status ${res.status}`)
}
return res.json()
}
export function useWishlist() {
return useQuery<{ items: WishlistItem[] }>({
queryKey: ["wishlist"],
queryFn: () => fetchWithError("/api/wishlist"),
})
}
export function useAddToWishlist() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: WishlistCreate) =>
fetchWithError("/api/wishlist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wishlist"] })
},
})
}
export function useRemoveFromWishlist() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
fetchWithError(`/api/wishlist/${id}`, { method: "DELETE" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wishlist"] })
},
})
}
export function usePromoteWishlistItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
fetchWithError(`/api/wishlist/${id}`, { method: "POST" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wishlist"] })
queryClient.invalidateQueries({ queryKey: ["drinks"] })
},
})
}