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:
+88
@@ -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("")
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+158
@@ -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") },
|
||||
)
|
||||
}
|
||||
}
|
||||
+94
@@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+73
@@ -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)
|
||||
}
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* 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.desktop.service.drafts
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.time.Instant
|
||||
|
||||
data class DraftMetadata(
|
||||
val title: String = "",
|
||||
val summary: String? = null,
|
||||
val image: String? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
val createdAt: String = Instant.now().toString(),
|
||||
val updatedAt: String = Instant.now().toString(),
|
||||
val published: Boolean = false,
|
||||
)
|
||||
|
||||
data class DraftEntry(
|
||||
val slug: String,
|
||||
val metadata: DraftMetadata,
|
||||
)
|
||||
|
||||
/**
|
||||
* Local draft storage for long-form articles.
|
||||
* Stores markdown content as .md files and metadata in index.json.
|
||||
* Uses atomic writes and restrictive file permissions.
|
||||
*/
|
||||
class DesktopDraftStore(
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val mapper = jacksonObjectMapper()
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val _drafts = MutableStateFlow<List<DraftEntry>>(emptyList())
|
||||
val drafts: StateFlow<List<DraftEntry>> = _drafts.asStateFlow()
|
||||
|
||||
private val draftsDir: File by lazy {
|
||||
val dir = File(System.getProperty("user.home"), ".amethyst/drafts")
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
setDirPermissions(dir)
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
private val indexFile: File get() = File(draftsDir, "index.json")
|
||||
|
||||
init {
|
||||
scope.launch(Dispatchers.IO) { loadIndex() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a slug to prevent path traversal and ensure safe filenames.
|
||||
*/
|
||||
private fun sanitizeSlug(slug: String): String {
|
||||
val sanitized =
|
||||
slug
|
||||
.replace("..", "")
|
||||
.replace("/", "")
|
||||
.replace("\\", "")
|
||||
.replace("\u0000", "")
|
||||
.trim()
|
||||
.lowercase()
|
||||
.replace(Regex("[^a-z0-9_-]"), "-")
|
||||
.replace(Regex("-+"), "-")
|
||||
.trimStart('-')
|
||||
.trimEnd('-')
|
||||
.take(128)
|
||||
|
||||
require(sanitized.isNotEmpty()) { "Slug cannot be empty after sanitization" }
|
||||
|
||||
// Validate canonical path stays within drafts dir
|
||||
val resolved = File(draftsDir, "$sanitized.md").canonicalPath
|
||||
require(resolved.startsWith(draftsDir.canonicalPath)) {
|
||||
"Slug resolves outside drafts directory"
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a slug from a title. Falls back to timestamp if title is blank.
|
||||
*/
|
||||
fun slugFromTitle(title: String): String {
|
||||
if (title.isBlank()) return "untitled-${Instant.now().epochSecond}"
|
||||
return sanitizeSlug(title)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves or updates a draft. Creates content file and updates index atomically.
|
||||
*/
|
||||
suspend fun saveDraft(
|
||||
slug: String,
|
||||
content: String,
|
||||
metadata: DraftMetadata,
|
||||
) {
|
||||
val safeSlug = sanitizeSlug(slug)
|
||||
|
||||
mutex.withLock {
|
||||
// Write content file atomically
|
||||
val contentFile = File(draftsDir, "$safeSlug.md")
|
||||
atomicWrite(contentFile, content)
|
||||
|
||||
// Update index
|
||||
val index = loadIndexMap().toMutableMap()
|
||||
index[safeSlug] = metadata.copy(updatedAt = Instant.now().toString())
|
||||
atomicWriteIndex(index)
|
||||
|
||||
// Refresh state
|
||||
_drafts.value =
|
||||
index.entries
|
||||
.map { DraftEntry(it.key, it.value) }
|
||||
.sortedByDescending { it.metadata.updatedAt }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a draft's content by slug.
|
||||
*/
|
||||
suspend fun loadContent(slug: String): String? {
|
||||
val safeSlug = sanitizeSlug(slug)
|
||||
val file = File(draftsDir, "$safeSlug.md")
|
||||
return if (file.exists()) file.readText() else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a draft's metadata by slug.
|
||||
*/
|
||||
suspend fun loadMetadata(slug: String): DraftMetadata? {
|
||||
val safeSlug = sanitizeSlug(slug)
|
||||
return mutex.withLock {
|
||||
loadIndexMap()[safeSlug]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a draft by slug.
|
||||
*/
|
||||
suspend fun deleteDraft(slug: String) {
|
||||
val safeSlug = sanitizeSlug(slug)
|
||||
mutex.withLock {
|
||||
File(draftsDir, "$safeSlug.md").delete()
|
||||
|
||||
val index = loadIndexMap().toMutableMap()
|
||||
index.remove(safeSlug)
|
||||
atomicWriteIndex(index)
|
||||
|
||||
_drafts.value =
|
||||
index.entries
|
||||
.map { DraftEntry(it.key, it.value) }
|
||||
.sortedByDescending { it.metadata.updatedAt }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a draft as published.
|
||||
*/
|
||||
suspend fun markPublished(slug: String) {
|
||||
val safeSlug = sanitizeSlug(slug)
|
||||
mutex.withLock {
|
||||
val index = loadIndexMap().toMutableMap()
|
||||
val existing = index[safeSlug] ?: return
|
||||
index[safeSlug] = existing.copy(published = true, updatedAt = Instant.now().toString())
|
||||
atomicWriteIndex(index)
|
||||
|
||||
_drafts.value =
|
||||
index.entries
|
||||
.map { DraftEntry(it.key, it.value) }
|
||||
.sortedByDescending { it.metadata.updatedAt }
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadIndexMap(): Map<String, DraftMetadata> {
|
||||
if (!indexFile.exists()) return emptyMap()
|
||||
return try {
|
||||
mapper.readValue(indexFile)
|
||||
} catch (e: Exception) {
|
||||
System.err.println("Failed to read drafts index: ${e.message}")
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadIndex() {
|
||||
mutex.withLock {
|
||||
_drafts.value =
|
||||
loadIndexMap()
|
||||
.entries
|
||||
.map { DraftEntry(it.key, it.value) }
|
||||
.sortedByDescending { it.metadata.updatedAt }
|
||||
}
|
||||
}
|
||||
|
||||
private fun atomicWrite(
|
||||
file: File,
|
||||
content: String,
|
||||
) {
|
||||
val tempFile = File(file.parentFile, "${file.name}.tmp")
|
||||
try {
|
||||
tempFile.writeText(content)
|
||||
setFilePermissions(tempFile)
|
||||
Files.move(
|
||||
tempFile.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
} finally {
|
||||
if (tempFile.exists()) tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun atomicWriteIndex(index: Map<String, DraftMetadata>) {
|
||||
val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(index)
|
||||
atomicWrite(indexFile, json)
|
||||
}
|
||||
|
||||
private fun setDirPermissions(dir: File) {
|
||||
try {
|
||||
Files.setPosixFilePermissions(
|
||||
dir.toPath(),
|
||||
setOf(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE,
|
||||
),
|
||||
)
|
||||
} catch (_: UnsupportedOperationException) {
|
||||
// Windows
|
||||
}
|
||||
}
|
||||
|
||||
private fun setFilePermissions(file: File) {
|
||||
try {
|
||||
Files.setPosixFilePermissions(
|
||||
file.toPath(),
|
||||
setOf(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
),
|
||||
)
|
||||
} catch (_: UnsupportedOperationException) {
|
||||
// Windows
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* 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.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.KeyEventType
|
||||
import androidx.compose.ui.input.key.isMetaPressed
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownToolbar
|
||||
import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel
|
||||
import com.vitorpamplona.amethyst.commons.compose.markdown.ArticleMediaRenderer
|
||||
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
|
||||
import com.vitorpamplona.amethyst.commons.model.nip23LongContent.LongFormPublishAction
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
|
||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DraftMetadata
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.Desktop
|
||||
import java.net.URI
|
||||
|
||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
|
||||
@Composable
|
||||
fun ArticleEditorScreen(
|
||||
draftSlug: String?,
|
||||
draftStore: DesktopDraftStore,
|
||||
account: AccountState.LoggedIn,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
onBack: () -> Unit,
|
||||
onPublished: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var title by remember { mutableStateOf("") }
|
||||
var summary by remember { mutableStateOf("") }
|
||||
var bannerUrl by remember { mutableStateOf("") }
|
||||
var tags by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var slug by remember { mutableStateOf(draftSlug ?: "") }
|
||||
var contentField by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var publishing by remember { mutableStateOf(false) }
|
||||
var saveMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Load existing draft
|
||||
LaunchedEffect(draftSlug) {
|
||||
if (draftSlug != null) {
|
||||
val meta = draftStore.loadMetadata(draftSlug)
|
||||
val body = draftStore.loadContent(draftSlug)
|
||||
if (meta != null) {
|
||||
title = meta.title
|
||||
summary = meta.summary ?: ""
|
||||
bannerUrl = meta.image ?: ""
|
||||
tags = meta.tags
|
||||
slug = draftSlug
|
||||
}
|
||||
if (body != null) {
|
||||
contentField = TextFieldValue(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-generate slug from title if creating a new draft
|
||||
LaunchedEffect(title) {
|
||||
if (draftSlug == null && title.isNotBlank()) {
|
||||
slug = draftStore.slugFromTitle(title)
|
||||
}
|
||||
}
|
||||
|
||||
val mediaRenderer =
|
||||
remember {
|
||||
object : ArticleMediaRenderer {
|
||||
@Composable
|
||||
override fun renderImage(
|
||||
url: String,
|
||||
alt: String?,
|
||||
) {
|
||||
// Simple text placeholder for editor preview
|
||||
Text(
|
||||
"[Image: ${alt ?: url}]",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLinkClick(url: String) {
|
||||
try {
|
||||
Desktop.getDesktop().browse(URI(url))
|
||||
} catch (_: Exception) {
|
||||
// Ignore unsupported or malformed URLs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveDraft() {
|
||||
if (slug.isBlank()) return
|
||||
scope.launch {
|
||||
draftStore.saveDraft(
|
||||
slug = slug,
|
||||
content = contentField.text,
|
||||
metadata =
|
||||
DraftMetadata(
|
||||
title = title,
|
||||
summary = summary.ifBlank { null },
|
||||
image = bannerUrl.ifBlank { null },
|
||||
tags = tags,
|
||||
),
|
||||
)
|
||||
saveMessage = "Saved"
|
||||
}
|
||||
}
|
||||
|
||||
fun publishArticle() {
|
||||
if (publishing) return
|
||||
publishing = true
|
||||
scope.launch {
|
||||
try {
|
||||
val event =
|
||||
LongFormPublishAction.publish(
|
||||
title = title,
|
||||
content = contentField.text,
|
||||
summary = summary.ifBlank { null },
|
||||
image = bannerUrl.ifBlank { null },
|
||||
tags = tags,
|
||||
dTag = slug.ifBlank { draftStore.slugFromTitle(title) },
|
||||
signer = account.signer,
|
||||
)
|
||||
relayManager.send(event)
|
||||
draftStore.markPublished(slug)
|
||||
onPublished()
|
||||
} catch (e: Exception) {
|
||||
saveMessage = "Publish failed: ${e.message}"
|
||||
} finally {
|
||||
publishing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.onPreviewKeyEvent { event ->
|
||||
// Ctrl/Cmd+S to save
|
||||
if (event.type == KeyEventType.KeyDown &&
|
||||
event.key == Key.S &&
|
||||
(if (isMacOS) event.isMetaPressed else event.isMetaPressed)
|
||||
) {
|
||||
saveDraft()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Top bar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = onBack) {
|
||||
Text("Back")
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
saveMessage?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = { saveDraft() }) {
|
||||
Text("Save")
|
||||
}
|
||||
Button(
|
||||
onClick = { publishArticle() },
|
||||
enabled = !publishing && title.isNotBlank() && contentField.text.isNotBlank(),
|
||||
) {
|
||||
Text(if (publishing) "Publishing..." else "Publish")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata panel (collapsible)
|
||||
MetadataPanel(
|
||||
title = title,
|
||||
onTitleChange = { title = it },
|
||||
summary = summary,
|
||||
onSummaryChange = { summary = it },
|
||||
bannerUrl = bannerUrl,
|
||||
onBannerUrlChange = { bannerUrl = it },
|
||||
tags = tags,
|
||||
onTagsChange = { tags = it },
|
||||
slug = slug,
|
||||
onSlugChange = { slug = it },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Markdown toolbar
|
||||
MarkdownToolbar(
|
||||
onInsert = { prefix, suffix ->
|
||||
val selection = contentField.selection
|
||||
val text = contentField.text
|
||||
val newText =
|
||||
text.substring(0, selection.start) +
|
||||
prefix +
|
||||
text.substring(selection.start, selection.end) +
|
||||
suffix +
|
||||
text.substring(selection.end)
|
||||
val newCursorPos = selection.start + prefix.length + (selection.end - selection.start)
|
||||
contentField =
|
||||
TextFieldValue(
|
||||
text = newText,
|
||||
selection =
|
||||
androidx.compose.ui.text
|
||||
.TextRange(newCursorPos),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
// Split pane: source left, preview right
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().weight(1f),
|
||||
) {
|
||||
// Source editor
|
||||
TextField(
|
||||
value = contentField,
|
||||
onValueChange = {
|
||||
contentField = it
|
||||
saveMessage = null
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(end = 4.dp),
|
||||
textStyle =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
placeholder = { Text("Write your article in markdown...") },
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// Preview
|
||||
SelectionContainer {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Column(modifier = Modifier.widthIn(max = 680.dp)) {
|
||||
if (contentField.text.isNotBlank()) {
|
||||
RenderMarkdown(
|
||||
content = contentField.text,
|
||||
mediaRenderer = mediaRenderer,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Preview will appear here...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
|
||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun DraftsScreen(
|
||||
draftStore: DesktopDraftStore,
|
||||
onOpenEditor: (slug: String?) -> Unit,
|
||||
) {
|
||||
val drafts by draftStore.drafts.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var deleteTarget by remember { mutableStateOf<DraftEntry?>(null) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Drafts",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Button(onClick = { onOpenEditor(null) }) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Text("New Draft", modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (drafts.isEmpty()) {
|
||||
Text(
|
||||
"No drafts yet. Click \"New Draft\" to start writing.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(drafts, key = { it.slug }) { entry ->
|
||||
DraftCard(
|
||||
entry = entry,
|
||||
onClick = { onOpenEditor(entry.slug) },
|
||||
onDelete = { deleteTarget = entry },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
deleteTarget?.let { entry ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleteTarget = null },
|
||||
title = { Text("Delete Draft") },
|
||||
text = {
|
||||
Text(
|
||||
"Delete \"${entry.metadata.title.ifBlank { entry.slug }}\"? This cannot be undone.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
scope.launch { draftStore.deleteDraft(entry.slug) }
|
||||
deleteTarget = null
|
||||
}) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { deleteTarget = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DraftCard(
|
||||
entry: DraftEntry,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = entry.metadata.title.ifBlank { "Untitled" },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = entry.metadata.updatedAt.take(10),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (entry.metadata.published) {
|
||||
Text(
|
||||
text = "Published",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete draft",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* 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.quartz.nip23LongContent
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
|
||||
import com.vitorpamplona.quartz.nip19Bech32.addressHints
|
||||
import com.vitorpamplona.quartz.nip19Bech32.addressIds
|
||||
import com.vitorpamplona.quartz.nip19Bech32.eventHints
|
||||
import com.vitorpamplona.quartz.nip19Bech32.eventIds
|
||||
import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints
|
||||
import com.vitorpamplona.quartz.nip19Bech32.pubKeys
|
||||
import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
|
||||
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
|
||||
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* NIP-23 Draft Long-form Content (kind 30024).
|
||||
* Same structure as LongTextNoteEvent (kind 30023) but for unpublished drafts.
|
||||
*/
|
||||
@Immutable
|
||||
class DraftLongTextNoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
AddressableEvent,
|
||||
EventHintProvider,
|
||||
PubKeyHintProvider,
|
||||
AddressHintProvider,
|
||||
PublishedAtProvider,
|
||||
RootScope,
|
||||
SearchableEvent {
|
||||
override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content
|
||||
|
||||
override fun eventHints(): List<EventIdHint> {
|
||||
val qHints = tags.mapNotNull(QTag::parseEventAsHint)
|
||||
val nip19Hints = citedNIP19().eventHints()
|
||||
return qHints + nip19Hints
|
||||
}
|
||||
|
||||
override fun linkedEventIds(): List<HexKey> {
|
||||
val qHints = tags.mapNotNull(QTag::parseEventId)
|
||||
val nip19Hints = citedNIP19().eventIds()
|
||||
return qHints + nip19Hints
|
||||
}
|
||||
|
||||
override fun addressHints(): List<AddressHint> {
|
||||
val qHints = tags.mapNotNull(QTag::parseAddressAsHint)
|
||||
val nip19Hints = citedNIP19().addressHints()
|
||||
return qHints + nip19Hints
|
||||
}
|
||||
|
||||
override fun linkedAddressIds(): List<String> {
|
||||
val qHints = tags.mapNotNull(QTag::parseAddressId)
|
||||
val nip19Hints = citedNIP19().addressIds()
|
||||
return qHints + nip19Hints
|
||||
}
|
||||
|
||||
override fun pubKeyHints(): List<PubKeyHint> {
|
||||
val pHints = tags.mapNotNull(PTag::parseAsHint)
|
||||
val nip19Hints = citedNIP19().pubKeyHints()
|
||||
return pHints + nip19Hints
|
||||
}
|
||||
|
||||
override fun linkedPubKeys(): List<HexKey> {
|
||||
val pHints = tags.mapNotNull(PTag::parseKey)
|
||||
val nip19Hints = citedNIP19().pubKeys()
|
||||
return pHints + nip19Hints
|
||||
}
|
||||
|
||||
override fun dTag() = tags.dTag()
|
||||
|
||||
override fun address() = Address(kind, pubKey, dTag())
|
||||
|
||||
override fun addressTag() = Address.assemble(kind, pubKey, dTag())
|
||||
|
||||
fun topics() = hashtags()
|
||||
|
||||
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
|
||||
|
||||
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
|
||||
|
||||
fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse)
|
||||
|
||||
override fun publishedAt(): Long? {
|
||||
val publishedAt = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
|
||||
if (publishedAt == null) return null
|
||||
return if (publishedAt <= createdAt) publishedAt else null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KIND = 30024
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun build(
|
||||
description: String,
|
||||
title: String,
|
||||
summary: String? = null,
|
||||
image: String? = null,
|
||||
publishedAt: Long? = null,
|
||||
dTag: String = Uuid.random().toString(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<DraftLongTextNoteEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, description, createdAt) {
|
||||
dTag(dTag)
|
||||
alt("Draft blog post: $title")
|
||||
|
||||
addUnique(TitleTag.assemble(title))
|
||||
summary?.let { addUnique(SummaryTag.assemble(it)) }
|
||||
image?.let { addUnique(ImageTag.assemble(it)) }
|
||||
publishedAt?.let { addUnique(PublishedAtTag.assemble(it)) }
|
||||
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.DraftLongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
@@ -272,6 +273,7 @@ class EventFactory {
|
||||
LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
DraftLongTextNoteEvent.KIND -> DraftLongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
MeetingRoomEvent.KIND -> MeetingRoomEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
MeetingSpaceEvent.KIND -> MeetingSpaceEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
Reference in New Issue
Block a user