Compare commits

...

10 commits

21 changed files with 8978 additions and 1816 deletions

2
.gitignore vendored
View file

@ -26,4 +26,6 @@
*.tsbuildinfo *.tsbuildinfo
# Dotenv and similar local-only files # Dotenv and similar local-only files
.env
.env.*
*.local *.local

View file

@ -0,0 +1,54 @@
import React, {useCallback, useMemo, useState} from 'react'
import {Autocomplete, Card, Text} from '@sanity/ui'
import {set, unset, StringInputProps} from 'sanity'
import {countryList} from '../schemaTypes/country-list'
const options = countryList.map((c) => ({value: c.value, payload: c}))
export function CountryInput(props: StringInputProps) {
const {value, onChange, readOnly} = props
const [query, setQuery] = useState('')
const filtered = useMemo(() => {
if (!query) return options
const q = query.toLowerCase()
return options.filter((o) => o.payload.title.toLowerCase().includes(q))
}, [query])
const handleSelect = useCallback(
(val: string) => {
onChange(val ? set(val) : unset())
},
[onChange],
)
const renderOption = useCallback(
(option: (typeof options)[number]) => (
<Card as="button" padding={3}>
<Text size={1}>{option.payload.title}</Text>
</Card>
),
[],
)
const selectedTitle = useMemo(() => {
if (!value) return undefined
return countryList.find((c) => c.value === value)?.title
}, [value])
return (
<Autocomplete
id="country-input"
options={filtered}
onSelect={handleSelect}
onQueryChange={(q) => setQuery(q ?? '')}
placeholder="Search for a country…"
renderOption={renderOption}
value={value || ''}
readOnly={readOnly}
openButton
filterOption={() => true}
renderValue={() => selectedTitle || value || ''}
/>
)
}

View file

@ -0,0 +1,125 @@
import React, {useEffect, useMemo, useRef, useState} from 'react'
import {Button, Card, Flex, Stack, Text, TextInput} from '@sanity/ui'
import {set, unset, StringInputProps, useClient, useFormValue} from 'sanity'
type WorkDoc = {
_id: string
title?: string
}
function computeTitle(workTitle: string | undefined, movement: string | undefined) {
const wt = (workTitle || '').trim()
const mv = (movement || '').trim()
if (wt && mv) return `${wt}: ${mv}`
if (wt) return wt
if (mv) return mv
return ''
}
export function TrackDisplayTitleInput(props: StringInputProps) {
const {value, onChange, readOnly, elementProps} = props
const workRef = useFormValue(['work', '_ref']) as string | undefined
const movement = useFormValue(['movement']) as string | undefined
const client = useClient({apiVersion: '2025-01-01'})
const [workTitle, setWorkTitle] = useState<string>('')
useEffect(() => {
let cancelled = false
async function run() {
if (!workRef) {
setWorkTitle('')
return
}
const doc = await client.fetch<WorkDoc | null>(`*[_type == "work" && _id == $id][0]{title}`, {
id: workRef,
})
if (!cancelled) setWorkTitle((doc?.title || '').trim())
}
run()
return () => {
cancelled = true
}
}, [client, workRef])
const computed = useMemo(() => computeTitle(workTitle, movement), [workTitle, movement])
const didAutofill = useRef(false)
useEffect(() => {
if (didAutofill.current) return
if (!workRef) return
if (value && String(value).trim()) return
if (!computed) return
if (readOnly) return
didAutofill.current = true
onChange(set(computed))
}, [workRef, value, computed, readOnly, onChange])
const hasStored = Boolean(value && String(value).trim())
const inWorkMode = Boolean(workRef)
if (inWorkMode) {
return (
<Card padding={3} radius={2} border>
<Stack space={3}>
<Flex direction="column" gap={2}>
<Text size={1} weight="semibold">
Display title (stored)
</Text>
<Text size={0} muted>
Generated once from Work/Movement and saved. It will not change automatically if the
Work changes.
</Text>
</Flex>
<TextInput
{...elementProps}
value={(value as string) || ''}
readOnly={true}
placeholder={computed || 'Select a Work to generate a title'}
/>
<Flex gap={2} wrap="wrap">
<Button
text={hasStored ? 'Overwrite from Work/Movement' : 'Generate from Work/Movement'}
mode="default"
disabled={readOnly || !computed}
onClick={() => onChange(set(computed))}
/>
<Button
text="Clear"
mode="ghost"
disabled={readOnly || !hasStored}
onClick={() => onChange(unset())}
/>
</Flex>
{!computed && (
<Text size={0} muted>
No computable title yet. Select a Work (and optionally add Movement).
</Text>
)}
</Stack>
</Card>
)
}
return (
<TextInput
{...elementProps}
value={(value as string) || ''}
readOnly={readOnly}
onChange={(e) => {
const next = e.currentTarget.value
onChange(next && next.trim() ? set(next) : unset())
}}
/>
)
}

View file

@ -0,0 +1,144 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {Stack, Button, Inline} from '@sanity/ui'
import {PatchEvent, set, unset} from 'sanity'
import {SlugInput, type SlugInputProps} from 'sanity'
import {useClient, useFormValue} from 'sanity'
type PersonDoc = {name?: string}
function slugify(input: string) {
const folded = input
.normalize('NFKD')
.replace(/\p{M}+/gu, '')
.replace(/ß/g, 'ss')
.replace(/ø/g, 'o')
.replace(/đ/g, 'd')
.replace(/ł/g, 'l')
return folded
.toLowerCase()
.trim()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/(^-|-$)+/g, '')
.slice(0, 96)
}
export function WorkSlugInput(props: SlugInputProps) {
const client = useClient({apiVersion: '2026-01-01'})
const title = (useFormValue(['title']) as string) || ''
const composerRef = (useFormValue(['composer', '_ref']) as string) || ''
const arrangerRef = (useFormValue(['arranger', '_ref']) as string) || ''
const [composerName, setComposerName] = useState<string>('')
const lastFetchedRef = useRef<string>('')
const [arrangerName, setArrangerName] = useState<string>('')
const lastFetchedArrangerRef = useRef<string>('')
const lastAutoSlugRef = useRef<string>('')
const desiredSource = useMemo(() => {
const parts = [composerName, title, arrangerName ? `arr ${arrangerName}` : null].filter(Boolean)
return parts.join(' ')
}, [composerName, title, arrangerName])
const generateSlug = useCallback(() => {
if (!desiredSource) return
const nextSlug = slugify(desiredSource)
lastAutoSlugRef.current = nextSlug
props.onChange(PatchEvent.from(nextSlug ? set({_type: 'slug', current: nextSlug}) : unset()))
}, [desiredSource, props.onChange])
useEffect(() => {
let alive = true
async function run() {
if (!composerRef) {
setComposerName('')
lastFetchedRef.current = ''
return
}
if (lastFetchedRef.current === composerRef) return
lastFetchedRef.current = composerRef
const doc = await client.fetch<PersonDoc>(`*[_type == "composer" && _id == $id][0]{name}`, {
id: composerRef,
})
if (!alive) return
setComposerName(doc?.name || '')
}
run()
return () => {
alive = false
}
}, [composerRef, client])
useEffect(() => {
let alive = true
async function run() {
if (!arrangerRef) {
setArrangerName('')
lastFetchedArrangerRef.current = ''
return
}
if (lastFetchedArrangerRef.current === arrangerRef) return
lastFetchedArrangerRef.current = arrangerRef
const doc = await client.fetch<PersonDoc>(`*[_type == "composer" && _id == $id][0]{name}`, {
id: arrangerRef,
})
if (!alive) return
setArrangerName(doc?.name || '')
}
run()
return () => {
alive = false
}
}, [arrangerRef, client])
useEffect(() => {
if (!composerName || !title) return
const current = props.value?.current || ''
const nextSlug = slugify(desiredSource)
if (!lastAutoSlugRef.current && current && current === nextSlug) {
lastAutoSlugRef.current = current
return
}
if (!current || current === lastAutoSlugRef.current) {
lastAutoSlugRef.current = nextSlug
props.onChange(PatchEvent.from(set({_type: 'slug', current: nextSlug})))
}
}, [composerName, arrangerName, title, desiredSource, props.value?.current, props.onChange])
return (
<Stack space={3}>
<SlugInput {...props} />
<Stack space={2}>
<Inline space={2}>
<Button
text="Generate"
mode="default"
onClick={generateSlug}
disabled={!composerName || !title}
/>
</Inline>
</Stack>
</Stack>
)
}

4129
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
{ {
"name": "trptkio", "name": "trptk-sanity",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"main": "package.json", "main": "package.json",
@ -9,16 +9,18 @@
"start": "sanity start", "start": "sanity start",
"build": "sanity build", "build": "sanity build",
"deploy": "sanity deploy", "deploy": "sanity deploy",
"deploy-graphql": "sanity graphql deploy" "deploy-graphql": "sanity graphql deploy",
"schema:extract": "sanity schema extract"
}, },
"keywords": [ "keywords": [
"sanity" "sanity"
], ],
"dependencies": { "dependencies": {
"@sanity/vision": "^5.2.0", "@sanity/vision": "^5.11.0",
"react": "^19.1", "react": "^19.1",
"react-dom": "^19.1", "react-dom": "^19.1",
"sanity": "^5.2.0", "sanity": "^5.11.0",
"sanity-plugin-media": "^4.1.1",
"styled-components": "^6.1.18" "styled-components": "^6.1.18"
}, },
"devDependencies": { "devDependencies": {

5
sanity-typegen.json Normal file
View file

@ -0,0 +1,5 @@
{
"path": "./schemaTypes/**/*.ts",
"schema": "./schema.json",
"generates": "./sanity.types.ts"
}

View file

@ -2,15 +2,35 @@ import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure' import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision' import {visionTool} from '@sanity/vision'
import {schemaTypes} from './schemaTypes' import {schemaTypes} from './schemaTypes'
import {media} from 'sanity-plugin-media'
import {CogIcon} from '@sanity/icons'
export default defineConfig({ export default defineConfig({
name: 'default', name: 'default',
title: 'TRPTK.io', title: 'TRPTK',
projectId: 'e0x723bq', projectId: 'e0x723bq',
dataset: 'production', dataset: 'production',
plugins: [structureTool(), visionTool()], plugins: [
structureTool({
structure: (S) =>
S.list()
.title('Content')
.items([
S.listItem()
.title('Settings')
.icon(CogIcon)
.child(S.document().schemaType('settings').documentId('settings')),
S.divider(),
...S.documentTypeListItems().filter(
(listItem) => !['settings'].includes(listItem.getId()!),
),
]),
}),
visionTool(),
media(),
],
schema: { schema: {
types: schemaTypes, types: schemaTypes,

735
sanity.types.ts Normal file
View file

@ -0,0 +1,735 @@
/**
* ---------------------------------------------------------------------------------
* This file has been generated by Sanity TypeGen.
* Command: `sanity typegen generate`
*
* Any modifications made directly to this file will be overwritten the next time
* the TypeScript definitions are generated. Please make changes to the Sanity
* schema definitions and/or GROQ queries if you need to update these types.
*
* For more information on how to use Sanity TypeGen, visit the official documentation:
* https://www.sanity.io/docs/sanity-typegen
* ---------------------------------------------------------------------------------
*/
// Source: schema.json
export type ReleaseReference = {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'release'
}
export type ArtistReference = {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'artist'
}
export type ComposerReference = {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'composer'
}
export type Settings = {
_id: string
_type: 'settings'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
featuredAlbum?: ReleaseReference
featuredArtist?: ArtistReference
featuredComposer?: ComposerReference
}
export type SanityImageAssetReference = {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
export type SanityFileAssetReference = {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.fileAsset'
}
export type WorkReference = {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'work'
}
export type Release = {
_id: string
_type: 'release'
_createdAt: string
_updatedAt: string
_rev: string
name?: string
albumArtist?: string
catalogNo?: string
slug?: Slug
upc?: string
releaseDate?: string
format?: 'single' | 'ep' | 'album' | 'boxset'
label?: 'TRPTK' | 'other'
shortDescription?: string
description?: Array<{
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'normal' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'blockquote'
listItem?: 'bullet' | 'number'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}>
albumCover?: {
asset?: SanityImageAssetReference
media?: unknown
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
}
bookletPdf?: {
asset?: SanityFileAssetReference
media?: unknown
_type: 'file'
}
tracks?: Array<{
work?: WorkReference
movement?: string
displayTitle?: string
artist?: string
duration?: string
previewMp3?: {
asset?: SanityFileAssetReference
media?: unknown
_type: 'file'
}
_type: 'track'
_key: string
}>
officialUrl?: string
spotifyUrl?: string
appleMusicUrl?: string
deezerUrl?: string
amazonMusicUrl?: string
tidalUrl?: string
qobuzUrl?: string
nativeDsdUrl?: string
credits?: Array<{
role?: string
name?: string
_type: 'credit'
_key: string
}>
recordingDate?: string
recordingLocation?: string
recordingFormat?: 'PCM 352.8 kHz 24 bit' | 'PCM 352.8 kHz 32 bit' | 'DSD 11.2 MHz 1 bit'
masteringFormat?: 'PCM 352.8 kHz 32 bit' | 'PCM 352.8 kHz 64 bit' | 'DSD 11.2 MHz 1 bit'
equipment?: Array<{
type?: string
name?: string
_type: 'equipmentItem'
_key: string
}>
genre?: Array<
| 'earlyMusic'
| 'baroque'
| 'classical'
| 'romantic'
| 'contemporary'
| 'worldMusic'
| 'jazz'
| 'crossover'
| 'electronic'
| 'minimal'
| 'popRock'
>
instrumentation?: Array<'solo' | 'chamber' | 'ensemble' | 'orchestra' | 'vocalChoral'>
artists?: Array<
{
_key: string
} & ArtistReference
>
reviews?: Array<{
quote?: string
author?: string
_type: 'review'
_key: string
}>
availableVariants?: Array<string>
}
export type SanityImageCrop = {
_type: 'sanity.imageCrop'
top?: number
bottom?: number
left?: number
right?: number
}
export type SanityImageHotspot = {
_type: 'sanity.imageHotspot'
x?: number
y?: number
height?: number
width?: number
}
export type Slug = {
_type: 'slug'
current?: string
source?: string
}
export type Work = {
_id: string
_type: 'work'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
composer?: ComposerReference
arranger?: ComposerReference
slug?: Slug
description?: Array<{
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'normal' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'blockquote'
listItem?: 'bullet' | 'number'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}>
}
export type Concert = {
_id: string
_type: 'concert'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
subtitle?: string
date?: string
time?: string
locationName?: string
city?: string
country?:
| 'AF'
| 'AL'
| 'DZ'
| 'AD'
| 'AO'
| 'AG'
| 'AR'
| 'AM'
| 'AU'
| 'AT'
| 'AZ'
| 'BS'
| 'BH'
| 'BD'
| 'BB'
| 'BY'
| 'BE'
| 'BZ'
| 'BJ'
| 'BT'
| 'BO'
| 'BA'
| 'BW'
| 'BR'
| 'BN'
| 'BG'
| 'BF'
| 'BI'
| 'CV'
| 'KH'
| 'CM'
| 'CA'
| 'CF'
| 'TD'
| 'CL'
| 'CN'
| 'CO'
| 'KM'
| 'CD'
| 'CG'
| 'CR'
| 'HR'
| 'CU'
| 'CY'
| 'CZ'
| 'DK'
| 'DJ'
| 'DM'
| 'DO'
| 'EC'
| 'EG'
| 'SV'
| 'GQ'
| 'ER'
| 'EE'
| 'SZ'
| 'ET'
| 'FJ'
| 'FI'
| 'FR'
| 'GA'
| 'GM'
| 'GE'
| 'DE'
| 'GH'
| 'GR'
| 'GD'
| 'GT'
| 'GN'
| 'GW'
| 'GY'
| 'HT'
| 'HN'
| 'HU'
| 'IS'
| 'IN'
| 'ID'
| 'IR'
| 'IQ'
| 'IE'
| 'IL'
| 'IT'
| 'CI'
| 'JM'
| 'JP'
| 'JO'
| 'KZ'
| 'KE'
| 'KI'
| 'XK'
| 'KW'
| 'KG'
| 'LA'
| 'LV'
| 'LB'
| 'LS'
| 'LR'
| 'LY'
| 'LI'
| 'LT'
| 'LU'
| 'MG'
| 'MW'
| 'MY'
| 'MV'
| 'ML'
| 'MT'
| 'MH'
| 'MR'
| 'MU'
| 'MX'
| 'FM'
| 'MD'
| 'MC'
| 'MN'
| 'ME'
| 'MA'
| 'MZ'
| 'MM'
| 'NA'
| 'NR'
| 'NP'
| 'NL'
| 'NZ'
| 'NI'
| 'NE'
| 'NG'
| 'KP'
| 'MK'
| 'NO'
| 'OM'
| 'PK'
| 'PW'
| 'PS'
| 'PA'
| 'PG'
| 'PY'
| 'PE'
| 'PH'
| 'PL'
| 'PT'
| 'QA'
| 'RO'
| 'RU'
| 'RW'
| 'KN'
| 'LC'
| 'VC'
| 'WS'
| 'SM'
| 'ST'
| 'SA'
| 'SN'
| 'RS'
| 'SC'
| 'SL'
| 'SG'
| 'SK'
| 'SI'
| 'SB'
| 'SO'
| 'ZA'
| 'KR'
| 'SS'
| 'ES'
| 'LK'
| 'SD'
| 'SR'
| 'SE'
| 'CH'
| 'SY'
| 'TW'
| 'TJ'
| 'TZ'
| 'TH'
| 'TL'
| 'TG'
| 'TO'
| 'TT'
| 'TN'
| 'TR'
| 'TM'
| 'TV'
| 'UG'
| 'UA'
| 'AE'
| 'GB'
| 'US'
| 'UY'
| 'UZ'
| 'VU'
| 'VA'
| 'VE'
| 'VN'
| 'YE'
| 'ZM'
| 'ZW'
artists?: Array<
{
_key: string
} & ArtistReference
>
ticketUrl?: string
}
export type Composer = {
_id: string
_type: 'composer'
_createdAt: string
_updatedAt: string
_rev: string
name?: string
sortKey?: string
birthYear?: number
deathYear?: number
slug?: Slug
image?: {
asset?: SanityImageAssetReference
media?: unknown
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
}
bio?: Array<{
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'normal' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'blockquote'
listItem?: 'bullet' | 'number'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}>
}
export type Blog = {
_id: string
_type: 'blog'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
subtitle?: string
featuredImage?: {
asset?: SanityImageAssetReference
media?: unknown
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
}
slug?: Slug
author?: 'Brendon Heinst' | 'Maya Fridman'
publishDate?: string
category?: 'News' | 'Behind the Scenes' | 'Music History' | 'Tech Talk'
content?: Array<
| {
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'normal' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'blockquote'
listItem?: 'bullet' | 'number'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}
| {
asset?: SanityImageAssetReference
media?: unknown
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
alt?: string
caption?: string
_type: 'image'
_key: string
}
| {
url?: string
_type: 'youtube'
_key: string
}
>
releases?: Array<
{
_key: string
} & ReleaseReference
>
artists?: Array<
{
_key: string
} & ArtistReference
>
composers?: Array<
{
_key: string
} & ComposerReference
>
works?: Array<
{
_key: string
} & WorkReference
>
}
export type Artist = {
_id: string
_type: 'artist'
_createdAt: string
_updatedAt: string
_rev: string
name?: string
sortKey?: string
slug?: Slug
role?: string
image?: {
asset?: SanityImageAssetReference
media?: unknown
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
}
bio?: Array<{
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'normal' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'blockquote'
listItem?: 'bullet' | 'number'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}>
}
export type MediaTag = {
_id: string
_type: 'media.tag'
_createdAt: string
_updatedAt: string
_rev: string
name?: Slug
}
export type SanityImagePaletteSwatch = {
_type: 'sanity.imagePaletteSwatch'
background?: string
foreground?: string
population?: number
title?: string
}
export type SanityImagePalette = {
_type: 'sanity.imagePalette'
darkMuted?: SanityImagePaletteSwatch
lightVibrant?: SanityImagePaletteSwatch
darkVibrant?: SanityImagePaletteSwatch
vibrant?: SanityImagePaletteSwatch
dominant?: SanityImagePaletteSwatch
lightMuted?: SanityImagePaletteSwatch
muted?: SanityImagePaletteSwatch
}
export type SanityImageDimensions = {
_type: 'sanity.imageDimensions'
height?: number
width?: number
aspectRatio?: number
}
export type SanityImageMetadata = {
_type: 'sanity.imageMetadata'
location?: Geopoint
dimensions?: SanityImageDimensions
palette?: SanityImagePalette
lqip?: string
blurHash?: string
thumbHash?: string
hasAlpha?: boolean
isOpaque?: boolean
}
export type SanityFileAsset = {
_id: string
_type: 'sanity.fileAsset'
_createdAt: string
_updatedAt: string
_rev: string
originalFilename?: string
label?: string
title?: string
description?: string
altText?: string
sha1hash?: string
extension?: string
mimeType?: string
size?: number
assetId?: string
uploadId?: string
path?: string
url?: string
source?: SanityAssetSourceData
}
export type SanityAssetSourceData = {
_type: 'sanity.assetSourceData'
name?: string
id?: string
url?: string
}
export type SanityImageAsset = {
_id: string
_type: 'sanity.imageAsset'
_createdAt: string
_updatedAt: string
_rev: string
originalFilename?: string
label?: string
title?: string
description?: string
altText?: string
sha1hash?: string
extension?: string
mimeType?: string
size?: number
assetId?: string
uploadId?: string
path?: string
url?: string
metadata?: SanityImageMetadata
source?: SanityAssetSourceData
}
export type Geopoint = {
_type: 'geopoint'
lat?: number
lng?: number
alt?: number
}
export type AllSanitySchemaTypes =
| ReleaseReference
| ArtistReference
| ComposerReference
| Settings
| SanityImageAssetReference
| SanityFileAssetReference
| WorkReference
| Release
| SanityImageCrop
| SanityImageHotspot
| Slug
| Work
| Concert
| Composer
| Blog
| Artist
| MediaTag
| SanityImagePaletteSwatch
| SanityImagePalette
| SanityImageDimensions
| SanityImageMetadata
| SanityFileAsset
| SanityAssetSourceData
| SanityImageAsset
| Geopoint
export declare const internalGroqTypeReferenceTo: unique symbol

4110
schema.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,68 @@
import {defineArrayMember, defineField, defineType} from 'sanity'
import {UsersIcon} from '@sanity/icons'
export const artistType = defineType({
name: 'artist',
title: 'Artist',
type: 'document',
icon: UsersIcon,
fieldsets: [{name: 'main', title: 'Main Info', options: {columns: 2}}],
fields: [
defineField({
name: 'name',
title: 'Name',
type: 'string',
fieldset: 'main',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'sortKey',
title: 'Sorting Key',
type: 'string',
fieldset: 'main',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
fieldset: 'main',
options: {source: 'name', maxLength: 96},
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'role',
title: 'Role',
type: 'string',
fieldset: 'main',
}),
defineField({
name: 'image',
title: 'Image',
type: 'image',
options: {hotspot: true},
}),
defineField({
name: 'bio',
title: 'Biography',
type: 'array',
of: [defineArrayMember({type: 'block'})],
}),
],
orderings: [
{
title: 'First Name (A → Z)',
name: 'nameAsc',
by: [{field: 'name', direction: 'asc'}],
},
{
title: 'Last Name (A → Z)',
name: 'sortKeyAsc',
by: [{field: 'sortKey', direction: 'asc'}],
},
],
preview: {
select: {title: 'name', subtitle: 'role', media: 'image'},
},
})

View file

@ -0,0 +1,11 @@
export const blogAuthors = [
{title: 'Brendon Heinst', value: 'Brendon Heinst'},
{title: 'Maya Fridman', value: 'Maya Fridman'},
]
export const blogCategories = [
{title: 'News', value: 'News'},
{title: 'Behind the Scenes', value: 'Behind the Scenes'},
{title: 'Music History', value: 'Music History'},
{title: 'Tech Talk', value: 'Tech Talk'},
]

203
schemaTypes/blog-type.ts Normal file
View file

@ -0,0 +1,203 @@
import {defineArrayMember, defineField, defineType} from 'sanity'
import {DocumentTextIcon} from '@sanity/icons'
import {blogAuthors, blogCategories} from './blog-options'
export const blogType = defineType({
name: 'blog',
title: 'Blog',
type: 'document',
icon: DocumentTextIcon,
groups: [
{name: 'main', title: 'Main', default: true},
{name: 'content', title: 'Content'},
{name: 'references', title: 'Related'},
],
fieldsets: [{name: 'details', title: 'Details', options: {columns: 2}}],
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
group: 'main',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'subtitle',
title: 'Subtitle',
type: 'string',
group: 'main',
}),
defineField({
name: 'featuredImage',
title: 'Featured Image',
type: 'image',
group: 'main',
options: {hotspot: true},
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
group: 'main',
fieldset: 'details',
options: {
source: 'title',
maxLength: 200,
},
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'author',
title: 'Author',
type: 'string',
group: 'main',
fieldset: 'details',
options: {
list: blogAuthors,
},
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'publishDate',
title: 'Publish Date',
type: 'date',
group: 'main',
fieldset: 'details',
description: 'Defaults to today. Override to backdate or schedule a post.',
initialValue: () => new Date().toISOString().split('T')[0],
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'category',
title: 'Category',
type: 'string',
group: 'main',
fieldset: 'details',
options: {
list: blogCategories,
},
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'content',
title: 'Content',
type: 'array',
group: 'content',
of: [
defineArrayMember({type: 'block'}),
defineArrayMember({
type: 'image',
options: {hotspot: true},
fields: [
defineField({
name: 'alt',
title: 'Alt text',
type: 'string',
description: 'Describe the image for accessibility.',
}),
defineField({
name: 'caption',
title: 'Caption',
type: 'string',
}),
],
}),
defineArrayMember({
type: 'object',
name: 'youtube',
title: 'YouTube Video',
fields: [
defineField({
name: 'url',
title: 'YouTube URL',
type: 'url',
validation: (Rule) =>
Rule.required()
.uri({scheme: ['https']})
.custom((url) => {
if (typeof url !== 'string') return true
if (
url.includes('youtube.com/watch') ||
url.includes('youtu.be/') ||
url.includes('youtube.com/embed/')
) {
return true
}
return 'Must be a valid YouTube URL'
}),
}),
],
preview: {
select: {url: 'url'},
prepare({url}) {
return {title: 'YouTube Video', subtitle: url}
},
},
}),
],
}),
defineField({
name: 'releases',
title: 'Related Release(s)',
type: 'array',
group: 'references',
of: [defineArrayMember({type: 'reference', to: [{type: 'release'}]})],
}),
defineField({
name: 'artists',
title: 'Related Artist(s)',
type: 'array',
group: 'references',
of: [defineArrayMember({type: 'reference', to: [{type: 'artist'}]})],
}),
defineField({
name: 'composers',
title: 'Related Composer(s)',
type: 'array',
group: 'references',
of: [defineArrayMember({type: 'reference', to: [{type: 'composer'}]})],
}),
defineField({
name: 'works',
title: 'Related Work(s)',
type: 'array',
group: 'references',
of: [defineArrayMember({type: 'reference', to: [{type: 'work'}]})],
}),
],
orderings: [
{
title: 'Publish Date (latest first)',
name: 'publishDateDesc',
by: [{field: 'publishDate', direction: 'desc'}],
},
{
title: 'Publish Date (oldest first)',
name: 'publishDateAsc',
by: [{field: 'publishDate', direction: 'asc'}],
},
],
preview: {
select: {
title: 'title',
author: 'author',
media: 'featuredImage',
category: 'category',
},
prepare({title, author, media, category}) {
return {
title: title || '(Untitled post)',
subtitle: [author, category].filter(Boolean).join(' · '),
media,
}
},
},
})

View file

@ -0,0 +1,94 @@
import {defineArrayMember, defineField, defineType} from 'sanity'
import {ComposeIcon} from '@sanity/icons'
export const composerType = defineType({
name: 'composer',
title: 'Composer',
type: 'document',
icon: ComposeIcon,
fieldsets: [{name: 'main', title: 'Main Info', options: {columns: 2}}],
fields: [
defineField({
name: 'name',
title: 'Name',
type: 'string',
fieldset: 'main',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'sortKey',
title: 'Sorting Key',
type: 'string',
fieldset: 'main',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'birthYear',
title: 'Year of Birth',
type: 'number',
fieldset: 'main',
}),
defineField({
name: 'deathYear',
title: 'Year of Death',
type: 'number',
fieldset: 'main',
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
fieldset: 'main',
options: {source: 'name', maxLength: 96},
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'image',
title: 'Image',
type: 'image',
options: {hotspot: true},
}),
defineField({
name: 'bio',
title: 'Biography',
type: 'array',
of: [defineArrayMember({type: 'block'})],
}),
],
orderings: [
{
title: 'First Name (A → Z)',
name: 'nameAsc',
by: [{field: 'name', direction: 'asc'}],
},
{
title: 'Last Name (A → Z)',
name: 'sortKeyAsc',
by: [{field: 'sortKey', direction: 'asc'}],
},
{
title: 'Year of Birth',
name: 'yobAsc',
by: [{field: 'birthYear', direction: 'asc'}],
},
],
preview: {
select: {
title: 'name',
birthYear: 'birthYear',
deathYear: 'deathYear',
media: 'image',
},
prepare({title, birthYear, deathYear, media}) {
const subtitle = birthYear
? deathYear
? `${birthYear}${deathYear}`
: birthYear.toString()
: ''
return {title, subtitle, media}
},
},
})

126
schemaTypes/concert-type.ts Normal file
View file

@ -0,0 +1,126 @@
import {defineArrayMember, defineField, defineType} from 'sanity'
import {CalendarIcon} from '@sanity/icons'
import {countryList} from './country-list'
import {CountryInput} from '../components/CountryInput'
export const concertType = defineType({
name: 'concert',
title: 'Concert',
type: 'document',
icon: CalendarIcon,
fieldsets: [{name: 'details', title: 'Details', options: {columns: 2}}],
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
}),
defineField({
name: 'subtitle',
title: 'Subtitle',
type: 'string',
}),
defineField({
name: 'date',
title: 'Date',
type: 'date',
fieldset: 'details',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'time',
title: 'Time',
type: 'string',
fieldset: 'details',
description: '24-hour format, e.g. 20:00',
validation: (Rule) =>
Rule.required().custom((value) => {
if (typeof value !== 'string') return 'Time is required'
return /^([01]\d|2[0-3]):[0-5]\d$/.test(value.trim())
? true
: 'Use HH:mm in 24-hour format (e.g. 14:30, 20:00)'
}),
}),
defineField({
name: 'locationName',
title: 'Location',
type: 'string',
fieldset: 'details',
description: 'e.g. "Concertgebouw"',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'city',
title: 'City',
type: 'string',
fieldset: 'details',
description: 'e.g. "Amsterdam"',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'country',
title: 'Country',
type: 'string',
fieldset: 'details',
components: {input: CountryInput},
options: {
list: countryList,
},
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'artists',
title: 'Related Artist(s)',
type: 'array',
of: [defineArrayMember({type: 'reference', to: [{type: 'artist'}]})],
}),
defineField({
name: 'ticketUrl',
title: 'Ticket URL',
type: 'url',
}),
],
orderings: [
{
title: 'Date (Asc.)',
name: 'dateAsc',
by: [
{field: 'date', direction: 'asc'},
{field: 'time', direction: 'asc'},
],
},
{
title: 'Date (Desc.)',
name: 'dateDesc',
by: [
{field: 'date', direction: 'desc'},
{field: 'time', direction: 'desc'},
],
},
],
preview: {
select: {
title: 'title',
date: 'date',
time: 'time',
locationName: 'locationName',
city: 'city',
},
prepare({title, date, time, locationName, city}) {
const parts = [locationName, city].filter(Boolean).join(', ')
const when = [date, time].filter(Boolean).join(' · ')
return {
title: title || parts || '(Untitled concert)',
subtitle: [when, title ? parts : null].filter(Boolean).join(' • '),
}
},
},
})

199
schemaTypes/country-list.ts Normal file
View file

@ -0,0 +1,199 @@
export const countryList = [
{title: 'Afghanistan', value: 'AF'},
{title: 'Albania', value: 'AL'},
{title: 'Algeria', value: 'DZ'},
{title: 'Andorra', value: 'AD'},
{title: 'Angola', value: 'AO'},
{title: 'Antigua and Barbuda', value: 'AG'},
{title: 'Argentina', value: 'AR'},
{title: 'Armenia', value: 'AM'},
{title: 'Australia', value: 'AU'},
{title: 'Austria', value: 'AT'},
{title: 'Azerbaijan', value: 'AZ'},
{title: 'Bahamas', value: 'BS'},
{title: 'Bahrain', value: 'BH'},
{title: 'Bangladesh', value: 'BD'},
{title: 'Barbados', value: 'BB'},
{title: 'Belarus', value: 'BY'},
{title: 'Belgium', value: 'BE'},
{title: 'Belize', value: 'BZ'},
{title: 'Benin', value: 'BJ'},
{title: 'Bhutan', value: 'BT'},
{title: 'Bolivia', value: 'BO'},
{title: 'Bosnia and Herzegovina', value: 'BA'},
{title: 'Botswana', value: 'BW'},
{title: 'Brazil', value: 'BR'},
{title: 'Brunei', value: 'BN'},
{title: 'Bulgaria', value: 'BG'},
{title: 'Burkina Faso', value: 'BF'},
{title: 'Burundi', value: 'BI'},
{title: 'Cabo Verde', value: 'CV'},
{title: 'Cambodia', value: 'KH'},
{title: 'Cameroon', value: 'CM'},
{title: 'Canada', value: 'CA'},
{title: 'Central African Republic', value: 'CF'},
{title: 'Chad', value: 'TD'},
{title: 'Chile', value: 'CL'},
{title: 'China', value: 'CN'},
{title: 'Colombia', value: 'CO'},
{title: 'Comoros', value: 'KM'},
{title: 'Congo (Democratic Republic)', value: 'CD'},
{title: 'Congo (Republic)', value: 'CG'},
{title: 'Costa Rica', value: 'CR'},
{title: 'Croatia', value: 'HR'},
{title: 'Cuba', value: 'CU'},
{title: 'Cyprus', value: 'CY'},
{title: 'Czech Republic', value: 'CZ'},
{title: 'Denmark', value: 'DK'},
{title: 'Djibouti', value: 'DJ'},
{title: 'Dominica', value: 'DM'},
{title: 'Dominican Republic', value: 'DO'},
{title: 'Ecuador', value: 'EC'},
{title: 'Egypt', value: 'EG'},
{title: 'El Salvador', value: 'SV'},
{title: 'Equatorial Guinea', value: 'GQ'},
{title: 'Eritrea', value: 'ER'},
{title: 'Estonia', value: 'EE'},
{title: 'Eswatini', value: 'SZ'},
{title: 'Ethiopia', value: 'ET'},
{title: 'Fiji', value: 'FJ'},
{title: 'Finland', value: 'FI'},
{title: 'France', value: 'FR'},
{title: 'Gabon', value: 'GA'},
{title: 'Gambia', value: 'GM'},
{title: 'Georgia', value: 'GE'},
{title: 'Germany', value: 'DE'},
{title: 'Ghana', value: 'GH'},
{title: 'Greece', value: 'GR'},
{title: 'Grenada', value: 'GD'},
{title: 'Guatemala', value: 'GT'},
{title: 'Guinea', value: 'GN'},
{title: 'Guinea-Bissau', value: 'GW'},
{title: 'Guyana', value: 'GY'},
{title: 'Haiti', value: 'HT'},
{title: 'Honduras', value: 'HN'},
{title: 'Hungary', value: 'HU'},
{title: 'Iceland', value: 'IS'},
{title: 'India', value: 'IN'},
{title: 'Indonesia', value: 'ID'},
{title: 'Iran', value: 'IR'},
{title: 'Iraq', value: 'IQ'},
{title: 'Ireland', value: 'IE'},
{title: 'Israel', value: 'IL'},
{title: 'Italy', value: 'IT'},
{title: 'Ivory Coast', value: 'CI'},
{title: 'Jamaica', value: 'JM'},
{title: 'Japan', value: 'JP'},
{title: 'Jordan', value: 'JO'},
{title: 'Kazakhstan', value: 'KZ'},
{title: 'Kenya', value: 'KE'},
{title: 'Kiribati', value: 'KI'},
{title: 'Kosovo', value: 'XK'},
{title: 'Kuwait', value: 'KW'},
{title: 'Kyrgyzstan', value: 'KG'},
{title: 'Laos', value: 'LA'},
{title: 'Latvia', value: 'LV'},
{title: 'Lebanon', value: 'LB'},
{title: 'Lesotho', value: 'LS'},
{title: 'Liberia', value: 'LR'},
{title: 'Libya', value: 'LY'},
{title: 'Liechtenstein', value: 'LI'},
{title: 'Lithuania', value: 'LT'},
{title: 'Luxembourg', value: 'LU'},
{title: 'Madagascar', value: 'MG'},
{title: 'Malawi', value: 'MW'},
{title: 'Malaysia', value: 'MY'},
{title: 'Maldives', value: 'MV'},
{title: 'Mali', value: 'ML'},
{title: 'Malta', value: 'MT'},
{title: 'Marshall Islands', value: 'MH'},
{title: 'Mauritania', value: 'MR'},
{title: 'Mauritius', value: 'MU'},
{title: 'Mexico', value: 'MX'},
{title: 'Micronesia', value: 'FM'},
{title: 'Moldova', value: 'MD'},
{title: 'Monaco', value: 'MC'},
{title: 'Mongolia', value: 'MN'},
{title: 'Montenegro', value: 'ME'},
{title: 'Morocco', value: 'MA'},
{title: 'Mozambique', value: 'MZ'},
{title: 'Myanmar', value: 'MM'},
{title: 'Namibia', value: 'NA'},
{title: 'Nauru', value: 'NR'},
{title: 'Nepal', value: 'NP'},
{title: 'Netherlands', value: 'NL'},
{title: 'New Zealand', value: 'NZ'},
{title: 'Nicaragua', value: 'NI'},
{title: 'Niger', value: 'NE'},
{title: 'Nigeria', value: 'NG'},
{title: 'North Korea', value: 'KP'},
{title: 'North Macedonia', value: 'MK'},
{title: 'Norway', value: 'NO'},
{title: 'Oman', value: 'OM'},
{title: 'Pakistan', value: 'PK'},
{title: 'Palau', value: 'PW'},
{title: 'Palestine', value: 'PS'},
{title: 'Panama', value: 'PA'},
{title: 'Papua New Guinea', value: 'PG'},
{title: 'Paraguay', value: 'PY'},
{title: 'Peru', value: 'PE'},
{title: 'Philippines', value: 'PH'},
{title: 'Poland', value: 'PL'},
{title: 'Portugal', value: 'PT'},
{title: 'Qatar', value: 'QA'},
{title: 'Romania', value: 'RO'},
{title: 'Russia', value: 'RU'},
{title: 'Rwanda', value: 'RW'},
{title: 'Saint Kitts and Nevis', value: 'KN'},
{title: 'Saint Lucia', value: 'LC'},
{title: 'Saint Vincent and the Grenadines', value: 'VC'},
{title: 'Samoa', value: 'WS'},
{title: 'San Marino', value: 'SM'},
{title: 'São Tomé and Príncipe', value: 'ST'},
{title: 'Saudi Arabia', value: 'SA'},
{title: 'Senegal', value: 'SN'},
{title: 'Serbia', value: 'RS'},
{title: 'Seychelles', value: 'SC'},
{title: 'Sierra Leone', value: 'SL'},
{title: 'Singapore', value: 'SG'},
{title: 'Slovakia', value: 'SK'},
{title: 'Slovenia', value: 'SI'},
{title: 'Solomon Islands', value: 'SB'},
{title: 'Somalia', value: 'SO'},
{title: 'South Africa', value: 'ZA'},
{title: 'South Korea', value: 'KR'},
{title: 'South Sudan', value: 'SS'},
{title: 'Spain', value: 'ES'},
{title: 'Sri Lanka', value: 'LK'},
{title: 'Sudan', value: 'SD'},
{title: 'Suriname', value: 'SR'},
{title: 'Sweden', value: 'SE'},
{title: 'Switzerland', value: 'CH'},
{title: 'Syria', value: 'SY'},
{title: 'Taiwan', value: 'TW'},
{title: 'Tajikistan', value: 'TJ'},
{title: 'Tanzania', value: 'TZ'},
{title: 'Thailand', value: 'TH'},
{title: 'Timor-Leste', value: 'TL'},
{title: 'Togo', value: 'TG'},
{title: 'Tonga', value: 'TO'},
{title: 'Trinidad and Tobago', value: 'TT'},
{title: 'Tunisia', value: 'TN'},
{title: 'Turkey', value: 'TR'},
{title: 'Turkmenistan', value: 'TM'},
{title: 'Tuvalu', value: 'TV'},
{title: 'Uganda', value: 'UG'},
{title: 'Ukraine', value: 'UA'},
{title: 'United Arab Emirates', value: 'AE'},
{title: 'United Kingdom', value: 'GB'},
{title: 'United States', value: 'US'},
{title: 'Uruguay', value: 'UY'},
{title: 'Uzbekistan', value: 'UZ'},
{title: 'Vanuatu', value: 'VU'},
{title: 'Vatican City', value: 'VA'},
{title: 'Venezuela', value: 'VE'},
{title: 'Vietnam', value: 'VN'},
{title: 'Yemen', value: 'YE'},
{title: 'Zambia', value: 'ZM'},
{title: 'Zimbabwe', value: 'ZW'},
]

View file

@ -1 +1,8 @@
export const schemaTypes = [] import {artistType} from './artist-type'
import {blogType} from './blog-type'
import {composerType} from './composer-type'
import {concertType} from './concert-type'
import {workType} from './work-type'
import {releaseType} from './release-type'
import {settingsType} from './settings-type'
export const schemaTypes = [artistType, blogType, composerType, concertType, workType, releaseType, settingsType]

563
schemaTypes/release-type.ts Normal file
View file

@ -0,0 +1,563 @@
import {defineArrayMember, defineField, defineType} from 'sanity'
import {PlayIcon} from '@sanity/icons'
import {TrackDisplayTitleInput} from '../components/TrackDisplayTitleInput'
export const releaseType = defineType({
name: 'release',
title: 'Release',
type: 'document',
icon: PlayIcon,
groups: [
{name: 'main', title: 'Main', default: true},
{name: 'text', title: 'Text'},
{name: 'media', title: 'Media'},
{name: 'tracklist', title: 'Tracklist'},
{name: 'links', title: 'Streaming Links'},
{name: 'references', title: 'Tags'},
{name: 'reviews', title: 'Reviews'},
{name: 'creditsSpecs', title: 'Credits & Specs'},
{name: 'medusa', title: 'Medusa'},
],
fieldsets: [
{name: 'main', title: 'Release Information', options: {columns: 2}},
{name: 'links', title: 'Streaming Links', options: {columns: 2}},
],
fields: [
defineField({
name: 'name',
title: 'Title',
type: 'string',
group: 'main',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'albumArtist',
title: 'Album Artist',
type: 'string',
group: 'main',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'catalogNo',
title: 'Catalog #',
type: 'string',
group: 'main',
fieldset: 'main',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: (doc: Record<string, unknown>) =>
[doc.albumArtist, doc.name].filter(Boolean).join(' '),
maxLength: 200,
},
validation: (Rule) => Rule.required(),
group: 'main',
fieldset: 'main',
}),
defineField({name: 'upc', title: 'UPC/EAN', type: 'string', group: 'main', fieldset: 'main'}),
defineField({
name: 'releaseDate',
title: 'Release Date',
type: 'date',
group: 'main',
fieldset: 'main',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'format',
title: 'Format',
type: 'string',
group: 'main',
fieldset: 'main',
options: {
list: [
{title: 'Single', value: 'single'},
{title: 'EP', value: 'ep'},
{title: 'Album', value: 'album'},
{title: 'Boxset', value: 'boxset'},
],
},
}),
defineField({
name: 'label',
title: 'Label',
type: 'string',
group: 'main',
fieldset: 'main',
options: {
list: [
{title: 'TRPTK', value: 'TRPTK'},
{title: 'Other', value: 'other'},
],
},
}),
defineField({
name: 'shortDescription',
title: 'Short Description',
type: 'text',
rows: 4,
group: 'text',
}),
defineField({
name: 'description',
title: 'Description',
type: 'array',
of: [defineArrayMember({type: 'block'})],
group: 'text',
}),
defineField({
name: 'albumCover',
title: 'Album Cover',
type: 'image',
group: 'media',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'bookletPdf',
title: 'Booklet PDF',
type: 'file',
options: {accept: 'application/pdf'},
group: 'media',
}),
defineField({
name: 'tracks',
title: 'Tracklist',
group: 'tracklist',
type: 'array',
of: [
defineArrayMember({
name: 'track',
title: 'Track',
type: 'object',
fields: [
defineField({
name: 'work',
title: 'Work',
type: 'reference',
to: [{type: 'work'}],
options: {disableNew: true},
}),
defineField({
name: 'movement',
title: 'Movement',
type: 'string',
}),
defineField({
name: 'displayTitle',
title: 'Display title',
type: 'string',
description: 'Use only when Work and Movement are empty.',
components: {
input: TrackDisplayTitleInput,
},
}),
defineField({
name: 'artist',
title: 'Artist',
description: 'If left empty, Album Artist will be used.',
type: 'string',
}),
defineField({
name: 'duration',
title: 'Duration',
description: 'm:ss, mm:ss, h:mm:ss, or hh:mm:ss',
type: 'string',
validation: (Rule) =>
Rule.required().custom((value) => {
if (typeof value !== 'string') return 'Duration is required'
const trimmed = value.trim()
const regex = /^(?:\d{1,2}:[0-5]\d:[0-5]\d|[0-5]?\d:[0-5]\d)$/
return regex.test(trimmed)
? true
: 'Use m:ss, mm:ss, h:mm:ss, or hh:mm:ss (e.g. 3:07, 03:07, 1:03:07, 12:03:07)'
}),
}),
defineField({
name: 'previewMp3',
title: '30s preview (MP3)',
type: 'file',
options: {accept: 'audio/mpeg'},
}),
],
preview: {
select: {
storedTitle: 'displayTitle',
workTitle: 'work.title',
movement: 'movement',
mp3: 'previewMp3',
composerName: 'work.composer.name',
arrangerName: 'work.arranger.name',
artist: 'artist',
albumArtist: 'albumArtist',
},
prepare({
storedTitle,
workTitle,
movement,
mp3,
composerName,
arrangerName,
artist,
albumArtist,
}) {
const wt = (workTitle || '').trim()
const mv = (movement || '').trim()
const computed = wt && mv ? `${wt}: ${mv}` : wt || mv
const title = (storedTitle && storedTitle.trim()) || computed || '(Untitled track)'
const subtitleParts = [
mp3 ? '♫' : null,
composerName || null,
arrangerName ? `(arr. ${arrangerName})` : null,
].filter(Boolean)
return {title, subtitle: subtitleParts.join(' ')}
},
},
}),
],
}),
defineField({
name: 'officialUrl',
title: 'Official Link',
type: 'url',
group: 'links',
fieldset: 'links',
}),
defineField({
name: 'spotifyUrl',
title: 'Spotify',
type: 'url',
group: 'links',
fieldset: 'links',
}),
defineField({
name: 'appleMusicUrl',
title: 'Apple Music',
type: 'url',
group: 'links',
fieldset: 'links',
}),
defineField({
name: 'deezerUrl',
title: 'Deezer',
type: 'url',
group: 'links',
fieldset: 'links',
}),
defineField({
name: 'amazonMusicUrl',
title: 'Amazon Music',
type: 'url',
group: 'links',
fieldset: 'links',
}),
defineField({name: 'tidalUrl', title: 'Tidal', type: 'url', group: 'links', fieldset: 'links'}),
defineField({name: 'qobuzUrl', title: 'Qobuz', type: 'url', group: 'links', fieldset: 'links'}),
defineField({
name: 'nativeDsdUrl',
title: 'NativeDSD',
type: 'url',
group: 'links',
fieldset: 'links',
}),
defineField({
name: 'credits',
title: 'Credits',
group: 'creditsSpecs',
type: 'array',
of: [
defineArrayMember({
name: 'credit',
title: 'Credit',
type: 'object',
fields: [
defineField({
name: 'role',
title: 'Role',
type: 'string',
description: 'e.g. “Recording engineer”',
}),
defineField({
name: 'name',
title: 'Name',
type: 'text',
rows: 2,
description: 'You may use “ | ” to indicate multiple names.',
}),
],
preview: {
select: {role: 'role', name: 'name'},
prepare({role, name}) {
return {title: name || '(No role)', subtitle: role}
},
},
}),
],
}),
defineField({
name: 'recordingDate',
title: 'Recording date(s)',
group: 'creditsSpecs',
type: 'string',
description: 'You may use “ | ” to indicate multiple dates.',
}),
defineField({
name: 'recordingLocation',
title: 'Recording location(s)',
group: 'creditsSpecs',
type: 'string',
description: 'You may use “ | ” to indicate multiple locations.',
}),
defineField({
name: 'recordingFormat',
title: 'Recording format',
group: 'creditsSpecs',
type: 'string',
options: {
list: [
{title: 'PCM 352.8 kHz 24 bit', value: 'PCM 352.8 kHz 24 bit'},
{title: 'PCM 352.8 kHz 32 bit', value: 'PCM 352.8 kHz 32 bit'},
{title: 'DSD 11.2 MHz 1 bit', value: 'DSD 11.2 MHz 1 bit'},
],
},
}),
defineField({
name: 'masteringFormat',
title: 'Mastering format',
group: 'creditsSpecs',
type: 'string',
options: {
list: [
{title: 'PCM 352.8 kHz 32 bit', value: 'PCM 352.8 kHz 32 bit'},
{title: 'PCM 352.8 kHz 64 bit', value: 'PCM 352.8 kHz 64 bit'},
{title: 'DSD 11.2 MHz 1 bit', value: 'DSD 11.2 MHz 1 bit'},
],
},
}),
defineField({
name: 'equipment',
title: 'Equipment',
group: 'creditsSpecs',
type: 'array',
of: [
defineArrayMember({
name: 'equipmentItem',
title: 'Equipment item',
type: 'object',
fields: [
defineField({
name: 'type',
title: 'Type',
type: 'string',
description: 'e.g. “Microphones”',
}),
defineField({
name: 'name',
title: 'Name',
type: 'text',
rows: 2,
description: 'You may use “ | ” to indicate multiple items.',
}),
],
preview: {
select: {type: 'type', name: 'name'},
prepare({type, name}) {
return {title: type || '(No type)', subtitle: name}
},
},
}),
],
}),
defineField({
name: 'genre',
title: 'Genre(s)',
type: 'array',
of: [
defineArrayMember({
type: 'string',
options: {
layout: 'tags' as const,
list: [
{title: 'Early Music', value: 'earlyMusic'},
{title: 'Baroque', value: 'baroque'},
{title: 'Classical', value: 'classical'},
{title: 'Romantic', value: 'romantic'},
{title: 'Contemporary', value: 'contemporary'},
{title: 'World Music', value: 'worldMusic'},
{title: 'Jazz', value: 'jazz'},
{title: 'Crossover', value: 'crossover'},
{title: 'Electronic', value: 'electronic'},
{title: 'Minimal', value: 'minimal'},
{title: 'Pop / Rock', value: 'popRock'},
],
},
} as any),
],
group: 'references',
}),
defineField({
name: 'instrumentation',
title: 'Instrumentation(s)',
type: 'array',
of: [
defineArrayMember({
type: 'string',
options: {
layout: 'tags' as const,
list: [
{title: 'Solo', value: 'solo'},
{title: 'Chamber', value: 'chamber'},
{title: 'Ensemble', value: 'ensemble'},
{title: 'Orchestral', value: 'orchestra'},
{title: 'Vocal / Choral', value: 'vocalChoral'},
],
},
} as any),
],
group: 'references',
}),
defineField({
name: 'artists',
title: 'Artist(s)',
group: 'references',
type: 'array',
of: [
defineArrayMember({
type: 'reference',
to: [{type: 'artist'}],
}),
],
}),
defineField({
name: 'reviews',
title: 'Reviews',
group: 'reviews',
type: 'array',
of: [
defineArrayMember({
type: 'object',
name: 'review',
fields: [
defineField({
name: 'quote',
title: 'Quote',
type: 'text',
rows: 4,
}),
defineField({
name: 'author',
title: 'Author',
type: 'string',
}),
],
preview: {
select: {
quote: 'quote',
author: 'author',
},
prepare({quote, author}) {
return {
title: quote
? quote.length > 60
? quote.substring(0, 60) + '...'
: quote
: '(Empty review)',
subtitle: author || '',
}
},
},
}),
],
}),
defineField({
name: 'availableVariants',
title: 'Available Variants',
type: 'array',
group: 'medusa',
of: [defineArrayMember({type: 'string'})],
options: {
list: [
{title: 'CD', value: 'cd'},
{title: 'SACD', value: 'sacd'},
{title: 'LP', value: 'lp'},
{title: 'Stereo FLAC 88/24', value: '88k24b2ch'},
{title: 'Stereo FLAC 176/24', value: '176k24b2ch'},
{title: 'Stereo FLAC 352/24', value: '352k24b2ch'},
{title: 'Stereo FLAC 352/32', value: '352k32b2ch'},
{title: 'Surround FLAC 88/24', value: '88k24b5ch'},
{title: 'Surround FLAC 176/24', value: '176k24b5ch'},
{title: 'Surround FLAC 352/24', value: '352k24b5ch'},
{title: 'Surround FLAC 352/32', value: '352k32b5ch'},
{title: 'Dolby Atmos, DTS:X & Auro-3D in MKV', value: 'mkv'},
{title: 'Auro-3D FLAC', value: 'a3d'},
{title: 'Dolby Atmos ADM 48kHz', value: 'adm48'},
{title: 'Dolby Atmos ADM 96kHz', value: 'adm96'},
{title: 'HD Video', value: 'hd'},
{title: '4K Video', value: '4k'},
],
},
}),
],
orderings: [
{
title: 'Release Date (latest first)',
name: 'releaseDateDesc',
by: [{field: 'releaseDate', direction: 'desc'}],
},
{
title: 'Release Date (oldest first)',
name: 'releaseDateAsc',
by: [{field: 'releaseDate', direction: 'asc'}],
},
{
title: 'Catalog # (Asc.)',
name: 'catalogNoAsc',
by: [{field: 'catalogNo', direction: 'asc'}],
},
{
title: 'Catalog # (Desc.)',
name: 'catalogNoDesc',
by: [{field: 'catalogNo', direction: 'desc'}],
},
],
preview: {
select: {
title: 'name',
artist: 'albumArtist',
catNo: 'catalogNo',
media: 'albumCover',
},
prepare({title, artist, catNo, media}) {
return {
title: title || '(Untitled release)',
subtitle: artist ? artist : '',
media,
}
},
},
})

View file

@ -0,0 +1,43 @@
import {defineField, defineType} from 'sanity'
import {CogIcon} from '@sanity/icons'
export const settingsType = defineType({
name: 'settings',
title: 'Settings',
type: 'document',
icon: CogIcon,
fields: [
defineField({
name: 'title',
type: 'string',
hidden: true,
initialValue: 'Settings',
}),
defineField({
name: 'featuredAlbum',
title: 'Featured Album',
type: 'reference',
to: [{type: 'release'}],
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'featuredArtist',
title: 'Featured Artist',
type: 'reference',
to: [{type: 'artist'}],
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'featuredComposer',
title: 'Featured Composer',
type: 'reference',
to: [{type: 'composer'}],
validation: (Rule) => Rule.required(),
}),
],
preview: {
prepare() {
return {title: 'Settings'}
},
},
})

88
schemaTypes/work-type.ts Normal file
View file

@ -0,0 +1,88 @@
import {defineArrayMember, defineField, defineType} from 'sanity'
import {DocumentTextIcon} from '@sanity/icons'
import {WorkSlugInput} from '../components/WorkSlugInput'
export const workType = defineType({
name: 'work',
title: 'Work',
type: 'document',
icon: DocumentTextIcon,
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'composer',
title: 'Composer',
type: 'reference',
to: [{type: 'composer'}],
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'arranger',
title: 'Arranger',
type: 'reference',
to: [{type: 'composer'}],
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
components: {
input: WorkSlugInput,
},
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'description',
title: 'Description',
type: 'array',
of: [defineArrayMember({type: 'block'})],
}),
],
orderings: [
{
title: 'Title (A → Z)',
name: 'titleAsc',
by: [{field: 'title', direction: 'asc'}],
},
{
title: 'Title (Z → A)',
name: 'titleDesc',
by: [{field: 'title', direction: 'desc'}],
},
],
preview: {
select: {
title: 'title',
composer: 'composer.name',
arranger: 'arranger.name',
},
prepare({title, composer, arranger}) {
let subtitle = ''
if (composer && arranger) {
subtitle = `${composer} (arr. ${arranger})`
} else if (composer) {
subtitle = composer
} else if (arranger) {
subtitle = `arr. ${arranger}`
}
return {
title,
subtitle,
}
},
},
})

View file

@ -0,0 +1,50 @@
import {getCliClient} from 'sanity/cli'
const client = getCliClient().withConfig({apiVersion: '2024-01-01'})
function toSlug(input: string): string {
return input
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // strip diacritics
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // remove non-alphanumeric
.trim()
.replace(/[\s-]+/g, '-') // spaces/hyphens → single hyphen
}
async function regenerateSlugs() {
const releases = await client.fetch<{_id: string; name?: string; albumArtist?: string}[]>(
`*[_type == "release"]{_id, name, albumArtist}`,
)
console.log(`Found ${releases.length} releases`)
const transaction = client.transaction()
let count = 0
for (const release of releases) {
const source = [release.albumArtist, release.name].filter(Boolean).join(' ')
if (!source) {
console.log(`Skipping ${release._id} — no albumArtist or name`)
continue
}
const slug = toSlug(source)
console.log(`${release._id}: "${source}" → ${slug}`)
transaction.patch(release._id, (p) => p.set({slug: {_type: 'slug', current: slug}}))
count++
}
if (count === 0) {
console.log('No documents to update.')
return
}
const result = await transaction.commit()
console.log(`Done! Updated ${result.documentIds.length} documents.`)
}
regenerateSlugs().catch((err) => {
console.error(err)
process.exit(1)
})