4
Vinyly...
Constructing a database of all my records.
Explore the Collection
surf 700+ records on my shelf
Archive Creation
The grunt work for this project involved flipping through my wall of records. While we may be thriving in the era of chatbot-assisted coding, robotic assistance is still far from capable when it comes to handling hundreds of fragile vintage jackets and discs. Although I regularly play some LPs, many of these records remain untouched for long periods. Therefore, the idea of passively logging each record as I played it was impractical. Instead, schlepping records over to my workstation and logging them manually through traditional data entry was both unavoidable and, sadly, rather unmusical.
Regarding initial details, I used a prompt with an array of objects for quick data entry. Part of the appeal of this process was leveraging human reasoning to input the minimal amount of data necessary for an AI prompt to complete the grunt work of filling out record details.
As a primarily frontend developer, I was already comfortable navigating and building with Node.js before this project. I felt confident tasking AI with composing Node.js code blocks, which I could review and tweak, particularly for file system operations, API transactions, and uploading the final product. I hope my work here encourages backend-hesitant developers to build a rapport with chatbots to expand their skill set without feeling pressured to achieve fluency. Each line of Node.js code generated by a chatbot made conceptual sense to me, even if I couldn’t reproduce it in the context of a technical interview. I admire developers who have a sharp grasp of core language concepts, but I’ve embraced a chatbot-driven world where attention to detail and literacy are no longer inseparable.
database and backend
In hindsight, I regret not consulting the data structure used by popular vinyl record databases like Discogs before starting this project. However, I stand by my key-value pairs for focusing on what matters most to me as a musician, DJ, collector, nerd, and nostalgist.
data type for each record
{
"title": "",
"artist": "",
"details": "random details found on the jacket or record",
"releaseDate": null,
"genre": "",
"recordLabel": "",
"trackList": [
{
"side": "",
"trackName": "",
"trackLength": "",
"trackOrder": null,
"credits": [
{
"name": "",
"roles": [
""
]
}
],
"tempo": "",
"musicalKey": ""
}
],
"keyCredits": [
{
"name": "",
"roles": [
""
]
}
],
"format": "45 rpm",
"quantity": 1,
"catNo": "",
"condition": "",
"collectionNo": 0 ,
},
I wrote a Node.js script to generate a report on the data as I worked, highlighting missing details and informing several rounds of AI prompts. This reporting feature was integrated into other aspects of the app, ensuring an updated report was generated after every action to outline tasks for each session.
reporting
const fs = require('fs');
// Function to extract and save all unique labels and their counts to a JSON file
const report = () => {
const inputFilePath = 'recordCollection.json';
const outputFilePath = 'report.json';
let finalReport = {
counts: {},
artistsArray: [],
uniqueLabels: [],
labelsArray: [],
uniqueGenres: [],
duplicateRecords: [],
incompleteRecords: [],
emptyLabel: [],
emptyGenre: [],
emptyTitle: [],
emptyArtist: [],
trackListLong: [],
specificFilter: [],
specificFilterCount: [],
};
if (!fs.existsSync(inputFilePath)) {
console.error(`File not found: ${inputFilePath}`);
return;
}
try {
// Read existing data
const data = fs.readFileSync(inputFilePath, 'utf-8');
const records = JSON.parse(data);
// Check if the records are in an array
if (!Array.isArray(records)) {
throw new Error('The data in the JSON file is not an array');
}
// Count total records, total 33s, total 45s
const counts = {};
counts.total = records.length;
counts.thirtythrees = records.filter(
(record) => record.format === '33 rpm'
).length;
counts.fortyfives = records.filter(
(record) => record.format === '45 rpm'
).length;
finalReport.counts = counts;
// Generate a list of labels
const labelsSet = new Set();
records.forEach((record) => {
if (record.recordLabel) {
record.recordLabel.split(',').forEach((label) => {
labelsSet.add(label.trim());
});
}
});
finalReport.uniqueLabels = Array.from(labelsSet).sort();
// Count the instances of each label
const labelCounts = {};
records.forEach((record) => {
if (record.recordLabel) {
record.recordLabel.split(',').forEach((label) => {
const trimmedLabel = label.trim();
if (trimmedLabel) {
if (!labelCounts[trimmedLabel]) {
labelCounts[trimmedLabel] = 0;
}
labelCounts[trimmedLabel] += 1;
}
});
}
});
const labelsArray = Object.entries(labelCounts).map(([label, count]) => ({
label,
count,
}));
finalReport.labelsArray = labelsArray.sort((a, b) => b.count - a.count);
// Check for incomplete record entries
records.forEach((record) => {
const requiredFields = ['title', 'artist', 'recordLabel', 'trackList'];
const isIncomplete = requiredFields.some(
(field) => !record.hasOwnProperty(field)
);
if (isIncomplete) {
finalReport.incompleteRecords.push(record);
}
});
// Scan for genres
const genreSet = new Set();
const capitalizeWords = (str) => {
return str
.split(' ')
.map(
(word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
)
.join(' ');
};
records.forEach((record) => {
if (record.genre) {
const genres = Array.isArray(record.genre)
? record.genre
: record.genre.split(',');
genres.forEach((genre) => {
const normalizedGenre = capitalizeWords(genre.trim().toLowerCase());
genreSet.add(normalizedGenre);
});
}
});
finalReport.uniqueGenres = Array.from(genreSet).sort();
// Check for duplicate records
records.forEach((record) => {
if (record.quantity > 1) {
finalReport.duplicateRecords.push({
title: record.title,
artist: record.artist,
quantity: record.quantity,
format: record.format,
});
}
});
// Count instances of artists
const artistCounts = {};
records.forEach((record) => {
if (record.artist) {
const normalizedArtist = record.artist.trim().toLowerCase();
if (!artistCounts[normalizedArtist]) {
artistCounts[normalizedArtist] = 0;
}
artistCounts[normalizedArtist] += 1;
}
});
const artistArray = Object.entries(artistCounts).map(([artist, count]) => ({
artist,
count,
}));
finalReport.artistsArray = artistArray.sort((a, b) => b.count - a.count);
// Find records without titles
let noTitle = [];
records.forEach((record) => {
if (record.title === '') {
noTitle.push({
title: record.title,
artist: record.artist,
details: record.details,
format: record.format,
});
}
});
finalReport.emptyTitle = noTitle;
// Find records without artist names
let noArtist = [];
records.forEach((record) => {
if (record.artist === '') {
noArtist.push({
title: record.title,
artist: record.artist,
details: record.details,
format: record.format,
});
}
});
finalReport.emptyArtist = noArtist;
// Find records without genres
let noGenre = [];
records.forEach((record) => {
if (record.genre === '') {
noGenre.push({
title: record.title,
artist: record.artist,
details: record.details,
});
}
});
finalReport.emptyGenre = noGenre;
// Find records without a label
let noLabel = [];
records.forEach((record) => {
if (record.recordLabel === '') {
noLabel.push({
title: record.title,
artist: record.artist,
details: record.details,
});
}
});
finalReport.emptyLabel = noLabel;
// Filter for records with at least 5 tracks
finalReport.trackListLong = records.filter(
(record) =>
record.trackList &&
Array.isArray(record.trackList) &&
record.trackList.length > 10
);
// Test a specific filter
finalReport.specificFilter = records.flatMap(
(record) =>
record.trackList &&
record.trackList
.filter((track) => track.musicalKey.toLowerCase() === 'c major')
.map((track) => ({
artist: record.artist ? record.artist : '',
recordTitle: record.title ? record.title : '',
collectionNo: record.collectionNo ? record.collectionNo : '',
trackName: track.trackName ? track.trackName : '',
musicalKey: track.musicalKey ? track.musicalKey : '',
tempo: track.tempo ? track.tempo : '',
trackLength: track.trackLength ? track.trackLength : '',
}))
);
// Count records with a specific filter
finalReport.specificFilterCount = finalReport.specificFilter.length;
// Write final report to a JSON file
fs.writeFileSync(
outputFilePath,
JSON.stringify(finalReport, null, 2),
'utf-8'
);
console.log(`counts saved to: ${outputFilePath}`);
} catch (error) {
console.error('Error processing the data:', error);
}
};
// Run the function
report();
The script starts by requiring the fs module for file operations and defining two file paths. The first is recordCollection.json, which contains raw data about the records. The second is report.json, where the final processed report is saved. The report is structured into a finalReport object with categories such as unique labels, artist counts, missing data, and other insights, initialized as empty arrays or objects. To ensure data integrity, the script validates the input file. If the file is missing, it logs an error and exits gracefully. It also checks whether the data is in the expected array format, throwing an error if not. These early validations help avoid runtime errors and make the script more robust.
The core features of the script include counting records and categorizing them. It calculates the total number of records, separates them by format (e.g., 33 rpm vs. 45 rpm), and tracks duplicates, storing their details for further review. This ensures a clear view of the collection's composition and highlights redundant entries. Label and genre processing adds another layer of organization. The script extracts unique record labels, counts their occurrences, and creates a sorted list. For genres, it normalizes names to maintain consistency—for example, converting "rock" to "Rock"—and sorts them alphabetically. This ensures clean, uniform data that’s easier to work with. The script also identifies incomplete records, compiling lists of entries missing key fields such as title, artist, record label, or track list. It highlights records with missing genres, labels, or titles, providing actionable insights to fill these gaps.
Artist insights are another important feature. Here we track how often each artist appears across records, creating a sorted list by frequency. This gives a quick overview of the most prominent contributors in the collection, adding depth to the analysis. For track-based filtering, the script isolates records with more than 10 tracks, highlighting albums with extensive content. It also filters tracks by attributes like key (e.g., C Major) and compiles details such as tempo and track length for further analysis. Worth noting that among the data returned by OpenAI, tempo and musical key were consistently unreliable, in some cases wildly inaccurate. These data points will have to be QCed by (my) human ear.
For me, this project bridges frontend development with backend capabilities. By leveraging tools like Node.js and working collaboratively with a chatbot for coding assistance, I’ve built a functional solution without needing complete backend fluency.
prompt for adding new records
const vinylRecords = [
{
title: '',
artist: '',
details: '',
},
{
title: '',
artist: '',
details: '',
},
// I would populate the fields above based on a quick scan of the jacket.
];
const generatePrompt = (record) => {
return `Using the details provided, research and provide comprehensive information for the vinyl record "${record.title}" by "${record.artist}". Use the following field names in your JSON response:
The "keyCredits" field should include an array of objects, each with a "name" and "roles" (array of strings) for important individuals who worked on the record, such as producers, engineers, and other notable contributors.
The "trackList" field should include an array of objects with details about each track on the record, including the side (A, B, C, D), track name, track length, track order, and any credits specific to each track.
Roles should be separate items, so if a credit was a guitarist and a vocalist, the roles array should have 2 items in it, one for guitars, one for vocals etc. Note that genres should be seperated by commas as in the following template.
Additional details: ${record.details}
you must return JSON based on the following example, and if you are missing data after your research, leave those fields blank, but you must provide those (empty) fields for me to enter data into manually:
{
"title": "",
"artist": "",
"releaseDate": "",
"genre": "example genre, example genre",
"recordLabel": "",
"quantity" : 1,
"format" : "33 rpm",
"trackList": [
{
"side": "",
"trackName": "",
"trackLength": "",
"trackOrder": "",
"tempo": "",
"musicalKey":"",
"credits": [
{
"name": "",
"roles": [""]
}
]
}
],
"keyCredits": [
{
"name": "",
"roles": [""]
}
]
}`;
};
module.exports = { vinylRecords, generatePrompt };
The initial input I took from each record sleeve or disc was simple: the name of the record, the artist, and a “details” field for additional observations. This field came in handy when the title or artist was unclear or unwieldy (e.g., compilations, spoken word, or non-musical records). It was also useful for catalog numbers, which allowed me to streamline the process. Catalog numbers often include formatting indicators—such as an “S” or “M” (for stereo or mono)—which helped me distinguish between different versions of the same record. For example, I logged two copies of The Doors' LP, one in stereo and one in mono. Personally, I enjoy both, but there’s something raw and immersive about mono recordings that transport the listener back to the era of their pressing. In my opinion, you never really “hear” mono, especially on a speaker system—recordings interact with the room acoustics in a way that creates a unique (stereo) listening experience for two ears.
Refining the prompt itself took some time. I workshopped it with single records, avoiding submitting more than 5–10 records at a time for AI processing until I was confident the results and the app’s workflow were efficient. Even then, I fetched data sequentially from OpenAI, keeping close track of the monetary cost of automated work and setting billing limits to get accustomed to the cost and time factors involved in choosing human labor or chatbot assistance for task. One issue I encountered was ensuring fallback instructions for missing data; when working with chatbots, you must proactively account for potential gaps in their understanding and provide explicit instructions for handling those situations. I do acknowledge that part of the problem with tempo and musical key data inaccuracy was the fault of the prompt writer: before my prompt requested blank fields in lieu of information, I demanded (too literally) for fields be filled, and much like the humans that preceded these LLMs, under pressure, sometimes they just deliver bullshit.
add new records
require('dotenv').config();
const fs = require('fs');
const OpenAI = require('openai');
const { vinylRecords, generatePrompt } = require('./newRecordsPrompt');
// Initialize OpenAI client
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// Function to get data from OpenAI using chat completion
const getOpenAIData = async (prompt) => {
try {
const response = await openai.chat.completions.create({
model: 'gpt-4-turbo',
messages: [{ role: 'user', content: prompt }],
max_tokens: 1500,
n: 1,
stop: null,
temperature: 0.7,
});
console.log('OpenAI API Response:', response); // Log API response
return response.choices[0].message.content.trim();
} catch (error) {
console.error('OpenAI API error:', error);
return null;
}
};
// Function to clean and parse JSON safely
const cleanAndParseJSON = (data) => {
try {
// Strip the markdown formatting if it exists
if (data.startsWith('```json')) {
data = data.replace('```json', '').replace('```', '').trim();
}
// Extract JSON part of the string
const jsonStartIndex = data.indexOf('{');
const jsonEndIndex = data.lastIndexOf('}');
if (jsonStartIndex !== -1 && jsonEndIndex !== -1) {
const jsonString = data.substring(jsonStartIndex, jsonEndIndex + 1);
return JSON.parse(jsonString);
}
throw new Error('No valid JSON found in the response');
} catch (error) {
console.error('Error parsing cleaned JSON data:', error.message);
console.error('Problematic data:', data); // Debug: print the problematic data
return null;
}
};
// Function to enrich vinyl data using OpenAI
const enrichVinylData = async (record) => {
const prompt = generatePrompt(record);
const enrichedData = await getOpenAIData(prompt);
if (enrichedData) {
const enrichedRecord = cleanAndParseJSON(enrichedData);
if (enrichedRecord) {
return { ...record, ...enrichedRecord };
}
}
return record;
};
// Function to enrich all vinyl records and save to JSON file
const enrichAllVinylRecords = async () => {
let enrichedRecords = [];
if (fs.existsSync('recordCollection.json')) {
try {
const existingData = fs.readFileSync('recordCollection.json', 'utf-8');
if (existingData) {
enrichedRecords = JSON.parse(existingData);
}
} catch (error) {
console.error('Error reading existing data:', error);
}
}
for (const record of vinylRecords) {
const enrichedRecord = await enrichVinylData(record);
enrichedRecords.push(enrichedRecord);
}
enrichedRecords.sort((a, b) => a.artist.localeCompare(b.artist));
fs.writeFileSync(
'recordCollectionWithNewRecords.json',
JSON.stringify(enrichedRecords, null, 2)
);
console.log(
'added vinyl records with existing collection to recordCollectionWithNewRecords.json'
);
};
enrichAllVinylRecords().catch((error) => {
console.error('Error adding vinyl records:', error);
});
This Node.js script leverages OpenAI’s GPT API to enrich metadata for a vinyl record collection, making the data more usable and insightful. By integrating OpenAI’s language model, the script automates the process of enhancing record details, bridging the gap between raw data and meaningful insights.
The script begins with an environment setup using dotenv to securely load API credentials. It handles file operations to read, write, and update JSON files, which store the vinyl record collection. By integrating OpenAI’s API, the app sends prompts to the model to generate enriched metadata for each record. Safeguards, such as error handling and JSON validation, ensure the process is reliable even when dealing with malformed or unexpected data.
The getOpenAIData(prompt) function sends prompts to OpenAI to fetch metadata. It uses the GPT-4 Turbo model, balancing creativity and precision by setting the temperature to 0.7. The enrichVinylData(record) function takes individual record data, sends it to OpenAI for enrichment, and merges the response with the original record. This involves generating tailored prompts, fetching enriched data with getOpenAIData, cleaning and parsing the results, and integrating the new data into the record.
uploading to CMS
require('dotenv').config();
const { createClient } = require('@sanity/client');
const crypto = require('crypto');
const data = require('./recordCollection.json'); // giant array of record objects
// Initialize Sanity client
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
token: process.env.SANITY_TOKEN,
useCdn: false,
});
// Function to generate a unique key for each array item
const generateKey = () => crypto.randomBytes(8).toString('hex');
// Function to upload data (first 10 records)
const uploadData = async () => {
const recordsToUpload = data; // Limit to the first 10 records
for (const record of recordsToUpload) {
try {
// Build the document based on the schema
const doc = {
_type: 'vinylRecord',
title: record.title,
artist: record.artist, // Assuming `artist` is just a string now
releaseDate: record.releaseDate || null, // Use null if empty
genre: record.genre || null,
recordLabel: record.recordLabel || null,
format: record.format || null,
quantity: record.quantity || 1,
catNo: record.catNo || null,
condition: record.condition || null,
collectionNo: record.collectionNo || null,
verifiedData: record.verifiedData || false, // Default to false if not present in JSON
trackList: record.trackList.map((track) => ({
_key: generateKey(), // Generate a unique key
side: track.side,
trackName: track.trackName,
trackLength: track.trackLength || null,
trackOrder: track.trackOrder || null,
credits: track.credits.map((credit) => ({
_key: generateKey(), // Generate a unique key for credits
name: credit.name || null,
roles: credit.roles || [],
})),
tempo: track.tempo || null,
musicalKey: track.musicalKey || null,
})),
keyCredits: record.keyCredits.map((credit) => ({
_key: generateKey(), // Generate a unique key for key credits
name: credit.name || null,
roles: credit.roles || [],
})),
};
// Only add `coverImage` and `recordImage` fields if they exist in the record
if (record.coverImage) {
doc.coverImage = record.coverImage;
}
if (record.recordImage) {
doc.recordImage = record.recordImage;
}
// Upload each document
await client.create(doc);
console.log(`Uploaded: ${record.title}`);
} catch (error) {
console.error(`Failed to upload ${record.title}: `, error.message);
}
}
};
// Run the upload function
uploadData()
.then(() => {
console.log('records uploaded');
process.exit();
})
.catch((error) => {
console.error('Error uploading records: ', error.message);
process.exit(1);
});
This script automates the upload process to Sanity CMS, transforming raw JSON data into schema-compliant documents that seamlessly integrate with a Sanity dataset. To ensure compatibility with Sanity’s schema, the script processes and transforms data. Missing fields, such as release dates or record labels, are set to null to maintain compliance. Arrays, including track lists and credits, are further processed to ensure data integrity, with each entry assigned a unique key for easier management within the CMS.
Once the data is prepared, the script begins uploading each record to Sanity as a new document. If a record fails to upload due to a missing field or a network issue, the script logs the error without halting the overall workflow.
SSR & frontend
server component in app directory
import React from 'react'
import {sanityClient} from '@/sanity/lib/client'
import VinylCollection from '@/components/Architecture/VinylCollection'
const fetchData = async ()=> {
const query = `
{
"collectionData": *[_type == "vinylRecord"] {
title,
artist,
releaseDate,
genre,
recordLabel,
favorite,
format,
quantity,
catNo,
condition,
collectionNo,
verifiedData,
"coverImage": coverImage.asset->url,
"recordImage": recordImage.asset->url,
keyCredits[] {
name,
roles[]
},
trackList[] {
trackName,
side,
trackLength,
trackOrder,
tempo,
musicalKey,
credits[] {
name,
roles[]
}
}
},
}
`;
const result = await sanityClient.fetch(query, {},
{next: {
revalidate: 5
}});
return { data: result };
};
const Vinyl = async () => {
const data = await fetchData();
const { collectionData } = data.data;
return (
<VinylCollection collectionData = {collectionData}/>
);
};
export default Vinyl;
By leveraging Sanity CMS as the backend and Server-Side Rendering (SSR) for efficient data fetching, this server component in the next.js app directory ensures fresh content and optimized performance. Data is fetched server-side using Sanity’s GROQ querying language. The revalidate option, set to 5 seconds, keeps the page content updated without overwhelming the backend. By fetching and rendering data on the server, the component minimizes initial load times and avoids the delays associated with client-side data fetching. This approach also improves SEO by delivering fully rendered content that search engines can easily index.
The use of SSR ensures that the page is always up-to-date without relying on client-side data fetching. This is particularly important for blog posts and record collections, which are central to the user experience. The approach also enhances SEO by providing fully rendered pages and boosts performance by reducing client-side overhead. New records or blog posts added in Sanity are automatically reflected on the page after revalidation.
client-side parent UI component
'use client';
import React, { useState, useEffect } from 'react';
import Fuse from 'fuse.js';
import GenreFilters from './components/genreFilters';
import YearFilters from './components/yearFilters';
import FormatFilters from './components/formatFilters';
import MusicalKeyFilters from './components/musicalKeyFilters';
import RecordListing from './components/recordListing';
import SearchBar from './components/searchBar';
import styles from './VinylCollection.module.css';
function VinylCollection({ collectionData }) {
const [selectedGenres, setSelectedGenres] = useState([]);
const [selectedFormat, setSelectedFormat] = useState(null); // "33 rpm" or "45 rpm"
const [selectedDecade, setSelectedDecade] = useState(); // Track selected decade
const [searchQuery, setSearchQuery] = useState(''); // Search query state
const [fuseResults, setFuseResults] = useState([]);
const [selectedKey, setSelectedKey] = useState(null); // For musical key (e.g., C, C#, Db)
const [selectedMode, setSelectedMode] = useState(null); // For major/minor
// Fuse.js options for searching the collection
const fuse = new Fuse(collectionData, {
keys: ['title', 'artist', 'genre'], // Adjust keys based on data structure
threshold: 0.3, // for fuzzy matching
});
// Handle search input change
const handleSearch = (e) => {
const query = e.target.value;
setSearchQuery(query);
// Perform search using Fuse.js
if (query.length > 0) {
const results = fuse.search(query);
setFuseResults(results.map((result) => result.item)); // Map Fuse.js result to actual records
} else {
setFuseResults([]); // Reset search results when query is cleared
}
};
// Handle search reset
const handleSearchReset = () => {
setSearchQuery(''); // Clear the search query
setFuseResults([]); // Reset the Fuse search results
};
// Use Fuse results if search query exists, otherwise use the full collection data
const recordsToDisplay =
searchQuery.length > 0 ? fuseResults : collectionData;
// Calculate total records based on search or full collection
const totalRecords = recordsToDisplay.length;
const selectMode = (mode) => setSelectedMode(mode); // 'major' or 'minor'
const selectKey = (key) => setSelectedKey(key); // e.g., 'C', 'C#', 'Db', etc.
// Filter the records based on selected genres, format, release date range, key, and mode
const filteredRecords = recordsToDisplay.filter((record) => {
// Filter by format
const formatMatches = selectedFormat
? record.format === selectedFormat
: true;
// Filter by genres (if none selected, show all)
const genresMatch =
selectedGenres.length === 0
? true
: selectedGenres.every((genre) =>
record.genre?.toLowerCase().includes(genre)
);
// Filter by release date
const releaseDateMatches = selectedDecade
? record.releaseDate >= selectedDecade &&
record.releaseDate <= selectedDecade + 9
: true;
// Filter by musical key and mode (ensure record.trackList exists and is an array)
const keyAndModeMatches =
record.trackList && Array.isArray(record.trackList)
? record.trackList.some((track) => {
const trackMusicalKey = track.musicalKey?.toLowerCase() || ''; // Get the musical key as a lowercase string
// Check if the track's musicalKey contains both the selected key and mode
const keyMatches = selectedKey
? trackMusicalKey.includes(selectedKey.toLowerCase())
: true;
const modeMatches = selectedMode
? trackMusicalKey.includes(selectedMode.toLowerCase())
: true;
return keyMatches && modeMatches; // Return true if both key and mode match
})
: true;
return (
formatMatches && genresMatch && releaseDateMatches && keyAndModeMatches // Check if the record contains tracks matching the key and mode
);
});
// Dynamically calculate available genres based on filtered records after search
const availableGenresSet = new Set();
filteredRecords.forEach((record) => {
if (record.genre) {
record.genre.split(',').forEach((genre) => {
availableGenresSet.add(genre.trim().toLowerCase());
});
}
});
const availableGenres = Array.from(availableGenresSet);
// Handle Genre Button Click
const toggleGenre = (genre) => {
setSelectedGenres((prevGenres) =>
prevGenres.includes(genre)
? prevGenres.filter((g) => g !== genre)
: [...prevGenres, genre]
);
};
// Handle Format Button Click
const selectFormat = (format) => {
setSelectedFormat(format); // Set the selected format
};
// Decade Selection Function
const selectDecade = (startYear) => {
setSelectedDecade(startYear); // Set the selected decade
};
// Reset Date Filter Function
const resetDateFilter = () => {
setSelectedDecade(null); // Deselect any selected decade
};
// Reset All Filters Function
const resetFilters = () => {
setSelectedGenres([]); // Reset genres
setSelectedFormat(null); // Reset format
resetDateFilter(); // Reset date range
handleSearchReset(); // Reset search bar
setSelectedKey(null); // Reset key filter
setSelectedMode(null); // Reset mode filter
};
return (
<div className={styles.collectionContainer}>
{/* Display Total Counts */}
<h3>{collectionData.length} records</h3>
{/* Search Bar */}
<SearchBar
handleSearch={handleSearch}
handleSearchReset={handleSearchReset}
searchQuery={searchQuery}
/>
{/* Display Filters if Search Results Exist or No Search Query */}
{filteredRecords.length > 1 && (
<>
<FormatFilters
selectedFormat={selectedFormat}
selectFormat={selectFormat}
/>
<YearFilters
selectDecade={selectDecade}
selectedDecade={selectedDecade}
resetDateFilter={resetDateFilter}
/>
<GenreFilters
availableGenres={availableGenres}
selectedGenres={selectedGenres}
toggleGenre={toggleGenre}
/>
<MusicalKeyFilters
selectMode={selectMode}
selectKey={selectKey}
selectedKey={selectedKey}
selectedMode={selectedMode}
/>
</>
)}
{/* Reset Filters Button */}
<button className={styles.masterReset} onClick={resetFilters}>
Reset All Filters
</button>
<h4>{filteredRecords.length ? filteredRecords.length : 'no '} results</h4>
{/* Display Filtered Records */}
<div className={styles.recordList}>
{filteredRecords.length > 0 ? (
filteredRecords.map((record, index) => (
<RecordListing
record={record}
index={index}
key={index}
selectedKey={selectedKey}
selectedMode={selectedMode}
/>
))
) : (
<p>No records match the selected filters.</p>
)}
</div>
</div>
);
}
export default VinylCollection;
The VinylCollection component is a client-side React interface designed to make exploring and filtering a vinyl record collection intuitive and engaging. With search, filtering, and reset capabilities, it provides users with a streamlined way to navigate large datasets, delivering information within a satisfying user experience.
At the core of the search functionality is Fuse.js, a powerful library for fuzzy search. Users can search for records by title, artist, or genre, with results dynamically updating as they type. A reset button is also available to quickly clear the search input and return to the full collection.
Filtering options add another layer of interactivity. Users can refine results by applying various criteria. Format filters allow users to view records by type, such as 33 rpm or 45 rpm. Year and decade filters help narrow down records to specific release periods, while genre filters dynamically generate options based on the dataset’s contents. Musical key filters provide an even finer level of detail, enabling users to select tracks in particular keys (e.g., C, C#, Db) and modes (major or minor). These filters are designed to work seamlessly together, ensuring that results accurately reflect the user’s combined selections.
Dynamic counts prominently show the total number of records and the results matching the current filters. Each record is rendered using a dedicated RecordListing component, which adapts to the active filters and displays relevant details. This presentation ensures users can quickly grasp the significance of their selected records while enjoying a polished UI.
State management underpins the component’s interactivity. Separate states track selected filters for genres, formats, decades, keys, and modes, as well as search results from Fuse.js. Whenever a search query or filter changes, the displayed records update instantly, maintaining a seamless and responsive user experience.
To enhance usability, the component includes robust reset functionality. Users can reset individual filters, such as clearing a selected genre or decade, or perform a master reset to clear all filters and the search query. This flexibility ensures users can easily experiment with different combinations of search and filters without feeling locked into their choices.
record listing subcomponent
import React, { useState } from 'react';
import styles from './RecordListing.module.css';
import TrackDetails from './trackDetails';
import Link from 'next/link';
import {
OpenSleeve,
ClosedSleeve,
OpenSleeveFav,
ClosedSleeveFav,
} from '@/SVG/icons';
const RecordListing = ({ index, record, selectedKey, selectedMode }) => {
const [displayDetails, setDisplayDetails] = useState(false);
const [displayTracks, setDisplayTracks] = useState(true);
const [displayCreds, setDisplayCreds] = useState(true);
return (
<div key={index} className={styles.recordContainer}>
<p
onClick={() => setDisplayDetails((prev) => !prev)}
className={styles.recordHeadingRow}
>
<span>
<span className={styles.iconToggle}>
{displayDetails || selectedKey || selectedMode ? (
record.favorite ? (
<OpenSleeveFav />
) : (
<OpenSleeve />
)
) : record.favorite ? (
<ClosedSleeveFav />
) : (
<ClosedSleeve />
)}
</span>
{record.artist && (
<>
<strong>{record.artist}</strong> :
</>
)}{' '}
{record.title} ({record.format}
{record.releaseDate && ', ' + record.releaseDate})
</span>
<div>
{record.relatedPost && (
<Link href={`/blog/${record.relatedPost.slug.current}`}>
<span className={styles.linkToPost}>read post</span>
</Link>
)}
<span className={styles.collectionNo}>#{record.collectionNo}</span>
</div>
</p>
<div>
{(displayDetails || selectedKey || selectedMode) && (
<div>
<div className={styles.recordDetailsContainer}>
<div>side</div>
<div>track</div>
<div>length</div>
<div>name & details</div>
<div>key</div>
<div>tempo</div>
{displayTracks &&
record.trackList
.filter((track) => {
const trackMusicalKey =
track.musicalKey?.toLowerCase() || '';
if (!selectedKey && !selectedMode) {
return true;
}
const keyMatches = selectedKey
? trackMusicalKey.includes(selectedKey.toLowerCase())
: true;
const modeMatches = selectedMode
? trackMusicalKey.includes(selectedMode.toLowerCase())
: true;
return keyMatches && modeMatches;
})
.map((track, index) => (
<React.Fragment key={index}>
<div>{track.side}</div>
<div>{track.trackOrder}</div>
<div>{track.trackLength}</div>
<TrackDetails index={index} track={track} />
<div>
{track.musicalKey !== 'unknown' && track.musicalKey}
</div>
<div>{track.tempo !== 'unknown' && track.tempo}</div>
</React.Fragment>
))}
</div>
<div className={styles.recordCreditsContainer}>
{displayCreds &&
record.keyCredits.map((cred, idx) => (
<React.Fragment key={idx}>
<div className={styles.credContainer}>
<span className={styles.credName}>{cred.name}</span>
<span className={styles.credDash}></span>
</div>
<div className={styles.credRole}>
{cred.roles.map((role, roleIdx) => (
<span key={roleIdx}>
{role}
{roleIdx < cred.roles.length - 1 && ','}
</span>
))}
</div>
</React.Fragment>
))}
</div>
</div>
)}
</div>
</div>
);
};
export default RecordListing;
The RecordListing subcomponent is a vital part of the user interface. It dynamically showcases a record's details. Users can expand or collapse additional information about a record using a toggle mechanism. Visual cues, such as the OpenSleeve and ClosedSleeve icons, indicate whether the record's details are currently displayed or hidden. This interactivity enhances engagement, allowing users to click on a record heading to reveal or hide more information, making navigation intuitive and efficient.
The track list dynamically adapts based on user-selected criteria. Tracks are filtered to match specific musical keys and modes, such as "C Major," ensuring that only relevant tracks are shown. For each track, details like the side (A or B), order, length, and name are displayed. If available, the track's key and tempo are also included. Tracks with unknown keys or tempos gracefully exclude this information to maintain a clean layout, avoiding unnecessary clutter.
For users interested in contributor details, the key credits section provides an in-depth breakdown of those involved in the record's creation. Contributor names are prominently displayed, along with their roles, which are neatly separated by commas for clarity. The visibility of credits is dynamically managed using a toggle, ensuring a clean interface when this information isn’t needed.
The component is designed with a focus on accessibility and user experience. Information is grouped logically into sections, such as the track list and credits, making navigation intuitive. Data is rendered dynamically based on user interaction and the record's available information, ensuring that users only see what’s relevant. The modular design of the component allows it to be reused across different sections of the vinyl collection interface, adding to its versatility.
State management is a key aspect of the component’s interactivity. Multiple states control different features: displayDetails manages whether the record’s details are expanded or collapsed, displayTracks determines the visibility of the track list, and displayCreds handles the display of key credits. These states ensure the component remains responsive to user preferences, providing a highly personalized experience.
subcomponent styling
/* Reusable Classes */
.text-truncate {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.full-width {
width: 100%;
}
.flex-column {
display: flex;
flex-direction: column;
}
.flex-start {
display: flex;
justify-content: flex-start;
align-items: start;
}
.grid-layout {
display: grid;
column-gap: 1rem;
row-gap: 5px;
padding-left: 1rem;
margin-top: 1rem;
}
/* Main Container Styles */
.recordContainer {
padding: 1rem 0;
border-bottom: 1px solid #ddd;
text-transform: capitalize;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.recordFav {
color: var(--whiteText);
text-transform: uppercase;
background-color: lightgreen;
border-radius: var(--globalBorderRadius);
padding: 0 1rem;
margin: 0 10px;
}
/* Record Details and Credits Grids */
.recordDetailsContainer,
.recordCreditsContainer {
overflow: hidden;
width: 100%;
display: grid;
justify-items: start;
align-items: start;
column-gap: 1rem;
row-gap: 5px;
padding-left: 1rem;
margin-top: 1rem;
}
.recordDetailsContainer {
grid-template-columns: repeat(6, 1fr);
}
.recordCreditsContainer {
grid-template-columns: repeat(2, 1fr);
}
.recordDetailsContainer > *,
.recordCreditsContainer > * {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
}
/* Track Name Styling */
.trackName {
min-width: 25vw;
}
/* Section Label */
.sectionLabel {
min-width: 100%;
color: white;
background-color: lightgray;
}
/* Track Roles Styling */
.trackRolesHoverContainer {
display: flex;
padding: 0 1rem;
width: 100%;
justify-content: flex-start;
border-left: 1px solid #ddd;
}
.trackRolesName {
padding-left: 5px;
}
.trackRolesList {
color: var(--grayText);
display: flex;
min-width: fit-content;
}
.trackRolesList > * {
padding-left: 5px;
}
/* Record Heading */
.recordHeadingRow {
display: flex;
justify-content: space-between;
}
/* Collection Number and Icon Toggle */
.collectionNo {
color: var(--grayText);
}
.iconToggle {
display: inline-block;
margin-right: 5px;
transform: translateY(2px);
}
/* Credit Container Styling */
.credContainer {
width: 100%;
display: flex;
height: 100%;
}
.credName {
width: fit-content;
}
.credDash {
display: flex;
width: 100%;
border-top: var(--globalBorder);
height: 100%;
color: var(--grayText);
transform: translateY(50%);
margin-left: 1rem;
}
.credRole {
color: var(--grayText);
}
Grids are used to organize detailed information, such as track listings and contributor credits. Track details are displayed in a six-column grid, showing attributes like side, track order, length, and musical key. Contributor credits are split into two columns, balancing compactness with readability.
Records marked as personal favorites are highlighted with a badge that features a light green background and uppercase text. This design draws attention to special items in the collection while maintaining harmony with the overall interface. Interactive features like toggling details are visually supported by icons that switch between "open" and "closed" states, seamlessly integrated into the record’s heading.
Track names are styled with a minimum width to ensure legibility, especially for longer titles. Missing or unknown details, such as musical keys or tempos, are excluded gracefully to avoid unnecessary placeholders that might clutter the interface. Contributor roles are displayed in muted tones, differentiating them from primary content like track names or record titles
The layout is designed with clarity and responsiveness in mind. A grid-based structure separates information into logical sections, making it easy to navigate. The styling adapts to different screen sizes, ensuring the component remains accessible and visually appealing across devices. Truncated text and streamlined grids prevent information overload, especially when displaying long lists of tracks or credits.
custom svg record icon
const OpenSleeve = () => (
<svg
width="12"
height="19"
viewBox="0 0 12 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect y="6" width="12" height="1" fill="black" />
<path
d="M1 6.79413H11C11.2761 6.79413 11.5 7.01799 11.5 7.29413V18C11.5 18.2762 11.2761 18.5 11 18.5H1C0.723858 18.5 0.5 18.2762 0.5 18V7.29413C0.5 7.01799 0.723858 6.79413 1 6.79413Z"
stroke="black"
/>
<g clip-path="url(#clip0_5_8)">
<g clip-path="url(#clip1_5_8)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6 12C9.31371 12 12 9.31371 12 6C12 2.68629 9.31371 0 6 0C2.68629 0 0 2.68629 0 6C0 9.31371 2.68629 12 6 12ZM6 8C7.10457 8 8 7.10457 8 6C8 4.89543 7.10457 4 6 4C4.89543 4 4 4.89543 4 6C4 7.10457 4.89543 8 6 8Z"
fill="black"
/>
</g>
</g>
<defs>
<clipPath id="clip0_5_8">
<rect width="12" height="6" fill="white" />
</clipPath>
<clipPath id="clip1_5_8">
<rect width="12" height="12" fill="white" transform="translate(0 -1)" />
</clipPath>
</defs>
</svg>
);
const ClosedSleeve = () => (
<svg
width="12"
height="19"
viewBox="0 0 12 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect y="6" width="12" height="1" fill="black" />
<path
d="M1 6.79413H11C11.2761 6.79413 11.5 7.01799 11.5 7.29413V18C11.5 18.2762 11.2761 18.5 11 18.5H1C0.723858 18.5 0.5 18.2762 0.5 18V7.29413C0.5 7.01799 0.723858 6.79413 1 6.79413Z"
stroke="black"
/>
</svg>
);
const OpenSleeveFav = () => (
<svg
width="12"
height="19"
viewBox="0 0 12 19"
fill="#A9D8A0"
xmlns="http://www.w3.org/2000/svg"
>
<rect y="6" width="12" height="1" fill="#4B9F6E" />
<path
d="M1 6.79413H11C11.2761 6.79413 11.5 7.01799 11.5 7.29413V18C11.5 18.2762 11.2761 18.5 11 18.5H1C0.723858 18.5 0.5 18.2762 0.5 18V7.29413C0.5 7.01799 0.723858 6.79413 1 6.79413Z"
stroke="#4B9F6E"
/>
<g clip-path="url(#clip0_5_8)">
<g clip-path="url(#clip1_5_8)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6 12C9.31371 12 12 9.31371 12 6C12 2.68629 9.31371 0 6 0C2.68629 0 0 2.68629 0 6C0 9.31371 2.68629 12 6 12ZM6 8C7.10457 8 8 7.10457 8 6C8 4.89543 7.10457 4 6 4C4.89543 4 4 4.89543 4 6C4 7.10457 4.89543 8 6 8Z"
fill="#2C6B3F"
/>
</g>
</g>
<defs>
<clipPath id="clip0_5_8">
<rect width="12" height="6" fill="white" />
</clipPath>
<clipPath id="clip1_5_8">
<rect width="12" height="12" fill="white" transform="translate(0 -1)" />
</clipPath>
</defs>
</svg>
);
const ClosedSleeveFav = () => (
<svg
width="12"
height="19"
viewBox="0 0 12 19"
fill="#A9D8A0"
xmlns="http://www.w3.org/2000/svg"
>
<rect y="6" width="12" height="1" fill="mint" />
<path
d="M1 6.79413H11C11.2761 6.79413 11.5 7.01799 11.5 7.29413V18C11.5 18.2762 11.2761 18.5 11 18.5H1C0.723858 18.5 0.5 18.2762 0.5 18V7.29413C0.5 7.01799 0.723858 6.79413 1 6.79413Z"
stroke="#4B9F6E"
/>
</svg>
);
export { ClosedSleeve, OpenSleeve, ClosedSleeveFav, OpenSleeveFav };
Custom SVG icons were created to visually represent vinyl record sleeves and their state changes, addressing the internet's unfortunate lack of iconography for vinyl as a media type. These icons were mocked up in Figma, exported, and integrated into the frontend. React's ability to handle SVGs simplified the transition from design to implementation. A key challenge was maintaining consistent container sizes across all icon variations. This consistency was essential to prevent alignment issues that could disrupt the layout or displace adjacent content.