diff --git a/packages/common/src/schemas/upload/uploadFormSchema.ts b/packages/common/src/schemas/upload/uploadFormSchema.ts index 3537e9e4534..ef85f513bbb 100644 --- a/packages/common/src/schemas/upload/uploadFormSchema.ts +++ b/packages/common/src/schemas/upload/uploadFormSchema.ts @@ -1,4 +1,4 @@ -import { Genre, Mood, NativeFile, MAX_DESCRIPTION_LENGTH } from '@audius/sdk' +import { Mood, NativeFile, MAX_DESCRIPTION_LENGTH } from '@audius/sdk' import { z } from 'zod' import { imageBlank } from '~/assets' @@ -54,11 +54,16 @@ const USDCPurchaseConditionsSchema = z }) .strict() -/** Same as SDK. */ +/** + * Same as SDK: arbitrary string up to 100 chars. The Genre enum is reserved + * for autocomplete suggestions; users may submit any value. + */ const GenreSchema = z - .enum(Object.values(Genre) as [Genre, ...Genre[]]) + .string() + .min(1, { message: messages.genreRequiredError }) + .max(100) .nullable() - .refine((val) => val !== null, { + .refine((val) => val !== null && val.length > 0, { message: messages.genreRequiredError }) diff --git a/packages/common/src/utils/genres.ts b/packages/common/src/utils/genres.ts index 5e779f12a0a..0a2c441dcbb 100644 --- a/packages/common/src/utils/genres.ts +++ b/packages/common/src/utils/genres.ts @@ -3,6 +3,15 @@ import { Genre as SDKGenre } from '@audius/sdk' /** Re-export SDK Genre as the canonical source for track metadata. */ export { Genre } from '@audius/sdk' +/** + * Track genre value. Any string up to 100 chars is accepted at the API/SDK + * layer; the {@link Genre} enum provides the canonical autocomplete values. + * Use this type for read-side / metadata typings so custom-genre tracks + * flow through without TS errors. Continue using `Genre` for write-side + * autocomplete sources and static lists where only known values are valid. + */ +export type { GenreString } from '@audius/sdk' + /** * UI-only value for "all genres" filter (e.g. trending page). * Not part of SDK Genre - use for filter state only. diff --git a/packages/mobile/examples/upload/App.tsx b/packages/mobile/examples/upload/App.tsx index 0814fb4835b..726f1ce8785 100644 --- a/packages/mobile/examples/upload/App.tsx +++ b/packages/mobile/examples/upload/App.tsx @@ -22,18 +22,10 @@ import { import { config } from './src/config' import { getSDK } from './src/sdk' -const GENRES = [ - 'Electronic', - 'Rock', - 'Hip-Hop/Rap', - 'Pop', - 'R&B/Soul', - 'Alternative', - 'Country', - 'Jazz', - 'Folk', - 'Classical' -] as const +// Genre is a freeform string up to 100 chars at the API/SDK layer. See +// `Genre` from @audius/sdk for the canonical autocomplete suggestions if +// you want to show a picker; this example uses a plain text input. +const MAX_GENRE_LENGTH = 100 type Screen = 'home' | 'signed-in' @@ -46,7 +38,7 @@ export default function App() { useState(null) const [coverUri, setCoverUri] = useState(null) const [title, setTitle] = useState('') - const [genre, setGenre] = useState<(typeof GENRES)[number]>('Electronic') + const [genre, setGenre] = useState('') const [description, setDescription] = useState('') const [uploading, setUploading] = useState(false) const [result, setResult] = useState(null) @@ -131,7 +123,7 @@ export default function App() { setAudioFile(null) setCoverUri(null) setTitle('') - setGenre('Electronic') + setGenre('') setDescription('') setResult(null) setScreen('home') @@ -302,31 +294,16 @@ export default function App() { /> Genre - - {GENRES.map((g) => ( - setGenre(g)} - > - - {g} - - - ))} - + `Use "${value}" as a custom genre` } -const genres = GENRES.map((genre) => ({ +const knownGenres = GENRES.map((genre) => ({ value: convertGenreLabelToValue(genre), label: genre })) +const useStyles = makeStyles(({ spacing, typography }) => ({ + searchInput: { + paddingVertical: spacing(3), + fontSize: typography.fontSize.large + } +})) + export const SelectGenreScreen = () => { - const [{ value }, , { setValue }] = useField('genre') + const [{ value }, , { setValue }] = useField('genre') + const [input, setInput] = useState('') + const styles = useStyles() + + const trimmed = input.trim() + const lower = trimmed.toLowerCase() + + const filtered = useMemo(() => { + if (trimmed === '') return knownGenres + return knownGenres.filter( + (g) => + g.label.toLowerCase().includes(lower) || + g.value.toLowerCase().includes(lower) + ) + }, [trimmed, lower]) + + const data = useMemo(() => { + if (trimmed === '') return knownGenres + const matchesKnownExactly = knownGenres.some( + (g) => + g.label.toLowerCase() === lower || g.value.toLowerCase() === lower + ) + if (matchesKnownExactly || trimmed.length > MAX_GENRE_LENGTH) { + return filtered + } + return [ + { value: trimmed, label: messages.useCustom(trimmed) }, + ...filtered + ] + }, [trimmed, lower, filtered]) return ( ( {item.label} @@ -28,7 +71,21 @@ export const SelectGenreScreen = () => { )} screenTitle={messages.screenTitle} icon={IconGenre} - searchText={messages.searchText} + disableSearch + header={ + + + + } value={value} onChange={setValue} />