29 changed files with 3027 additions and 39 deletions
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
|
||||
// Handles tab switching for the left article list sidebar, matching the right sidebar logic
|
||||
export default class extends Controller { |
||||
static targets = ['tab', 'panel']; |
||||
|
||||
switch(event) { |
||||
const panel = event.currentTarget.dataset.panel; |
||||
this.tabTargets.forEach(tab => { |
||||
tab.classList.toggle('is-active', tab.dataset.panel === panel); |
||||
}); |
||||
this.panelTargets.forEach(panelEl => { |
||||
panelEl.classList.toggle('is-hidden', panelEl.dataset.panel !== panel); |
||||
}); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
|
||||
export default class extends Controller { |
||||
static targets = ['jsonTextarea', 'status', 'dirtyHint']; |
||||
|
||||
connect() { |
||||
console.log('JSON panel controller connected'); |
||||
this.isDirty = false; |
||||
|
||||
// Load initial JSON from the Nostr publish controller
|
||||
this.loadInitialJson(); |
||||
} |
||||
|
||||
loadInitialJson() { |
||||
// Wait a bit for the Nostr publish controller to initialize
|
||||
setTimeout(() => { |
||||
const nostrController = this.getNostrPublishController(); |
||||
if (nostrController && nostrController.hasJsonTextareaTarget) { |
||||
const json = nostrController.jsonTextareaTarget.value; |
||||
if (json && this.hasJsonTextareaTarget) { |
||||
this.jsonTextareaTarget.value = json; |
||||
this.formatJson(); |
||||
} |
||||
} |
||||
}, 500); |
||||
} |
||||
|
||||
regenerateJson() { |
||||
const nostrController = this.getNostrPublishController(); |
||||
if (nostrController && typeof nostrController.regenerateJsonPreview === 'function') { |
||||
nostrController.regenerateJsonPreview(); |
||||
|
||||
// Copy the regenerated JSON to our textarea
|
||||
setTimeout(() => { |
||||
if (nostrController.hasJsonTextareaTarget && this.hasJsonTextareaTarget) { |
||||
this.jsonTextareaTarget.value = nostrController.jsonTextareaTarget.value; |
||||
this.formatJson(); |
||||
this.isDirty = false; |
||||
this.updateDirtyHint(); |
||||
this.showStatus('JSON rebuilt from form', 'success'); |
||||
} |
||||
}, 100); |
||||
} |
||||
} |
||||
|
||||
onJsonInput(event) { |
||||
this.isDirty = true; |
||||
this.updateDirtyHint(); |
||||
this.validateJson(); |
||||
|
||||
// Sync to the hidden Nostr publish textarea
|
||||
const nostrController = this.getNostrPublishController(); |
||||
if (nostrController && nostrController.hasJsonTextareaTarget) { |
||||
nostrController.jsonTextareaTarget.value = event.target.value; |
||||
} |
||||
} |
||||
|
||||
validateJson() { |
||||
if (!this.hasJsonTextareaTarget) return; |
||||
|
||||
try { |
||||
const json = JSON.parse(this.jsonTextareaTarget.value); |
||||
const required = ['kind', 'created_at', 'tags', 'content', 'pubkey']; |
||||
const missing = required.filter(field => !(field in json)); |
||||
|
||||
if (missing.length > 0) { |
||||
this.showStatus(`Missing: ${missing.join(', ')}`, 'warning'); |
||||
} else { |
||||
this.showStatus('Valid JSON', 'success'); |
||||
} |
||||
} catch (e) { |
||||
this.showStatus('Invalid JSON', 'error'); |
||||
} |
||||
} |
||||
|
||||
formatJson() { |
||||
if (!this.hasJsonTextareaTarget) return; |
||||
|
||||
try { |
||||
const json = JSON.parse(this.jsonTextareaTarget.value); |
||||
this.jsonTextareaTarget.value = JSON.stringify(json, null, 2); |
||||
this.showStatus('Formatted', 'success'); |
||||
} catch (e) { |
||||
// Silently fail if JSON is invalid
|
||||
} |
||||
} |
||||
|
||||
showStatus(message, type = 'info') { |
||||
if (!this.hasStatusTarget) return; |
||||
|
||||
this.statusTarget.textContent = message; |
||||
this.statusTarget.className = `json-status json-status--${type}`; |
||||
|
||||
setTimeout(() => { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.textContent = ''; |
||||
this.statusTarget.className = 'json-status'; |
||||
} |
||||
}, 3000); |
||||
} |
||||
|
||||
updateDirtyHint() { |
||||
if (this.hasDirtyHintTarget) { |
||||
this.dirtyHintTarget.style.display = this.isDirty ? 'inline' : 'none'; |
||||
} |
||||
} |
||||
|
||||
getNostrPublishController() { |
||||
const element = document.querySelector('[data-controller*="nostr--nostr-publish"]'); |
||||
if (!element) return null; |
||||
|
||||
return this.application.getControllerForElementAndIdentifier( |
||||
element, |
||||
'nostr--nostr-publish' |
||||
); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,211 @@
@@ -0,0 +1,211 @@
|
||||
// assets/controllers/editor/layout_controller.js
|
||||
import {Controller} from '@hotwired/stimulus'; |
||||
|
||||
export default class extends Controller { |
||||
static targets = [ |
||||
'modeTab', 'editPane', 'markdownPane', 'previewPane', |
||||
'previewBody', 'previewTitle', |
||||
'previewSummary', 'previewImage', 'previewImagePlaceholder', 'previewAuthor', 'previewDate', |
||||
'markdownEditor', 'markdownTitle', 'markdownCode', 'status' |
||||
]; |
||||
|
||||
connect() { |
||||
console.log('Editor layout controller connected'); |
||||
this.autoSaveTimer = null; |
||||
|
||||
// Live preview for summary and image fields
|
||||
const summaryInput = this.element.querySelector('textarea[name*="[summary]"], textarea[name="editor[summary]"]'); |
||||
const imageInput = this.element.querySelector('input[name*="[image]"], input[name="editor[image]"]'); |
||||
if (summaryInput) { |
||||
summaryInput.addEventListener('input', () => this.updatePreview()); |
||||
summaryInput.addEventListener('change', () => this.updatePreview()); |
||||
} |
||||
if (imageInput) { |
||||
imageInput.addEventListener('input', () => this.updatePreview()); |
||||
imageInput.addEventListener('change', () => this.updatePreview()); |
||||
} |
||||
} |
||||
|
||||
switchMode(event) { |
||||
const mode = event.currentTarget.dataset.mode; |
||||
|
||||
// Update tab states
|
||||
this.modeTabTargets.forEach(tab => { |
||||
tab.classList.toggle('is-active', tab.dataset.mode === mode); |
||||
}); |
||||
|
||||
// Toggle panes - hide all, then show the selected one
|
||||
this.editPaneTarget.classList.toggle('is-hidden', mode !== 'edit'); |
||||
this.markdownPaneTarget.classList.toggle('is-hidden', mode !== 'markdown'); |
||||
this.previewPaneTarget.classList.toggle('is-hidden', mode !== 'preview'); |
||||
|
||||
// Update content when switching modes
|
||||
if (mode === 'markdown') { |
||||
this.updateMarkdown(); |
||||
} else if (mode === 'preview') { |
||||
this.updatePreview(); |
||||
} |
||||
} |
||||
|
||||
updateMarkdown() { |
||||
// Get title from form
|
||||
const titleInput = this.element.querySelector('input[name*="[title]"]'); |
||||
if (titleInput && this.hasMarkdownTitleTarget) { |
||||
this.markdownTitleTarget.value = titleInput.value || ''; |
||||
} |
||||
|
||||
// Get markdown from Quill controller
|
||||
const markdownInput = this.element.querySelector('input[name="editor[content_md]"]'); |
||||
const markdown = markdownInput ? markdownInput.value || '' : ''; |
||||
|
||||
// Set code block content and highlight
|
||||
if (this.hasMarkdownCodeTarget) { |
||||
this.markdownCodeTarget.textContent = markdown; |
||||
if (window.Prism && Prism.highlightElement) { |
||||
Prism.highlightElement(this.markdownCodeTarget); |
||||
} |
||||
} |
||||
} |
||||
|
||||
async updatePreview() { |
||||
if (!this.hasPreviewBodyTarget) return; |
||||
|
||||
// Get title from form
|
||||
const titleInput = this.element.querySelector('input[name*="[title]"], input[name="editor[title]"]'); |
||||
const summaryInput = this.element.querySelector('textarea[name*="[summary]"], textarea[name="editor[summary]"]'); |
||||
const imageInput = this.element.querySelector('input[name*="[image]"], input[name="editor[image]"]'); |
||||
const markdownInput = this.element.querySelector('input[name="editor[content_md]"]'); |
||||
const authorInput = this.element.querySelector('input[name*="[author]"]'); |
||||
const dateInput = this.element.querySelector('input[name*="[publishedAt]"]') || this.element.querySelector('input[name*="[createdAt]"]'); |
||||
|
||||
// Title
|
||||
const title = titleInput ? titleInput.value.trim() : ''; |
||||
if (this.hasPreviewTitleTarget) { |
||||
this.previewTitleTarget.textContent = title || 'Article title'; |
||||
} |
||||
|
||||
// Author (placeholder logic)
|
||||
if (this.hasPreviewAuthorTarget) { |
||||
let author = authorInput ? authorInput.value.trim() : ''; |
||||
this.previewAuthorTarget.textContent = author || 'Author'; |
||||
} |
||||
|
||||
// Date (placeholder logic)
|
||||
if (this.hasPreviewDateTarget) { |
||||
const now = new Date(); |
||||
this.previewDateTarget.textContent = now.toLocaleDateString(undefined, { |
||||
year: 'numeric', |
||||
month: 'long', |
||||
day: 'numeric' |
||||
}); |
||||
} |
||||
|
||||
// Summary (always use form value)
|
||||
const summary = summaryInput ? summaryInput.value.trim() : ''; |
||||
if (this.hasPreviewSummaryTarget) { |
||||
this.previewSummaryTarget.textContent = summary || 'No summary provided. This is where your article summary will appear.'; |
||||
this.previewSummaryTarget.classList.toggle('placeholder', !summary); |
||||
} |
||||
|
||||
// Image (always use form value)
|
||||
const imageUrl = imageInput ? imageInput.value.trim() : ''; |
||||
if (this.hasPreviewImageTarget && this.hasPreviewImagePlaceholderTarget) { |
||||
if (imageUrl) { |
||||
this.previewImageTarget.src = imageUrl; |
||||
this.previewImageTarget.style.display = ''; |
||||
this.previewImagePlaceholderTarget.style.display = 'none'; |
||||
} else { |
||||
this.previewImageTarget.src = ''; |
||||
this.previewImageTarget.style.display = 'none'; |
||||
this.previewImagePlaceholderTarget.style.display = ''; |
||||
} |
||||
} |
||||
|
||||
// Body (markdown to HTML via backend)
|
||||
let html = '<p><em>Loading preview...</em></p>'; |
||||
this.previewBodyTarget.innerHTML = html; |
||||
if (markdownInput) { |
||||
try { |
||||
const response = await fetch('/editor/markdown/preview', { |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
'X-Requested-With': 'XMLHttpRequest', |
||||
}, |
||||
body: JSON.stringify({ markdown: markdownInput.value || '' }) |
||||
}); |
||||
if (response.ok) { |
||||
const data = await response.json(); |
||||
html = data.html || '<p><em>No content yet. Start writing your article!</em></p>'; |
||||
} else { |
||||
html = '<p><em>Failed to load preview.</em></p>'; |
||||
} |
||||
} catch (e) { |
||||
html = '<p><em>Error loading preview.</em></p>'; |
||||
} |
||||
this.previewBodyTarget.innerHTML = html; |
||||
} else { |
||||
this.previewBodyTarget.innerHTML = '<p><em>No content yet. Start writing your article!</em></p>'; |
||||
} |
||||
} |
||||
|
||||
saveDraft() { |
||||
console.log('Saving draft...'); |
||||
|
||||
// Mark as draft - set checkbox to true
|
||||
const draftCheckbox = this.element.querySelector('input[name*="[isDraft]"]'); |
||||
if (draftCheckbox) { |
||||
draftCheckbox.checked = true; |
||||
} |
||||
|
||||
// Submit the form
|
||||
const form = this.element.querySelector('form'); |
||||
if (form) { |
||||
this.updateStatus('Saving draft...'); |
||||
form.requestSubmit(); |
||||
} |
||||
} |
||||
|
||||
publish() { |
||||
console.log('Publishing article...'); |
||||
|
||||
// Mark as NOT draft - set checkbox to false
|
||||
const draftCheckbox = this.element.querySelector('input[name*="[isDraft]"]'); |
||||
if (draftCheckbox) { |
||||
draftCheckbox.checked = false; |
||||
} |
||||
|
||||
// Find the Nostr publish controller and trigger publish
|
||||
const nostrController = this.application.getControllerForElementAndIdentifier( |
||||
this.element.querySelector('[data-controller*="nostr--nostr-publish"]'), |
||||
'nostr--nostr-publish' |
||||
); |
||||
|
||||
if (nostrController) { |
||||
nostrController.publish(); |
||||
} else { |
||||
console.error('Nostr publish controller not found'); |
||||
alert('Could not find publishing controller. Please try again.'); |
||||
} |
||||
} |
||||
|
||||
|
||||
updateStatus(message) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.textContent = message; |
||||
|
||||
// Clear status after 3 seconds
|
||||
setTimeout(() => { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.textContent = ''; |
||||
} |
||||
}, 3000); |
||||
} |
||||
} |
||||
|
||||
disconnect() { |
||||
if (this.autoSaveTimer) { |
||||
clearTimeout(this.autoSaveTimer); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
|
||||
export default class extends Controller { |
||||
static targets = ['tab', 'panel']; |
||||
|
||||
switch(event) { |
||||
const panelName = event.currentTarget.dataset.panel; |
||||
|
||||
// Update tab states
|
||||
this.tabTargets.forEach(tab => { |
||||
tab.classList.toggle( |
||||
'is-active', |
||||
tab.dataset.panel === panelName |
||||
); |
||||
}); |
||||
|
||||
// Update panel visibility
|
||||
this.panelTargets.forEach(panel => { |
||||
const isActive = panel.dataset.panel === panelName; |
||||
panel.classList.toggle('is-hidden', !isActive); |
||||
}); |
||||
} |
||||
} |
||||
@ -0,0 +1,782 @@
@@ -0,0 +1,782 @@
|
||||
@import "../vendor/prismjs/themes/prism.min.css"; /* Editor IDE-like layout styles */ |
||||
|
||||
/* Main container - takes full viewport */ |
||||
main[data-controller="editor--layout"] { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
z-index: 100; |
||||
background: var(--background, #ffffff); |
||||
margin-top: 60px; |
||||
padding: 0; |
||||
} |
||||
|
||||
/* Header - fixed at top */ |
||||
.editor-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
padding: var(--spacing-2); |
||||
min-height: 60px; |
||||
flex-shrink: 0; |
||||
border-bottom: 1px solid var(--color-border); |
||||
} |
||||
|
||||
.editor-header-left { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
.editor-title { |
||||
font-size: 1rem; |
||||
font-weight: 500; |
||||
color: var(--text-primary, #111827); |
||||
} |
||||
|
||||
.editor-header-right { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.75rem; |
||||
} |
||||
|
||||
.editor-status { |
||||
font-size: 0.875rem; |
||||
padding-right: 0.5rem; |
||||
} |
||||
|
||||
/* Main content area - fills remaining space */ |
||||
.editor-main { |
||||
flex: 1; |
||||
display: grid; |
||||
grid-template-columns: 260px minmax(0, 2.5fr) minmax(280px, 1fr); |
||||
min-height: 0; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
/* Center editor area */ |
||||
.editor-center { |
||||
display: flex; |
||||
flex-direction: column; |
||||
border-right: 1px solid var(--border-color, #e5e7eb); |
||||
min-height: 0; |
||||
background: var(--background, #ffffff); |
||||
} |
||||
|
||||
.editor-center-tabs { |
||||
display: flex; |
||||
border-bottom: 1px solid var(--border-color, #e5e7eb); |
||||
background: var(--surface, #f9fafb); |
||||
} |
||||
|
||||
.editor-tab { |
||||
padding: 0.625rem 1rem; |
||||
border: none; |
||||
background: transparent; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
font-weight: 400; |
||||
color: var(--text-secondary, #6b7280); |
||||
transition: all 0.2s ease; |
||||
} |
||||
|
||||
.editor-tab:hover { |
||||
background: var(--hover, #f3f4f6); |
||||
} |
||||
|
||||
.editor-tab.is-active { |
||||
border-bottom: 2px solid var(--primary, #2563eb); |
||||
font-weight: 600; |
||||
color: var(--text-primary, #111827); |
||||
background: var(--background, #ffffff); |
||||
} |
||||
|
||||
.editor-center-content { |
||||
flex: 1; |
||||
min-height: 0; |
||||
overflow: hidden; |
||||
display: flex; |
||||
position: relative; |
||||
} |
||||
|
||||
.editor-center-content > form { |
||||
flex: 1; |
||||
display: flex; |
||||
min-height: 0; |
||||
margin: 0; |
||||
} |
||||
|
||||
.editor-pane { |
||||
flex: 1; |
||||
overflow: hidden; |
||||
padding: 1rem; |
||||
min-height: 0; |
||||
} |
||||
|
||||
.editor-pane.is-hidden { |
||||
display: none; |
||||
} |
||||
|
||||
/* Markdown pane */ |
||||
.editor-pane--markdown { |
||||
display: flex; |
||||
flex-direction: column; |
||||
padding: 1rem; |
||||
overflow: hidden; |
||||
height: 100%; |
||||
} |
||||
|
||||
.markdown-editor-wrapper { |
||||
flex: 1; |
||||
display: flex; |
||||
flex-direction: column; |
||||
min-height: 0; |
||||
} |
||||
|
||||
.editor-pane--markdown .editor-title-input { |
||||
margin-bottom: 1rem; |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
.markdown-editor { |
||||
flex: 1; |
||||
width: 100%; |
||||
min-height: 300px; |
||||
padding: 1rem; |
||||
line-height: 1.6; |
||||
resize: none; |
||||
overflow-y: auto; |
||||
overflow-x: hidden; |
||||
word-break: break-word; |
||||
white-space: pre-wrap; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
/* Editor specific styles */ |
||||
.editor-pane--edit { |
||||
display: flex; |
||||
flex-direction: column; |
||||
min-height: 0; |
||||
height: 100%; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
.editor-title-input { |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.editor-title-field { |
||||
font-size: 1.5rem; |
||||
font-weight: 600; |
||||
border: none; |
||||
border-bottom: 1px solid var(--border-color, #e5e7eb); |
||||
border-radius: 0; |
||||
padding: 0.5rem 0; |
||||
} |
||||
|
||||
.editor-title-field:focus { |
||||
outline: none; |
||||
border-bottom-color: var(--primary, #2563eb); |
||||
box-shadow: none; |
||||
} |
||||
|
||||
/* Quill editor in pane */ |
||||
.editor-pane--edit .quill { |
||||
flex: 1; |
||||
display: flex; |
||||
flex-direction: column; |
||||
min-height: 0; |
||||
} |
||||
|
||||
.editor-pane--edit .ql-toolbar { |
||||
flex-shrink: 0; |
||||
border-left: none; |
||||
border-right: none; |
||||
border-top: none; |
||||
} |
||||
|
||||
.editor-pane--edit .ql-container { |
||||
flex: 1; |
||||
overflow-y: auto; |
||||
overflow-x: hidden; |
||||
border-left: none; |
||||
border-right: none; |
||||
border-bottom: none; |
||||
display: flex; |
||||
flex-direction: column; |
||||
min-height: 0; |
||||
} |
||||
|
||||
.editor-pane--edit #editor { |
||||
flex: 1; |
||||
overflow-y: auto; |
||||
min-height: 300px; |
||||
} |
||||
|
||||
/* Preview pane */ |
||||
.editor-pane--preview { |
||||
padding: 0; |
||||
margin: 0 !important; |
||||
overflow-y: auto; |
||||
background-color: var(--color-bg-light); |
||||
} |
||||
|
||||
.preview-container { |
||||
max-width: 720px; |
||||
margin: 0 auto; |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: stretch; |
||||
gap: 1.5rem; |
||||
padding: 0 var(--spacing-2); |
||||
} |
||||
|
||||
.preview-image-wrap { |
||||
width: 100%; |
||||
min-height: 180px; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
background: var(--surface, #f3f4f6); |
||||
overflow: hidden; |
||||
position: relative; |
||||
} |
||||
|
||||
.preview-image { |
||||
max-width: 100%; |
||||
display: block; |
||||
margin: 0 auto; |
||||
object-fit: cover; |
||||
} |
||||
|
||||
.preview-image-placeholder { |
||||
width: 100%; |
||||
min-height: 180px; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
color: #bbb; |
||||
font-size: 1.1rem; |
||||
font-style: italic; |
||||
} |
||||
|
||||
.preview-summary.placeholder { |
||||
color: #bbb; |
||||
font-style: italic; |
||||
} |
||||
|
||||
#editor-preview { |
||||
line-height: 1.7; |
||||
color: var(--text-primary, #374151); |
||||
font-size: 1.08rem; |
||||
margin-top: 1.5rem; |
||||
} |
||||
|
||||
#editor-preview img { |
||||
max-width: 100%; |
||||
height: auto; |
||||
border-radius: 0.5rem; |
||||
margin: 1rem 0; |
||||
} |
||||
|
||||
/* Sidebar - fixed, with scrollable panels */ |
||||
.editor-sidebar { |
||||
position: static; |
||||
display: flex; |
||||
flex-direction: column; |
||||
min-width: 280px; |
||||
max-width: 400px; |
||||
background: var(--surface, #f9fafb); |
||||
overflow: hidden; |
||||
margin-top: 0; |
||||
padding: 0; |
||||
max-height: unset; |
||||
} |
||||
|
||||
.editor-sidebar-tabs { |
||||
display: flex; |
||||
flex-direction: column; |
||||
flex-shrink: 0; |
||||
border-bottom: 1px solid var(--border-color, #e5e7eb); |
||||
background: var(--surface-dark, #f3f4f6); |
||||
} |
||||
|
||||
.editor-sidebar-tab { |
||||
flex: 1; |
||||
min-width: 80px; |
||||
padding: 0.625rem 0.5rem; |
||||
font-size: 0.75rem; |
||||
border: none; |
||||
background: transparent; |
||||
cursor: pointer; |
||||
color: var(--text-secondary, #6b7280); |
||||
transition: all 0.2s ease; |
||||
text-align: center; |
||||
} |
||||
|
||||
.editor-sidebar-tab:hover { |
||||
background: var(--hover, #e5e7eb); |
||||
} |
||||
|
||||
.editor-sidebar-tab.is-active { |
||||
background: var(--surface, #f9fafb); |
||||
font-weight: 600; |
||||
color: var(--text-primary, #111827); |
||||
} |
||||
|
||||
.editor-sidebar-panels { |
||||
flex: 1; |
||||
overflow: auto; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.editor-panel.is-hidden { |
||||
display: none; |
||||
} |
||||
|
||||
/* Panel sections */ |
||||
.panel-section { |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
.panel-section h3 { |
||||
font-size: 1rem; |
||||
font-weight: 600; |
||||
margin-bottom: 0.5rem; |
||||
color: var(--text-primary, #111827); |
||||
} |
||||
|
||||
.panel-section h4 { |
||||
font-size: 0.875rem; |
||||
font-weight: 600; |
||||
margin-bottom: 0.5rem; |
||||
margin-top: 0.75rem; |
||||
color: var(--text-primary, #111827); |
||||
} |
||||
|
||||
.panel-help { |
||||
font-size: 0.75rem; |
||||
color: var(--text-secondary, #6b7280); |
||||
margin-bottom: 1rem; |
||||
line-height: 1.4; |
||||
} |
||||
|
||||
.panel-section .form-control, |
||||
.panel-section .form-select { |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
.panel-section .form-control-sm { |
||||
font-size: 0.8125rem; |
||||
} |
||||
|
||||
.panel-subsection { |
||||
margin-top: 1.5rem; |
||||
padding-top: 1rem; |
||||
border-top: 1px solid var(--border-color, #e5e7eb); |
||||
} |
||||
|
||||
/* Info groups for publishing panel */ |
||||
.info-group { |
||||
margin-bottom: 0.75rem; |
||||
} |
||||
|
||||
.info-group label { |
||||
display: block; |
||||
font-size: 0.75rem; |
||||
font-weight: 600; |
||||
color: var(--text-secondary, #6b7280); |
||||
margin-bottom: 0.25rem; |
||||
text-transform: uppercase; |
||||
letter-spacing: 0.025em; |
||||
} |
||||
|
||||
.info-group .info-value { |
||||
font-size: 0.875rem; |
||||
color: var(--text-primary, #111827); |
||||
} |
||||
|
||||
.info-group code { |
||||
font-size: 0.8125rem; |
||||
padding: 0.125rem 0.25rem; |
||||
background: var(--code-bg, #f3f4f6); |
||||
border-radius: 0.25rem; |
||||
} |
||||
|
||||
/* Mobile action buttons - hidden on desktop */ |
||||
.editor-mobile-actions { |
||||
display: none; |
||||
} |
||||
|
||||
/* Article list sidebar */ |
||||
.editor-articlelist-sidebar { |
||||
width: 260px; |
||||
min-width: 200px; |
||||
max-width: 320px; |
||||
background: var(--surface, #f9fafb); |
||||
border-right: 1px solid var(--border-color, #e5e7eb); |
||||
display: flex; |
||||
flex-direction: column; |
||||
padding: 0; |
||||
overflow-y: auto; |
||||
height: 100%; |
||||
max-height: 100%; |
||||
margin: 0; |
||||
position: static; |
||||
} |
||||
|
||||
.articlelist-header { |
||||
font-size: 1.1rem; |
||||
font-weight: 600; |
||||
padding: 1rem 1.25rem 0.5rem 1.25rem; |
||||
color: var(--text-primary, #111827); |
||||
border-bottom: 1px solid var(--border-color, #e5e7eb); |
||||
background: var(--surface-dark, #f3f4f6); |
||||
} |
||||
|
||||
.articlelist-content { |
||||
flex: 1; |
||||
overflow-y: auto; |
||||
font-size: 80%; |
||||
} |
||||
|
||||
.articlelist-placeholder { |
||||
color: #bbb; |
||||
font-style: italic; |
||||
padding: 1.5rem 0; |
||||
text-align: center; |
||||
} |
||||
|
||||
/* Filesystem-style reading list folders */ |
||||
.readinglist-fs { |
||||
margin: 0; |
||||
padding: 0; |
||||
list-style: none; |
||||
} |
||||
.readinglist-folder { |
||||
margin-bottom: 0.5rem; |
||||
padding-left: 0.25rem; |
||||
} |
||||
.readinglist-toggle { |
||||
background: none; |
||||
border: none; |
||||
color: var(--text-primary, #111827); |
||||
font-weight: 600; |
||||
font-size: 1rem; |
||||
cursor: pointer; |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5em; |
||||
padding: 0.25em 0; |
||||
width: 100%; |
||||
text-align: left; |
||||
} |
||||
.readinglist-folder details { |
||||
margin-bottom: 0.5rem; |
||||
padding-left: 0.25rem; |
||||
} |
||||
.readinglist-folder summary { |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: start; |
||||
font-weight: 600; |
||||
font-size: 1rem; |
||||
outline: none; |
||||
list-style: none; |
||||
background: none; |
||||
border: none; |
||||
padding: 0.25em 0; |
||||
} |
||||
.readinglist-summary { |
||||
color: #888; |
||||
font-weight: 400; |
||||
} |
||||
.readinglist-folder .readinglist-articles { |
||||
margin-top: 0.25em; |
||||
padding-left: 0.5em; |
||||
} |
||||
.article-icon { |
||||
display: inline-flex; |
||||
align-items: center; |
||||
margin-right: 0.5em; |
||||
font-size: 0.95em; |
||||
vertical-align: middle; |
||||
} |
||||
.article-kind { |
||||
font-size: 0.85em; |
||||
font-weight: 700; |
||||
margin-left: 0.1em; |
||||
margin-right: 0.2em; |
||||
} |
||||
.readinglist-article { |
||||
margin-bottom: var(--spacing-2); |
||||
font-size: 0.97em; |
||||
margin-left: var(--spacing-2); |
||||
} |
||||
.readinglist-empty { |
||||
color: #bbb; |
||||
font-style: italic; |
||||
margin-left: 1.5em; |
||||
} |
||||
|
||||
/* Responsive adjustments */ |
||||
@media (max-width: 1024px) { |
||||
.editor-main { |
||||
grid-template-columns: 180px minmax(0, 2fr) minmax(260px, 1fr); |
||||
} |
||||
.editor-articlelist-sidebar { |
||||
width: 180px; |
||||
min-width: 120px; |
||||
font-size: 0.95rem; |
||||
} |
||||
} |
||||
|
||||
@media (max-width: 768px) { |
||||
/* Disable fixed positioning on mobile */ |
||||
main[data-controller="editor--layout"] { |
||||
position: static; |
||||
height: auto; |
||||
min-height: 100vh; |
||||
} |
||||
|
||||
.editor-header { |
||||
position: static; |
||||
} |
||||
|
||||
.editor-main { |
||||
grid-template-columns: 1fr; |
||||
height: auto; |
||||
min-height: calc(100vh - 60px); |
||||
} |
||||
|
||||
.editor-center { |
||||
overflow: visible; |
||||
} |
||||
|
||||
.editor-center-content { |
||||
overflow: visible; |
||||
} |
||||
|
||||
.editor-pane { |
||||
overflow: visible; |
||||
} |
||||
|
||||
.editor-sidebar { |
||||
display: none !important; |
||||
} |
||||
|
||||
.editor-articlelist-sidebar { |
||||
display: none !important; |
||||
} |
||||
|
||||
/* Hide the mode tabs on mobile */ |
||||
.editor-center-tabs { |
||||
display: none !important; |
||||
} |
||||
|
||||
/* Force edit pane to always show, hide preview */ |
||||
.editor-pane--edit { |
||||
display: flex !important; |
||||
} |
||||
|
||||
.editor-pane--markdown, |
||||
.editor-pane--preview { |
||||
display: none !important; |
||||
} |
||||
|
||||
/* Adjust header for mobile */ |
||||
.editor-header { |
||||
padding: 0.75rem; |
||||
min-height: auto; |
||||
} |
||||
|
||||
.editor-header-left { |
||||
flex: 1; |
||||
} |
||||
|
||||
.editor-title { |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
/* Hide header action buttons on mobile */ |
||||
.editor-header-right { |
||||
display: none !important; |
||||
} |
||||
|
||||
/* Show mobile action buttons at bottom */ |
||||
.editor-mobile-actions { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.75rem; |
||||
padding: 1rem; |
||||
background: var(--surface, #f9fafb); |
||||
border-top: 1px solid var(--border-color, #e5e7eb); |
||||
margin-top: auto; |
||||
} |
||||
|
||||
.editor-mobile-actions .btn { |
||||
width: 100%; |
||||
justify-content: center; |
||||
font-size: 1rem; |
||||
padding: 0.875rem 1rem; |
||||
} |
||||
|
||||
/* Alternative: buttons at bottom of form */ |
||||
.editor-center { |
||||
display: flex; |
||||
flex-direction: column; |
||||
height: auto; |
||||
} |
||||
|
||||
.editor-center-content { |
||||
flex: 1; |
||||
display: flex; |
||||
flex-direction: column; |
||||
min-height: 0; |
||||
} |
||||
|
||||
.editor-center-content > form { |
||||
flex: 1; |
||||
display: flex; |
||||
flex-direction: column; |
||||
min-height: 0; |
||||
} |
||||
|
||||
.editor-pane--edit { |
||||
flex: 1; |
||||
display: flex; |
||||
flex-direction: column; |
||||
min-height: 0; |
||||
padding-bottom: 0; |
||||
} |
||||
|
||||
/* Ensure Quill editor is properly sized on mobile */ |
||||
.editor-pane--edit .ql-container { |
||||
min-height: 300px; |
||||
flex: 1; |
||||
} |
||||
|
||||
.editor-title-field { |
||||
font-size: 1.25rem; |
||||
} |
||||
|
||||
.editor-title-input { |
||||
flex-shrink: 0; |
||||
} |
||||
} |
||||
|
||||
/* Dark theme support */ |
||||
[data-theme="dark"] .editor-shell { |
||||
--background: #1f2937; |
||||
--surface: #111827; |
||||
--surface-dark: #0f172a; |
||||
--border-color: #374151; |
||||
--text-primary: #f9fafb; |
||||
--text-secondary: #9ca3af; |
||||
--hover: #374151; |
||||
--primary: #3b82f6; |
||||
--code-bg: #374151; |
||||
} |
||||
|
||||
/* JSON Panel Styles */ |
||||
.json-editor-container { |
||||
position: relative; |
||||
margin-bottom: 0.5rem; |
||||
} |
||||
|
||||
.json-textarea { |
||||
width: 100%; |
||||
min-height: 400px; |
||||
padding: 0.75rem; |
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; |
||||
font-size: 0.8125rem; |
||||
line-height: 1.5; |
||||
border: 1px solid var(--border-color, #e5e7eb); |
||||
border-radius: 0.375rem; |
||||
background: var(--background, #ffffff); |
||||
color: var(--text-primary, #111827); |
||||
resize: vertical; |
||||
} |
||||
|
||||
.json-textarea:focus { |
||||
outline: none; |
||||
border-color: var(--primary, #2563eb); |
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); |
||||
} |
||||
|
||||
.json-status { |
||||
font-size: 0.75rem; |
||||
padding: 0.25rem 0.5rem; |
||||
border-radius: 0.25rem; |
||||
margin-top: 0.5rem; |
||||
transition: all 0.2s ease; |
||||
} |
||||
|
||||
.json-status--success { |
||||
color: #059669; |
||||
background: #d1fae5; |
||||
} |
||||
|
||||
.json-status--warning { |
||||
color: #d97706; |
||||
background: #fef3c7; |
||||
} |
||||
|
||||
.json-status--error { |
||||
color: #dc2626; |
||||
background: #fee2e2; |
||||
} |
||||
|
||||
[data-theme="dark"] .json-status--success { |
||||
color: #34d399; |
||||
background: #064e3b; |
||||
} |
||||
|
||||
[data-theme="dark"] .json-status--warning { |
||||
color: #fbbf24; |
||||
background: #78350f; |
||||
} |
||||
|
||||
[data-theme="dark"] .json-status--error { |
||||
color: #f87171; |
||||
background: #7f1d1d; |
||||
} |
||||
|
||||
[data-theme="dark"] .json-textarea { |
||||
background: #1f2937; |
||||
border-color: #374151; |
||||
} |
||||
|
||||
/* Prism.js CSS for markdown syntax highlighting - asset-mapper import */ |
||||
|
||||
.markdown-highlight { |
||||
background: #f5f2f0; |
||||
border-radius: 0.375rem; |
||||
font-size: 0.95rem; |
||||
padding: 1rem; |
||||
overflow-x: auto; |
||||
min-height: 300px; |
||||
max-height: 600px; |
||||
font-family: 'Fira Mono', 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', monospace; |
||||
line-height: 1.6; |
||||
margin: 0; |
||||
} |
||||
|
||||
.markdown-highlight code { |
||||
background: none; |
||||
color: inherit; |
||||
padding: 0; |
||||
font-size: inherit; |
||||
font-family: inherit; |
||||
white-space: pre; |
||||
word-break: normal; |
||||
word-wrap: normal; |
||||
border: none; |
||||
box-shadow: none; |
||||
outline: none; |
||||
} |
||||
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
<?php |
||||
|
||||
namespace App\Controller\Editor; |
||||
|
||||
use App\Util\CommonMark\Converter; |
||||
use League\CommonMark\Exception\CommonMarkException; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\HttpFoundation\JsonResponse; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\Routing\Annotation\Route; |
||||
|
||||
class MarkdownController extends AbstractController |
||||
{ |
||||
/** |
||||
* Process markdown preview request |
||||
*/ |
||||
#[Route('/editor/markdown/preview', name: 'editor_markdown_preview', methods: ['POST'])] |
||||
public function preview(Request $request, Converter $converter): JsonResponse |
||||
{ |
||||
$data = json_decode($request->getContent(), true); |
||||
$markdown = $data['markdown'] ?? ''; |
||||
try { |
||||
$html = $converter->convertToHtml($markdown); |
||||
return new JsonResponse(['html' => $html]); |
||||
} catch (CommonMarkException $e) { |
||||
return new JsonResponse(['error' => 'Failed to convert markdown: ' . $e->getMessage()], 400); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
|
||||
<?php |
||||
|
||||
namespace App\Controller\Search; |
||||
|
||||
use App\Enum\RolesEnum; |
||||
use App\Service\Search\UserSearchInterface; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\HttpFoundation\JsonResponse; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
|
||||
#[Route('/api/users')] |
||||
class UserSearchController extends AbstractController |
||||
{ |
||||
public function __construct( |
||||
private readonly UserSearchInterface $userSearch |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* Search users by query string |
||||
* GET /api/users/search?q=query&limit=20&offset=0 |
||||
*/ |
||||
#[Route('/search', name: 'api_users_search', methods: ['GET'])] |
||||
public function search(Request $request): JsonResponse |
||||
{ |
||||
$query = $request->query->get('q', ''); |
||||
$limit = min((int) $request->query->get('limit', 12), 100); |
||||
$offset = max((int) $request->query->get('offset', 0), 0); |
||||
|
||||
if (empty(trim($query))) { |
||||
return $this->json([ |
||||
'error' => 'Query parameter "q" is required', |
||||
'users' => [] |
||||
], Response::HTTP_BAD_REQUEST); |
||||
} |
||||
|
||||
$users = $this->userSearch->search($query, $limit, $offset); |
||||
|
||||
return $this->json([ |
||||
'query' => $query, |
||||
'count' => count($users), |
||||
'limit' => $limit, |
||||
'offset' => $offset, |
||||
'users' => array_map(fn($user) => [ |
||||
'id' => $user->getId(), |
||||
'npub' => $user->getNpub(), |
||||
'displayName' => $user->getDisplayName(), |
||||
'name' => $user->getName(), |
||||
'nip05' => $user->getNip05(), |
||||
'about' => $user->getAbout(), |
||||
'picture' => $user->getPicture(), |
||||
'website' => $user->getWebsite(), |
||||
'lud16' => $user->getLud16(), |
||||
], $users) |
||||
]); |
||||
} |
||||
|
||||
/** |
||||
* Get featured writers with optional search |
||||
* GET /api/users/featured-writers?q=query&limit=12 |
||||
*/ |
||||
#[Route('/featured-writers', name: 'api_users_featured_writers', methods: ['GET'])] |
||||
public function featuredWriters(Request $request): JsonResponse |
||||
{ |
||||
$query = $request->query->get('q'); |
||||
$limit = min((int) $request->query->get('limit', 12), 100); |
||||
$offset = max((int) $request->query->get('offset', 0), 0); |
||||
|
||||
$users = $this->userSearch->findByRole( |
||||
RolesEnum::FEATURED_WRITER->value, |
||||
$query, |
||||
$limit, |
||||
$offset |
||||
); |
||||
|
||||
return $this->json([ |
||||
'query' => $query, |
||||
'count' => count($users), |
||||
'limit' => $limit, |
||||
'offset' => $offset, |
||||
'users' => array_map(fn($user) => [ |
||||
'id' => $user->getId(), |
||||
'npub' => $user->getNpub(), |
||||
'displayName' => $user->getDisplayName(), |
||||
'name' => $user->getName(), |
||||
'nip05' => $user->getNip05(), |
||||
'about' => $user->getAbout(), |
||||
'picture' => $user->getPicture(), |
||||
'website' => $user->getWebsite(), |
||||
'lud16' => $user->getLud16(), |
||||
], $users) |
||||
]); |
||||
} |
||||
|
||||
/** |
||||
* Find users by their npubs |
||||
* POST /api/users/by-npubs |
||||
* Body: {"npubs": ["npub1...", "npub2..."]} |
||||
*/ |
||||
#[Route('/by-npubs', name: 'api_users_by_npubs', methods: ['POST'])] |
||||
public function byNpubs(Request $request): JsonResponse |
||||
{ |
||||
$data = json_decode($request->getContent(), true); |
||||
$npubs = $data['npubs'] ?? []; |
||||
|
||||
if (empty($npubs) || !is_array($npubs)) { |
||||
return $this->json([ |
||||
'error' => 'Field "npubs" is required and must be an array', |
||||
'users' => [] |
||||
], Response::HTTP_BAD_REQUEST); |
||||
} |
||||
|
||||
$limit = min((int) ($data['limit'] ?? 200), 200); |
||||
$users = $this->userSearch->findByNpubs($npubs, $limit); |
||||
|
||||
return $this->json([ |
||||
'count' => count($users), |
||||
'users' => array_map(fn($user) => [ |
||||
'id' => $user->getId(), |
||||
'npub' => $user->getNpub(), |
||||
'displayName' => $user->getDisplayName(), |
||||
'name' => $user->getName(), |
||||
'nip05' => $user->getNip05(), |
||||
'about' => $user->getAbout(), |
||||
'picture' => $user->getPicture(), |
||||
'website' => $user->getWebsite(), |
||||
'lud16' => $user->getLud16(), |
||||
], $users) |
||||
]); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
|
||||
<?php |
||||
|
||||
namespace App\Controller\Search; |
||||
|
||||
use App\Enum\RolesEnum; |
||||
use App\Service\Search\UserSearchInterface; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
|
||||
class UserSearchPageController extends AbstractController |
||||
{ |
||||
public function __construct( |
||||
private readonly UserSearchInterface $userSearch |
||||
) { |
||||
} |
||||
|
||||
#[Route('/users/search', name: 'users_search_page', methods: ['GET'])] |
||||
public function searchPage(Request $request): Response |
||||
{ |
||||
$query = $request->query->get('q', ''); |
||||
$limit = min((int) $request->query->get('limit', 12), 100); |
||||
$users = []; |
||||
$resultsCount = 0; |
||||
|
||||
if (!empty(trim($query))) { |
||||
$users = $this->userSearch->search($query, $limit); |
||||
$resultsCount = count($users); |
||||
} |
||||
|
||||
return $this->render('user_search/search.html.twig', [ |
||||
'query' => $query, |
||||
'users' => $users, |
||||
'resultsCount' => $resultsCount, |
||||
'limit' => $limit, |
||||
]); |
||||
} |
||||
|
||||
#[Route('/users/featured', name: 'featured_writers_page', methods: ['GET'])] |
||||
public function featuredWritersPage(Request $request): Response |
||||
{ |
||||
$query = $request->query->get('q'); |
||||
$limit = min((int) $request->query->get('limit', 12), 100); |
||||
|
||||
$users = $this->userSearch->findByRole( |
||||
RolesEnum::FEATURED_WRITER->value, |
||||
$query, |
||||
$limit |
||||
); |
||||
|
||||
return $this->render('user_search/featured_writers.html.twig', [ |
||||
'query' => $query, |
||||
'users' => $users, |
||||
'resultsCount' => count($users), |
||||
]); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
<?php |
||||
|
||||
namespace App\ReadModel\RedisView; |
||||
|
||||
/** |
||||
* Redis view model for a reading list, containing child RedisBaseObject articles |
||||
*/ |
||||
final class RedisReadingListView |
||||
{ |
||||
public function __construct( |
||||
public string $title, |
||||
public ?string $summary, |
||||
/** @var RedisBaseObject[] $articles */ |
||||
public array $articles |
||||
) {} |
||||
} |
||||
|
||||
@ -0,0 +1,355 @@
@@ -0,0 +1,355 @@
|
||||
{# templates/editor/layout.html.twig #} |
||||
{% extends 'base.html.twig' %} |
||||
|
||||
{% form_theme form _self 'pages/_advanced_metadata.html.twig' %} |
||||
|
||||
{% block quill_widget %} |
||||
<div {{ stimulus_controller('publishing--quill') }} class="quill" data-id="{{ id }}" > |
||||
<div id="editor"> |
||||
{{ value|raw }} |
||||
</div> |
||||
<input type="hidden" name="editor[content_md]" data-publishing--quill-target="markdown"> |
||||
<input type="hidden" {{ block('widget_attributes') }} value="{{ value }}" /> |
||||
</div> |
||||
{% endblock %} |
||||
|
||||
{% block header %} |
||||
<header class="editor-header"> |
||||
<div class="editor-header-left"> |
||||
<a href="{{ path('home') }}" class="btn">← Back</a> |
||||
<div class="editor-title"> |
||||
{{ article.title|default('New article') }} |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="editor-header-right"> |
||||
{# Status indicator #} |
||||
<span class="editor-status text-muted" data-editor--layout-target="status"> |
||||
{% if article.id %}Editing{% else %}New article{% endif %} |
||||
</span> |
||||
<button type="button" class="btn btn-secondary" data-action="editor--layout#saveDraft"> |
||||
Save draft |
||||
</button> |
||||
<button type="button" class="btn btn-primary" data-action="editor--layout#publish"> |
||||
Publish |
||||
</button> |
||||
</div> |
||||
</header> |
||||
{% endblock %} |
||||
|
||||
{% block layout %} |
||||
<main data-controller="editor--layout"> |
||||
<div class="editor-main"> |
||||
{# Insert the article list sidebar as the first grid column #} |
||||
<aside class="editor-articlelist-sidebar" data-controller="editor--articlelist-panels"> |
||||
<div class="editor-sidebar-tabs"> |
||||
<button |
||||
type="button" |
||||
class="editor-sidebar-tab is-active" |
||||
data-editor--articlelist-panels-target="tab" |
||||
data-panel="articles" |
||||
data-action="editor--articlelist-panels#switch" |
||||
> |
||||
Articles |
||||
</button> |
||||
</div> |
||||
<section class="editor-sidebar-panels"> |
||||
<div |
||||
class="editor-panel" |
||||
data-editor--articlelist-panels-target="panel" |
||||
data-panel="articles" |
||||
> |
||||
<div class="articlelist-content" data-articlelist-target="list"> |
||||
{% if is_granted('ROLE_USER') %} |
||||
{% if readingLists is defined and readingLists|length > 0 %} |
||||
{% for list in readingLists %} |
||||
<details class="mb-2"> |
||||
<summary> |
||||
{{ list.title|default('Untitled List') }} |
||||
{% if list.summary is defined and list.summary %} |
||||
<span class="readinglist-summary">— {{ list.summary }}</span> |
||||
{% endif %} |
||||
</summary> |
||||
<ul class="list-unstyled"> |
||||
{% if list.articles|length > 0 %} |
||||
{% for articleObj in list.articles %} |
||||
{% set article = articleObj.article %} |
||||
<li class="readinglist-article"> |
||||
<span class="article-icon" title="{{ article.kind == 30024 ? 'Draft' : 'Published' }}"> |
||||
{% if article.kind == 30024 %} |
||||
<span style="color: orange; font-weight: bold;">●</span><span class="article-kind">D</span> |
||||
{% else %} |
||||
<span style="color: #22c55e; font-weight: bold;">●</span><span class="article-kind">A</span> |
||||
{% endif %} |
||||
</span> |
||||
<a href="{{ path('editor-preview-npub-slug', {npub: article.pubkey|toNpub , slug: article.slug}) }}"> |
||||
{{ article.title|default(article.slug) }} |
||||
</a> |
||||
<span>by {{ articleObj.author.name }}</span> |
||||
</li> |
||||
{% endfor %} |
||||
{% else %} |
||||
<li class="readinglist-empty">No articles in this list.</li> |
||||
{% endif %} |
||||
</ul> |
||||
</details> |
||||
{% endfor %} |
||||
{% endif %} |
||||
<ul class="list-unstyled"> |
||||
{% for recent in recentArticles %} |
||||
<li class="mb-2"> |
||||
<span class="article-icon" title="Published"> |
||||
<span style="color: #22c55e; font-weight: bold;">●</span><span class="article-kind">A</span> |
||||
</span> |
||||
<a href="{{ path('editor-edit-slug', {slug: recent.slug}) }}"> |
||||
{{ recent.title }} ({{ recent.publishedAt|date('Y-m-d') }}) |
||||
</a> |
||||
</li> |
||||
{% else %} |
||||
<li>No recent articles found.</li> |
||||
{% endfor %} |
||||
</ul> |
||||
<ul class="list-unstyled"> |
||||
{% for draft in drafts %} |
||||
<li> |
||||
<span class="article-icon" title="Draft"> |
||||
<span style="color: orange; font-weight: bold;">●</span><span class="article-kind">D</span> |
||||
</span> |
||||
<a href="{{ path('editor-edit-slug', {slug: draft.slug}) }}"> |
||||
{{ draft.title }} ({{ draft.updatedAt|date('Y-m-d') }}) |
||||
</a> |
||||
</li> |
||||
{% else %} |
||||
<li>No drafts found.</li> |
||||
{% endfor %} |
||||
</ul> |
||||
{% else %} |
||||
<div class="articlelist-placeholder">Sign in to see your articles.</div> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
</section> |
||||
</aside> |
||||
{# Center editor area (middle grid column) #} |
||||
<div class="editor-center"> |
||||
<div class="editor-center-tabs"> |
||||
<button |
||||
type="button" |
||||
class="editor-tab is-active" |
||||
data-editor--layout-target="modeTab" |
||||
data-mode="edit" |
||||
data-action="editor--layout#switchMode" |
||||
> |
||||
Editor |
||||
</button> |
||||
<button |
||||
type="button" |
||||
class="editor-tab" |
||||
data-editor--layout-target="modeTab" |
||||
data-mode="markdown" |
||||
data-action="editor--layout#switchMode" |
||||
> |
||||
Markdown |
||||
</button> |
||||
<button |
||||
type="button" |
||||
class="editor-tab" |
||||
data-editor--layout-target="modeTab" |
||||
data-mode="preview" |
||||
data-action="editor--layout#switchMode" |
||||
> |
||||
Preview |
||||
</button> |
||||
</div> |
||||
|
||||
<div class="editor-center-content"> |
||||
{{ form_start(form) }} |
||||
|
||||
<div |
||||
class="editor-pane editor-pane--edit" |
||||
data-editor--layout-target="editPane" |
||||
> |
||||
{# Title field at top of editor #} |
||||
<div class="editor-title-input"> |
||||
{{ form_row(form.title, { |
||||
'label': false, |
||||
'attr': {'placeholder': 'Article title', 'class': 'form-control editor-title-field'} |
||||
}) }} |
||||
</div> |
||||
|
||||
{# QuillJS editor container #} |
||||
{{ form_row(form.content, {'label': false}) }} |
||||
|
||||
{# Hidden field for draft status - controlled by Save Draft / Publish buttons #} |
||||
<div style="display: none;"> |
||||
{{ form_widget(form.isDraft) }} |
||||
</div> |
||||
|
||||
{# Mobile action buttons at bottom #} |
||||
<div class="editor-mobile-actions"> |
||||
<button type="button" class="btn btn-secondary btn-lg" data-action="editor--layout#saveDraft"> |
||||
Save draft |
||||
</button> |
||||
<button type="button" class="btn btn-primary btn-lg" data-action="editor--layout#publish"> |
||||
Publish |
||||
</button> |
||||
</div> |
||||
</div> |
||||
|
||||
<div |
||||
class="editor-pane editor-pane--markdown is-hidden" |
||||
data-editor--layout-target="markdownPane" |
||||
> |
||||
{# Markdown editor #} |
||||
<div class="markdown-editor-wrapper"> |
||||
<div class="editor-title-input"> |
||||
<input |
||||
type="text" |
||||
class="form-control editor-title-field" |
||||
placeholder="Article title" |
||||
data-editor--layout-target="markdownTitle" |
||||
readonly |
||||
/> |
||||
</div> |
||||
<pre class="markdown-highlight"><code class="language-markdown" data-editor--layout-target="markdownCode"></code></pre> |
||||
</div> |
||||
</div> |
||||
|
||||
<div |
||||
class="editor-pane editor-pane--preview is-hidden" |
||||
data-editor--layout-target="previewPane" |
||||
> |
||||
<div class="card preview-container"> |
||||
<div class="card-header"> |
||||
<h1 class="card-title preview-title" data-editor--layout-target="previewTitle"> |
||||
{{ article.title|default('Article title') }} |
||||
</h1> |
||||
<div class="byline"> |
||||
<span> |
||||
By <span class="preview-author" data-editor--layout-target="previewAuthor">...</span> |
||||
</span> |
||||
<span> |
||||
<small class="preview-date" data-editor--layout-target="previewDate">Date</small> |
||||
</span> |
||||
</div> |
||||
</div> |
||||
<div class="card-body"> |
||||
<div class="lede preview-summary" data-editor--layout-target="previewSummary"></div> |
||||
<div class="article__image preview-image-wrap"> |
||||
<img class="preview-image" data-editor--layout-target="previewImage" src="" alt="Cover image preview" style="display:none;"/> |
||||
<div class="preview-image-placeholder" data-editor--layout-target="previewImagePlaceholder" style="display:none;"> |
||||
<span>No cover image</span> |
||||
</div> |
||||
</div> |
||||
<div class="article-main" data-editor--layout-target="previewBody"> |
||||
{# Filled by JS #} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{# Right sidebar (last grid column) #} |
||||
<aside class="editor-sidebar" data-controller="editor--panels"> |
||||
<div class="editor-sidebar-tabs"> |
||||
<button |
||||
type="button" |
||||
class="editor-sidebar-tab is-active" |
||||
data-editor--panels-target="tab" |
||||
data-panel="metadata" |
||||
data-action="editor--panels#switch" |
||||
> |
||||
Metadata |
||||
</button> |
||||
<button |
||||
type="button" |
||||
class="editor-sidebar-tab" |
||||
data-editor--panels-target="tab" |
||||
data-panel="advanced" |
||||
data-action="editor--panels#switch" |
||||
> |
||||
Advanced |
||||
</button> |
||||
{# Media tab temporarily hidden - will be redesigned later |
||||
<button |
||||
type="button" |
||||
class="editor-sidebar-tab" |
||||
data-editor--panels-target="tab" |
||||
data-panel="media" |
||||
data-action="editor--panels#switch" |
||||
> |
||||
Media |
||||
</button> |
||||
#} |
||||
<button |
||||
type="button" |
||||
class="editor-sidebar-tab" |
||||
data-editor--panels-target="tab" |
||||
data-panel="json" |
||||
data-action="editor--panels#switch" |
||||
> |
||||
Event JSON |
||||
</button> |
||||
</div> |
||||
|
||||
<section class="editor-sidebar-panels"> |
||||
<div |
||||
class="editor-panel" |
||||
data-editor--panels-target="panel" |
||||
data-panel="metadata" |
||||
> |
||||
{% include 'editor/panels/_metadata.html.twig' with { form: form } %} |
||||
</div> |
||||
|
||||
<div |
||||
class="editor-panel is-hidden" |
||||
data-editor--panels-target="panel" |
||||
data-panel="advanced" |
||||
> |
||||
{% include 'editor/panels/_advanced.html.twig' with { form: form } %} |
||||
</div> |
||||
|
||||
{# Media panel temporarily hidden - will be redesigned later |
||||
<div |
||||
class="editor-panel is-hidden" |
||||
data-editor--panels-target="panel" |
||||
data-panel="media" |
||||
> |
||||
{% include 'editor/panels/_media.html.twig' %} |
||||
</div> |
||||
#} |
||||
|
||||
<div |
||||
class="editor-panel is-hidden" |
||||
data-editor--panels-target="panel" |
||||
data-panel="json" |
||||
> |
||||
{% include 'editor/panels/_json.html.twig' %} |
||||
</div> |
||||
</section> |
||||
</aside> |
||||
|
||||
{{ form_end(form) }} |
||||
</div> |
||||
|
||||
{# Hidden container for Nostr publishing #} |
||||
<div style="display: none;" {{ stimulus_controller('nostr--nostr-publish', { |
||||
publishUrl: path('api-article-publish') |
||||
}) }} data-nostr--nostr-publish-target="form" data-slug="{{ article.slug|default('') }}"> |
||||
<div data-nostr--nostr-publish-target="status"></div> |
||||
<div data-nostr--nostr-publish-target="jsonContainer"> |
||||
<textarea |
||||
style="display: none;" |
||||
data-nostr--nostr-publish-target="jsonTextarea" |
||||
data-action="input->nostr--nostr-publish#onJsonInput" |
||||
></textarea> |
||||
</div> |
||||
<button style="display: none;" data-nostr--nostr-publish-target="jsonToggle"></button> |
||||
<span style="display: none;" data-nostr--nostr-publish-target="jsonDirtyHint"></span> |
||||
<button style="display: none;" data-nostr--nostr-publish-target="publishButton"></button> |
||||
</div> |
||||
</main> |
||||
{% endblock %} |
||||
|
||||
{% block footer %} |
||||
{% endblock %} |
||||
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
<div class="panel-section"> |
||||
{{ form_row(form.advancedMetadata) }} |
||||
</div> |
||||
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
<div |
||||
class="panel-section" |
||||
data-controller="publishing--image-upload" |
||||
> |
||||
<h3>Media library</h3> |
||||
<p class="panel-help"> |
||||
Upload images to use in your article. |
||||
</p> |
||||
|
||||
<div class="mb-3"> |
||||
<label for="upload-provider">Upload to</label> |
||||
<select id="upload-provider" class="form-select form-select-sm" data-publishing--image-upload-target="provider"> |
||||
<option value="sovbit">files.sovbit.host</option> |
||||
<option value="nostrbuild">nostr.build</option> |
||||
<option value="nostrcheck">nostrcheck.me</option> |
||||
</select> |
||||
</div> |
||||
|
||||
<button |
||||
type="button" |
||||
class="btn btn-sm btn-primary w-100 mb-3" |
||||
data-action="click->publishing--image-upload#openDialog"> |
||||
Upload Image |
||||
</button> |
||||
|
||||
<div data-publishing--image-upload-target="dialog" class="iu-dialog"> |
||||
<div class="iu-backdrop" data-action="click->publishing--image-upload#closeDialog"></div> |
||||
<div class="iu-modal"> |
||||
<div class="modal-header"> |
||||
<h5>Upload Image</h5> |
||||
<button type="button" class="close" data-action="click->publishing--image-upload#closeDialog">×</button> |
||||
</div> |
||||
<div class="modal-body"> |
||||
<div> |
||||
<label for="upload-provider-modal">Upload to</label> |
||||
<select id="upload-provider-modal" class="form-select form-select-sm" data-publishing--image-upload-target="provider"> |
||||
<option value="sovbit">files.sovbit.host</option> |
||||
<option value="nostrbuild">nostr.build</option> |
||||
<option value="nostrcheck">nostrcheck.me</option> |
||||
</select> |
||||
</div> |
||||
|
||||
<div data-publishing--image-upload-target="dropArea" class="upload-area"> |
||||
<span>Drag & drop or click to select an image</span> |
||||
<input type="file" accept="image/*" data-publishing--image-upload-target="fileInput"> |
||||
</div> |
||||
|
||||
<div data-publishing--image-upload-target="progress" class="upload-progress"></div> |
||||
<div data-publishing--image-upload-target="error" class="upload-error"></div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="panel-help"> |
||||
<small>Tip: You can also paste images directly into the editor.</small> |
||||
</div> |
||||
</div> |
||||
|
||||
@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
|
||||
<div class="panel-section"> |
||||
{{ form_row(form.slug, { |
||||
'label': 'Slug', |
||||
'help': 'URL-friendly identifier', |
||||
'attr': {'class': 'form-control form-control-sm'} |
||||
}) }} |
||||
|
||||
{{ form_row(form.summary, { |
||||
'label': 'Summary', |
||||
'help': 'Brief description for previews', |
||||
'attr': {'class': 'form-control form-control-sm', 'rows': 3} |
||||
}) }} |
||||
|
||||
{{ form_row(form.topics, { |
||||
'label': 'Tags', |
||||
'attr': {'class': 'form-control form-control-sm'} |
||||
}) }} |
||||
|
||||
<div data-controller="publishing--image-upload"> |
||||
{{ form_row(form.image, { |
||||
'label': 'Cover Image', |
||||
'help': 'Enter URL or upload an image', |
||||
'attr': {'class': 'form-control form-control-sm', 'data-publishing--image-upload-target': 'urlInput'} |
||||
}) }} |
||||
|
||||
<button |
||||
type="button" |
||||
class="btn btn-sm btn-outline-primary mt-1 w-100" |
||||
data-action="click->publishing--image-upload#openDialog"> |
||||
Upload Image |
||||
</button> |
||||
|
||||
<div data-publishing--image-upload-target="dialog" class="iu-dialog"> |
||||
<div class="iu-backdrop" data-action="click->publishing--image-upload#closeDialog"></div> |
||||
<div class="iu-modal"> |
||||
<div class="modal-header"> |
||||
<h5>Upload Cover Image</h5> |
||||
<button type="button" class="close" data-action="click->publishing--image-upload#closeDialog">×</button> |
||||
</div> |
||||
<div class="modal-body"> |
||||
<div class="mb-3"> |
||||
<label for="cover-upload-provider">Upload to</label> |
||||
<select id="cover-upload-provider" class="form-select form-select-sm" data-publishing--image-upload-target="provider"> |
||||
<option value="sovbit">files.sovbit.host</option> |
||||
<option value="nostrbuild">nostr.build</option> |
||||
<option value="nostrcheck">nostrcheck.me</option> |
||||
</select> |
||||
</div> |
||||
|
||||
<div data-publishing--image-upload-target="dropArea" class="upload-area"> |
||||
<span>Drag & drop or click to select an image</span> |
||||
<input type="file" accept="image/*" data-publishing--image-upload-target="fileInput"> |
||||
</div> |
||||
|
||||
<div data-publishing--image-upload-target="progress" class="upload-progress"></div> |
||||
<div data-publishing--image-upload-target="error" class="upload-error"></div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
{{ form_row(form.clientTag, { |
||||
'label': 'Add client tag (Decent Newsroom)', |
||||
'row_attr': {'class': 'form-check d-flex flex-row'}, |
||||
'label_attr': {'class': 'form-check-label'}, |
||||
'attr': {'class': 'form-check-input'} |
||||
}) }} |
||||
</div> |
||||
@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
|
||||
<div class="panel-section"> |
||||
<h3>Publishing info</h3> |
||||
|
||||
{% if article.id %} |
||||
<div class="info-group"> |
||||
<label>Status</label> |
||||
<div class="info-value"> |
||||
{% if article.isDraft %} |
||||
<span class="badge bg-secondary">Draft</span> |
||||
{% else %} |
||||
<span class="badge bg-success">Published</span> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
|
||||
{% if article.publishedAt %} |
||||
<div class="info-group"> |
||||
<label>Published</label> |
||||
<div class="info-value"> |
||||
{{ article.publishedAt|date('Y-m-d H:i') }} |
||||
</div> |
||||
</div> |
||||
{% endif %} |
||||
|
||||
{% if article.updatedAt %} |
||||
<div class="info-group"> |
||||
<label>Last updated</label> |
||||
<div class="info-value"> |
||||
{{ article.updatedAt|date('Y-m-d H:i') }} |
||||
</div> |
||||
</div> |
||||
{% endif %} |
||||
|
||||
{% if article.slug %} |
||||
<div class="info-group"> |
||||
<label>Slug</label> |
||||
<div class="info-value"> |
||||
<code>{{ article.slug }}</code> |
||||
</div> |
||||
</div> |
||||
{% endif %} |
||||
{% else %} |
||||
<p class="text-muted"> |
||||
This is a new article. Fill in the details and publish when ready. |
||||
</p> |
||||
{% endif %} |
||||
|
||||
<div class="panel-subsection mt-3"> |
||||
<h4>Quick actions</h4> |
||||
<button |
||||
type="button" |
||||
class="btn btn-sm btn-outline-secondary w-100 mb-2" |
||||
data-action="click->editor--layout#saveDraft" |
||||
> |
||||
Save draft |
||||
</button> |
||||
<button |
||||
type="button" |
||||
class="btn btn-sm btn-primary w-100" |
||||
data-action="click->editor--layout#publish" |
||||
> |
||||
Publish to Nostr |
||||
</button> |
||||
</div> |
||||
</div> |
||||
|
||||
@ -0,0 +1,404 @@
@@ -0,0 +1,404 @@
|
||||
{% extends 'layout.html.twig' %} |
||||
|
||||
{% block title %}Featured Writers - Decent Newsroom{% endblock %} |
||||
|
||||
{% block body %} |
||||
<div class="featured-writers-page"> |
||||
<div class="container"> |
||||
<header class="page-header"> |
||||
<h1>Featured Writers</h1> |
||||
<p class="subtitle">Discover our curated list of exceptional contributors</p> |
||||
</header> |
||||
|
||||
{% if query %} |
||||
<div class="search-box"> |
||||
<form action="{{ path('featured_writers_page') }}" method="get" class="search-form"> |
||||
<div class="input-group"> |
||||
<input |
||||
type="text" |
||||
name="q" |
||||
value="{{ query }}" |
||||
placeholder="Search featured writers..." |
||||
class="search-input" |
||||
> |
||||
<button type="submit" class="search-button">Search</button> |
||||
<a href="{{ path('featured_writers_page') }}" class="clear-button">Clear</a> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
|
||||
<div class="results-header"> |
||||
<h2>Found {{ resultsCount }} featured writer{{ resultsCount != 1 ? 's' : '' }}</h2> |
||||
</div> |
||||
{% else %} |
||||
<div class="search-box"> |
||||
<form action="{{ path('featured_writers_page') }}" method="get" class="search-form"> |
||||
<div class="input-group"> |
||||
<input |
||||
type="text" |
||||
name="q" |
||||
placeholder="Filter featured writers..." |
||||
class="search-input" |
||||
> |
||||
<button type="submit" class="search-button">Filter</button> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
{% endif %} |
||||
|
||||
{% if users|length > 0 %} |
||||
<div class="writers-grid"> |
||||
{% for user in users %} |
||||
<div class="writer-card"> |
||||
<div class="writer-header"> |
||||
<div class="writer-avatar"> |
||||
{% if user.picture %} |
||||
<img src="{{ user.picture }}" alt="{{ user.displayName ?? user.name ?? 'Writer' }}" loading="lazy"> |
||||
{% else %} |
||||
<div class="avatar-placeholder"> |
||||
{{ (user.displayName ?? user.name ?? user.npub[:8])|slice(0, 2)|upper }} |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
{% if user.banner %} |
||||
<div class="writer-banner" style="background-image: url('{{ user.banner }}')"></div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
<div class="writer-content"> |
||||
<h3 class="writer-name"> |
||||
{{ user.displayName ?? user.name ?? 'Anonymous' }} |
||||
</h3> |
||||
|
||||
{% if user.nip05 %} |
||||
<p class="writer-nip05"> |
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path> |
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline> |
||||
</svg> |
||||
{{ user.nip05 }} |
||||
</p> |
||||
{% endif %} |
||||
|
||||
{% if user.about %} |
||||
<p class="writer-about"> |
||||
{{ user.about|length > 150 ? user.about|slice(0, 150) ~ '...' : user.about }} |
||||
</p> |
||||
{% endif %} |
||||
|
||||
<div class="writer-links"> |
||||
{% if user.website %} |
||||
<a href="{{ user.website }}" target="_blank" rel="noopener noreferrer" class="writer-link"> |
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||
<circle cx="12" cy="12" r="10"></circle> |
||||
<line x1="2" y1="12" x2="22" y2="12"></line> |
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path> |
||||
</svg> |
||||
Website |
||||
</a> |
||||
{% endif %} |
||||
{% if user.lud16 %} |
||||
<span class="writer-lightning" title="Lightning: {{ user.lud16 }}"> |
||||
⚡ Tips enabled |
||||
</span> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
<div class="writer-npub" title="{{ user.npub }}"> |
||||
{{ user.npub[:16] }}... |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{% endfor %} |
||||
</div> |
||||
{% else %} |
||||
<div class="no-results"> |
||||
{% if query %} |
||||
<p>No featured writers found matching "{{ query }}"</p> |
||||
<a href="{{ path('featured_writers_page') }}" class="button">View All Featured Writers</a> |
||||
{% else %} |
||||
<p>No featured writers yet</p> |
||||
{% endif %} |
||||
</div> |
||||
{% endif %} |
||||
|
||||
<div class="back-link"> |
||||
<a href="{{ path('users_search_page') }}">← Back to User Search</a> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<style> |
||||
.featured-writers-page { |
||||
padding: 2rem 0; |
||||
min-height: 60vh; |
||||
} |
||||
|
||||
.container { |
||||
max-width: 1400px; |
||||
margin: 0 auto; |
||||
padding: 0 1rem; |
||||
} |
||||
|
||||
.page-header { |
||||
text-align: center; |
||||
margin-bottom: 2rem; |
||||
} |
||||
|
||||
.page-header h1 { |
||||
font-size: 2.5rem; |
||||
margin-bottom: 0.5rem; |
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||
-webkit-background-clip: text; |
||||
-webkit-text-fill-color: transparent; |
||||
background-clip: text; |
||||
} |
||||
|
||||
.subtitle { |
||||
color: #666; |
||||
font-size: 1.1rem; |
||||
} |
||||
|
||||
.search-box { |
||||
max-width: 600px; |
||||
margin: 0 auto 2rem; |
||||
} |
||||
|
||||
.input-group { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.search-input { |
||||
flex: 1; |
||||
padding: 0.75rem 1rem; |
||||
font-size: 1rem; |
||||
border: 2px solid #e0e0e0; |
||||
border-radius: 8px; |
||||
} |
||||
|
||||
.search-button, .clear-button { |
||||
padding: 0.75rem 1.5rem; |
||||
border: none; |
||||
border-radius: 8px; |
||||
cursor: pointer; |
||||
font-size: 1rem; |
||||
font-weight: 500; |
||||
text-decoration: none; |
||||
display: inline-block; |
||||
} |
||||
|
||||
.search-button { |
||||
background: #667eea; |
||||
color: white; |
||||
} |
||||
|
||||
.clear-button { |
||||
background: #6c757d; |
||||
color: white; |
||||
} |
||||
|
||||
.results-header { |
||||
text-align: center; |
||||
margin-bottom: 2rem; |
||||
} |
||||
|
||||
.writers-grid { |
||||
display: grid; |
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); |
||||
gap: 2rem; |
||||
margin-top: 2rem; |
||||
} |
||||
|
||||
.writer-card { |
||||
background: white; |
||||
border: 1px solid #e0e0e0; |
||||
border-radius: 16px; |
||||
overflow: hidden; |
||||
transition: all 0.3s; |
||||
} |
||||
|
||||
.writer-card:hover { |
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); |
||||
transform: translateY(-4px); |
||||
} |
||||
|
||||
.writer-header { |
||||
position: relative; |
||||
height: 120px; |
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||
} |
||||
|
||||
.writer-banner { |
||||
width: 100%; |
||||
height: 100%; |
||||
background-size: cover; |
||||
background-position: center; |
||||
} |
||||
|
||||
.writer-avatar { |
||||
position: absolute; |
||||
bottom: -40px; |
||||
left: 50%; |
||||
transform: translateX(-50%); |
||||
width: 100px; |
||||
height: 100px; |
||||
border-radius: 50%; |
||||
overflow: hidden; |
||||
border: 4px solid white; |
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
||||
} |
||||
|
||||
.writer-avatar img { |
||||
width: 100%; |
||||
height: 100%; |
||||
object-fit: cover; |
||||
} |
||||
|
||||
.avatar-placeholder { |
||||
width: 100%; |
||||
height: 100%; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||
color: white; |
||||
font-size: 2rem; |
||||
font-weight: bold; |
||||
} |
||||
|
||||
.writer-content { |
||||
padding: 3rem 1.5rem 1.5rem; |
||||
text-align: center; |
||||
} |
||||
|
||||
.writer-name { |
||||
font-size: 1.5rem; |
||||
margin-bottom: 0.5rem; |
||||
font-weight: 700; |
||||
} |
||||
|
||||
.writer-nip05 { |
||||
display: inline-flex; |
||||
align-items: center; |
||||
gap: 0.3rem; |
||||
color: #28a745; |
||||
font-size: 0.9rem; |
||||
margin-bottom: 1rem; |
||||
background: #e8f5e9; |
||||
padding: 0.25rem 0.75rem; |
||||
border-radius: 12px; |
||||
} |
||||
|
||||
.writer-about { |
||||
color: #555; |
||||
font-size: 1rem; |
||||
line-height: 1.6; |
||||
margin-bottom: 1.25rem; |
||||
min-height: 60px; |
||||
} |
||||
|
||||
.writer-links { |
||||
display: flex; |
||||
justify-content: center; |
||||
gap: 1rem; |
||||
margin-bottom: 1rem; |
||||
padding-bottom: 1rem; |
||||
border-bottom: 1px solid #f0f0f0; |
||||
} |
||||
|
||||
.writer-link { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.4rem; |
||||
color: #007bff; |
||||
text-decoration: none; |
||||
font-size: 0.95rem; |
||||
} |
||||
|
||||
.writer-link:hover { |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
.writer-lightning { |
||||
color: #ff9800; |
||||
font-size: 0.95rem; |
||||
} |
||||
|
||||
.writer-npub { |
||||
font-family: monospace; |
||||
font-size: 0.85rem; |
||||
color: #999; |
||||
background: #f8f9fa; |
||||
padding: 0.5rem; |
||||
border-radius: 8px; |
||||
} |
||||
|
||||
.no-results { |
||||
text-align: center; |
||||
padding: 4rem 2rem; |
||||
color: #666; |
||||
} |
||||
|
||||
.no-results .button { |
||||
display: inline-block; |
||||
margin-top: 1rem; |
||||
padding: 0.75rem 1.5rem; |
||||
background: #667eea; |
||||
color: white; |
||||
text-decoration: none; |
||||
border-radius: 8px; |
||||
} |
||||
|
||||
.back-link { |
||||
text-align: center; |
||||
margin-top: 3rem; |
||||
padding-top: 2rem; |
||||
border-top: 1px solid #e0e0e0; |
||||
} |
||||
|
||||
.back-link a { |
||||
color: #007bff; |
||||
text-decoration: none; |
||||
font-size: 1rem; |
||||
} |
||||
|
||||
.back-link a:hover { |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
@media (max-width: 768px) { |
||||
.writers-grid { |
||||
grid-template-columns: 1fr; |
||||
} |
||||
|
||||
.page-header h1 { |
||||
font-size: 2rem; |
||||
} |
||||
} |
||||
|
||||
[data-theme="dark"] .writer-card { |
||||
background: #2d3748; |
||||
border-color: #4a5568; |
||||
} |
||||
|
||||
[data-theme="dark"] .writer-avatar { |
||||
border-color: #2d3748; |
||||
} |
||||
|
||||
[data-theme="dark"] .writer-npub { |
||||
background: #1a202c; |
||||
} |
||||
|
||||
[data-theme="dark"] .writer-nip05 { |
||||
background: rgba(40, 167, 69, 0.2); |
||||
} |
||||
|
||||
[data-theme="dark"] .search-input { |
||||
background: #2d3748; |
||||
border-color: #4a5568; |
||||
color: white; |
||||
} |
||||
</style> |
||||
{% endblock %} |
||||
|
||||
@ -0,0 +1,416 @@
@@ -0,0 +1,416 @@
|
||||
{% extends 'layout.html.twig' %} |
||||
|
||||
{% block title %}User Search - Decent Newsroom{% endblock %} |
||||
|
||||
{% block body %} |
||||
<div class="user-search-page"> |
||||
<div class="container"> |
||||
<header class="page-header"> |
||||
<h1>Search Users</h1> |
||||
<p class="subtitle">Find writers, contributors, and Nostr users</p> |
||||
</header> |
||||
|
||||
<div class="search-box"> |
||||
<form action="{{ path('users_search_page') }}" method="get" class="search-form"> |
||||
<div class="input-group"> |
||||
<input |
||||
type="text" |
||||
name="q" |
||||
value="{{ query }}" |
||||
placeholder="Search by name, display name, NIP-05, or about..." |
||||
class="search-input" |
||||
autofocus |
||||
> |
||||
<button type="submit" class="search-button"> |
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||
<circle cx="11" cy="11" r="8"></circle> |
||||
<path d="m21 21-4.35-4.35"></path> |
||||
</svg> |
||||
Search |
||||
</button> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
|
||||
{% if query %} |
||||
<div class="search-results"> |
||||
<div class="results-header"> |
||||
<h2> |
||||
{% if resultsCount > 0 %} |
||||
Found {{ resultsCount }} user{{ resultsCount != 1 ? 's' : '' }} |
||||
{% else %} |
||||
No users found |
||||
{% endif %} |
||||
</h2> |
||||
{% if resultsCount > 0 %} |
||||
<p class="results-info">Search query: <strong>{{ query }}</strong></p> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
{% if users|length > 0 %} |
||||
<div class="users-grid"> |
||||
{% for user in users %} |
||||
<div class="user-card"> |
||||
<div class="user-avatar"> |
||||
{% if user.picture %} |
||||
<img src="{{ user.picture }}" alt="{{ user.displayName ?? user.name ?? 'User' }}" loading="lazy"> |
||||
{% else %} |
||||
<div class="avatar-placeholder"> |
||||
{{ (user.displayName ?? user.name ?? user.npub[:8])|slice(0, 2)|upper }} |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
<div class="user-info"> |
||||
<h3 class="user-name"> |
||||
{{ user.displayName ?? user.name ?? 'Anonymous' }} |
||||
</h3> |
||||
{% if user.nip05 %} |
||||
<p class="user-nip05"> |
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||
<path d="M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"></path> |
||||
<path d="M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0z"></path> |
||||
</svg> |
||||
{{ user.nip05 }} |
||||
</p> |
||||
{% endif %} |
||||
{% if user.about %} |
||||
<p class="user-about"> |
||||
{{ user.about|length > 120 ? user.about|slice(0, 120) ~ '...' : user.about }} |
||||
</p> |
||||
{% endif %} |
||||
<div class="user-meta"> |
||||
<span class="user-npub" title="{{ user.npub }}"> |
||||
{{ user.npub[:12] }}...{{ user.npub[-8:] }} |
||||
</span> |
||||
{% if user.website %} |
||||
<a href="{{ user.website }}" target="_blank" rel="noopener noreferrer" class="user-website"> |
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||
<circle cx="12" cy="12" r="10"></circle> |
||||
<line x1="2" y1="12" x2="22" y2="12"></line> |
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path> |
||||
</svg> |
||||
Website |
||||
</a> |
||||
{% endif %} |
||||
{% if user.lud16 %} |
||||
<span class="user-lightning" title="Lightning: {{ user.lud16 }}"> |
||||
⚡ |
||||
</span> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{% endfor %} |
||||
</div> |
||||
{% else %} |
||||
<div class="no-results"> |
||||
<p>No users found matching "{{ query }}"</p> |
||||
<p class="help-text">Try different keywords or check your spelling</p> |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
{% else %} |
||||
<div class="search-suggestions"> |
||||
<h3>Quick Links</h3> |
||||
<div class="suggestion-links"> |
||||
<a href="{{ path('featured_writers_page') }}" class="suggestion-card"> |
||||
<h4>Featured Writers</h4> |
||||
<p>Browse our featured contributors</p> |
||||
</a> |
||||
</div> |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
|
||||
<style> |
||||
.user-search-page { |
||||
padding: 2rem 0; |
||||
min-height: 60vh; |
||||
} |
||||
|
||||
.container { |
||||
max-width: 1200px; |
||||
margin: 0 auto; |
||||
padding: 0 1rem; |
||||
} |
||||
|
||||
.page-header { |
||||
text-align: center; |
||||
margin-bottom: 2rem; |
||||
} |
||||
|
||||
.page-header h1 { |
||||
font-size: 2.5rem; |
||||
margin-bottom: 0.5rem; |
||||
} |
||||
|
||||
.subtitle { |
||||
color: #666; |
||||
font-size: 1.1rem; |
||||
} |
||||
|
||||
.search-box { |
||||
max-width: 700px; |
||||
margin: 0 auto 3rem; |
||||
} |
||||
|
||||
.search-form { |
||||
width: 100%; |
||||
} |
||||
|
||||
.input-group { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.search-input { |
||||
flex: 1; |
||||
padding: 1rem; |
||||
font-size: 1rem; |
||||
border: 2px solid #e0e0e0; |
||||
border-radius: 8px; |
||||
transition: border-color 0.2s; |
||||
} |
||||
|
||||
.search-input:focus { |
||||
outline: none; |
||||
border-color: #007bff; |
||||
} |
||||
|
||||
.search-button { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
padding: 1rem 1.5rem; |
||||
background: #007bff; |
||||
color: white; |
||||
border: none; |
||||
border-radius: 8px; |
||||
cursor: pointer; |
||||
font-size: 1rem; |
||||
font-weight: 500; |
||||
transition: background 0.2s; |
||||
} |
||||
|
||||
.search-button:hover { |
||||
background: #0056b3; |
||||
} |
||||
|
||||
.results-header { |
||||
margin-bottom: 2rem; |
||||
} |
||||
|
||||
.results-header h2 { |
||||
font-size: 1.8rem; |
||||
margin-bottom: 0.5rem; |
||||
} |
||||
|
||||
.results-info { |
||||
color: #666; |
||||
font-size: 0.95rem; |
||||
} |
||||
|
||||
.users-grid { |
||||
display: grid; |
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
||||
gap: 1.5rem; |
||||
margin-top: 2rem; |
||||
} |
||||
|
||||
.user-card { |
||||
background: white; |
||||
border: 1px solid #e0e0e0; |
||||
border-radius: 12px; |
||||
padding: 1.5rem; |
||||
transition: box-shadow 0.2s, transform 0.2s; |
||||
} |
||||
|
||||
.user-card:hover { |
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); |
||||
transform: translateY(-2px); |
||||
} |
||||
|
||||
.user-avatar { |
||||
width: 80px; |
||||
height: 80px; |
||||
margin: 0 auto 1rem; |
||||
border-radius: 50%; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
.user-avatar img { |
||||
width: 100%; |
||||
height: 100%; |
||||
object-fit: cover; |
||||
} |
||||
|
||||
.avatar-placeholder { |
||||
width: 100%; |
||||
height: 100%; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||
color: white; |
||||
font-size: 1.5rem; |
||||
font-weight: bold; |
||||
} |
||||
|
||||
.user-info { |
||||
text-align: center; |
||||
} |
||||
|
||||
.user-name { |
||||
font-size: 1.3rem; |
||||
margin-bottom: 0.5rem; |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.user-nip05 { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
gap: 0.3rem; |
||||
color: #28a745; |
||||
font-size: 0.9rem; |
||||
margin-bottom: 0.75rem; |
||||
} |
||||
|
||||
.user-about { |
||||
color: #555; |
||||
font-size: 0.95rem; |
||||
line-height: 1.5; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.user-meta { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 0.75rem; |
||||
justify-content: center; |
||||
align-items: center; |
||||
font-size: 0.85rem; |
||||
color: #777; |
||||
padding-top: 0.75rem; |
||||
border-top: 1px solid #f0f0f0; |
||||
} |
||||
|
||||
.user-npub { |
||||
font-family: monospace; |
||||
background: #f5f5f5; |
||||
padding: 0.25rem 0.5rem; |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
.user-website { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.25rem; |
||||
color: #007bff; |
||||
text-decoration: none; |
||||
} |
||||
|
||||
.user-website:hover { |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
.user-lightning { |
||||
font-size: 1.2rem; |
||||
} |
||||
|
||||
.no-results { |
||||
text-align: center; |
||||
padding: 3rem; |
||||
color: #666; |
||||
} |
||||
|
||||
.help-text { |
||||
color: #999; |
||||
font-size: 0.9rem; |
||||
margin-top: 0.5rem; |
||||
} |
||||
|
||||
.search-suggestions { |
||||
margin-top: 3rem; |
||||
} |
||||
|
||||
.search-suggestions h3 { |
||||
font-size: 1.5rem; |
||||
margin-bottom: 1.5rem; |
||||
} |
||||
|
||||
.suggestion-links { |
||||
display: grid; |
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); |
||||
gap: 1.5rem; |
||||
} |
||||
|
||||
.suggestion-card { |
||||
display: block; |
||||
padding: 2rem; |
||||
background: #f8f9fa; |
||||
border: 1px solid #e0e0e0; |
||||
border-radius: 12px; |
||||
text-decoration: none; |
||||
color: inherit; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.suggestion-card:hover { |
||||
background: white; |
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); |
||||
transform: translateY(-2px); |
||||
} |
||||
|
||||
.suggestion-card h4 { |
||||
font-size: 1.2rem; |
||||
margin-bottom: 0.5rem; |
||||
color: #007bff; |
||||
} |
||||
|
||||
.suggestion-card p { |
||||
color: #666; |
||||
font-size: 0.95rem; |
||||
} |
||||
|
||||
@media (max-width: 768px) { |
||||
.page-header h1 { |
||||
font-size: 2rem; |
||||
} |
||||
|
||||
.users-grid { |
||||
grid-template-columns: 1fr; |
||||
} |
||||
|
||||
.input-group { |
||||
flex-direction: column; |
||||
} |
||||
|
||||
.search-button { |
||||
justify-content: center; |
||||
} |
||||
} |
||||
|
||||
[data-theme="dark"] .user-card, |
||||
[data-theme="dark"] .suggestion-card { |
||||
background: #2d3748; |
||||
border-color: #4a5568; |
||||
} |
||||
|
||||
[data-theme="dark"] .search-input { |
||||
background: #2d3748; |
||||
border-color: #4a5568; |
||||
color: white; |
||||
} |
||||
|
||||
[data-theme="dark"] .user-npub { |
||||
background: #1a202c; |
||||
} |
||||
|
||||
[data-theme="dark"] .user-meta { |
||||
border-color: #4a5568; |
||||
} |
||||
</style> |
||||
{% endblock %} |
||||
|
||||
Loading…
Reference in new issue