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:
68
src/hooks/use-backup.ts
Normal file
68
src/hooks/use-backup.ts
Normal 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
137
src/hooks/use-drinks.ts
Normal 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
151
src/hooks/use-ratings.ts
Normal 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
109
src/hooks/use-scan.ts
Normal 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"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
35
src/hooks/use-search-history.ts
Normal file
35
src/hooks/use-search-history.ts
Normal 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
76
src/hooks/use-wishlist.ts
Normal 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"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user