Browse Source

Editor refactor

imwald
Nuša Pukšič 4 weeks ago
parent
commit
df85a7ed78
  1. 3
      assets/app.js
  2. 17
      assets/controllers/editor/articlelist-panels_controller.js
  3. 118
      assets/controllers/editor/json-panel_controller.js
  4. 211
      assets/controllers/editor/layout_controller.js
  5. 23
      assets/controllers/editor/panels_controller.js
  6. 28
      assets/controllers/nostr/nostr_publish_controller.js
  7. 10
      assets/controllers/publishing/quill_controller.js
  8. 7
      assets/styles/03-components/article.css
  9. 7
      assets/styles/03-components/form.css
  10. 782
      assets/styles/editor-layout.css
  11. 86
      src/Controller/ArticleController.php
  12. 30
      src/Controller/Editor/MarkdownController.php
  13. 134
      src/Controller/Search/UserSearchController.php
  14. 59
      src/Controller/Search/UserSearchPageController.php
  15. 2
      src/Form/EditorType.php
  16. 2
      src/ReadModel/RedisView/RedisArticleView.php
  17. 17
      src/ReadModel/RedisView/RedisReadingListView.php
  18. 65
      src/ReadModel/RedisView/RedisViewFactory.php
  19. 42
      src/Service/RedisViewStore.php
  20. 6
      templates/base.html.twig
  21. 355
      templates/editor/layout.html.twig
  22. 3
      templates/editor/panels/_advanced.html.twig
  23. 40
      templates/editor/panels/_json.html.twig
  24. 58
      templates/editor/panels/_media.html.twig
  25. 68
      templates/editor/panels/_metadata.html.twig
  26. 66
      templates/editor/panels/_publishing.html.twig
  27. 5
      templates/pages/_advanced_metadata.html.twig
  28. 404
      templates/user_search/featured_writers.html.twig
  29. 416
      templates/user_search/search.html.twig

3
assets/app.js

@ -38,6 +38,9 @@ import './styles/03-components/search.css'; @@ -38,6 +38,9 @@ import './styles/03-components/search.css';
import './styles/03-components/image-upload.css';
import './styles/03-components/zaps.css';
// Editor layout
import './styles/editor-layout.css';
// 04 - Page-specific styles
import './styles/04-pages/landing.css';
import './styles/04-pages/admin.css';

17
assets/controllers/editor/articlelist-panels_controller.js

@ -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);
});
}
}

118
assets/controllers/editor/json-panel_controller.js

@ -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'
);
}
}

211
assets/controllers/editor/layout_controller.js

@ -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);
}
}
}

23
assets/controllers/editor/panels_controller.js

@ -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);
});
}
}

28
assets/controllers/nostr/nostr_publish_controller.js

@ -84,15 +84,13 @@ function validateAdvancedMetadata(metadata) { @@ -84,15 +84,13 @@ function validateAdvancedMetadata(metadata) {
export default class extends Controller {
static targets = ['form', 'publishButton', 'status', 'jsonContainer', 'jsonTextarea', 'jsonToggle', 'jsonDirtyHint'];
static values = {
publishUrl: String,
csrfToken: String
publishUrl: String
};
connect() {
console.log('Nostr publish controller connected');
try {
console.debug('[nostr-publish] publishUrl:', this.publishUrlValue || '(none)');
console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue));
console.debug('[nostr-publish] existing slug:', (this.element.dataset.slug || '(none)'));
} catch (_) {}
@ -138,17 +136,15 @@ export default class extends Controller { @@ -138,17 +136,15 @@ export default class extends Controller {
if (this.hasJsonDirtyHintTarget) this.jsonDirtyHintTarget.style.display = '';
}
async publish(event) {
async publish(event = null) {
if (event) {
event.preventDefault();
}
if (!this.publishUrlValue) {
this.showError('Publish URL is not configured');
return;
}
if (!this.csrfTokenValue) {
this.showError('Missing CSRF token');
return;
}
if (!window.nostr) {
this.showError('Nostr extension not found');
@ -260,9 +256,16 @@ export default class extends Controller { @@ -260,9 +256,16 @@ export default class extends Controller {
}
collectFormData() {
// Find the actual form element within our target
const form = this.formTarget.querySelector('form');
if (!form) throw new Error('Form element not found');
// Find the actual form element in the editor (it's not within our hidden container)
// Try multiple selectors to be robust
const form = document.querySelector('.editor-center-content form')
|| document.querySelector('form[name="editor"]')
|| document.querySelector('.editor-main form');
if (!form) {
console.error('Could not find form element. Available forms:', document.querySelectorAll('form'));
throw new Error('Form element not found');
}
const fd = new FormData(form);
@ -418,8 +421,7 @@ export default class extends Controller { @@ -418,8 +421,7 @@ export default class extends Controller {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': this.csrfTokenValue
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
event: signedEvent,

10
assets/controllers/publishing/quill_controller.js

@ -142,6 +142,9 @@ export default class extends Controller { @@ -142,6 +142,9 @@ export default class extends Controller {
this.quill = new Quill(editorEl, options);
// Expose globally for preview functionality
window.appQuill = this.quill;
// If there were formulas in the loaded HTML, we need to convert them to proper embeds
if (hasFormulas) {
this.convertFormulasToEmbeds();
@ -321,6 +324,13 @@ export default class extends Controller { @@ -321,6 +324,13 @@ export default class extends Controller {
this.quill.setContents(deltaOps, 'silent');
}
disconnect() {
// Clean up global reference
if (window.appQuill === this.quill) {
window.appQuill = null;
}
}
}
/* ---------- Delta → Markdown with $...$ / $$...$$ ---------- */

7
assets/styles/03-components/article.css

@ -125,13 +125,6 @@ blockquote p { @@ -125,13 +125,6 @@ blockquote p {
aspect-ratio: 16/9;
}
.ql-toolbar {
position: sticky;
top: 80px;
background-color: var(--color-bg);
z-index: 10;
}
.ql-snow .ql-tooltip.ql-image-tooltip {
white-space: nowrap;
}

7
assets/styles/03-components/form.css

@ -27,8 +27,11 @@ input, textarea, select { @@ -27,8 +27,11 @@ input, textarea, select {
width: 100%;
}
input, textarea, select, .quill {
background-color: var(--color-bg);
textarea {
max-width: initial;
}
input, textarea, select {
color: var(--color-text);
border: 1px solid var(--color-primary);
border-radius: 0; /* Sharp edges */

782
assets/styles/editor-layout.css

@ -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;
}

86
src/Controller/ArticleController.php

@ -11,6 +11,7 @@ use App\Service\NostrClient; @@ -11,6 +11,7 @@ use App\Service\NostrClient;
use App\Service\Nostr\NostrEventBuilder;
use App\Service\Nostr\NostrEventParser;
use App\Service\RedisCacheService;
use App\Service\RedisViewStore;
use App\Util\CommonMark\Converter;
use Doctrine\ORM\EntityManagerInterface;
use nostriphant\NIP19\Bech32;
@ -24,8 +25,9 @@ use Symfony\Component\HttpFoundation\JsonResponse; @@ -24,8 +25,9 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use App\ReadModel\RedisView\RedisReadingListView;
use App\ReadModel\RedisView\RedisBaseObject;
use App\ReadModel\RedisView\RedisArticleView;
class ArticleController extends AbstractController
{
@ -196,6 +198,7 @@ class ArticleController extends AbstractController @@ -196,6 +198,7 @@ class ArticleController extends AbstractController
NostrClient $nostrClient,
EntityManagerInterface $entityManager,
NostrEventParser $eventParser,
RedisViewStore $redisViewStore,
$slug = null
): Response
{
@ -257,6 +260,12 @@ class ArticleController extends AbstractController @@ -257,6 +260,12 @@ class ArticleController extends AbstractController
}
}
$readingLists = [];
if ($user) {
$currentPubkey = $key->convertToHex($user->getUserIdentifier());
$readingLists = $redisViewStore->buildAndCacheUserReadingLists($entityManager, $currentPubkey);
}
$form = $this->createForm(EditorType::class, $article, ['action' => $formAction]);
// Populate advanced metadata form data
if ($advancedMetadata) {
@ -266,11 +275,79 @@ class ArticleController extends AbstractController @@ -266,11 +275,79 @@ class ArticleController extends AbstractController
$form->handleRequest($request);
// load template with content editor
return $this->render('pages/editor.html.twig', [
return $this->render('editor/layout.html.twig', [
'article' => $article,
'form' => $form->createView(),
'recentArticles' => $recentArticles,
'drafts' => $drafts,
'readingLists' => $readingLists,
]);
}
#[Route('/article-editor/preview/{npub}/{slug}', name: 'editor-preview-npub-slug')]
public function previewArticle(
$npub,
$slug,
EntityManagerInterface $entityManager,
NostrEventParser $eventParser,
RedisViewStore $redisViewStore,
Request $request,
NostrClient $nostrClient
): Response {
// This route previews another user's article, but sidebar shows current user's lists for navigation.
$advancedMetadata = null;
$key = new Key();
$pubkey = $key->convertToHex($npub);
$slug = urldecode($slug);
$repository = $entityManager->getRepository(Article::class);
$article = $repository->findOneBy(['slug' => $slug, 'pubkey' => $pubkey]);
if (!$article) {
throw $this->createNotFoundException('The article could not be found');
}
// Parse advanced metadata from the raw event if available
if ($article->getRaw()) {
$tags = $article->getRaw()['tags'] ?? [];
$advancedMetadata = $eventParser->parseAdvancedMetadata($tags);
}
$formAction = $this->generateUrl('editor-preview-npub-slug', ['npub' => $npub, 'slug' => $slug]);
$form = $this->createForm(EditorType::class, $article, ['action' => $formAction]);
if ($advancedMetadata) {
$form->get('advancedMetadata')->setData($advancedMetadata);
}
$form->handleRequest($request);
// Load current user's recent articles, drafts, and reading lists for sidebar
$recentArticles = [];
$drafts = [];
$readingLists = [];
$user = $this->getUser();
if ($user) {
$currentPubkey = $key->convertToHex($user->getUserIdentifier());
$recentArticles = $entityManager->getRepository(Article::class)
->findBy(['pubkey' => $currentPubkey, 'kind' => KindsEnum::LONGFORM], ['createdAt' => 'DESC'], 5);
// Collapse by slug, keep only latest revision
$recentArticles = array_reduce($recentArticles, function ($carry, $item) {
if (!isset($carry[$item->getSlug()])) {
$carry[$item->getSlug()] = $item;
}
return $carry;
});
$recentArticles = array_values($recentArticles ?? []);
// get drafts
$since = new \DateTime();
$aWeekAgo = $since->sub(new \DateInterval('P1D'))->getTimestamp();
$nostrClient->getLongFormContentForPubkey($currentPubkey, $aWeekAgo, KindsEnum::LONGFORM_DRAFT->value);
$drafts = $entityManager->getRepository(Article::class)
->findBy(['pubkey' => $currentPubkey, 'kind' => KindsEnum::LONGFORM_DRAFT], ['createdAt' => 'DESC'], 5);
$readingLists = $redisViewStore->buildAndCacheUserReadingLists($entityManager, $currentPubkey);
}
return $this->render('editor/layout.html.twig', [
'article' => $article,
'form' => $form->createView(),
'recentArticles' => $recentArticles,
'drafts' => $drafts,
'readingLists' => $readingLists,
]);
}
@ -284,7 +361,6 @@ class ArticleController extends AbstractController @@ -284,7 +361,6 @@ class ArticleController extends AbstractController
EntityManagerInterface $entityManager,
NostrClient $nostrClient,
CacheItemPoolInterface $articlesCache,
CsrfTokenManagerInterface $csrfTokenManager,
LoggerInterface $logger,
NostrEventParser $eventParser
): JsonResponse {
@ -452,6 +528,4 @@ class ArticleController extends AbstractController @@ -452,6 +528,4 @@ class ArticleController extends AbstractController
return $data;
}
}

30
src/Controller/Editor/MarkdownController.php

@ -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);
}
}
}

134
src/Controller/Search/UserSearchController.php

@ -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)
]);
}
}

59
src/Controller/Search/UserSearchPageController.php

@ -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),
]);
}
}

2
src/Form/EditorType.php

@ -58,7 +58,7 @@ class EditorType extends AbstractType @@ -58,7 +58,7 @@ class EditorType extends AbstractType
'required' => false,
])
->add('advancedMetadata', AdvancedMetadataType::class, [
'label' => 'Advanced metadata',
'label' => false,
'required' => false,
'mapped' => false,
]);

2
src/ReadModel/RedisView/RedisArticleView.php

@ -21,6 +21,6 @@ final class RedisArticleView @@ -21,6 +21,6 @@ final class RedisArticleView
public ?string $contentHtml = null, // processedHtml for article detail pages
public ?\DateTimeImmutable $publishedAt = null,
public array $topics = [], // For topic filtering
public ?int $kind = null // Added: kind for template access
) {}
}

17
src/ReadModel/RedisView/RedisReadingListView.php

@ -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
) {}
}

65
src/ReadModel/RedisView/RedisViewFactory.php

@ -3,8 +3,11 @@ @@ -3,8 +3,11 @@
namespace App\ReadModel\RedisView;
use App\Entity\Article;
use App\Entity\Event;
use App\Entity\Highlight;
use App\Enum\KindsEnum;
use App\Service\RedisCacheService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
@ -72,6 +75,7 @@ class RedisViewFactory @@ -72,6 +75,7 @@ class RedisViewFactory
contentHtml: $article->getProcessedHtml(),
publishedAt: $article->getPublishedAt(),
topics: $article->getTopics() ?? [],
kind: $article->getKind()->value,
);
}
@ -254,6 +258,7 @@ class RedisViewFactory @@ -254,6 +258,7 @@ class RedisViewFactory
'contentHtml' => $view->contentHtml,
'publishedAt' => $view->publishedAt?->format(\DateTimeInterface::ATOM),
'topics' => $view->topics,
'kind' => $view->kind, // Add kind to normalization
];
}
@ -271,6 +276,7 @@ class RedisViewFactory @@ -271,6 +276,7 @@ class RedisViewFactory
contentHtml: $data['contentHtml'] ?? null,
publishedAt: isset($data['publishedAt']) ? new \DateTimeImmutable($data['publishedAt']) : null,
topics: $data['topics'] ?? [],
kind: $data['kind'] ?? null // Add kind to denormalization
);
}
@ -297,5 +303,62 @@ class RedisViewFactory @@ -297,5 +303,62 @@ class RedisViewFactory
refs: $data['refs'] ?? [],
);
}
}
/**
* Build the user's reading lists view (array of RedisReadingListView), collating articles.
* Handles all DB lookups and view construction.
*
* @param EntityManagerInterface $em
* @param string $pubkey
* @return RedisReadingListView[]
*/
public function buildUserReadingListsView(EntityManagerInterface $em, string $pubkey): array
{
$readingListsRaw = $em->getRepository(Event::class)
->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX->value], ['created_at' => 'DESC']) ?? [];
$readingListsRaw = array_reduce($readingListsRaw, function ($carry, $item) {
$slug = $item->getSlug();
if (!isset($carry[$slug])) {
$carry[$slug] = $item;
}
return $carry;
}, []);
$readingListsRaw = array_values($readingListsRaw);
$readingLists = [];
foreach ($readingListsRaw as $list) {
$tags = $list->getTags();
$articleSlugs = [];
foreach ($tags as $tag) {
if (is_array($tag) && $tag[0] === 'a' && isset($tag[1])) {
// Slug from coordinate
$parts = explode(':', $tag[1], 3);
$articleSlugs[] = $parts[2];
}
}
$articles = [];
if ($articleSlugs) {
$dbArticles = $em->getRepository(Article::class)
->createQueryBuilder('a')
->where('a.slug IN (:slugs)')
->setParameter('slugs', $articleSlugs)
->getQuery()->getResult();
$dbArticlesBySlug = [];
foreach ($dbArticles as $a) {
$dbArticlesBySlug[$a->getSlug()] = $a;
}
foreach ($articleSlugs as $slug) {
$a = $dbArticlesBySlug[$slug] ?? null;
if ($a) {
$articles[] = $this->articleBaseObject($a);
}
}
}
$readingLists[] = new RedisReadingListView(
$list->getTitle(),
$list->getSummary(),
$articles
);
}
return $readingLists;
}
}

42
src/Service/RedisViewStore.php

@ -4,6 +4,8 @@ namespace App\Service; @@ -4,6 +4,8 @@ namespace App\Service;
use App\ReadModel\RedisView\RedisViewFactory;
use App\ReadModel\RedisView\RedisBaseObject;
use App\ReadModel\RedisView\RedisReadingListView;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
@ -230,5 +232,43 @@ class RedisViewStore @@ -230,5 +232,43 @@ class RedisViewStore
{
return strlen($data) > 10240; // 10KB
}
}
/**
* Store all articles from user reading lists as RedisBaseObject[]
* @param string $pubkey
* @param RedisReadingListView[] $readingLists
*/
public function storeUserReadingListArticles(string $pubkey, array $readingLists): void
{
$allArticles = [];
foreach ($readingLists as $list) {
foreach ($list->articles as $articleObj) {
if ($articleObj instanceof RedisBaseObject) {
$allArticles[] = $articleObj;
}
}
}
$this->storeUserArticles($pubkey, $allArticles);
}
/**
* Build and cache user reading lists, returning the final view for the template.
* Handles all DB lookups and stores all articles as RedisBaseObject in Redis.
*
* @param EntityManagerInterface $em
* @param string $pubkey
* @return RedisReadingListView[]
*/
public function buildAndCacheUserReadingLists(EntityManagerInterface $em, string $pubkey): array
{
$readingLists = $this->factory->buildUserReadingListsView($em, $pubkey);
$allArticles = [];
foreach ($readingLists as $list) {
foreach ($list->articles as $articleObj) {
$allArticles[] = $articleObj;
}
}
$this->storeUserArticles($pubkey . ':readinglists', $allArticles);
return $readingLists;
}
}

6
templates/base.html.twig

@ -27,7 +27,9 @@ @@ -27,7 +27,9 @@
</head>
<body data-controller="service-worker visit-analytics" data-visit-analytics-path-value="{{ app.request.pathInfo }}">
<twig:Header />
{% block header %}
<twig:Header />
{% endblock %}
{% block layout %}{% endblock %}
@ -42,9 +44,11 @@ @@ -42,9 +44,11 @@
</div>
</div>
{% block footer %}
<footer>
<twig:Footer />
</footer>
{% endblock %}
</body>
</html>

355
templates/editor/layout.html.twig

@ -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">&mdash; {{ 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 %}

3
templates/editor/panels/_advanced.html.twig

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
<div class="panel-section">
{{ form_row(form.advancedMetadata) }}
</div>

40
templates/editor/panels/_json.html.twig

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
<div class="panel-section" data-controller="editor--json-panel">
<h3>Raw Nostr Event</h3>
<p class="panel-help">
View and edit the raw Nostr event JSON. Changes here will override form values when publishing.
</p>
<div class="mb-3">
<button
type="button"
class="btn btn-sm btn-primary w-100"
data-action="click->editor--json-panel#regenerateJson"
>
Rebuild from form
</button>
</div>
<div class="json-editor-container">
<label for="json-editor-textarea" class="visually-hidden">Raw Nostr Event JSON</label>
<textarea
id="json-editor-textarea"
class="json-textarea"
data-editor--json-panel-target="jsonTextarea"
data-nostr--nostr-publish-target="jsonTextarea"
data-action="input->nostr--nostr-publish#onJsonInput input->editor--json-panel#onJsonInput"
rows="20"
spellcheck="false"
></textarea>
<div class="json-status" data-editor--json-panel-target="status"></div>
</div>
<div class="panel-help mt-2">
<small>
<strong>Required fields:</strong> kind, created_at, tags, content, pubkey<br>
<span class="text-warning" data-editor--json-panel-target="dirtyHint" style="display:none">
JSON modified - will override form values
</span>
</small>
</div>
</div>

58
templates/editor/panels/_media.html.twig

@ -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">&times;</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 &amp; 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>

68
templates/editor/panels/_metadata.html.twig

@ -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">&times;</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 &amp; 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>

66
templates/editor/panels/_publishing.html.twig

@ -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>

5
templates/pages/_advanced_metadata.html.twig

@ -1,8 +1,5 @@ @@ -1,8 +1,5 @@
{% block _editor_advancedMetadata_widget %}
<div class="advanced-metadata-section" {{ stimulus_controller('advanced-metadata') }}>
<details class="mb-4">
<summary class="h5 cursor-pointer">Zap splits and content warning</summary>
<div class="mt-2 p-3 border rounded">
<div class="row hidden">
<div class="col-md-6">
{{ form_row(form.doNotRepublish) }}
@ -109,7 +106,5 @@ @@ -109,7 +106,5 @@
</div>
</div>
</div>
</details>
</div>
{% endblock %}

404
templates/user_search/featured_writers.html.twig

@ -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 %}

416
templates/user_search/search.html.twig

@ -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…
Cancel
Save