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

121
src/lib/csv.ts Normal file
View File

@@ -0,0 +1,121 @@
/**
* CSV utilities — RFC 4180 compliant, no external dependencies.
*/
/** Escape a field value for CSV (wrap in quotes if needed) */
function escapeField(value: string): string {
if (
value.includes(",") ||
value.includes('"') ||
value.includes("\n") ||
value.includes("\r")
) {
return `"${value.replace(/"/g, '""')}"`
}
return value
}
/** Convert an array of objects to a CSV string */
export function objectsToCsv(
headers: string[],
rows: Record<string, string>[]
): string {
const lines: string[] = []
// Header row
lines.push(headers.map(escapeField).join(","))
// Data rows
for (const row of rows) {
const fields = headers.map((h) => escapeField(row[h] ?? ""))
lines.push(fields.join(","))
}
return lines.join("\n")
}
/**
* Parse a CSV string into an array of objects keyed by header names.
* Uses a state-machine parser to correctly handle quoted fields.
*/
export function csvToObjects(csvText: string): Record<string, string>[] {
const rows = parseCsvRows(csvText)
if (rows.length < 1) return []
const headers = rows[0]
const result: Record<string, string>[] = []
for (let i = 1; i < rows.length; i++) {
const row = rows[i]
// Skip empty rows
if (row.length === 1 && row[0] === "") continue
const obj: Record<string, string> = {}
for (let j = 0; j < headers.length; j++) {
obj[headers[j]] = row[j] ?? ""
}
result.push(obj)
}
return result
}
/** State-machine CSV parser that handles quoted fields correctly */
function parseCsvRows(text: string): string[][] {
const rows: string[][] = []
let currentRow: string[] = []
let currentField = ""
let inQuotes = false
for (let i = 0; i < text.length; i++) {
const char = text[i]
const nextChar = text[i + 1]
if (inQuotes) {
if (char === '"') {
if (nextChar === '"') {
// Escaped quote
currentField += '"'
i++ // skip next quote
} else {
// End of quoted field
inQuotes = false
}
} else {
currentField += char
}
} else {
if (char === '"' && currentField === "") {
// Start of quoted field
inQuotes = true
} else if (char === ",") {
currentRow.push(currentField)
currentField = ""
} else if (char === "\n") {
currentRow.push(currentField)
currentField = ""
rows.push(currentRow)
currentRow = []
} else if (char === "\r") {
// Skip carriage return (handle \r\n)
if (nextChar === "\n") {
i++ // skip \n
}
currentRow.push(currentField)
currentField = ""
rows.push(currentRow)
currentRow = []
} else {
currentField += char
}
}
}
// Handle last field/row
if (currentField !== "" || currentRow.length > 0) {
currentRow.push(currentField)
rows.push(currentRow)
}
return rows
}