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:
+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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user