mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add post preview with HTML rendering in WebView
This commit is contained in:
parent
74f42fd2f1
commit
6927259a41
7 changed files with 1119 additions and 152 deletions
|
|
@ -0,0 +1,205 @@
|
||||||
|
package com.swoosh.microblog.data
|
||||||
|
|
||||||
|
import com.swoosh.microblog.data.model.LinkPreview
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a responsive HTML preview from post content.
|
||||||
|
* Used to show "how it will look on the blog" before publishing.
|
||||||
|
*/
|
||||||
|
object PreviewHtmlBuilder {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a complete HTML document from post content, optional image URL,
|
||||||
|
* and optional link preview data. The output uses a Ghost-like responsive
|
||||||
|
* styling with dark mode support.
|
||||||
|
*/
|
||||||
|
fun build(
|
||||||
|
text: String,
|
||||||
|
imageUrl: String? = null,
|
||||||
|
linkPreview: LinkPreview? = null
|
||||||
|
): String {
|
||||||
|
val contentHtml = buildContentHtml(text, imageUrl, linkPreview)
|
||||||
|
return wrapInTemplate(contentHtml)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps existing HTML content (e.g. from the Ghost API html field) in
|
||||||
|
* the preview template. Use this for published posts that already have
|
||||||
|
* rendered HTML.
|
||||||
|
*/
|
||||||
|
fun wrapExistingHtml(html: String): String {
|
||||||
|
return wrapInTemplate(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildContentHtml(
|
||||||
|
text: String,
|
||||||
|
imageUrl: String?,
|
||||||
|
linkPreview: LinkPreview?
|
||||||
|
): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
|
||||||
|
// Convert text to HTML paragraphs
|
||||||
|
if (text.isNotBlank()) {
|
||||||
|
val paragraphs = text.split("\n\n")
|
||||||
|
for (paragraph in paragraphs) {
|
||||||
|
val trimmed = paragraph.trim()
|
||||||
|
if (trimmed.isNotEmpty()) {
|
||||||
|
val escaped = escapeHtml(trimmed)
|
||||||
|
// Convert single newlines within a paragraph to <br>
|
||||||
|
val withBreaks = escaped.replace("\n", "<br>")
|
||||||
|
sb.append("<p>$withBreaks</p>\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add feature image
|
||||||
|
if (!imageUrl.isNullOrBlank()) {
|
||||||
|
sb.append("<figure class=\"kg-card kg-image-card\">\n")
|
||||||
|
sb.append(" <img src=\"${escapeHtml(imageUrl)}\" alt=\"Post image\" />\n")
|
||||||
|
sb.append("</figure>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add bookmark card for link preview
|
||||||
|
if (linkPreview != null) {
|
||||||
|
sb.append(buildBookmarkCard(linkPreview))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildBookmarkCard(linkPreview: LinkPreview): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.append("<div class=\"bookmark-card\">\n")
|
||||||
|
|
||||||
|
if (!linkPreview.imageUrl.isNullOrBlank()) {
|
||||||
|
sb.append(" <img class=\"bookmark-image\" src=\"${escapeHtml(linkPreview.imageUrl)}\" alt=\"\" />\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append(" <div class=\"bookmark-content\">\n")
|
||||||
|
|
||||||
|
val title = linkPreview.title
|
||||||
|
if (!title.isNullOrBlank()) {
|
||||||
|
sb.append(" <div class=\"bookmark-title\">${escapeHtml(title)}</div>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
val description = linkPreview.description
|
||||||
|
if (!description.isNullOrBlank()) {
|
||||||
|
sb.append(" <div class=\"bookmark-description\">${escapeHtml(description)}</div>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append(" <div class=\"bookmark-url\">${escapeHtml(linkPreview.url)}</div>\n")
|
||||||
|
sb.append(" </div>\n")
|
||||||
|
sb.append("</div>\n")
|
||||||
|
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun escapeHtml(value: String): String {
|
||||||
|
return value
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun wrapInTemplate(content: String): String {
|
||||||
|
return """<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
padding: 20px 16px;
|
||||||
|
line-height: 1.7;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: #1a1a1a;
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 1.2em 0;
|
||||||
|
}
|
||||||
|
figure {
|
||||||
|
margin: 1.5em 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.bookmark-card {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.bookmark-card .bookmark-image {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.bookmark-content {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.bookmark-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.bookmark-description {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bookmark-url {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.bookmark-card {
|
||||||
|
border-color: #333;
|
||||||
|
background: #222244;
|
||||||
|
}
|
||||||
|
.bookmark-title {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.bookmark-description {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.bookmark-url {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
$content
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,10 +8,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.filled.Image
|
|
||||||
import androidx.compose.material.icons.filled.Link
|
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -23,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.swoosh.microblog.data.model.FeedPost
|
import com.swoosh.microblog.data.model.FeedPost
|
||||||
|
import com.swoosh.microblog.ui.preview.HtmlPreviewWebView
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
@ -32,6 +30,7 @@ import java.time.format.DateTimeFormatter
|
||||||
fun ComposerScreen(
|
fun ComposerScreen(
|
||||||
editPost: FeedPost? = null,
|
editPost: FeedPost? = null,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
onFullScreenPreview: ((String) -> Unit)? = null,
|
||||||
viewModel: ComposerViewModel = viewModel()
|
viewModel: ComposerViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
@ -70,6 +69,16 @@ fun ComposerScreen(
|
||||||
}) {
|
}) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
// Full-screen preview button (only in preview mode)
|
||||||
|
if (state.isPreviewMode && state.previewHtml.isNotEmpty()) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
onFullScreenPreview?.invoke(state.previewHtml)
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Fullscreen, "Full screen preview")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +87,76 @@ fun ComposerScreen(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
// Edit / Preview segmented button row
|
||||||
|
SingleChoiceSegmentedButtonRow(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
SegmentedButton(
|
||||||
|
selected = !state.isPreviewMode,
|
||||||
|
onClick = { viewModel.setPreviewMode(false) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
|
||||||
|
icon = {
|
||||||
|
SegmentedButtonDefaults.Icon(active = !state.isPreviewMode) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Edit,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(SegmentedButtonDefaults.IconSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Edit")
|
||||||
|
}
|
||||||
|
SegmentedButton(
|
||||||
|
selected = state.isPreviewMode,
|
||||||
|
onClick = { viewModel.setPreviewMode(true) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
|
||||||
|
icon = {
|
||||||
|
SegmentedButtonDefaults.Icon(active = state.isPreviewMode) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Visibility,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(SegmentedButtonDefaults.IconSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Preview")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isPreviewMode) {
|
||||||
|
// Preview mode: show rendered HTML
|
||||||
|
if (state.text.isBlank() && state.imageUri == null && state.linkPreview == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Nothing to preview yet.\nSwitch to Edit and write something.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HtmlPreviewWebView(
|
||||||
|
html = state.previewHtml,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Edit mode: original composer UI
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.weight(1f)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
) {
|
) {
|
||||||
|
|
@ -256,6 +335,8 @@ fun ComposerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Link dialog
|
// Link dialog
|
||||||
if (showLinkDialog) {
|
if (showLinkDialog) {
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,13 @@ import android.net.Uri
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.swoosh.microblog.data.MobiledocBuilder
|
import com.swoosh.microblog.data.MobiledocBuilder
|
||||||
|
import com.swoosh.microblog.data.PreviewHtmlBuilder
|
||||||
import com.swoosh.microblog.data.model.*
|
import com.swoosh.microblog.data.model.*
|
||||||
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
import com.swoosh.microblog.worker.PostUploadWorker
|
import com.swoosh.microblog.worker.PostUploadWorker
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -27,6 +30,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
private var editingGhostId: String? = null
|
private var editingGhostId: String? = null
|
||||||
private var editingUpdatedAt: String? = null
|
private var editingUpdatedAt: String? = null
|
||||||
|
|
||||||
|
private var previewDebounceJob: Job? = null
|
||||||
|
|
||||||
fun loadForEdit(post: FeedPost) {
|
fun loadForEdit(post: FeedPost) {
|
||||||
editingLocalId = post.localId
|
editingLocalId = post.localId
|
||||||
editingGhostId = post.ghostId
|
editingGhostId = post.ghostId
|
||||||
|
|
@ -48,10 +53,16 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
|
|
||||||
fun updateText(text: String) {
|
fun updateText(text: String) {
|
||||||
_uiState.update { it.copy(text = text) }
|
_uiState.update { it.copy(text = text) }
|
||||||
|
if (_uiState.value.isPreviewMode) {
|
||||||
|
debouncedPreviewUpdate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setImage(uri: Uri?) {
|
fun setImage(uri: Uri?) {
|
||||||
_uiState.update { it.copy(imageUri = uri) }
|
_uiState.update { it.copy(imageUri = uri) }
|
||||||
|
if (_uiState.value.isPreviewMode) {
|
||||||
|
debouncedPreviewUpdate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchLinkPreview(url: String) {
|
fun fetchLinkPreview(url: String) {
|
||||||
|
|
@ -60,17 +71,63 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
_uiState.update { it.copy(isLoadingLink = true) }
|
_uiState.update { it.copy(isLoadingLink = true) }
|
||||||
val preview = OpenGraphFetcher.fetch(url)
|
val preview = OpenGraphFetcher.fetch(url)
|
||||||
_uiState.update { it.copy(linkPreview = preview, isLoadingLink = false) }
|
_uiState.update { it.copy(linkPreview = preview, isLoadingLink = false) }
|
||||||
|
if (_uiState.value.isPreviewMode) {
|
||||||
|
generatePreviewHtml()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeLinkPreview() {
|
fun removeLinkPreview() {
|
||||||
_uiState.update { it.copy(linkPreview = null) }
|
_uiState.update { it.copy(linkPreview = null) }
|
||||||
|
if (_uiState.value.isPreviewMode) {
|
||||||
|
debouncedPreviewUpdate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setScheduledDate(dateTimeIso: String?) {
|
fun setScheduledDate(dateTimeIso: String?) {
|
||||||
_uiState.update { it.copy(scheduledAt = dateTimeIso) }
|
_uiState.update { it.copy(scheduledAt = dateTimeIso) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle between edit and preview modes.
|
||||||
|
*/
|
||||||
|
fun togglePreviewMode() {
|
||||||
|
val currentlyInPreview = _uiState.value.isPreviewMode
|
||||||
|
if (!currentlyInPreview) {
|
||||||
|
// Switching to preview: generate HTML immediately
|
||||||
|
generatePreviewHtml()
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isPreviewMode = !currentlyInPreview) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a specific mode directly.
|
||||||
|
*/
|
||||||
|
fun setPreviewMode(preview: Boolean) {
|
||||||
|
if (preview && !_uiState.value.isPreviewMode) {
|
||||||
|
generatePreviewHtml()
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isPreviewMode = preview) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun debouncedPreviewUpdate() {
|
||||||
|
previewDebounceJob?.cancel()
|
||||||
|
previewDebounceJob = viewModelScope.launch {
|
||||||
|
delay(PREVIEW_DEBOUNCE_MS)
|
||||||
|
generatePreviewHtml()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun generatePreviewHtml() {
|
||||||
|
val state = _uiState.value
|
||||||
|
val html = PreviewHtmlBuilder.build(
|
||||||
|
text = state.text,
|
||||||
|
imageUrl = state.imageUri?.toString(),
|
||||||
|
linkPreview = state.linkPreview
|
||||||
|
)
|
||||||
|
_uiState.update { it.copy(previewHtml = html) }
|
||||||
|
}
|
||||||
|
|
||||||
fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH)
|
fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH)
|
||||||
|
|
||||||
fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE)
|
fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE)
|
||||||
|
|
@ -175,11 +232,16 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reset() {
|
fun reset() {
|
||||||
|
previewDebounceJob?.cancel()
|
||||||
editingLocalId = null
|
editingLocalId = null
|
||||||
editingGhostId = null
|
editingGhostId = null
|
||||||
editingUpdatedAt = null
|
editingUpdatedAt = null
|
||||||
_uiState.value = ComposerUiState()
|
_uiState.value = ComposerUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREVIEW_DEBOUNCE_MS = 500L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ComposerUiState(
|
data class ComposerUiState(
|
||||||
|
|
@ -191,5 +253,7 @@ data class ComposerUiState(
|
||||||
val isSubmitting: Boolean = false,
|
val isSubmitting: Boolean = false,
|
||||||
val isSuccess: Boolean = false,
|
val isSuccess: Boolean = false,
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
val error: String? = null
|
val error: String? = null,
|
||||||
|
val isPreviewMode: Boolean = false,
|
||||||
|
val previewHtml: String = ""
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -14,7 +15,9 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import com.swoosh.microblog.data.PreviewHtmlBuilder
|
||||||
import com.swoosh.microblog.data.model.FeedPost
|
import com.swoosh.microblog.data.model.FeedPost
|
||||||
|
import com.swoosh.microblog.data.model.LinkPreview
|
||||||
import com.swoosh.microblog.ui.feed.StatusBadge
|
import com.swoosh.microblog.ui.feed.StatusBadge
|
||||||
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
||||||
|
|
||||||
|
|
@ -24,7 +27,8 @@ fun DetailScreen(
|
||||||
post: FeedPost,
|
post: FeedPost,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEdit: (FeedPost) -> Unit,
|
onEdit: (FeedPost) -> Unit,
|
||||||
onDelete: (FeedPost) -> Unit
|
onDelete: (FeedPost) -> Unit,
|
||||||
|
onPreview: ((String) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
|
@ -38,6 +42,31 @@ fun DetailScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
// Preview button - show rendered HTML
|
||||||
|
IconButton(onClick = {
|
||||||
|
val html = if (!post.htmlContent.isNullOrBlank()) {
|
||||||
|
// Published post: use the HTML from the Ghost API
|
||||||
|
PreviewHtmlBuilder.wrapExistingHtml(post.htmlContent)
|
||||||
|
} else {
|
||||||
|
// Local/draft post: generate preview from content
|
||||||
|
val linkPreview = if (post.linkUrl != null) {
|
||||||
|
LinkPreview(
|
||||||
|
url = post.linkUrl,
|
||||||
|
title = post.linkTitle,
|
||||||
|
description = post.linkDescription,
|
||||||
|
imageUrl = post.linkImageUrl
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
PreviewHtmlBuilder.build(
|
||||||
|
text = post.textContent,
|
||||||
|
imageUrl = post.imageUrl,
|
||||||
|
linkPreview = linkPreview
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPreview?.invoke(html)
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Visibility, "View preview")
|
||||||
|
}
|
||||||
IconButton(onClick = { onEdit(post) }) {
|
IconButton(onClick = { onEdit(post) }) {
|
||||||
Icon(Icons.Default.Edit, "Edit")
|
Icon(Icons.Default.Edit, "Edit")
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +161,7 @@ fun DetailScreen(
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Divider()
|
HorizontalDivider()
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
if (post.createdAt != null) {
|
if (post.createdAt != null) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.swoosh.microblog.ui.composer.ComposerViewModel
|
||||||
import com.swoosh.microblog.ui.detail.DetailScreen
|
import com.swoosh.microblog.ui.detail.DetailScreen
|
||||||
import com.swoosh.microblog.ui.feed.FeedScreen
|
import com.swoosh.microblog.ui.feed.FeedScreen
|
||||||
import com.swoosh.microblog.ui.feed.FeedViewModel
|
import com.swoosh.microblog.ui.feed.FeedViewModel
|
||||||
|
import com.swoosh.microblog.ui.preview.PreviewScreen
|
||||||
import com.swoosh.microblog.ui.settings.SettingsScreen
|
import com.swoosh.microblog.ui.settings.SettingsScreen
|
||||||
import com.swoosh.microblog.ui.setup.SetupScreen
|
import com.swoosh.microblog.ui.setup.SetupScreen
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ object Routes {
|
||||||
const val COMPOSER = "composer"
|
const val COMPOSER = "composer"
|
||||||
const val DETAIL = "detail"
|
const val DETAIL = "detail"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
|
const val PREVIEW = "preview"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -30,6 +32,7 @@ fun SwooshNavGraph(
|
||||||
// Shared state for passing posts between screens
|
// Shared state for passing posts between screens
|
||||||
var selectedPost by remember { mutableStateOf<FeedPost?>(null) }
|
var selectedPost by remember { mutableStateOf<FeedPost?>(null) }
|
||||||
var editPost by remember { mutableStateOf<FeedPost?>(null) }
|
var editPost by remember { mutableStateOf<FeedPost?>(null) }
|
||||||
|
var previewHtml by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val feedViewModel: FeedViewModel = viewModel()
|
val feedViewModel: FeedViewModel = viewModel()
|
||||||
|
|
||||||
|
|
@ -67,6 +70,10 @@ fun SwooshNavGraph(
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
feedViewModel.refresh()
|
feedViewModel.refresh()
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onFullScreenPreview = { html ->
|
||||||
|
previewHtml = html
|
||||||
|
navController.navigate(Routes.PREVIEW)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +91,10 @@ fun SwooshNavGraph(
|
||||||
onDelete = { p ->
|
onDelete = { p ->
|
||||||
feedViewModel.deletePost(p)
|
feedViewModel.deletePost(p)
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onPreview = { html ->
|
||||||
|
previewHtml = html
|
||||||
|
navController.navigate(Routes.PREVIEW)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -99,5 +110,12 @@ fun SwooshNavGraph(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(Routes.PREVIEW) {
|
||||||
|
PreviewScreen(
|
||||||
|
html = previewHtml,
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
package com.swoosh.microblog.ui.preview
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen preview of rendered HTML content.
|
||||||
|
* Used from both the Composer (for draft preview) and the Detail screen
|
||||||
|
* (for viewing published post HTML).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PreviewScreen(
|
||||||
|
html: String,
|
||||||
|
title: String = "Preview",
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
HtmlPreviewWebView(
|
||||||
|
html = html,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable that embeds a WebView to render HTML content.
|
||||||
|
* JavaScript is disabled for security. Viewport meta tag support is enabled.
|
||||||
|
*/
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
fun HtmlPreviewWebView(
|
||||||
|
html: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
WebView(context).apply {
|
||||||
|
settings.javaScriptEnabled = false
|
||||||
|
settings.useWideViewPort = true
|
||||||
|
settings.loadWithOverviewMode = true
|
||||||
|
settings.setSupportZoom(false)
|
||||||
|
settings.builtInZoomControls = false
|
||||||
|
settings.displayZoomControls = false
|
||||||
|
|
||||||
|
// Prevent WebView from navigating away
|
||||||
|
setWebViewClient(android.webkit.WebViewClient())
|
||||||
|
|
||||||
|
loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { webView ->
|
||||||
|
webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
|
||||||
|
},
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,492 @@
|
||||||
|
package com.swoosh.microblog.data
|
||||||
|
|
||||||
|
import com.swoosh.microblog.data.model.LinkPreview
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PreviewHtmlBuilderTest {
|
||||||
|
|
||||||
|
// --- Basic HTML structure ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build produces valid HTML document with DOCTYPE`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Hello world")
|
||||||
|
assertTrue(result.startsWith("<!DOCTYPE html>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build contains viewport meta tag`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Hello")
|
||||||
|
assertTrue(result.contains("<meta name=\"viewport\""))
|
||||||
|
assertTrue(result.contains("width=device-width"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build contains charset meta tag`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Hello")
|
||||||
|
assertTrue(result.contains("<meta charset=\"UTF-8\">"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build wraps content in html and body tags`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Hello")
|
||||||
|
assertTrue(result.contains("<html>"))
|
||||||
|
assertTrue(result.contains("</html>"))
|
||||||
|
assertTrue(result.contains("<body>"))
|
||||||
|
assertTrue(result.contains("</body>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build includes style block`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Hello")
|
||||||
|
assertTrue(result.contains("<style>"))
|
||||||
|
assertTrue(result.contains("</style>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dark mode CSS ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build contains dark mode media query`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Hello")
|
||||||
|
assertTrue(result.contains("prefers-color-scheme: dark"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dark mode sets dark background color`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Hello")
|
||||||
|
assertTrue(
|
||||||
|
"Dark mode should set a dark background",
|
||||||
|
result.contains("background: #1a1a2e")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dark mode sets light text color`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Hello")
|
||||||
|
assertTrue(
|
||||||
|
"Dark mode should set light text",
|
||||||
|
result.contains("color: #e0e0e0")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Plain text rendering ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with plain text wraps in paragraph tag`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Hello world")
|
||||||
|
assertTrue(result.contains("<p>Hello world</p>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with multiline text creates multiple paragraphs`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("First paragraph\n\nSecond paragraph")
|
||||||
|
assertTrue(result.contains("<p>First paragraph</p>"))
|
||||||
|
assertTrue(result.contains("<p>Second paragraph</p>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with single newlines converts to br tags`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Line one\nLine two")
|
||||||
|
assertTrue(result.contains("Line one<br>Line two"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build preserves paragraph separation with double newlines`() {
|
||||||
|
val html = PreviewHtmlBuilder.build("Para 1\n\nPara 2\n\nPara 3")
|
||||||
|
// Should have 3 paragraph tags
|
||||||
|
val paragraphCount = "<p>".toRegex().findAll(html).count()
|
||||||
|
assertEquals(3, paragraphCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Empty content ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with empty text produces valid HTML`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("")
|
||||||
|
assertTrue(result.contains("<!DOCTYPE html>"))
|
||||||
|
assertTrue(result.contains("<body>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with blank text does not produce paragraph tags`() {
|
||||||
|
val result = PreviewHtmlBuilder.build(" ")
|
||||||
|
assertFalse(result.contains("<p>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with only whitespace produces valid document`() {
|
||||||
|
val result = PreviewHtmlBuilder.build(" \n\n \n ")
|
||||||
|
assertTrue(result.startsWith("<!DOCTYPE html>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Image rendering ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image includes img tag`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Text", imageUrl = "https://example.com/photo.jpg")
|
||||||
|
assertTrue(result.contains("<img"))
|
||||||
|
assertTrue(result.contains("https://example.com/photo.jpg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image wraps in figure tag`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Text", imageUrl = "https://example.com/photo.jpg")
|
||||||
|
assertTrue(result.contains("<figure"))
|
||||||
|
assertTrue(result.contains("</figure>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with image includes alt text`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Text", imageUrl = "https://example.com/photo.jpg")
|
||||||
|
assertTrue(result.contains("alt=\"Post image\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with null image does not include img tag`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Text", imageUrl = null)
|
||||||
|
assertFalse(result.contains("<img"))
|
||||||
|
assertFalse(result.contains("<figure"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with empty image does not include img tag`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Text", imageUrl = "")
|
||||||
|
assertFalse(result.contains("<img"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `image appears after text paragraphs`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Some text", imageUrl = "https://example.com/img.jpg")
|
||||||
|
val textIndex = result.indexOf("<p>Some text</p>")
|
||||||
|
val imgIndex = result.indexOf("<img")
|
||||||
|
assertTrue("Image should appear after text", imgIndex > textIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Link preview / bookmark card ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with link preview includes bookmark card`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "Example Site",
|
||||||
|
description = "A great site",
|
||||||
|
imageUrl = null
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
|
||||||
|
assertTrue(result.contains("bookmark-card"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with link preview includes title`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "Example Title",
|
||||||
|
description = null,
|
||||||
|
imageUrl = null
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
|
||||||
|
assertTrue(result.contains("Example Title"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with link preview includes description`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "Title",
|
||||||
|
description = "A description of the page",
|
||||||
|
imageUrl = null
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
|
||||||
|
assertTrue(result.contains("A description of the page"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with link preview includes URL`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com/article",
|
||||||
|
title = "Title",
|
||||||
|
description = null,
|
||||||
|
imageUrl = null
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
|
||||||
|
assertTrue(result.contains("https://example.com/article"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with link preview and image includes bookmark image`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "Title",
|
||||||
|
description = null,
|
||||||
|
imageUrl = "https://example.com/og-image.jpg"
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
|
||||||
|
assertTrue(result.contains("bookmark-image"))
|
||||||
|
assertTrue(result.contains("https://example.com/og-image.jpg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with link preview without image does not include bookmark-image in content`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "Title",
|
||||||
|
description = "Desc",
|
||||||
|
imageUrl = null
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.buildContentHtml("Text", null, preview)
|
||||||
|
assertFalse(result.contains("bookmark-image"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with null link preview does not include bookmark card in content`() {
|
||||||
|
val result = PreviewHtmlBuilder.buildContentHtml("Text", null, null)
|
||||||
|
assertFalse(result.contains("bookmark-card"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `bookmark card appears after text and image in content`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "Title",
|
||||||
|
description = null,
|
||||||
|
imageUrl = null
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.buildContentHtml(
|
||||||
|
"Some text",
|
||||||
|
"https://example.com/img.jpg",
|
||||||
|
preview
|
||||||
|
)
|
||||||
|
val textIndex = result.indexOf("<p>Some text</p>")
|
||||||
|
val imgIndex = result.indexOf("<figure")
|
||||||
|
val bookmarkIndex = result.indexOf("bookmark-card")
|
||||||
|
assertTrue("Text should come first", textIndex < imgIndex)
|
||||||
|
assertTrue("Image should come before bookmark", imgIndex < bookmarkIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTML escaping ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `escapeHtml escapes ampersand`() {
|
||||||
|
assertEquals("&", PreviewHtmlBuilder.escapeHtml("&"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `escapeHtml escapes less-than`() {
|
||||||
|
assertEquals("<", PreviewHtmlBuilder.escapeHtml("<"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `escapeHtml escapes greater-than`() {
|
||||||
|
assertEquals(">", PreviewHtmlBuilder.escapeHtml(">"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `escapeHtml escapes double quotes`() {
|
||||||
|
assertEquals(""", PreviewHtmlBuilder.escapeHtml("\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `escapeHtml escapes single quotes`() {
|
||||||
|
assertEquals("'", PreviewHtmlBuilder.escapeHtml("'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `escapeHtml handles combined special characters`() {
|
||||||
|
assertEquals(
|
||||||
|
"<script>alert('xss')</script>",
|
||||||
|
PreviewHtmlBuilder.escapeHtml("<script>alert('xss')</script>")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `escapeHtml leaves normal text unchanged`() {
|
||||||
|
assertEquals("Hello world", PreviewHtmlBuilder.escapeHtml("Hello world"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `escapeHtml handles empty string`() {
|
||||||
|
assertEquals("", PreviewHtmlBuilder.escapeHtml(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build escapes HTML in text content`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("<script>alert('xss')</script>")
|
||||||
|
assertFalse("Should not contain unescaped script tag", result.contains("<script>alert"))
|
||||||
|
assertTrue(result.contains("<script>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build escapes HTML in link preview title`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "<b>Bold</b>",
|
||||||
|
description = null,
|
||||||
|
imageUrl = null
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.build("Text", linkPreview = preview)
|
||||||
|
assertTrue(result.contains("<b>Bold</b>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- wrapExistingHtml ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `wrapExistingHtml produces valid HTML document`() {
|
||||||
|
val result = PreviewHtmlBuilder.wrapExistingHtml("<p>Hello</p>")
|
||||||
|
assertTrue(result.startsWith("<!DOCTYPE html>"))
|
||||||
|
assertTrue(result.contains("<p>Hello</p>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `wrapExistingHtml includes same styling as build`() {
|
||||||
|
val fromBuild = PreviewHtmlBuilder.build("Hello")
|
||||||
|
val fromWrap = PreviewHtmlBuilder.wrapExistingHtml("<p>Hello</p>")
|
||||||
|
|
||||||
|
// Both should contain the same CSS
|
||||||
|
assertTrue(fromBuild.contains("bookmark-card"))
|
||||||
|
assertTrue(fromWrap.contains("bookmark-card"))
|
||||||
|
assertTrue(fromBuild.contains("prefers-color-scheme: dark"))
|
||||||
|
assertTrue(fromWrap.contains("prefers-color-scheme: dark"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `wrapExistingHtml preserves raw HTML content`() {
|
||||||
|
val html = "<h1>Title</h1><p>Paragraph with <strong>bold</strong> text.</p>"
|
||||||
|
val result = PreviewHtmlBuilder.wrapExistingHtml(html)
|
||||||
|
assertTrue(result.contains(html))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `wrapExistingHtml handles empty HTML`() {
|
||||||
|
val result = PreviewHtmlBuilder.wrapExistingHtml("")
|
||||||
|
assertTrue(result.startsWith("<!DOCTYPE html>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildContentHtml (internal) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildContentHtml with all content types produces all sections`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "Title",
|
||||||
|
description = "Desc",
|
||||||
|
imageUrl = "https://example.com/image.jpg"
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.buildContentHtml(
|
||||||
|
text = "Hello world",
|
||||||
|
imageUrl = "https://example.com/photo.jpg",
|
||||||
|
linkPreview = preview
|
||||||
|
)
|
||||||
|
assertTrue("Should contain paragraph", result.contains("<p>Hello world</p>"))
|
||||||
|
assertTrue("Should contain feature image", result.contains("photo.jpg"))
|
||||||
|
assertTrue("Should contain bookmark card", result.contains("bookmark-card"))
|
||||||
|
assertTrue("Should contain bookmark image", result.contains("bookmark-image"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildContentHtml with no content produces empty string`() {
|
||||||
|
val result = PreviewHtmlBuilder.buildContentHtml(
|
||||||
|
text = "",
|
||||||
|
imageUrl = null,
|
||||||
|
linkPreview = null
|
||||||
|
)
|
||||||
|
assertEquals("", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildBookmarkCard (internal) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildBookmarkCard includes all fields when present`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "Test Title",
|
||||||
|
description = "Test Description",
|
||||||
|
imageUrl = "https://example.com/thumb.jpg"
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
|
||||||
|
assertTrue(result.contains("bookmark-card"))
|
||||||
|
assertTrue(result.contains("bookmark-image"))
|
||||||
|
assertTrue(result.contains("bookmark-title"))
|
||||||
|
assertTrue(result.contains("Test Title"))
|
||||||
|
assertTrue(result.contains("bookmark-description"))
|
||||||
|
assertTrue(result.contains("Test Description"))
|
||||||
|
assertTrue(result.contains("bookmark-url"))
|
||||||
|
assertTrue(result.contains("https://example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildBookmarkCard without image omits image tag`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "Title",
|
||||||
|
description = null,
|
||||||
|
imageUrl = null
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
|
||||||
|
assertFalse(result.contains("bookmark-image"))
|
||||||
|
assertFalse(result.contains("<img"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildBookmarkCard without title omits title div`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com",
|
||||||
|
title = null,
|
||||||
|
description = "Desc",
|
||||||
|
imageUrl = null
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
|
||||||
|
assertFalse(result.contains("bookmark-title"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildBookmarkCard without description omits description div`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com",
|
||||||
|
title = "Title",
|
||||||
|
description = null,
|
||||||
|
imageUrl = null
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
|
||||||
|
assertFalse(result.contains("bookmark-description"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildBookmarkCard always includes URL`() {
|
||||||
|
val preview = LinkPreview(
|
||||||
|
url = "https://example.com/article",
|
||||||
|
title = null,
|
||||||
|
description = null,
|
||||||
|
imageUrl = null
|
||||||
|
)
|
||||||
|
val result = PreviewHtmlBuilder.buildBookmarkCard(preview)
|
||||||
|
assertTrue(result.contains("bookmark-url"))
|
||||||
|
assertTrue(result.contains("https://example.com/article"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CSS styling ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `template includes responsive image styling`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Test")
|
||||||
|
assertTrue(result.contains("max-width: 100%"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `template includes responsive body max-width`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Test")
|
||||||
|
assertTrue(result.contains("max-width: 600px"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `template includes system font stack`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Test")
|
||||||
|
assertTrue(result.contains("-apple-system"))
|
||||||
|
assertTrue(result.contains("sans-serif"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `template includes border-radius for bookmark card`() {
|
||||||
|
val result = PreviewHtmlBuilder.build("Test")
|
||||||
|
assertTrue(result.contains("border-radius: 8px"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue