feat(reads): Phase 2 — editor, drafts, publish, highlights

- Create DraftLongTextNoteEvent (kind 30024) in quartz with EventFactory registration
- Create LongFormPublishAction for signing + publishing kind 30023 events
- Create split-pane ArticleEditorScreen with live markdown preview
- Create MarkdownToolbar (bold, italic, heading, link, image, code, quote)
- Create MetadataPanel (title, summary, banner, tags, slug)
- Create DesktopDraftStore with JSON index + .md files, atomic writes, slug sanitization
- Create DraftsScreen with draft list, delete, new draft button
- Create HighlightCreator dialog for kind 9802 highlight creation
- Add Drafts nav rail entry and Editor/Drafts screen routing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-24 06:25:39 +02:00
parent 5ef36363cd
commit 4fcbffdb3b
9 changed files with 1376 additions and 0 deletions
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.compose.editor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Toolbar for inserting markdown formatting at cursor position.
* Each button calls [onInsert] with the prefix and suffix to wrap around selection.
*/
@Composable
fun MarkdownToolbar(
onInsert: (prefix: String, suffix: String) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
ToolbarButton(label = "B", fontWeight = FontWeight.Bold) {
onInsert("**", "**")
}
ToolbarButton(label = "I") {
onInsert("*", "*")
}
ToolbarButton(label = "H") {
onInsert("## ", "")
}
ToolbarButton(label = "[]") {
onInsert("[", "](url)")
}
ToolbarButton(label = "img") {
onInsert("![alt](", ")")
}
ToolbarButton(label = "<>") {
onInsert("```\n", "\n```")
}
ToolbarButton(label = ">") {
onInsert("> ", "")
}
}
}
@Composable
private fun ToolbarButton(
label: String,
fontWeight: FontWeight = FontWeight.Normal,
onClick: () -> Unit,
) {
FilledTonalIconButton(onClick = onClick) {
Text(
text = label,
fontWeight = fontWeight,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
@@ -0,0 +1,158 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.compose.editor
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.unit.dp
/**
* Form fields for article metadata: title, summary, banner, tags, slug.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MetadataPanel(
title: String,
onTitleChange: (String) -> Unit,
summary: String,
onSummaryChange: (String) -> Unit,
bannerUrl: String,
onBannerUrlChange: (String) -> Unit,
tags: List<String>,
onTagsChange: (List<String>) -> Unit,
slug: String,
onSlugChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var tagInput by remember { mutableStateOf("") }
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = title,
onValueChange = onTitleChange,
label = { Text("Title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = summary,
onValueChange = onSummaryChange,
label = { Text("Summary") },
maxLines = 3,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = bannerUrl,
onValueChange = onBannerUrlChange,
label = { Text("Banner Image URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Tags chip input
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = tagInput,
onValueChange = { tagInput = it },
label = { Text("Add tag (Enter to add)") },
singleLine = true,
modifier =
Modifier.weight(1f).onKeyEvent { event ->
if (event.key == Key.Enter && tagInput.isNotBlank()) {
val newTag = tagInput.trim().lowercase()
if (newTag !in tags) {
onTagsChange(tags + newTag)
}
tagInput = ""
true
} else {
false
}
},
)
}
if (tags.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
tags.forEach { tag ->
AssistChip(
onClick = { onTagsChange(tags - tag) },
label = { Text(tag) },
trailingIcon = {
Icon(
Icons.Default.Close,
contentDescription = "Remove $tag",
modifier = Modifier.size(16.dp),
)
},
)
}
}
}
}
OutlinedTextField(
value = slug,
onValueChange = onSlugChange,
label = { Text("Slug (d-tag)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("Used as the unique identifier for this article") },
)
}
}
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.compose.highlight
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
/**
* Dialog for creating a NIP-84 highlight from selected text.
* Shows the highlighted text (read-only) and a comment input field.
*/
@Composable
fun HighlightCreator(
highlightText: String,
onConfirm: (highlightText: String, comment: String) -> Unit,
onDismiss: () -> Unit,
) {
var comment by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create Highlight") },
text = {
Column {
Text(
text = "Selected text:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Text(
text = highlightText,
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 8.dp),
maxLines = 6,
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = comment,
onValueChange = { comment = it },
label = { Text("Comment (optional)") },
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(highlightText, comment) }) {
Text("Highlight")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.nip23LongContent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Shared action for publishing long-form content (NIP-23 kind 30023).
* Handles title, summary, image, tags, and d-tag for addressable events.
*/
object LongFormPublishAction {
/**
* Publishes a long-form text note (NIP-23 kind 30023).
*
* @param title The article title
* @param content The markdown body content
* @param summary Optional article summary
* @param image Optional banner image URL
* @param tags List of hashtag topics
* @param dTag Unique identifier for this addressable event (slug)
* @param signer The NostrSigner to sign the event
* @return Signed LongTextNoteEvent ready to broadcast
* @throws IllegalStateException if signer is not writeable
*/
suspend fun publish(
title: String,
content: String,
summary: String?,
image: String?,
tags: List<String>,
dTag: String,
signer: NostrSigner,
): LongTextNoteEvent {
if (!signer.isWriteable()) {
throw IllegalStateException("Cannot publish: signer is not writeable")
}
val template =
LongTextNoteEvent.build(
description = content,
title = title,
summary = summary,
image = image,
publishedAt = TimeUtils.now(),
dTag = dTag,
) {
tags.forEach { hashtag(it) }
}
return signer.sign(template)
}
}