diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b4b6a..797c036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2026-05-12 + +### Added +- **Precise Color Picker & Live Preview:** Replaced the generic color text inputs in Add and Edit forms with an interactive Color Input component. This new component combines text search with a native hex color picker and opacity slider, providing accurate visual representation while maintaining searchability. The form now features a live-updating `DiscPreview` that reflects changes in real-time. Hex color overrides seamlessly propagate to inventory lists, bag checklists, and flight charts. +- **Custom Molds:** Added the ability to create Custom Molds directly inline from the "Add Disc" form. If a disc does not exist in the Discit API, users can now define the disc's name, brand, category, and flight numbers to add it to their vault. + +### Fixed +- **Secondary Patterns:** Standardized Secondary Pattern names (e.g. "Halo/Rim") to consistently display friendly labels across both the editor forms and the detail views instead of the raw database values. Fixed an issue where long pattern names would truncate in form inputs on smaller screens. +- **Disc Visuals:** Fixed an SVG rendering bug where translucent or "Clear" secondary colors in Half 'n Half and Halo patterns incorrectly showed the base disc color underneath them. +- **Location Picker:** Fixed an issue where heavily nested location paths would be truncated after selection. The dropdown button now expands vertically to wrap long text. + ## [0.5.2] - 2026-05-07 ### Fixed diff --git a/package-lock.json b/package-lock.json index 911164b..0c67fba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "discvault", - "version": "0.1.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "discvault", - "version": "0.1.0", + "version": "0.6.0", "dependencies": { "@prisma/client": "^6.19.2", "@types/papaparse": "^5.5.2", diff --git a/package.json b/package.json index abcce67..73b8184 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "discvault", - "version": "0.5.0", + "version": "0.6.0", "private": true, "scripts": { "dev": "next dev -p 3001", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 833091c..7916447 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,6 +58,8 @@ model Inventory { userGlide Float? userTurn Float? secondaryColor String? + colorHex String? + secondaryColorHex String? secondaryPattern String? collection DiscCollection? @relation(fields: [collectionId], references: [id]) mold Mold @relation(fields: [moldId], references: [id]) diff --git a/src/app/actions/inventory.ts b/src/app/actions/inventory.ts index bfe2aa6..24a295c 100644 --- a/src/app/actions/inventory.ts +++ b/src/app/actions/inventory.ts @@ -52,6 +52,8 @@ export async function addDisc(formData: FormData) { const userGlide = parseUserFlight(formData.get('userGlide')) const userTurn = parseUserFlight(formData.get('userTurn')) const userFade = parseUserFlight(formData.get('userFade')) + const colorHex = (formData.get('colorHex') as string) || null + const secondaryColorHex = (formData.get('secondaryColorHex') as string) || null if (!moldId) throw new Error('Mold ID is required') @@ -63,6 +65,8 @@ export async function addDisc(formData: FormData) { weight, color, secondaryColor, + colorHex, + secondaryColorHex, secondaryPattern, plastic, stamp, @@ -103,6 +107,8 @@ export async function updateDisc(id: string, formData: FormData) { const userGlide = parseUserFlight(formData.get('userGlide')) const userTurn = parseUserFlight(formData.get('userTurn')) const userFade = parseUserFlight(formData.get('userFade')) + const colorHex = (formData.get('colorHex') as string) || null + const secondaryColorHex = (formData.get('secondaryColorHex') as string) || null try { await prisma.inventory.update({ @@ -112,6 +118,8 @@ export async function updateDisc(id: string, formData: FormData) { weight, color, secondaryColor, + colorHex, + secondaryColorHex, secondaryPattern, plastic, stamp, @@ -243,7 +251,9 @@ export async function importDiscs(records: any[], targetCollectionId?: string) { plastic: record.plastic || null, weight: parseSafeFloat(record.weight, NaN) || null, color: record.color || null, + colorHex: record.colorHex || null, secondaryColor: record.secondaryColor || null, + secondaryColorHex: record.secondaryColorHex || null, secondaryPattern: record.secondaryPattern || null, stamp: record.stamp || null, stampFoil: record.stampFoil || null, diff --git a/src/app/actions/molds.ts b/src/app/actions/molds.ts index 06ae9e7..1e30437 100644 --- a/src/app/actions/molds.ts +++ b/src/app/actions/molds.ts @@ -96,3 +96,44 @@ export async function syncMolds() { revalidatePath('/') return { updatedCount, createdCount } } + +function getStability(turn: number, fade: number): string { + const score = turn + fade + if (score <= -2) return 'Understable' + if (score <= 0) return 'Stable' + if (score <= 2) return 'Overstable' + return 'Very Overstable' +} + +export async function createCustomMold(formData: FormData) { + const name = formData.get('name') as string + const brand = formData.get('brand') as string + const category = formData.get('category') as string + const speed = parseFloat(formData.get('speed') as string) + const glide = parseFloat(formData.get('glide') as string) + const turn = parseFloat(formData.get('turn') as string) + const fade = parseFloat(formData.get('fade') as string) + + if (!name || !brand || !category || isNaN(speed) || isNaN(glide) || isNaN(turn) || isNaN(fade)) { + throw new Error('All fields are required to create a custom mold.') + } + + const stability = getStability(turn, fade) + + const newMold = await prisma.mold.create({ + data: { + name, + brand, + category, + speed, + glide, + turn, + fade, + stability, + isCustom: true + } + }) + + revalidatePath('/settings') + return newMold +} diff --git a/src/app/v/[vaultId]/bagcheck/page.tsx b/src/app/v/[vaultId]/bagcheck/page.tsx index 5aa00a2..0e9d3b6 100644 --- a/src/app/v/[vaultId]/bagcheck/page.tsx +++ b/src/app/v/[vaultId]/bagcheck/page.tsx @@ -81,7 +81,9 @@ export default async function BagCheckPage({ plastic: d.plastic, weight: d.weight, color: d.color, + colorHex: d.colorHex, secondaryColor: d.secondaryColor, + secondaryColorHex: d.secondaryColorHex, secondaryPattern: d.secondaryPattern, stampFoil: d.stampFoil, location: d.location, diff --git a/src/components/AddDiscForm.tsx b/src/components/AddDiscForm.tsx index 1c9f4a4..ab41af4 100644 --- a/src/components/AddDiscForm.tsx +++ b/src/components/AddDiscForm.tsx @@ -18,9 +18,13 @@ import { useState, useEffect, useCallback } from 'react' import { addDisc } from '@/app/actions/inventory' -import { Search, Loader2 } from 'lucide-react' +import { createCustomMold } from '@/app/actions/molds' +import { Search, Loader2, Plus } from 'lucide-react' import LocationPicker from '@/components/LocationPicker' import { type LocationNode } from '@/lib/locationTree' +import { SECONDARY_PATTERN_LABELS } from '@/lib/constants' +import ColorInput from './ColorInput' +import DiscPreview from './DiscPreview' interface Mold { id: string @@ -41,6 +45,61 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) { const [loading, setLoading] = useState(false) const [isSearching, setIsSearching] = useState(false) const [selectedLocation, setSelectedLocation] = useState(null) + const [isCustomMode, setIsCustomMode] = useState(false) + const [isCreatingCustom, setIsCreatingCustom] = useState(false) + + // Preview State + const [previewState, setPreviewState] = useState({ + color: '', + colorHex: null as string | null, + secondaryColor: '', + secondaryColorHex: null as string | null, + secondaryPattern: '', + stampFoil: '', + weight: '', + plastic: '' + }) + + const handleCreateCustomMold = async () => { + const brandInput = document.querySelector('input[name="customBrand"]') as HTMLInputElement + const nameInput = document.querySelector('input[name="customName"]') as HTMLInputElement + const categoryInput = document.querySelector('select[name="customCategory"]') as HTMLSelectElement + const speedInput = document.querySelector('input[name="customSpeed"]') as HTMLInputElement + const glideInput = document.querySelector('input[name="customGlide"]') as HTMLInputElement + const turnInput = document.querySelector('input[name="customTurn"]') as HTMLInputElement + const fadeInput = document.querySelector('input[name="customFade"]') as HTMLInputElement + + if (!brandInput.value || !nameInput.value || !speedInput.value || !glideInput.value || !turnInput.value || !fadeInput.value) { + alert("Please fill out all custom mold fields") + return + } + + setIsCreatingCustom(true) + const formData = new FormData() + formData.append('brand', brandInput.value) + formData.append('name', nameInput.value) + formData.append('category', categoryInput.value) + formData.append('speed', speedInput.value) + formData.append('glide', glideInput.value) + formData.append('turn', turnInput.value) + formData.append('fade', fadeInput.value) + + try { + const newMold = await createCustomMold(formData) + handleSelectMold({ + id: newMold.id, + name: newMold.name, + brand: newMold.brand, + category: newMold.category + }) + setIsCustomMode(false) + } catch (e) { + console.error(e) + alert("Failed to create custom mold") + } finally { + setIsCreatingCustom(false) + } + } const searchMolds = useCallback(async (q: string) => { if (q.length < 2) { @@ -98,7 +157,7 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) { {loading && } - {isSearching && molds.length > 0 && ( + {isSearching && ( )} - + + {isCustomMode && !selectedMold && ( +
+
+

+ Create Custom Mold +

+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ )} +
@@ -125,6 +258,7 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) { step="0.1" name="weight" placeholder="175" + onChange={e => setPreviewState(s => ({ ...s, weight: e.target.value }))} className="w-full px-5 py-4 bg-white border border-slate-200 rounded-2xl font-black text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm" />
@@ -134,6 +268,7 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) { type="text" name="plastic" placeholder="Star, Champion..." + onChange={e => setPreviewState(s => ({ ...s, plastic: e.target.value }))} className="w-full px-5 py-4 bg-white border border-slate-200 rounded-2xl font-black text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm" />
@@ -147,61 +282,83 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) { -
-
- - -
-
- - +
+
+
+ setPreviewState(s => ({ ...s, color: text, colorHex: hex }))} + /> + setPreviewState(s => ({ ...s, secondaryColor: text, secondaryColorHex: hex }))} + /> +
+ +
+
+ + +
+
+ + +
+
+ + setPreviewState(s => ({ ...s, stampFoil: e.target.value }))} + className="w-full px-3 py-4 bg-white border border-slate-200 rounded-2xl font-black text-sm text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm" + /> +
+
-
- - + + {/* Live Preview */} +
+ {selectedMold || isCustomMode ? ( + + ) : ( +
+
+ Preview +
+

Select a mold to see preview

+
+ )}
-
-
- - -
-
- - -
-
+
@@ -241,7 +398,7 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) { +
+ +
+ setText(e.target.value)} + placeholder="Blue, Pink..." + className="w-full pl-5 pr-14 py-4 bg-white border border-slate-200 rounded-2xl font-black text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm text-ellipsis overflow-hidden whitespace-nowrap" + /> + + {/* Color Picker Swatch */} +
+ { + setHex(e.target.value) + setIsOverridden(true) + }} + className="absolute -inset-2 w-12 h-12 cursor-pointer bg-transparent border-0 outline-none" + /> +
+
+ + {/* Opacity Slider */} +
+
+ Opacity + + {Math.round(opacity * 100)}% + +
+ { + setOpacity(parseFloat(e.target.value)) + setIsOverridden(true) + }} + className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-500" + /> +
+ + {/* Hidden input for the DB hex override */} + +
+ ) +} diff --git a/src/components/DiscDetailView.tsx b/src/components/DiscDetailView.tsx index fd48390..4e094ce 100644 --- a/src/components/DiscDetailView.tsx +++ b/src/components/DiscDetailView.tsx @@ -4,6 +4,7 @@ import { Edit3, ArrowLeft, MapPin, Zap } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import DiscPreview from './DiscPreview' +import { SECONDARY_PATTERN_LABELS } from '@/lib/constants' interface DiscDetailViewProps { disc: { @@ -11,7 +12,9 @@ interface DiscDetailViewProps { collectionId: string | null weight: number | null color: string | null + colorHex: string | null secondaryColor?: string | null + secondaryColorHex?: string | null secondaryPattern?: string | null plastic: string | null stamp: string | null @@ -132,7 +135,9 @@ export default function DiscDetailView({ disc, categoryColors, vaultId, bagPaths {disc.color && (
Secondary Pattern - {disc.secondaryPattern || '—'} + {disc.secondaryPattern ? (SECONDARY_PATTERN_LABELS[disc.secondaryPattern] || disc.secondaryPattern) : '—'}
Condition diff --git a/src/components/DiscPreview.tsx b/src/components/DiscPreview.tsx index 787cf5b..6186186 100644 --- a/src/components/DiscPreview.tsx +++ b/src/components/DiscPreview.tsx @@ -23,7 +23,9 @@ import PreviewModal from './PreviewModal' interface DiscPreviewProps { color?: string | null + colorHex?: string | null secondaryColor?: string | null + secondaryColorHex?: string | null secondaryPattern?: string | null stampFoil?: string | null size?: number @@ -45,7 +47,9 @@ interface DiscPreviewProps { export default function DiscPreview({ color, + colorHex, secondaryColor, + secondaryColorHex, secondaryPattern, stampFoil, size = 24, @@ -58,8 +62,8 @@ export default function DiscPreview({ disc }: DiscPreviewProps) { const [isModalOpen, setIsModalOpen] = useState(false) - const baseColor = parseDiscColor(color) - const secColor = parseDiscColor(secondaryColor) + const baseColor = parseDiscColor(color, colorHex) + const secColor = parseDiscColor(secondaryColor, secondaryColorHex) const foilColor = parseDiscColor(stampFoil) // We'll use a coordinate system from -50 to 50 for the internal rendering @@ -128,12 +132,26 @@ export default function DiscPreview({ {isTuned && showTunedOutline && isHovered && } {/* Base Disc */} - + {pattern === 'Split' && secondaryColor ? ( + <> + + + + ) : ( + + )} {/* Secondary Patterns - Clipped to disc bounds */} {pattern === 'Halo' && secondaryColor && ( - + )} {pattern === 'Burst' && secondaryColor && ( diff --git a/src/components/EditDiscForm.tsx b/src/components/EditDiscForm.tsx index 6e67276..4bf5232 100644 --- a/src/components/EditDiscForm.tsx +++ b/src/components/EditDiscForm.tsx @@ -22,6 +22,9 @@ import { Trash2, ArrowLeft, Inbox } from 'lucide-react' import { useRouter } from 'next/navigation' import LocationPicker from '@/components/LocationPicker' import { type LocationNode } from '@/lib/locationTree' +import { SECONDARY_PATTERN_LABELS } from '@/lib/constants' +import ColorInput from './ColorInput' +import DiscPreview from './DiscPreview' interface Collection { id: string @@ -35,7 +38,9 @@ interface EditDiscFormProps { collectionId: string | null weight: number | null color: string | null + colorHex: string | null secondaryColor: string | null + secondaryColorHex: string | null secondaryPattern: string | null plastic: string | null stamp: string | null @@ -69,6 +74,18 @@ export default function EditDiscForm({ disc, collections }: Omit(disc.userTurn !== null && disc.userTurn !== undefined ? String(disc.userTurn) : '') const [userFade, setUserFade] = useState(disc.userFade !== null && disc.userFade !== undefined ? String(disc.userFade) : '') const hasTuning = userGlide !== '' || userTurn !== '' || userFade !== '' + + // Preview State + const [previewState, setPreviewState] = useState({ + color: disc.color || '', + colorHex: disc.colorHex, + secondaryColor: disc.secondaryColor || '', + secondaryColorHex: disc.secondaryColorHex, + secondaryPattern: disc.secondaryPattern || '', + stampFoil: disc.stampFoil || '', + weight: disc.weight ? String(disc.weight) : '', + plastic: disc.plastic || '' + }) const currentTree = (() => { if (!selectedVaultId) return [] @@ -141,6 +158,7 @@ export default function EditDiscForm({ disc, collections }: Omit setPreviewState(s => ({ ...s, weight: e.target.value }))} className="w-full px-5 py-4 bg-white border border-slate-200 rounded-2xl font-black text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm" />
@@ -150,6 +168,7 @@ export default function EditDiscForm({ disc, collections }: Omit setPreviewState(s => ({ ...s, plastic: e.target.value }))} className="w-full px-5 py-4 bg-white border border-slate-200 rounded-2xl font-black text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm" />
@@ -164,59 +183,74 @@ export default function EditDiscForm({ disc, collections }: Omit
-
-
- - -
-
- - -
-
- - -
-
+
+
+
+ setPreviewState(s => ({ ...s, color: text, colorHex: hex }))} + /> + setPreviewState(s => ({ ...s, secondaryColor: text, secondaryColorHex: hex }))} + /> +
-
-
- - +
+
+ + +
+
+ + +
+
+ + setPreviewState(s => ({ ...s, stampFoil: e.target.value }))} + className="w-full px-3 py-4 bg-white border border-slate-200 rounded-2xl font-black text-sm text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm" + /> +
+
-
- - +
diff --git a/src/components/FlightChart.tsx b/src/components/FlightChart.tsx index d83aed9..fd53a63 100644 --- a/src/components/FlightChart.tsx +++ b/src/components/FlightChart.tsx @@ -35,7 +35,9 @@ interface Disc { fade: number } color?: string | null + colorHex?: string | null secondaryColor?: string | null + secondaryColorHex?: string | null secondaryPattern?: string | null stampFoil?: string | null plastic?: string | null @@ -336,7 +338,9 @@ export default function FlightChart({ discs, vaultId, defaultShowFlightPaths = f - + {selected ? ( <> - {selected.inBag && } - {selected.path} + {selected.inBag && } + {selected.path} ) : ( - No location set + No location set )}
diff --git a/src/components/PreviewModal.tsx b/src/components/PreviewModal.tsx index 1943331..061216b 100644 --- a/src/components/PreviewModal.tsx +++ b/src/components/PreviewModal.tsx @@ -20,6 +20,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { X } from 'lucide-react' import DiscPreview from './DiscPreview' import { useEffect } from 'react' +import { SECONDARY_PATTERN_LABELS } from '@/lib/constants' interface PreviewModalProps { isOpen: boolean @@ -30,7 +31,9 @@ interface PreviewModalProps { brand: string } color?: string | null + colorHex?: string | null secondaryColor?: string | null + secondaryColorHex?: string | null secondaryPattern?: string | null stampFoil?: string | null plastic?: string | null @@ -82,7 +85,9 @@ export default function PreviewModal({ isOpen, onClose, disc }: PreviewModalProp
{disc.secondaryPattern && (
- {disc.secondaryPattern} Pattern + {SECONDARY_PATTERN_LABELS[disc.secondaryPattern] || disc.secondaryPattern} Pattern
)}
diff --git a/src/lib/colorParser.ts b/src/lib/colorParser.ts index d0232e2..f040dd6 100644 --- a/src/lib/colorParser.ts +++ b/src/lib/colorParser.ts @@ -1,14 +1,33 @@ import tinycolor from 'tinycolor2'; -export function parseDiscColor(input: string | null | undefined): string { - if (!input) return '#94a3b8'; // Default slate-400 +export function parseDiscColor(input: string | null | undefined, hexOverride?: string | null): string { + if (!input && !hexOverride) return '#94a3b8'; // Default slate-400 - let colorStr = input.toLowerCase().trim(); - const isClear = colorStr.includes('clear'); + let colorStr = (input || '').toLowerCase().trim(); + const isClear = colorStr.includes('clear') || colorStr === 'ice'; if (isClear) { colorStr = colorStr.replace('clear', '').trim(); } + const applyClear = (hex: string) => { + if (!isClear) return hex; + const c = tinycolor(hex).toRgb(); + return `rgba(${c.r}, ${c.g}, ${c.b}, 0.5)`; + } + + if (hexOverride) { + // If we have an 8-char hex, the alpha is embedded in the hex. + // If it's a 6-char hex and isClear, we apply 0.5 opacity. + const parsedHex = tinycolor(hexOverride); + if (parsedHex.isValid()) { + // If it already has an alpha < 1, just return its hex8 or rgba + if (parsedHex.getAlpha() < 1) { + return parsedHex.toRgbString(); + } + return applyClear(parsedHex.toHexString()); + } + } + // 1. Handle specific Disc Golf exact matches / overrides if (colorStr === 'glow' || colorStr === 'moonshine' || colorStr === 'eclipse') { return isClear ? 'rgba(220, 242, 181, 0.3)' : '#dcf2b5'; @@ -40,12 +59,6 @@ export function parseDiscColor(input: string | null | undefined): string { if (hasDark) colorStr = colorStr.replace('dark', '').trim(); if (hasDeep) colorStr = colorStr.replace('deep', '').trim(); - const applyClear = (hex: string) => { - if (!isClear) return hex; - const c = tinycolor(hex).toRgb(); - return `rgba(${c.r}, ${c.g}, ${c.b}, 0.5)`; - } - const applyModifiers = (color: tinycolor.Instance) => { if (hasNeon) color = color.saturate(100).lighten(10); if (hasLight) color = color.lighten(20); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 679be62..810b165 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -5,3 +5,11 @@ export const DEFAULT_CATEGORY_COLORS: Record = { 'Putter': '#10b981', // emerald 'Utility': '#8b5cf6', // violet } + +export const SECONDARY_PATTERN_LABELS: Record = { + 'Halo': 'Halo/Rim', + 'Burst': 'Center Burst', + 'Split': 'Half \'n Half', + 'Swirl': 'Swirly', + 'Speckled': 'Speckled' +}