Browse Source

refine music tracks

add a music track type
imwald
Silberengel 2 weeks ago
parent
commit
b600fcf0b6
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 37
      src/components/Note/MusicTrackNote.tsx
  4. 325
      src/components/PostEditor/PostContent.tsx
  5. 61
      src/components/PostEditor/PostTextarea/Preview.tsx
  6. 14
      src/components/PostEditor/PostTextarea/index.tsx
  7. 69
      src/lib/draft-event.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.18.3", "version": "23.19.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.18.3", "version": "23.19.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.18.3", "version": "23.19.0",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

37
src/components/Note/MusicTrackNote.tsx

@ -11,8 +11,28 @@ import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
import MediaPlayer from '../MediaPlayer' import MediaPlayer from '../MediaPlayer'
/** Tags already shown on the music card — omit from caption markdown so they are not rendered twice. */
const MUSIC_TRACK_CAPTION_OMIT_TAGS = new Set([
'd',
'title',
'artist',
'url',
'image',
'video',
'album',
'duration',
'format',
'language',
'track_number',
'released',
'explicit',
'alt',
'genre'
])
export default function MusicTrackNote({ export default function MusicTrackNote({
event, event,
className, className,
@ -37,6 +57,11 @@ export default function MusicTrackNote({
() => (track ? primalR2aMirrorForBlossomPrimalUrl(track.audioUrl) ?? undefined : undefined), () => (track ? primalR2aMirrorForBlossomPrimalUrl(track.audioUrl) ?? undefined : undefined),
[track] [track]
) )
const captionEvent = useMemo(() => {
if (!caption) return null
const tags = event.tags.filter(([name]) => !MUSIC_TRACK_CAPTION_OMIT_TAGS.has(name))
return { ...event, content: caption, tags } as Event
}, [event, caption])
if (!track) { if (!track) {
return ( return (
@ -86,8 +111,16 @@ export default function MusicTrackNote({
</div> </div>
) : null} ) : null}
</div> </div>
{caption ? ( {captionEvent ? (
<p className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{caption}</p> <div className="mt-2 min-w-0 text-sm text-muted-foreground">
<MarkdownArticle
event={captionEvent}
hideMetadata
lazyMedia={!mustLoad}
parentImageUrl={track.imageUrl}
className="prose-sm prose-headings:text-muted-foreground prose-p:text-muted-foreground"
/>
</div>
) : null} ) : null}
</div> </div>
) )

325
src/components/PostEditor/PostContent.tsx

@ -35,6 +35,7 @@ import {
createCitationExternalDraftEvent, createCitationExternalDraftEvent,
createCitationHardcopyDraftEvent, createCitationHardcopyDraftEvent,
createCitationPromptDraftEvent, createCitationPromptDraftEvent,
createMusicTrackDraftEvent,
collectUploadImetaTagsForContentUrls, collectUploadImetaTagsForContentUrls,
mergeUploadImetaTagsInto, mergeUploadImetaTagsInto,
stripImwaldAttributionTags stripImwaldAttributionTags
@ -330,6 +331,17 @@ export default function PostContent({
const [isWikiArticle, setIsWikiArticle] = useState(false) const [isWikiArticle, setIsWikiArticle] = useState(false)
const [isNostrSpecification, setIsNostrSpecification] = useState(false) const [isNostrSpecification, setIsNostrSpecification] = useState(false)
const [isPublicationContent, setIsPublicationContent] = useState(false) const [isPublicationContent, setIsPublicationContent] = useState(false)
const [isMusicTrack, setIsMusicTrack] = useState(false)
const [musicTrackDTag, setMusicTrackDTag] = useState('')
const [musicTrackTitle, setMusicTrackTitle] = useState('')
const [musicTrackArtist, setMusicTrackArtist] = useState('')
const [musicTrackAudioUrl, setMusicTrackAudioUrl] = useState('')
const [musicTrackImageUrl, setMusicTrackImageUrl] = useState('')
const [musicTrackAlbum, setMusicTrackAlbum] = useState('')
const [musicTrackDuration, setMusicTrackDuration] = useState('')
const [musicTrackFormat, setMusicTrackFormat] = useState('')
const [musicTrackLanguage, setMusicTrackLanguage] = useState('')
const [musicTrackGenres, setMusicTrackGenres] = useState('')
const [nostrSpecAffectedKindRows, setNostrSpecAffectedKindRows] = useState<NostrSpecAffectedKindRow[]>( const [nostrSpecAffectedKindRows, setNostrSpecAffectedKindRows] = useState<NostrSpecAffectedKindRow[]>(
() => [newNostrSpecAffectedKindRow()] () => [newNostrSpecAffectedKindRow()]
) )
@ -387,6 +399,7 @@ export default function PostContent({
const [mediaNoteUploadPending, setMediaNoteUploadPending] = useState(false) const [mediaNoteUploadPending, setMediaNoteUploadPending] = useState(false)
/** Stable auto d-tag when the field is left empty; `{ slug, value }` resets when article subtype changes. */ /** Stable auto d-tag when the field is left empty; `{ slug, value }` resets when article subtype changes. */
const articleDTagFallbackRef = useRef<{ slug: string; value: string } | null>(null) const articleDTagFallbackRef = useRef<{ slug: string; value: string } | null>(null)
const musicTrackDTagFallbackRef = useRef<string | null>(null)
useEffect(() => { useEffect(() => {
if (articleDTag.trim()) { if (articleDTag.trim()) {
@ -590,6 +603,8 @@ export default function PostContent({
return ExtendedKind.NOSTR_SPECIFICATION return ExtendedKind.NOSTR_SPECIFICATION
} else if (isPublicationContent) { } else if (isPublicationContent) {
return ExtendedKind.PUBLICATION_CONTENT return ExtendedKind.PUBLICATION_CONTENT
} else if (isMusicTrack) {
return ExtendedKind.MUSIC_TRACK
} else if (isCitationInternal) { } else if (isCitationInternal) {
return ExtendedKind.CITATION_INTERNAL return ExtendedKind.CITATION_INTERNAL
} else if (isCitationExternal) { } else if (isCitationExternal) {
@ -615,6 +630,7 @@ export default function PostContent({
isWikiArticle, isWikiArticle,
isNostrSpecification, isNostrSpecification,
isPublicationContent, isPublicationContent,
isMusicTrack,
isCitationInternal, isCitationInternal,
isCitationExternal, isCitationExternal,
isCitationHardcopy, isCitationHardcopy,
@ -654,6 +670,7 @@ export default function PostContent({
(!isCitationExternal || (!!citationExternalUrl.trim() && !!citationAccessedOn.trim())) && (!isCitationExternal || (!!citationExternalUrl.trim() && !!citationAccessedOn.trim())) &&
(!isCitationHardcopy || !!citationAccessedOn.trim()) && (!isCitationHardcopy || !!citationAccessedOn.trim()) &&
(!isCitationPrompt || (!!citationPromptLlm.trim() && !!citationAccessedOn.trim())) && (!isCitationPrompt || (!!citationPromptLlm.trim() && !!citationAccessedOn.trim())) &&
(!isMusicTrack || (!!musicTrackTitle.trim() && !!musicTrackAudioUrl.trim())) &&
relayCapBlockInfo === null relayCapBlockInfo === null
) )
}, [ }, [
@ -686,7 +703,10 @@ export default function PostContent({
threadIsReadingGroup, threadIsReadingGroup,
threadReadingAuthor, threadReadingAuthor,
threadReadingSubject, threadReadingSubject,
relayCapBlockInfo relayCapBlockInfo,
isMusicTrack,
musicTrackTitle,
musicTrackAudioUrl
]) ])
const getDeterminedKindRef = useRef(getDeterminedKind) const getDeterminedKindRef = useRef(getDeterminedKind)
@ -805,6 +825,38 @@ export default function PostContent({
nostrSpecAffectedKindRows nostrSpecAffectedKindRows
]) ])
const musicTrackPreviewMetadata = useMemo(() => {
if (!isMusicTrack) return undefined
const genres = musicTrackGenres.trim()
? musicTrackGenres.split(/[,\s]+/).filter((s) => s.trim())
: []
const durationRaw = Number.parseInt(musicTrackDuration.trim(), 10)
return {
dTag: musicTrackDTag.trim() || undefined,
title: musicTrackTitle.trim() || undefined,
audioUrl: musicTrackAudioUrl.trim() || undefined,
artist: musicTrackArtist.trim() || undefined,
imageUrl: musicTrackImageUrl.trim() || undefined,
album: musicTrackAlbum.trim() || undefined,
durationSec: Number.isFinite(durationRaw) && durationRaw > 0 ? durationRaw : undefined,
format: musicTrackFormat.trim() || undefined,
language: musicTrackLanguage.trim() || undefined,
genres: genres.length > 0 ? genres : undefined
}
}, [
isMusicTrack,
musicTrackDTag,
musicTrackTitle,
musicTrackAudioUrl,
musicTrackArtist,
musicTrackImageUrl,
musicTrackAlbum,
musicTrackDuration,
musicTrackFormat,
musicTrackLanguage,
musicTrackGenres
])
const mergedExtraPreviewTags = useMemo((): string[][] | undefined => { const mergedExtraPreviewTags = useMemo((): string[][] | undefined => {
const contextual = const contextual =
isDiscussionThread && !parentEvent isDiscussionThread && !parentEvent
@ -944,6 +996,37 @@ export default function PostContent({
? articleSubject.split(/[,\s]+/).filter(s => s.trim()) ? articleSubject.split(/[,\s]+/).filter(s => s.trim())
: [] : []
if (isMusicTrack) {
const trimmedDTag = musicTrackDTag.trim()
let effectiveDTag = trimmedDTag
if (!effectiveDTag) {
if (!musicTrackDTagFallbackRef.current) {
musicTrackDTagFallbackRef.current = `music-track-${Math.floor(Date.now() / 1000)}`
}
effectiveDTag = musicTrackDTagFallbackRef.current
} else {
musicTrackDTagFallbackRef.current = null
}
const genres = musicTrackGenres.trim()
? musicTrackGenres.split(/[,\s]+/).filter((s) => s.trim())
: []
const durationRaw = Number.parseInt(musicTrackDuration.trim(), 10)
return await createMusicTrackDraftEvent(cleanedText, mentions, {
dTag: effectiveDTag,
title: musicTrackTitle.trim(),
audioUrl: musicTrackAudioUrl.trim(),
artist: musicTrackArtist.trim() || undefined,
imageUrl: musicTrackImageUrl.trim() || undefined,
album: musicTrackAlbum.trim() || undefined,
durationSec: Number.isFinite(durationRaw) && durationRaw > 0 ? durationRaw : undefined,
format: musicTrackFormat.trim() || undefined,
language: musicTrackLanguage.trim() || undefined,
genres,
addClientTag,
isNsfw
})
}
// Articles // Articles
const isArticleDraft = const isArticleDraft =
isLongFormArticle || isWikiArticle || isNostrSpecification || isPublicationContent isLongFormArticle || isWikiArticle || isNostrSpecification || isPublicationContent
@ -1160,6 +1243,17 @@ export default function PostContent({
isWikiArticle, isWikiArticle,
isNostrSpecification, isNostrSpecification,
isPublicationContent, isPublicationContent,
isMusicTrack,
musicTrackDTag,
musicTrackTitle,
musicTrackArtist,
musicTrackAudioUrl,
musicTrackImageUrl,
musicTrackAlbum,
musicTrackDuration,
musicTrackFormat,
musicTrackLanguage,
musicTrackGenres,
isCitationInternal, isCitationInternal,
isCitationExternal, isCitationExternal,
isCitationHardcopy, isCitationHardcopy,
@ -1502,6 +1596,7 @@ export default function PostContent({
setIsWikiArticle(false) setIsWikiArticle(false)
setIsNostrSpecification(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsMusicTrack(false)
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
setIsCitationHardcopy(false) setIsCitationHardcopy(false)
@ -1526,6 +1621,7 @@ export default function PostContent({
setIsWikiArticle(false) setIsWikiArticle(false)
setIsNostrSpecification(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsMusicTrack(false)
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
setIsCitationHardcopy(false) setIsCitationHardcopy(false)
@ -1553,6 +1649,7 @@ export default function PostContent({
setIsWikiArticle(false) setIsWikiArticle(false)
setIsNostrSpecification(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsMusicTrack(false)
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
setIsCitationHardcopy(false) setIsCitationHardcopy(false)
@ -1616,7 +1713,7 @@ export default function PostContent({
} }
const canUseMediaKindFromUrlButton = useMemo(() => { const canUseMediaKindFromUrlButton = useMemo(() => {
if (parentEvent || isDiscussionThread || isPublicMessage) return false if (parentEvent || isDiscussionThread || isPublicMessage || isMusicTrack) return false
if (mediaNoteKind !== null && mediaUrl) return false if (mediaNoteKind !== null && mediaUrl) return false
if (mediaImetaTags.length > 0) return true if (mediaImetaTags.length > 0) return true
if (mediaUrl) return true if (mediaUrl) return true
@ -1627,6 +1724,7 @@ export default function PostContent({
parentEvent, parentEvent,
isDiscussionThread, isDiscussionThread,
isPublicMessage, isPublicMessage,
isMusicTrack,
mediaNoteKind, mediaNoteKind,
mediaUrl, mediaUrl,
mediaImetaTags, mediaImetaTags,
@ -1635,7 +1733,7 @@ export default function PostContent({
/** When the editor already contains a media URL (e.g. after drop/paste) but kind stayed 1. */ /** When the editor already contains a media URL (e.g. after drop/paste) but kind stayed 1. */
const handleUseMediaNoteKindFromUrl = () => { const handleUseMediaNoteKindFromUrl = () => {
if (parentEvent || isDiscussionThread || isPublicMessage) return if (parentEvent || isDiscussionThread || isPublicMessage || isMusicTrack) return
if (mediaNoteKind !== null && mediaUrl) { if (mediaNoteKind !== null && mediaUrl) {
toast.info(t('Already publishing as a media note')) toast.info(t('Already publishing as a media note'))
return return
@ -1658,6 +1756,7 @@ export default function PostContent({
setIsWikiArticle(false) setIsWikiArticle(false)
setIsNostrSpecification(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsMusicTrack(false)
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
setIsCitationHardcopy(false) setIsCitationHardcopy(false)
@ -1691,6 +1790,7 @@ export default function PostContent({
!isWikiArticle && !isWikiArticle &&
!isNostrSpecification && !isNostrSpecification &&
!isPublicationContent && !isPublicationContent &&
!isMusicTrack &&
!isCitationInternal && !isCitationInternal &&
!isCitationExternal && !isCitationExternal &&
!isCitationHardcopy && !isCitationHardcopy &&
@ -1706,6 +1806,7 @@ export default function PostContent({
isWikiArticle, isWikiArticle,
isNostrSpecification, isNostrSpecification,
isPublicationContent, isPublicationContent,
isMusicTrack,
isCitationInternal, isCitationInternal,
isCitationExternal, isCitationExternal,
isCitationHardcopy, isCitationHardcopy,
@ -1715,6 +1816,31 @@ export default function PostContent({
] ]
) )
const handleMusicTrackToggle = () => {
if (parentEvent) return
setIsMusicTrack((prev) => !prev)
if (!isMusicTrack) {
setIsPoll(false)
setIsPublicMessage(false)
setIsHighlight(false)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
setIsMusicTrack(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
setIsCitationPrompt(false)
setIsDiscussionThread(false)
setMediaNoteKind(null)
setMediaUrl('')
setMediaImetaTags([])
composerImetaTagsRef.current = []
musicTrackDTagFallbackRef.current = null
}
}
const handleHighlightToggle = () => { const handleHighlightToggle = () => {
if (parentEvent) return if (parentEvent) return
@ -1727,6 +1853,7 @@ export default function PostContent({
setIsWikiArticle(false) setIsWikiArticle(false)
setIsNostrSpecification(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsMusicTrack(false)
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
setIsCitationHardcopy(false) setIsCitationHardcopy(false)
@ -1746,6 +1873,11 @@ export default function PostContent({
setIsPoll(false) setIsPoll(false)
setIsPublicMessage(false) setIsPublicMessage(false)
setIsHighlight(false) setIsHighlight(false)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsNostrSpecification(false)
setIsPublicationContent(false)
setIsMusicTrack(false)
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
setIsCitationHardcopy(false) setIsCitationHardcopy(false)
@ -2148,6 +2280,30 @@ export default function PostContent({
} }
return // Don't set media note kind for non-audio in replies/PMs return // Don't set media note kind for non-audio in replies/PMs
} }
} else if (isMusicTrack) {
const fileType = uploadingFile.type
const fileName = uploadingFile.name.toLowerCase()
const isAudioMime =
fileType.startsWith('audio/') ||
fileType === 'audio/mp4' ||
fileType === 'audio/x-m4a' ||
fileType === 'audio/m4a'
const isAudioExt = /\.(mp3|m4a|mka|ogg|wav|opus|aac|flac|mpeg)$/i.test(fileName)
const isAudio = isAudioMime || isAudioExt || /\.m4a$/i.test(fileName)
const isImage =
fileType.startsWith('image/') || imageUrlLooksLikeHttpImage(url)
if (isAudio) {
setMusicTrackAudioUrl(url)
const ext = fileName.split('.').pop()
if (ext && !musicTrackFormat.trim()) {
setMusicTrackFormat(ext)
}
} else if (isImage) {
setMusicTrackImageUrl(url)
} else if (!urlAlreadyInEditor) {
appendUploadedUrlToComposer(url, imageUrlLooksLikeHttpImage(url))
}
} else { } else {
// For new posts, check if file is ambiguous (could be audio or video) // For new posts, check if file is ambiguous (could be audio or video)
if (isAmbiguousMediaFile(uploadingFile)) { if (isAmbiguousMediaFile(uploadingFile)) {
@ -2172,7 +2328,7 @@ export default function PostContent({
// Don't throw - just log the error so the upload doesn't fail completely // Don't throw - just log the error so the upload doesn't fail completely
} }
if (!mediaNoteUploaderIntentRef.current) { if (!mediaNoteUploaderIntentRef.current && !isMusicTrack) {
clearNonMediaNoteComposerModes() clearNonMediaNoteComposerModes()
} }
@ -2223,6 +2379,7 @@ export default function PostContent({
setIsCitationHardcopy(false) setIsCitationHardcopy(false)
setIsCitationPrompt(false) setIsCitationPrompt(false)
setIsDiscussionThread(false) setIsDiscussionThread(false)
setIsMusicTrack(false)
// Clear article metadata when switching off article mode // Clear article metadata when switching off article mode
if (type === null) { if (type === null) {
@ -2263,6 +2420,7 @@ export default function PostContent({
setIsWikiArticle(false) setIsWikiArticle(false)
setIsNostrSpecification(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsMusicTrack(false)
setIsDiscussionThread(false) setIsDiscussionThread(false)
// Set default accessedOn if not already set // Set default accessedOn if not already set
@ -2294,6 +2452,18 @@ export default function PostContent({
setIsWikiArticle(false) setIsWikiArticle(false)
setIsNostrSpecification(false) setIsNostrSpecification(false)
setIsPublicationContent(false) setIsPublicationContent(false)
setIsMusicTrack(false)
setMusicTrackDTag('')
setMusicTrackTitle('')
setMusicTrackArtist('')
setMusicTrackAudioUrl('')
setMusicTrackImageUrl('')
setMusicTrackAlbum('')
setMusicTrackDuration('')
setMusicTrackFormat('')
setMusicTrackLanguage('')
setMusicTrackGenres('')
musicTrackDTagFallbackRef.current = null
setIsCitationInternal(false) setIsCitationInternal(false)
setIsCitationExternal(false) setIsCitationExternal(false)
setIsCitationHardcopy(false) setIsCitationHardcopy(false)
@ -2702,6 +2872,138 @@ export default function PostContent({
</div> </div>
)} )}
{isMusicTrack && (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="space-y-2">
<Label htmlFor="music-track-dtag" className="text-sm font-medium">
{t('D-Tag')}
</Label>
<Input
id="music-track-dtag"
value={musicTrackDTag}
onChange={(e) => setMusicTrackDTag(e.target.value)}
placeholder={t('e.g., my-song-slug')}
/>
<p className="text-xs text-muted-foreground">{t('articleDTagDefaultHint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="music-track-title" className="text-sm font-medium">
{t('Title')} <span className="text-destructive">*</span>
</Label>
<Input
id="music-track-title"
value={musicTrackTitle}
onChange={(e) => setMusicTrackTitle(e.target.value)}
placeholder={t('Track title')}
className={!musicTrackTitle.trim() ? 'border-destructive' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="music-track-artist" className="text-sm font-medium">
{t('Artist')}
</Label>
<Input
id="music-track-artist"
value={musicTrackArtist}
onChange={(e) => setMusicTrackArtist(e.target.value)}
placeholder={t('Artist name (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="music-track-audio-url" className="text-sm font-medium">
{t('Audio URL')} <span className="text-destructive">*</span>
</Label>
<Input
id="music-track-audio-url"
value={musicTrackAudioUrl}
onChange={(e) => setMusicTrackAudioUrl(e.target.value)}
placeholder={t('https://example.com/track.m4a')}
className={!musicTrackAudioUrl.trim() ? 'border-destructive' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="music-track-image-url" className="text-sm font-medium">
{t('Cover image URL')}
</Label>
<Input
id="music-track-image-url"
value={musicTrackImageUrl}
onChange={(e) => setMusicTrackImageUrl(e.target.value)}
placeholder={t('https://example.com/cover.png')}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="music-track-album" className="text-sm font-medium">
{t('Album')}
</Label>
<Input
id="music-track-album"
value={musicTrackAlbum}
onChange={(e) => setMusicTrackAlbum(e.target.value)}
placeholder={t('Album (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="music-track-duration" className="text-sm font-medium">
{t('Duration (seconds)')}
</Label>
<Input
id="music-track-duration"
type="number"
min={1}
value={musicTrackDuration}
onChange={(e) => setMusicTrackDuration(e.target.value)}
placeholder="245"
/>
</div>
<div className="space-y-2">
<Label htmlFor="music-track-format" className="text-sm font-medium">
{t('Format')}
</Label>
<Input
id="music-track-format"
value={musicTrackFormat}
onChange={(e) => setMusicTrackFormat(e.target.value)}
placeholder="mp3"
/>
</div>
<div className="space-y-2">
<Label htmlFor="music-track-language" className="text-sm font-medium">
{t('Language')}
</Label>
<Input
id="music-track-language"
value={musicTrackLanguage}
onChange={(e) => setMusicTrackLanguage(e.target.value)}
placeholder="de"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="music-track-genres" className="text-sm font-medium">
{t('Genres')}
</Label>
<Input
id="music-track-genres"
value={musicTrackGenres}
onChange={(e) => setMusicTrackGenres(e.target.value)}
placeholder={t('heimatsound, deutschland')}
/>
<p className="text-xs text-muted-foreground">
{t('Comma-separated genre tags (kind 36787 also adds t=music automatically)', {
defaultValue: 'Comma-separated genre tags (t=music is added automatically)'
})}
</p>
</div>
<p className="text-xs text-muted-foreground">
{t('Use the editor below for lyrics or notes (Markdown).', {
defaultValue: 'Use the editor below for lyrics or notes (Markdown).'
})}
</p>
</div>
)}
{/* Citation metadata fields */} {/* Citation metadata fields */}
{(isCitationInternal || {(isCitationInternal ||
isCitationExternal || isCitationExternal ||
@ -3077,6 +3379,7 @@ export default function PostContent({
pollCreateData={isPoll ? pollCreateData : undefined} pollCreateData={isPoll ? pollCreateData : undefined}
extraPreviewTags={mergedExtraPreviewTags} extraPreviewTags={mergedExtraPreviewTags}
articleMetadata={articlePreviewMetadata} articleMetadata={articlePreviewMetadata}
musicTrackMetadata={musicTrackPreviewMetadata}
addClientTag={addClientTag} addClientTag={addClientTag}
mediaImetaTags={mediaImetaTags} mediaImetaTags={mediaImetaTags}
mediaUrl={mediaUrl} mediaUrl={mediaUrl}
@ -3086,6 +3389,7 @@ export default function PostContent({
isWikiArticle ? FileText : isWikiArticle ? FileText :
isNostrSpecification ? FileText : isNostrSpecification ? FileText :
isPublicationContent ? Book : isPublicationContent ? Book :
isMusicTrack ? Music :
isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt ? Quote : isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt ? Quote :
isHighlight ? Highlighter : isHighlight ? Highlighter :
isPublicMessage ? MessageCircle : isPublicMessage ? MessageCircle :
@ -3098,6 +3402,7 @@ export default function PostContent({
isWikiArticle ? t('Wiki Article (AsciiDoc)') : isWikiArticle ? t('Wiki Article (AsciiDoc)') :
isNostrSpecification ? t('Nostr Specification') : isNostrSpecification ? t('Nostr Specification') :
isPublicationContent ? t('Publication Note') : isPublicationContent ? t('Publication Note') :
isMusicTrack ? t('Music Track', { defaultValue: 'Music Track' }) :
isCitationInternal ? t('Internal Citation') : isCitationInternal ? t('Internal Citation') :
isCitationExternal ? t('External Citation') : isCitationExternal ? t('External Citation') :
isCitationHardcopy ? t('Hardcopy Citation') : isCitationHardcopy ? t('Hardcopy Citation') :
@ -3173,6 +3478,18 @@ export default function PostContent({
</div> </div>
{isMediaNoteComposerMode && <Check className="h-4 w-4 shrink-0 text-primary" />} {isMediaNoteComposerMode && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={handleMusicTrackToggle} className="gap-3 py-2 cursor-pointer">
<Music className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium leading-none">{t('Music Track', { defaultValue: 'Music Track' })}</span>
<span className="text-xs text-muted-foreground mt-0.5">
{t('Publish audio with cover art and lyrics (kind 36787)', {
defaultValue: 'Publish audio with cover art and lyrics (kind 36787)'
})}
</span>
</div>
{isMusicTrack && <Check className="h-4 w-4 shrink-0 text-primary" />}
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={handleHighlightToggle} className="gap-3 py-2 cursor-pointer"> <DropdownMenuItem onClick={handleHighlightToggle} className="gap-3 py-2 cursor-pointer">
<Highlighter className="h-4 w-4 shrink-0 text-muted-foreground" /> <Highlighter className="h-4 w-4 shrink-0 text-muted-foreground" />

61
src/components/PostEditor/PostTextarea/Preview.tsx

@ -31,6 +31,7 @@ export default function Preview({
mediaImetaTags, mediaImetaTags,
mediaUrl, mediaUrl,
articleMetadata, articleMetadata,
musicTrackMetadata,
extraPreviewTags, extraPreviewTags,
addClientTag = true addClientTag = true
}: { }: {
@ -50,6 +51,18 @@ export default function Preview({
/** Kind 30817: each number becomes a `k` tag. */ /** Kind 30817: each number becomes a `k` tag. */
affectedKinds?: number[] affectedKinds?: number[]
} }
musicTrackMetadata?: {
dTag?: string
title?: string
audioUrl?: string
artist?: string
imageUrl?: string
album?: string
durationSec?: number
format?: string
language?: string
genres?: string[]
}
/** Merged into the fake event (e.g. kind 11 discussion title / topic tags). */ /** Merged into the fake event (e.g. kind 11 discussion title / topic tags). */
extraPreviewTags?: string[][] extraPreviewTags?: string[][]
/** When true (default), preview matches publish: Imwald `client` + attribution `alt` tags and badge. */ /** When true (default), preview matches publish: Imwald `client` + attribution `alt` tags and badge. */
@ -168,6 +181,44 @@ export default function Preview({
tags.push(...normalizedTopics.map((topic) => ['t', topic])) tags.push(...normalizedTopics.map((topic) => ['t', topic]))
} }
} }
if (musicTrackMetadata && kind === ExtendedKind.MUSIC_TRACK) {
if (musicTrackMetadata.dTag) {
tags.push(['d', musicTrackMetadata.dTag])
}
if (musicTrackMetadata.title) {
tags.push(['title', musicTrackMetadata.title])
}
if (musicTrackMetadata.audioUrl) {
tags.push(['url', musicTrackMetadata.audioUrl])
}
tags.push(['t', 'music'])
if (musicTrackMetadata.artist) {
tags.push(['artist', musicTrackMetadata.artist])
}
if (musicTrackMetadata.imageUrl) {
tags.push(['image', musicTrackMetadata.imageUrl])
}
if (musicTrackMetadata.album) {
tags.push(['album', musicTrackMetadata.album])
}
if (musicTrackMetadata.durationSec) {
tags.push(['duration', String(musicTrackMetadata.durationSec)])
}
if (musicTrackMetadata.format) {
tags.push(['format', musicTrackMetadata.format])
}
if (musicTrackMetadata.language) {
tags.push(['language', musicTrackMetadata.language])
}
if (musicTrackMetadata.genres?.length) {
for (const g of musicTrackMetadata.genres) {
const topic = normalizeTopic(g.trim())
if (topic && topic !== 'music') {
tags.push(['t', topic])
}
}
}
}
if (extraPreviewTags?.length) { if (extraPreviewTags?.length) {
tags.push(...extraPreviewTags) tags.push(...extraPreviewTags)
} }
@ -176,7 +227,7 @@ export default function Preview({
stripped.push(buildClientTag()) stripped.push(buildClientTag())
} }
return stripped return stripped
}, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, kind, extraPreviewTags, addClientTag]) }, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, musicTrackMetadata, kind, extraPreviewTags, addClientTag])
const fakeEvent = useMemo(() => { const fakeEvent = useMemo(() => {
// For voice comments, include the media URL in content if not already there // For voice comments, include the media URL in content if not already there
@ -277,6 +328,14 @@ export default function Preview({
) )
} }
if (kind === ExtendedKind.MUSIC_TRACK) {
return withClientBadge(
<Card className={cn('p-3', className, selectableClass)}>
<ContentPreview event={fakeEvent} />
</Card>
)
}
return withClientBadge( return withClientBadge(
<Card className={cn('p-3', className, selectableClass)}> <Card className={cn('p-3', className, selectableClass)}>
<Content event={fakeEvent} className="h-full" mustLoadMedia /> <Content event={fakeEvent} className="h-full" mustLoadMedia />

14
src/components/PostEditor/PostTextarea/index.tsx

@ -78,6 +78,18 @@ const PostTextarea = forwardRef<
topics?: string[] topics?: string[]
affectedKinds?: number[] affectedKinds?: number[]
} }
musicTrackMetadata?: {
dTag?: string
title?: string
audioUrl?: string
artist?: string
imageUrl?: string
album?: string
durationSec?: number
format?: string
language?: string
genres?: string[]
}
extraPreviewTags?: string[][] extraPreviewTags?: string[][]
addClientTag?: boolean addClientTag?: boolean
} }
@ -103,6 +115,7 @@ const PostTextarea = forwardRef<
mediaImetaTags, mediaImetaTags,
mediaUrl, mediaUrl,
articleMetadata, articleMetadata,
musicTrackMetadata,
extraPreviewTags, extraPreviewTags,
addClientTag = true addClientTag = true
}, },
@ -311,6 +324,7 @@ const PostTextarea = forwardRef<
mediaImetaTags={mediaImetaTags} mediaImetaTags={mediaImetaTags}
mediaUrl={mediaUrl} mediaUrl={mediaUrl}
articleMetadata={articleMetadata} articleMetadata={articleMetadata}
musicTrackMetadata={musicTrackMetadata}
extraPreviewTags={extraPreviewTags} extraPreviewTags={extraPreviewTags}
addClientTag={addClientTag} addClientTag={addClientTag}
/> />

69
src/lib/draft-event.ts

@ -2020,6 +2020,75 @@ export async function createVideoDraftEvent(
}) })
} }
export async function createMusicTrackDraftEvent(
content: string,
mentions: string[],
options: {
dTag: string
title: string
audioUrl: string
artist?: string
imageUrl?: string
album?: string
durationSec?: number
format?: string
language?: string
genres?: string[]
addClientTag?: boolean
isNsfw?: boolean
}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const hashtags = extractHashtags(transformedEmojisContent)
const tags: string[][] = []
tags.push(buildDTag(normalizeDTag(options.dTag)))
tags.push(buildTitleTag(options.title))
tags.push(['url', options.audioUrl.trim()])
tags.push(['t', 'music'])
if (options.artist?.trim()) {
tags.push(['artist', options.artist.trim()])
}
if (options.imageUrl?.trim()) {
tags.push(['image', options.imageUrl.trim()])
}
if (options.album?.trim()) {
tags.push(['album', options.album.trim()])
}
if (options.durationSec != null && options.durationSec > 0) {
tags.push(['duration', String(Math.floor(options.durationSec))])
}
if (options.format?.trim()) {
tags.push(['format', options.format.trim()])
}
if (options.language?.trim()) {
tags.push(['language', options.language.trim()])
}
const genreTags = new Set<string>()
for (const g of options.genres ?? []) {
const t = normalizeTopic(g.trim())
if (t && t !== 'music') genreTags.add(t)
}
for (const h of hashtags) {
if (h !== 'music') genreTags.add(h)
}
for (const g of genreTags) {
tags.push(['t', g])
}
tags.push(...emojiTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
return setDraftEventCache({
kind: ExtendedKind.MUSIC_TRACK,
content: transformedEmojisContent,
tags
})
}
// Article draft event functions // Article draft event functions
export async function createLongFormArticleDraftEvent( export async function createLongFormArticleDraftEvent(

Loading…
Cancel
Save