Browse Source

Styles reorganization

imwald
Nuša Pukšič 3 months ago
parent
commit
59bfd93303
  1. 51
      assets/app.js
  2. 20
      assets/styles/01-base/fonts.css
  3. 13
      assets/styles/01-base/reset.css
  4. 39
      assets/styles/01-base/spacing.css
  5. 0
      assets/styles/01-base/theme.css
  6. 145
      assets/styles/01-base/typography.css
  7. 175
      assets/styles/02-layout/header.css
  8. 28
      assets/styles/02-layout/layout.css
  9. 0
      assets/styles/03-components/a2hs.css
  10. 64
      assets/styles/03-components/article.css
  11. 6
      assets/styles/03-components/button.css
  12. 29
      assets/styles/03-components/card.css
  13. 0
      assets/styles/03-components/cards-shared.css
  14. 15
      assets/styles/03-components/form.css
  15. 98
      assets/styles/03-components/image-upload.css
  16. 9
      assets/styles/03-components/modal.css
  17. 56
      assets/styles/03-components/nostr-previews.css
  18. 4
      assets/styles/03-components/notice.css
  19. 4
      assets/styles/03-components/og.css
  20. 0
      assets/styles/03-components/picture-event.css
  21. 21
      assets/styles/03-components/search.css
  22. 11
      assets/styles/03-components/spinner.css
  23. 193
      assets/styles/04-pages/admin.css
  24. 8
      assets/styles/04-pages/analytics.css
  25. 123
      assets/styles/04-pages/author-media.css
  26. 11
      assets/styles/04-pages/landing.css
  27. 15
      assets/styles/05-utilities/utilities.css
  28. 520
      assets/styles/app.css
  29. 3
      assets/styles/app.scss
  30. 52
      assets/styles/components/_nostr_previews.scss
  31. 47
      src/Controller/AuthorController.php
  32. 2
      src/Controller/EventController.php
  33. 30
      src/Service/NostrClient.php
  34. 2
      templates/admin/analytics.html.twig
  35. 9
      templates/admin/articles.html.twig
  36. 29
      templates/admin/magazine_editor.html.twig
  37. 18
      templates/admin/magazines.html.twig
  38. 4
      templates/components/SearchComponent.html.twig
  39. 142
      templates/event/_kind20_picture.html.twig
  40. 201
      templates/event/index.html.twig
  41. 2
      templates/pages/article.html.twig
  42. 92
      templates/pages/author-media.html.twig
  43. 5
      templates/pages/author.html.twig
  44. 14
      templates/pages/editor.html.twig

51
assets/app.js

@ -5,22 +5,41 @@ import './bootstrap.js';
* This file will be included onto the page via the importmap() Twig function, * This file will be included onto the page via the importmap() Twig function,
* which should already be in your base.html.twig. * which should already be in your base.html.twig.
*/ */
import './styles/fonts.css';
import './styles/theme.css';
import './styles/app.css';
import './styles/layout.css';
import './styles/button.css';
import './styles/card.css';
import './styles/article.css';
import './styles/og.css';
import './styles/form.css';
import './styles/notice.css';
import './styles/spinner.css';
import './styles/a2hs.css';
import './styles/analytics.css';
import './styles/modal.css';
import './styles/utilities.css';
import './styles/landing.css';
// 01 - Base styles (theme, fonts, typography, reset)
import './styles/01-base/fonts.css';
import './styles/01-base/theme.css';
import './styles/01-base/spacing.css';
import './styles/01-base/reset.css';
import './styles/01-base/typography.css';
// 02 - Layout (grid, header, navigation, main content)
import './styles/02-layout/layout.css';
import './styles/02-layout/header.css';
// 03 - Components (reusable UI components)
import './styles/03-components/button.css';
import './styles/03-components/cards-shared.css';
import './styles/03-components/card.css';
import './styles/03-components/form.css';
import './styles/03-components/article.css';
import './styles/03-components/modal.css';
import './styles/03-components/notice.css';
import './styles/03-components/spinner.css';
import './styles/03-components/a2hs.css';
import './styles/03-components/og.css';
import './styles/03-components/nostr-previews.css';
import './styles/03-components/picture-event.css';
import './styles/03-components/search.css';
import './styles/03-components/image-upload.css';
// 04 - Page-specific styles
import './styles/04-pages/landing.css';
import './styles/04-pages/admin.css';
import './styles/04-pages/analytics.css';
import './styles/04-pages/author-media.css';
// 05 - Utilities (last for highest specificity)
import './styles/05-utilities/utilities.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉'); console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');

20
assets/styles/fonts.css → assets/styles/01-base/fonts.css

@ -4,7 +4,7 @@
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: url('../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ src: url('../../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
} }
/* eb-garamond-italic - latin_latin-ext */ /* eb-garamond-italic - latin_latin-ext */
@font-face { @font-face {
@ -12,7 +12,7 @@
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: italic; font-style: italic;
font-weight: 400; font-weight: 400;
src: url('../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ src: url('../../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
} }
/* eb-garamond-500 - latin_latin-ext */ /* eb-garamond-500 - latin_latin-ext */
@font-face { @font-face {
@ -20,7 +20,7 @@
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
src: url('../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ src: url('../../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
} }
/* eb-garamond-500italic - latin_latin-ext */ /* eb-garamond-500italic - latin_latin-ext */
@font-face { @font-face {
@ -28,7 +28,7 @@
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: italic; font-style: italic;
font-weight: 500; font-weight: 500;
src: url('../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ src: url('../../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
} }
/* eb-garamond-600 - latin_latin-ext */ /* eb-garamond-600 - latin_latin-ext */
@font-face { @font-face {
@ -36,7 +36,7 @@
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
src: url('../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ src: url('../../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
} }
/* eb-garamond-600italic - latin_latin-ext */ /* eb-garamond-600italic - latin_latin-ext */
@font-face { @font-face {
@ -44,7 +44,7 @@
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: italic; font-style: italic;
font-weight: 600; font-weight: 600;
src: url('../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ src: url('../../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
} }
/* eb-garamond-700 - latin_latin-ext */ /* eb-garamond-700 - latin_latin-ext */
@font-face { @font-face {
@ -52,7 +52,7 @@
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
src: url('../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ src: url('../../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
} }
/* eb-garamond-700italic - latin_latin-ext */ /* eb-garamond-700italic - latin_latin-ext */
@font-face { @font-face {
@ -60,7 +60,7 @@
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: italic; font-style: italic;
font-weight: 700; font-weight: 700;
src: url('../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ src: url('../../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
} }
/* eb-garamond-800 - latin_latin-ext */ /* eb-garamond-800 - latin_latin-ext */
@font-face { @font-face {
@ -68,7 +68,7 @@
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: normal; font-style: normal;
font-weight: 800; font-weight: 800;
src: url('../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-800.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ src: url('../../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-800.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
} }
/* eb-garamond-800italic - latin_latin-ext */ /* eb-garamond-800italic - latin_latin-ext */
@font-face { @font-face {
@ -76,5 +76,5 @@
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: italic; font-style: italic;
font-weight: 800; font-weight: 800;
src: url('../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-800italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ src: url('../../fonts/eb-garamond/eb-garamond-v30-latin_latin-ext-800italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
} }

13
assets/styles/01-base/reset.css

@ -0,0 +1,13 @@
/**
* Base Reset
* Minimal reset and base element styles
*/
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
}

39
assets/styles/01-base/spacing.css

@ -0,0 +1,39 @@
/**
* Spacing System
* Define consistent spacing variables based on an 8px base unit
* This creates a predictable rhythm across the entire application
*/
:root {
/* Base spacing unit: 8px */
--spacing-base: 0.5rem; /* 8px */
/* Spacing scale (multiples of base unit) */
--spacing-0: 0;
--spacing-1: 0.25rem; /* 4px - micro spacing */
--spacing-2: 0.5rem; /* 8px - small spacing */
--spacing-3: 1rem; /* 16px - medium spacing */
--spacing-4: 1.5rem; /* 24px - large spacing */
--spacing-5: 2rem; /* 32px - xl spacing */
--spacing-6: 3rem; /* 48px - xxl spacing */
--spacing-7: 4rem; /* 64px - huge spacing */
--spacing-8: 6rem; /* 96px - massive spacing */
/* Common use-case aliases */
--spacing-xs: var(--spacing-1);
--spacing-sm: var(--spacing-2);
--spacing-md: var(--spacing-3);
--spacing-lg: var(--spacing-4);
--spacing-xl: var(--spacing-5);
--spacing-2xl: var(--spacing-6);
--spacing-3xl: var(--spacing-7);
--spacing-4xl: var(--spacing-8);
/* Component-specific spacing */
--button-padding-y: var(--spacing-2);
--button-padding-x: var(--spacing-4);
--card-padding: var(--spacing-4);
--input-padding: var(--spacing-2);
--section-spacing: var(--spacing-6);
}

0
assets/styles/theme.css → assets/styles/01-base/theme.css

145
assets/styles/01-base/typography.css

@ -0,0 +1,145 @@
/**
* Typography Styles
* Base typography including headings, paragraphs, links, and text utilities
*/
body {
display: flex;
flex-direction: column;
min-height: 100vh;
max-width: 100%;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family), sans-serif;
margin: 0;
padding: 0;
line-height: 1.6;
}
/* Headings */
h1, h2, h3, h4, h5, h6 {
font-family: var(--heading-font), serif;
font-weight: 600;
line-height: 1.1;
color: var(--color-primary);
margin: 30px 0 10px;
}
h1 {
font-size: 3.2rem;
margin-top: 0.25em;
font-weight: 300;
}
h1.brand {
font-family: var(--brand-font), serif;
color: var(--color-primary);
font-size: 3.2rem;
}
h1.brand a {
color: var(--brand-color);
text-decoration: none;
}
h1.brand a:hover {
text-decoration: none;
}
h1:not(.brand) > a:hover {
text-decoration: none;
font-weight: 500;
}
h2 {
font-size: 2.2rem;
}
h2.brand {
font-family: var(--brand-font), serif;
color: var(--color-primary);
}
h3 {
font-size: 2rem;
}
h4 {
font-size: 1.9rem;
}
h5 {
font-size: 1.75rem;
}
h6 {
font-size: 1.5rem;
}
/* Sidebar heading overrides */
aside h1 {
font-size: 1.2rem;
}
aside h2 {
font-size: 1.1rem;
}
aside p.lede {
font-size: 1rem;
}
/* Paragraphs and text */
p {
margin: 0 0 15px;
}
.lede {
font-family: var(--main-body-font), serif;
font-size: 1.6rem;
word-wrap: break-word;
font-weight: 300;
}
strong:not(>h2), .strong {
color: var(--color-primary);
}
/* Links */
a {
color: var(--color-secondary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Images and icons */
img {
max-width: 100%;
height: auto;
}
svg.icon {
width: 2em;
height: 2em;
}
/* Utility classes */
.hidden {
display: none;
}
.divider {
border: 2px solid var(--color-primary);
margin: 20px 0;
}
.truncate {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}

175
assets/styles/02-layout/header.css

@ -0,0 +1,175 @@
/**
* Header Component
* Main site header with navigation categories
*/
.header {
text-align: center;
z-index: 1000;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
.header .container {
display: flex;
flex-direction: column;
}
.header__categories ul {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
padding: 0;
}
.header__categories li {
list-style: none;
}
.header__categories li a:hover {
text-decoration: none;
}
/**
* Shared Card Styles
* Styles that are used across different card types in the app
*/
.card {
background-color: var(--color-bg);
color: var(--color-text);
padding: 0;
margin: 0 0 2rem 0;
border-radius: 0; /* Sharp edges */
}
.card a:hover {
text-decoration: none;
color: var(--color-text);
cursor: pointer;
}
.card a:hover h2 {
color: var(--color-text);
}
.card.bordered {
border: 2px solid var(--color-border);
}
.card-header {
margin: 10px 0;
}
.header__image {
position: relative;
width: 100%;
overflow: hidden;
}
.header__image::before {
content: "";
display: block;
padding-top: 56.25%; /* 16:9 aspect ratio */
}
.header__image img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.card-body {
font-size: 1rem;
}
.card-footer {
border-top: 1px solid var(--color-border);
margin: 20px 0;
}
/* Featured cards layout */
.featured-cat {
border-bottom: 2px solid var(--color-border);
padding-left: 10px;
}
.featured-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.featured-list > * {
box-sizing: border-box;
margin-bottom: 10px;
padding: 10px;
}
div:nth-child(odd) .featured-list {
flex-direction: row-reverse;
}
.featured-list div:first-child {
flex: 0 0 66%;
}
.featured-list div:last-child {
flex: 0 0 34%;
}
.featured-list h2.card-title {
font-size: 1.5rem;
}
.featured-list p.lede {
font-size: 1.4rem;
}
.featured-list .card {
margin-bottom: 20px;
}
.featured-list .card:not(:last-child) {
border-bottom: 1px solid var(--color-border);
}
/* Article list cards */
.article-list .metadata {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: baseline;
}
.article-list .metadata p {
margin: 0;
}
/* Responsive adjustments */
@media (max-width: 1024px) {
.featured-list {
flex-direction: column !important;
}
.featured-list .card-header {
margin-top: 20px;
}
.featured-list .card {
border-bottom: 1px solid var(--color-border) !important;
}
.featured-list > * {
margin-bottom: 10px;
padding: 0;
}
}

28
assets/styles/layout.css → assets/styles/02-layout/layout.css

@ -29,7 +29,7 @@ nav, aside {
} }
nav { nav {
padding: 1em; padding: var(--spacing-3);
overflow-y: auto; /* Ensure the menu is scrollable if content is too long */ overflow-y: auto; /* Ensure the menu is scrollable if content is too long */
} }
@ -47,7 +47,7 @@ nav ul {
} }
nav li { nav li {
margin: 0.5em 0; margin: var(--spacing-2) 0;
} }
nav a { nav a {
@ -77,7 +77,7 @@ main {
flex-direction: column; flex-direction: column;
margin-top: 90px; margin-top: 90px;
flex-grow: 1; flex-grow: 1;
padding: 0 1em; padding: 0 var(--spacing-3);
word-break: break-word; word-break: break-word;
} }
@ -94,7 +94,7 @@ main.static {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: var(--spacing-3);
} }
.user-menu { .user-menu {
@ -240,17 +240,29 @@ footer {
background-color: #333; background-color: #333;
color: white; color: white;
text-align: center; text-align: center;
padding: 1em 0; padding: var(--spacing-3) 0;
position: relative; position: relative;
width: 100%; width: 100%;
} }
footer .footer-links { footer .footer-links {
margin: 24px 0; margin: var(--spacing-4) 0;
} }
.search input { footer a {
flex-grow: 1; color: var(--color-accent, #8FCB7E);
text-decoration: none;
transition: color 0.2s ease;
}
footer a:hover {
color: white;
text-decoration: underline;
}
footer p {
margin: var(--spacing-2) 0;
color: rgba(255, 255, 255, 0.8);
} }
nav > header, aside > header { /* prevent global header fixed rules applying to nested headers */ nav > header, aside > header { /* prevent global header fixed rules applying to nested headers */

0
assets/styles/a2hs.css → assets/styles/03-components/a2hs.css

64
assets/styles/article.css → assets/styles/03-components/article.css

@ -1,17 +1,22 @@
/**
* Article Component
* Article-specific styling (content, actions, metadata)
*/
.article-main { .article-main {
margin-top: 30px; margin-top: var(--spacing-5);
} }
.article-main h2, .article-main h3, .article-main h2, .article-main h3,
.article-main h4, .article-main h5, .article-main h6 { .article-main h4, .article-main h5, .article-main h6 {
margin-top: 2em; margin-top: var(--spacing-5);
} }
.article-actions { .article-actions {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
gap: 1rem; gap: var(--spacing-3);
margin: 1rem 0; margin: var(--spacing-3) 0;
} }
.article-main p, .article-main p,
@ -38,28 +43,28 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
margin: 2rem 0; margin: var(--spacing-5) 0;
padding-top: 0.5rem; padding-top: var(--spacing-2);
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
font-size: 1rem; font-size: 1rem;
} }
blockquote { blockquote {
border-left: 6px solid var(--color-bg-light); border-left: 6px solid var(--color-bg-light);
padding-left: 3px; padding-left: var(--spacing-1);
margin: 50px 0 50px 3px; margin: var(--spacing-6) 0 var(--spacing-6) var(--spacing-1);
} }
blockquote p { blockquote p {
font-size: 1.6rem; font-size: 1.6rem;
font-style: italic; font-style: italic;
color: var(--color-text-mid); color: var(--color-text-mid);
padding-left: 30px; padding-left: var(--spacing-5);
} }
.table-of-contents { .table-of-contents {
border-left: var(--color-secondary) 6px solid; border-left: var(--color-secondary) 6px solid;
margin: 2em 0; margin: var(--spacing-5) 0;
} }
.table-of-contents li { .table-of-contents li {
@ -70,7 +75,7 @@ blockquote p {
.heading-permalink { .heading-permalink {
float: left; float: left;
padding-right: 0; padding-right: 0;
margin-left: -30px; margin-left: calc(var(--spacing-5) * -1);
line-height: 1.2; line-height: 1.2;
color: var(--color-secondary); color: var(--color-secondary);
} }
@ -111,3 +116,40 @@ blockquote p {
.ql-snow .ql-tooltip.ql-image-tooltip::before { .ql-snow .ql-tooltip.ql-image-tooltip::before {
content: 'Image:'; content: 'Image:';
} }
/* Article tags/topics */
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin: var(--spacing-4) 0;
}
.tag {
display: inline-block;
padding: var(--spacing-1) var(--spacing-3);
background-color: var(--color-primary);
color: var(--color-text-contrast);
border-radius: 1.5rem;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.tag:hover {
background-color: var(--color-secondary);
transform: translateY(-1px);
text-decoration: none;
}
/* Hashtag styling (for inline hashtags in content) */
.hashtag {
color: var(--color-secondary);
font-weight: 500;
}
.hashtag:hover {
color: var(--color-primary);
text-decoration: underline;
}

6
assets/styles/button.css → assets/styles/03-components/button.css

@ -1,9 +1,13 @@
/**
* Button Component
* Primary button styles and variants
*/
button, .btn, a.btn { button, .btn, a.btn {
background-color: var(--color-primary); background-color: var(--color-primary);
color: var(--color-text-contrast); color: var(--color-text-contrast);
border: 2px solid var(--color-primary); border: 2px solid var(--color-primary);
padding: 10px 20px; padding: var(--button-padding-y) var(--button-padding-x);
text-transform: uppercase; text-transform: uppercase;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;

29
assets/styles/card.css → assets/styles/03-components/card.css

@ -1,11 +1,17 @@
/**
* Card Component
* Specific card implementations (price lists, comments, etc.)
* For shared card styles, see cards-shared.css
*/
h2.card-title { h2.card-title {
margin-top: 10px; margin-top: var(--spacing-2);
} }
.price-list { .price-list {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 20px; gap: var(--spacing-4);
} }
.price-list .card { .price-list .card {
@ -14,17 +20,24 @@ h2.card-title {
justify-content: start; justify-content: start;
border: var(--color-primary) solid 1px; border: var(--color-primary) solid 1px;
flex-grow: 1; flex-grow: 1;
padding: 20px; padding: var(--spacing-4);
flex-basis: 300px; flex-basis: 300px;
} }
.price-list .card .features { .price-list .card .features {
list-style: none; padding: 0; list-style: none;
padding: 0;
} }
.price { font-size: 22px; font-weight: bold; color: var(--color-secondary); } .price {
font-size: 22px;
font-weight: bold;
color: var(--color-secondary);
}
.price-list .card .features li { padding: 8px 0; } .price-list .card .features li {
padding: var(--spacing-2) 0;
}
.price-list .card button:last-child { .price-list .card button:last-child {
margin-top: auto; margin-top: auto;
@ -51,7 +64,7 @@ h2.card-title {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: var(--color-bg-light); background-color: var(--color-bg-light);
padding: 10px; padding: var(--spacing-2);
} }
.card.comment.zap-comment { .card.comment.zap-comment {
@ -62,7 +75,7 @@ h2.card-title {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 10px; margin-bottom: var(--spacing-2);
} }
.card.comment .metadata p { .card.comment .metadata p {

0
assets/styles/03-components/cards-shared.css

15
assets/styles/form.css → assets/styles/03-components/form.css

@ -1,14 +1,19 @@
/**
* Form Component
* Form input styles and layout
*/
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
clear: both; clear: both;
margin-bottom: 1em; margin-bottom: var(--spacing-3);
} }
form > div:not(.actions) { form > div:not(.actions) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-bottom: 1.5em; margin-bottom: var(--spacing-4);
} }
label { label {
@ -30,7 +35,7 @@ input, textarea, select, .quill {
} }
input, textarea, select { input, textarea, select {
padding: 10px; padding: var(--input-padding);
} }
textarea, input, select { textarea, input, select {
@ -65,7 +70,7 @@ input:focus, textarea:focus, select:focus {
.image-with-preview img.avatar { .image-with-preview img.avatar {
width: 40px; width: 40px;
height: 40px; height: 40px;
margin-right: 1em; margin-right: var(--spacing-3);
} }
.image-with-preview input { .image-with-preview input {
@ -86,7 +91,7 @@ fieldset {
} }
.actions { .actions {
margin-bottom: 1.5em; margin-bottom: var(--spacing-4);
} }
form ul.list-unstyled li > div > label { form ul.list-unstyled li > div > label {

98
assets/styles/03-components/image-upload.css

@ -0,0 +1,98 @@
/* Image Upload Dialog Styles */
.iu-dialog {
display: none;
}
.iu-dialog.active {
display: block;
}
.iu-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.iu-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--color-bg, #fff);
border-radius: 0.5rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.3);
max-width: 90%;
width: 500px;
max-height: 90vh;
overflow-y: auto;
z-index: 1001;
}
.iu-modal .modal-header {
padding: var(--spacing-3);
border-bottom: 1px solid var(--color-border, #dee2e6);
display: flex;
justify-content: space-between;
align-items: center;
}
.iu-modal .modal-header h5 {
margin: 0;
}
.iu-modal .modal-header .close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--color-text);
}
.iu-modal .modal-body {
padding: var(--spacing-3);
}
.iu-modal .modal-body > div {
margin-bottom: var(--spacing-3);
}
.upload-area {
border: 2px dashed #ccc;
padding: var(--spacing-5);
text-align: center;
cursor: pointer;
min-height: 4em;
border-radius: 0.25rem;
transition: border-color 0.2s;
}
.upload-area:hover {
border-color: var(--color-primary, #007bff);
}
.upload-area input[type="file"] {
display: none;
}
.upload-progress {
display: none;
margin-top: 1em;
}
.upload-progress.active {
display: block;
}
.upload-error {
color: red;
margin-top: 1em;
display: none;
}
.upload-error.active {
display: block;
}

9
assets/styles/modal.css → assets/styles/03-components/modal.css

@ -4,6 +4,7 @@
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
z-index: 999; z-index: 999;
} }
.iu-modal { .iu-modal {
position: fixed; position: fixed;
top: 50%; top: 50%;
@ -13,16 +14,18 @@
width: min(600px, 90vw); width: min(600px, 90vw);
max-height: 85vh; max-height: 85vh;
overflow: auto; overflow: auto;
padding: 1rem 1.25rem; padding: var(--spacing-3) var(--spacing-4);
z-index: 1000; z-index: 1000;
} }
.iu-modal .modal-header { .iu-modal .modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: .5rem; gap: var(--spacing-2);
margin-bottom: .5rem; margin-bottom: var(--spacing-2);
} }
.iu-modal .close { .iu-modal .close {
appearance: none; appearance: none;
border: 0; border: 0;

56
assets/styles/03-components/nostr-previews.css

@ -0,0 +1,56 @@
/**
* Nostr Previews Component
* Styles for Nostr event and profile preview cards
* Converted from SCSS to plain CSS
*/
.nostr-preview {
margin-top: var(--spacing-2);
}
.nostr-preview .nostr-event-preview,
.nostr-preview .nostr-profile-preview {
border-left: 3px solid #6c5ce7;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.nostr-preview .nostr-profile-preview {
border-left-color: #00b894;
}
.nostr-preview .card-title {
margin-bottom: var(--spacing-2);
font-size: 1rem;
}
.nostr-preview .card-text {
font-size: 0.9rem;
}
.nostr-preview .card-footer {
padding: var(--spacing-2) var(--spacing-3);
}
.nostr-previews h6 {
font-size: 0.9rem;
margin-bottom: var(--spacing-3);
}
.nostr-previews .preview-container {
max-height: 500px;
overflow-y: auto;
padding-right: var(--spacing-2);
}
/* Style for nostr links in text */
.nostr-link {
color: #6c5ce7;
background-color: rgba(108, 92, 231, 0.1);
padding: 0 var(--spacing-1);
border-radius: 3px;
text-decoration: none;
}
.nostr-link:hover {
background-color: rgba(108, 92, 231, 0.2);
}

4
assets/styles/notice.css → assets/styles/03-components/notice.css

@ -1,7 +1,7 @@
.notice { .notice {
padding: 10px; /* Padding around the content */ padding: var(--spacing-2); /* Padding around the content */
border-radius: 5px; /* Rounded corners */ border-radius: 5px; /* Rounded corners */
margin: 10px 0; /* Margin above and below the notice */ margin: var(--spacing-2) 0; /* Margin above and below the notice */
} }
.notice p { .notice p {

4
assets/styles/og.css → assets/styles/03-components/og.css

@ -1,7 +1,7 @@
.og-preview-card { .og-preview-card {
max-width: 100%; max-width: 100%;
padding: 20px; padding: var(--spacing-4);
margin: 10px 0; margin: var(--spacing-2) 0;
background-color: var(--color-bg); background-color: var(--color-bg);
} }

0
assets/styles/03-components/picture-event.css

21
assets/styles/03-components/search.css

@ -0,0 +1,21 @@
/* Search Component Styles */
.search-component {
margin-bottom: 1rem;
}
.search-credits {
text-align: right;
}
.search-credits .help-text {
font-size: 0.875rem;
}
.search-loading {
text-align: center;
}
.search form {
margin-bottom: 1rem;
}

11
assets/styles/spinner.css → assets/styles/03-components/spinner.css

@ -1,8 +1,14 @@
/**
* Spinner Component
* Main loading spinner (dual-ring design)
* Use this for full-page or section loading states
*/
.spinner { .spinner {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin: 1em 0; margin: var(--spacing-3) 0;
} }
.lds-dual-ring { .lds-dual-ring {
@ -10,12 +16,13 @@
width: 30px; width: 30px;
height: 30px; height: 30px;
} }
.lds-dual-ring:after { .lds-dual-ring:after {
content: " "; content: " ";
display: block; display: block;
width: 32px; width: 32px;
height: 32px; height: 32px;
margin: 4px; margin: var(--spacing-1);
border-radius: 50%; border-radius: 50%;
border: 4px solid var(--color-primary); border: 4px solid var(--color-primary);
border-color: var(--color-primary) transparent var(--color-primary) transparent; border-color: var(--color-primary) transparent var(--color-primary) transparent;

193
assets/styles/04-pages/admin.css

@ -0,0 +1,193 @@
/* Admin Panel Styles */
/* Analytics */
.analytics-container {
padding: var(--spacing-3);
}
.analytics-card {
margin-bottom: var(--spacing-5);
padding: var(--spacing-3);
background-color: var(--color-card-bg, #f8f9fa);
border-radius: 0.5rem;
}
.analytics-stats {
list-style: none;
padding: 0;
margin: 0;
}
.analytics-stats li {
padding: var(--spacing-2) 0;
}
.analytics-table {
width: 100%;
border-collapse: collapse;
}
.analytics-table th {
padding: var(--spacing-3);
text-align: left;
border-bottom: 2px solid var(--color-border, #dee2e6);
}
.analytics-table th.text-right,
.analytics-table th[style*="text-align: right"] {
min-width: 100px;
text-align: right;
}
.analytics-table td {
padding: var(--spacing-3);
border-bottom: 1px solid var(--color-border, #dee2e6);
}
.analytics-table td.text-right {
text-align: right;
}
.analytics-info {
padding: var(--spacing-3);
background-color: var(--color-info-bg, #e9f5ff);
border-radius: 0.25rem;
}
/* Articles Table */
.admin-articles-table {
width: 100%;
border-collapse: collapse;
}
.admin-articles-table tr {
padding-bottom: var(--spacing-1);
}
.admin-articles-table td {
padding: var(--spacing-2);
vertical-align: top;
}
.admin-articles-table button {
margin-left: var(--spacing-2);
}
.admin-articles-table form {
display: inline;
}
/* Magazine Editor */
.magazine-editor-layout {
display: flex;
gap: var(--spacing-4);
}
.magazine-editor-section {
flex: 1;
min-width: 320px;
}
.magazine-search-form {
margin-bottom: var(--spacing-3);
display: flex;
gap: var(--spacing-2);
}
.magazine-search-form input[type="text"] {
flex: 1;
}
.magazine-table {
width: 100%;
border-collapse: collapse;
}
.magazine-table th {
padding: 0.25rem 0.5rem;
text-align: left;
border-bottom: 1px solid var(--color-border, #dee2e6);
}
.magazine-table td {
padding: 0.25rem 0.5rem;
vertical-align: top;
}
.magazine-table td.actions {
text-align: right;
}
.magazine-table td.author {
white-space: nowrap;
}
/* NIP-68 Picture Event Styles */
.picture-event {
margin: 1rem 0;
}
.picture-title {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.content-warning {
padding: 1rem;
background-color: var(--color-warning-bg, #fff3cd);
border: 1px solid var(--color-warning-border, #ffc107);
border-radius: 0.25rem;
margin-bottom: 1rem;
}
.btn-show-nsfw {
margin-left: 0.5rem;
padding: 0.25rem 0.5rem;
background-color: var(--color-primary);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
.btn-show-nsfw:hover {
opacity: 0.9;
}
.picture-gallery {
display: block;
}
.picture-gallery.hidden {
display: none;
}
.picture-item {
position: relative;
margin-bottom: 1rem;
}
.picture-image {
max-width: 100%;
height: auto;
display: block;
}
.annotated-users {
position: relative;
}
.user-tag {
position: absolute;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
white-space: nowrap;
}
.picture-alt {
margin-top: 0.5rem;
font-style: italic;
color: var(--color-text-muted, #6c757d);
}

8
assets/styles/analytics.css → assets/styles/04-pages/analytics.css

@ -1,14 +1,14 @@
.analytics-container { .analytics-container {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: var(--spacing-4);
} }
.analytics-card { .analytics-card {
background: lightgray; background: lightgray;
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: var(--spacing-4);
margin-bottom: 20px; margin-bottom: var(--spacing-4);
box-shadow: 0 2px 5px rgba(0,0,0,0.1); box-shadow: 0 2px 5px rgba(0,0,0,0.1);
} }
@ -18,7 +18,7 @@
} }
.analytics-table th, .analytics-table td { .analytics-table th, .analytics-table td {
padding: 10px; padding: var(--spacing-2);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }

123
assets/styles/04-pages/author-media.css

@ -0,0 +1,123 @@
.profile-tabs {
display: flex;
gap: 1rem;
margin: 1.5rem 0;
border-bottom: 2px solid #e0e0e0;
}
.tab-link {
padding: 0.75rem 1.5rem;
text-decoration: none;
color: #666;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
transition: all 0.3s ease;
}
.tab-link:hover {
color: #333;
border-bottom-color: #ccc;
}
.tab-link.active {
color: #0066cc;
border-bottom-color: #0066cc;
font-weight: 600;
}
.masonry-grid {
column-count: 3;
column-gap: 1.5rem;
margin: 2rem 0;
}
@media (max-width: 1200px) {
.masonry-grid {
column-count: 2;
}
}
@media (max-width: 768px) {
.masonry-grid {
column-count: 1;
}
}
.masonry-item {
break-inside: avoid;
margin-bottom: 1.5rem;
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.masonry-item:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.masonry-link, .masonry-link:hover {
display: block;
text-decoration: none;
color: inherit;
}
.masonry-image-container {
width: 100%;
overflow: hidden;
background-color: #f5f5f5;
}
.masonry-image {
width: 100%;
height: auto;
display: block;
transition: transform 0.3s ease;
}
.masonry-item:hover .masonry-image {
transform: scale(1.05);
}
.masonry-caption {
padding: 1rem 1rem 0.5rem;
}
.masonry-caption h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #333;
line-height: 1.4;
}
.masonry-description {
padding: 0 1rem;
font-size: 0.9rem;
color: #666;
line-height: 1.5;
}
.masonry-meta {
padding: 0.75rem 1rem;
border-top: 1px solid #f0f0f0;
margin-top: 0.5rem;
}
.event-date {
font-size: 0.85rem;
color: #999;
}
.no-media {
text-align: center;
padding: 3rem 1rem;
color: #666;
}
.no-media p {
font-size: 1.1rem;
}

11
assets/styles/landing.css → assets/styles/04-pages/landing.css

@ -1,4 +1,3 @@
.center{ text-align: center; } .center{ text-align: center; }
/* Eyebrow + lede */ /* Eyebrow + lede */
@ -7,12 +6,12 @@
letter-spacing: .12em; letter-spacing: .12em;
font-size: .8rem; font-size: .8rem;
color: var(--color-text-mid); color: var(--color-text-mid);
margin: 0 0 1rem; margin: 0 0 var(--spacing-3);
} }
/* Hero split */ /* Hero split */
.ln-hero{ .ln-hero{
margin-top: 100px; margin-top: var(--spacing-8);
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
} }
@ -27,7 +26,7 @@
} }
/* Generic section shell */ /* Generic section shell */
.ln-section{ position: relative; padding: 3.2rem 0; } .ln-section{ position: relative; padding: var(--section-spacing) 0; }
/* Split layout for features */ /* Split layout for features */
.ln-split{ .ln-split{
@ -35,9 +34,9 @@
grid-template-columns: 280px 1fr; grid-template-columns: 280px 1fr;
align-items: start; align-items: start;
} }
.ln-split__aside{ position: sticky; top: 24px; align-self: start; } .ln-split__aside{ position: sticky; top: var(--spacing-4); align-self: start; }
.ln-split__body .measure{ max-width: 70ch; } .ln-split__body .measure{ max-width: 70ch; }
.cta-row{ margin-top: .6rem; } .cta-row{ margin-top: var(--spacing-2); }
/* Section palettes (alternating) */ /* Section palettes (alternating) */
.ln-section--search{ .ln-section--search{

15
assets/styles/utilities.css → assets/styles/05-utilities/utilities.css

@ -1,4 +1,9 @@
/* Utility classes (plain CSS) - spacing, text, layout, alerts, etc. */ /**
* Utility Classes
* Bootstrap-style utility classes for quick styling adjustments
* Note: For main loading spinners, use .lds-dual-ring from spinner.css
* .spinner-border is for inline/button spinners
*/
/* Spacing scale: 0=0, 1=.25rem, 2=.5rem, 3=1rem, 4=1.5rem, 5=3rem */ /* Spacing scale: 0=0, 1=.25rem, 2=.5rem, 3=1rem, 4=1.5rem, 5=3rem */
.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important} .m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}
@ -9,13 +14,15 @@
.mx-0{margin-left:0!important;margin-right:0!important}.mx-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-3{margin-left:1rem!important;margin-right:1rem!important}.mx-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-5{margin-left:3rem!important;margin-right:3rem!important} .mx-0{margin-left:0!important;margin-right:0!important}.mx-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-3{margin-left:1rem!important;margin-right:1rem!important}.mx-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-5{margin-left:3rem!important;margin-right:3rem!important}
.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important} .my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}
.mx-auto{margin-left:auto!important;margin-right:auto!important} .mx-auto{margin-left:auto!important;margin-right:auto!important}
/* Display & layout */ /* Display & layout */
.d-flex{display:flex!important;flex-direction:column} .d-flex{display:flex!important;flex-direction:column}
.d-inline{display:inline!important} .d-inline{display:inline!important}
.d-block{display:block!important} .d-block{display:block!important}
.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important} .gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}
.flex-row{flex-direction:row} .flex-wrap{flex-wrap: wrap} .flex-row{flex-direction:row}
.flex-wrap{flex-wrap: wrap}
.justify-content-between{justify-content:space-between!important} .justify-content-between{justify-content:space-between!important}
.justify-content-center{justify-content:center!important} .justify-content-center{justify-content:center!important}
.align-items-center{align-items:center!important} .align-items-center{align-items:center!important}
@ -43,7 +50,7 @@
/* Buttons - sizes only (base buttons defined elsewhere) */ /* Buttons - sizes only (base buttons defined elsewhere) */
.btn.btn-sm{padding:.25rem .5rem;font-size:.875rem;line-height:1.25;border-radius:.2rem} .btn.btn-sm{padding:.25rem .5rem;font-size:.875rem;line-height:1.25;border-radius:.2rem}
/* Spinner (bootstrap-like) */ /* Inline spinner (for buttons, etc.) */
.spinner-border{display:inline-block;width:1.5rem;height:1.5rem;border:.2em solid currentColor;border-right-color:transparent;border-radius:50%;animation:spinner-border .75s linear infinite} .spinner-border{display:inline-block;width:1.5rem;height:1.5rem;border:.2em solid currentColor;border-right-color:transparent;border-radius:50%;animation:spinner-border .75s linear infinite}
.spinner-border-sm{width:1rem;height:1rem;border-width:.15em} .spinner-border-sm{width:1rem;height:1rem;border-width:.15em}
@keyframes spinner-border{to{transform:rotate(360deg)}} @keyframes spinner-border{to{transform:rotate(360deg)}}
@ -52,4 +59,4 @@
details>summary{cursor:pointer} details>summary{cursor:pointer}
/* Text truncation with ellipsis */ /* Text truncation with ellipsis */
.line-clamp-5 {display: -webkit-box;-webkit-box-orient: vertical;-webkit-line-clamp: 5;overflow: hidden;text-overflow: ellipsis;} .line-clamp-5{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:5;overflow:hidden;text-overflow:ellipsis}

520
assets/styles/app.css

@ -1,520 +0,0 @@
body {
display: flex;
flex-direction: column;
min-height: 100vh;
max-width: 100%;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family), sans-serif;
margin: 0;
padding: 0;
line-height: 1.6;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--heading-font), serif;
font-weight: 600;
line-height: 1.1;
color: var(--color-primary);
margin: 30px 0 10px;
}
h1 {
font-size: 3.2rem;
margin-top: 0.25em;
font-weight: 300;
}
h1.brand {
font-family: var(--brand-font), serif;
color: var(--color-primary);
font-size: 3.2rem;
}
@media screen and (max-width: 600px) {
h1.brand {
font-size: 2.3rem;
}
}
h1.brand a {
color: var(--brand-color);
}
h1:not(.brand) > a:hover {
text-decoration: none;
font-weight: 500;
}
h2 {
font-size: 2.2rem;
}
h2.brand {
font-family: var(--brand-font), serif;
color: var(--color-primary);
}
h3 {
font-size: 2rem;
}
h4 {
font-size: 1.9rem;
}
h5 {
font-size: 1.75rem;
}
h6 {
font-size: 1.5rem;
}
p {
margin: 0 0 15px;
}
aside h1 {
font-size: 1.2rem;
}
aside h2 {
font-size: 1.1rem;
}
aside p.lede {
font-size: 1rem;
}
.lede {
font-family: var(--main-body-font), serif;
font-size: 1.6rem;
word-wrap: break-word;
font-weight: 300;
}
strong:not(>h2), .strong {
color: var(--color-primary);
}
.hidden {
display: none;
}
a {
color: var(--color-secondary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.card a:hover {
text-decoration: none;
color: var(--color-text);
cursor: pointer;
}
.card a:hover h2 {
color: var(--color-text);
}
img {
max-width: 100%;
height: auto;
}
svg.icon {
width: 2em;
height: 2em;
}
.divider {
border: 2px solid var(--color-primary);
margin: 20px 0;
}
.hashtag {
color: var(--color-secondary);
}
.card {
background-color: var(--color-bg);
color: var(--color-text);
padding: 0;
margin: 0 0 2rem 0;
border-radius: 0; /* Sharp edges */
}
.featured-cat {
border-bottom: 2px solid var(--color-border);
padding-left: 10px;
}
.featured-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.featured-list > * {
box-sizing: border-box; /* so padding/border don't break the layout */
margin-bottom: 10px;
padding: 10px;
}
@media (max-width: 1024px) {
.featured-list {
flex-direction: column !important;
}
.featured-list .card-header {
margin-top: 20px;
}
.featured-list .card {
border-bottom: 1px solid var(--color-border) !important;
}
.featured-list > * {
margin-bottom: 10px;
padding: 0;
}
}
div:nth-child(odd) .featured-list {
flex-direction: row-reverse;
}
.featured-list div:first-child {
flex: 0 0 66%; /* each item takes up 50% width = 2 columns */
}
.featured-list div:last-child {
flex: 0 0 34%; /* each item takes up 50% width = 2 columns */
}
.featured-list h2.card-title {
font-size: 1.5rem;
}
.featured-list p.lede {
font-size: 1.4rem;
}
.featured-list .card {
margin-bottom: 20px;
}
.featured-list .card:not(:last-child) {
border-bottom: 1px solid var(--color-border);
}
.article-list .metadata {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: baseline;
}
.article-list .metadata p {
margin: 0;
}
.truncate {
display: -webkit-box;
-webkit-line-clamp: 3; /* limit to 3 lines */
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.card.bordered {
border: 2px solid var(--color-border);
}
.card-header {
margin: 10px 0;
}
.header__image {
position: relative;
width: 100%;
overflow: hidden; /* Ensures any overflow is hidden */
}
.header__image::before {
content: "";
display: block;
padding-top: 56.25%; /* 16:9 aspect ratio (9 / 16 * 100 = 56.25%) */
}
.header__image img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover; /* Ensures the image covers the entire area while maintaining its aspect ratio */
}
.card-body {
font-size: 1rem;
}
.card-footer {
border-top: 1px solid var(--color-border);
margin: 20px 0;
}
.header {
text-align: center;
z-index: 1000; /* Ensure it stays on top */
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
background-color: var(--color-bg); /* Black background */
border-bottom: 1px solid var(--color-border);
}
.header .container {
display: flex;
flex-direction: column;
}
.header__categories ul {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
padding: 0;
}
.header__categories li {
list-style: none;
}
.header__categories li a:hover {
text-decoration: none;
font-weight: bold;
}
.header__categories a.active {
font-weight: bold;
}
.header__logo h1 {
font-weight: normal;
}
.header__logo img {
height: 40px; /* Adjust the height as needed */
}
.header__logo a:hover {
text-decoration: none;
}
.header__user {
position: relative;
display: flex;
align-items: center;
}
.header__avatar img {
height: 40px; /* Adjust the avatar size as needed */
width: 40px;
border-radius: 50%;
cursor: pointer;
}
.header__dropdown {
display: none;
position: absolute;
top: 50px; /* Adjust this depending on the header.html.twig height */
right: 0;
background-color: var(--color-text); /* White dropdown */
border: 2px solid var(--color-bg); /* Black border */
list-style: none;
padding: 10px 0;
z-index: 1000;
border-radius: 0; /* Sharp edges */
}
.header__dropdown ul {
margin: 0;
padding: 0;
}
.header__dropdown li {
padding: 10px 20px;
}
.header__dropdown li a {
color: var(--color-bg); /* Black text */
text-decoration: none;
}
.header__dropdown li a:hover {
background-color: var(--color-bg); /* Black background on hover */
color: var(--color-text); /* White text on hover */
display: block;
}
footer p {
margin: 0;
}
footer a {
color: var(--color-text-contrast);
}
/* Tags container */
.tags {
margin: 10px 0;
display: flex;
flex-wrap: wrap; /* Allows tags to wrap to the next line if needed */
gap: 10px; /* Adds spacing between individual tags */
}
/* Individual tag */
.tag {
background-color: var(--color-bg-light);
color: var(--color-text-mid);
padding: 3px 6px; /* Padding around the tag text */
border-radius: 20px; /* Rounded corners (pill-shaped) */
font-size: 0.75em; /* Slightly smaller text */
cursor: pointer; /* Cursor turns to pointer for clickable tags */
text-decoration: none; /* Removes any text decoration (e.g., underline) */
display: inline-block; /* Makes sure each tag behaves like a block with padding */
transition: background-color 0.3s ease; /* Smooth hover effect */
}
/*!* Hover effect for tags *!*/
/*.tag:hover {*/
/* color: var(--color-text-contrast);*/
/*}*/
/* Optional: Responsive adjustments for smaller screens */
@media (max-width: 768px) {
.tag {
font-size: 0.8em; /* Slightly smaller text for mobile */
}
}
.card.card__horizontal {
display: flex;
justify-content: space-between;
align-items: center;
h1 {
font-size: 2rem;
}
.card-content {
flex: 1;
margin-right: 30px;
padding: 0 8px;
}
.card-image img {
width: 220px;
max-height: 220px;
object-fit: contain;
}
}
.article__image img {
margin: 1rem 0;
width: 100%;
}
.badge {
background-color: var(--color-primary);
color: var(--color-bg);
padding: 3px 8px;
border-radius: 20px;
font-family: var(--font-family), sans-serif;
font-weight: bold;
font-size: 0.65em;
text-transform: uppercase;
margin-right: 5px;
vertical-align: super;
}
.badge.badge__secondary {
background-color: var(--color-secondary);
}
.avatar {
width: 24px; /* Adjust the size as needed */
height: 24px; /* Adjust the size as needed */
border-radius: 50%; /* Makes the image circular */
object-fit: cover; /* Ensures the image scales correctly */
display: inline-block;
vertical-align: middle;
}
.alert {
padding: 10px 20px; /* Padding around the text */
border-radius: 5px; /* Rounded corners */
margin: 20px 0; /* Spacing around the alert */
}
.alert.alert-success {
background-color: var(--color-secondary);
color: var(--color-text-contrast);
}
/* Tabs Container */
.nav-tabs {
display: flex; /* Arrange items in a row */
justify-content: center;
padding: 0; /* Remove padding */
margin: 0; /* Remove margin */
list-style: none; /* Remove list item styling */
}
/* Individual Tab Item */
.nav-tabs .nav-item {
margin: 0; /* No margin around list items */
}
/* NON-Active Tab */
.nav-tabs .nav-link {
color: var(--color-text);
background-color: transparent;
border: none;
}
/* Active Tab */
.nav-tabs .nav-link.active {
color: var(--color-text-contrast);
background-color: var(--color-primary);
font-weight: bold;
}
/* Content Container */
.tab-content {
padding: 15px; /* Spacing inside the content */
border-top: none; /* Remove border overlap with active tab */
}
/* Quill editor */
#editor {
height: 400px;
margin-bottom: 20px;
}
/* Search */
label.search {
width: 100%;
justify-content: center;
margin-bottom: 15px;
}

3
assets/styles/app.scss

@ -1,3 +0,0 @@
// ...existing code...
@import "components/nostr_previews";
// ...existing code...

52
assets/styles/components/_nostr_previews.scss

@ -1,52 +0,0 @@
.nostr-preview {
margin-top: 0.5rem;
.nostr-event-preview, .nostr-profile-preview {
border-left: 3px solid #6c5ce7;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.nostr-profile-preview {
border-left-color: #00b894;
}
.card-title {
margin-bottom: 0.5rem;
font-size: 1rem;
}
.card-text {
font-size: 0.9rem;
}
.card-footer {
padding: 0.5rem 1rem;
}
}
.nostr-previews {
h6 {
font-size: 0.9rem;
margin-bottom: 1rem;
}
.preview-container {
// For multiple previews
max-height: 500px;
overflow-y: auto;
padding-right: 10px;
}
}
// Style for nostr links in text
.nostr-link {
color: #6c5ce7;
background-color: rgba(108, 92, 231, 0.1);
padding: 0 3px;
border-radius: 3px;
text-decoration: none;
&:hover {
background-color: rgba(108, 92, 231, 0.2);
}
}

47
src/Controller/AuthorController.php

@ -10,12 +10,59 @@ use Elastica\Query\Terms;
use Exception; use Exception;
use FOS\ElasticaBundle\Finder\FinderInterface; use FOS\ElasticaBundle\Finder\FinderInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use swentel\nostr\Nip19\Nip19Helper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
class AuthorController extends AbstractController class AuthorController extends AbstractController
{ {
/**
* @throws Exception
*/
#[Route('/p/{npub}/media', name: 'author-media', requirements: ['npub' => '^npub1.*'])]
public function media($npub, NostrClient $nostrClient, RedisCacheService $redisCacheService): Response
{
$keys = new Key();
$pubkey = $keys->convertToHex($npub);
$author = $redisCacheService->getMetadata($npub);
// Retrieve picture events (kind 20) for the author
try {
$pictureEvents = $nostrClient->getPictureEventsForPubkey($npub, 30);
} catch (Exception $e) {
$pictureEvents = [];
}
// Deduplicate by event ID
$uniqueEvents = [];
foreach ($pictureEvents as $event) {
if (!isset($uniqueEvents[$event->id])) {
$uniqueEvents[$event->id] = $event;
}
}
// Convert back to indexed array and sort by date (newest first)
$pictureEvents = array_values($uniqueEvents);
usort($pictureEvents, function ($a, $b) {
return $b->created_at <=> $a->created_at;
});
// Encode event IDs as note1... for each event
foreach ($pictureEvents as $event) {
$nip19 = new Nip19Helper(); // The NIP-19 helper class.
$event->noteId = $nip19->encodeNote($event->id);
}
return $this->render('pages/author-media.html.twig', [
'author' => $author,
'npub' => $npub,
'pictureEvents' => $pictureEvents,
'is_author_profile' => true,
]);
}
/** /**
* @throws Exception * @throws Exception
*/ */

2
src/Controller/EventController.php

@ -24,7 +24,7 @@ class EventController extends AbstractController
/** /**
* @throws Exception * @throws Exception
*/ */
#[Route('/e/{nevent}', name: 'nevent', requirements: ['nevent' => '^nevent1.*'])] #[Route('/e/{nevent}', name: 'nevent', requirements: ['nevent' => '^(nevent|note)1.*'])]
public function index($nevent, NostrClient $nostrClient, RedisCacheService $redisCacheService, NostrLinkParser $nostrLinkParser, LoggerInterface $logger): Response public function index($nevent, NostrClient $nostrClient, RedisCacheService $redisCacheService, NostrLinkParser $nostrLinkParser, LoggerInterface $logger): Response
{ {
$logger->info('Accessing event page', ['nevent' => $nevent]); $logger->info('Accessing event page', ['nevent' => $nevent]);

30
src/Service/NostrClient.php

@ -516,6 +516,36 @@ class NostrClient
}); });
} }
/**
* Get picture events (kind 20) for a specific author
* @throws \Exception
*/
public function getPictureEventsForPubkey(string $ident, int $limit = 20): array
{
// Add user relays to the default set
$authorRelays = $this->getTopReputableRelaysForAuthor($ident);
// Create a RelaySet from the author's relays
$relaySet = $this->defaultRelaySet;
if (!empty($authorRelays)) {
$relaySet = $this->createRelaySet($authorRelays);
}
// Create request for kind 20 (picture events)
$request = $this->createNostrRequest(
kinds: [20], // NIP-68 Picture events
filters: [
'authors' => [$ident],
'limit' => $limit
],
relaySet: $relaySet
);
// Process the response and return raw events
return $this->processResponse($request->send(), function($event) {
return $event; // Return the raw event
});
}
public function getArticles(array $slugs): array public function getArticles(array $slugs): array
{ {
$articles = []; $articles = [];

2
templates/admin/analytics.html.twig

@ -22,7 +22,7 @@
<thead> <thead>
<tr> <tr>
<th>Route</th> <th>Route</th>
<th style="min-width: 100px;text-align: right;">#</th> <th class="text-right">#</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

9
templates/admin/articles.html.twig

@ -2,7 +2,7 @@
{% block body %} {% block body %}
<h1>Latest 50 Articles</h1> <h1>Latest 50 Articles</h1>
<table> <table class="admin-articles-table">
<thead> <thead>
<tr> <tr>
<th>Title</th> <th>Title</th>
@ -12,7 +12,7 @@
</thead> </thead>
<tbody> <tbody>
{% for article in articles %} {% for article in articles %}
<tr style="padding-bottom: 5px;"> <tr>
<td><a href="{{ path('article-slug', {slug: article.slug|url_encode}) }}">{{ article.title }}</a></td> <td><a href="{{ path('article-slug', {slug: article.slug|url_encode}) }}">{{ article.title }}</a></td>
<td>{{ article.summary|slice(0, 100) }}{% if article.summary|length > 100 %}...{% endif %}</td> <td>{{ article.summary|slice(0, 100) }}{% if article.summary|length > 100 %}...{% endif %}</td>
<td> <td>
@ -20,11 +20,10 @@
<span class="hidden" data-copy-to-clipboard-target="textToCopy">30023:{{ article.pubkey }}:{{ article.slug }}</span> <span class="hidden" data-copy-to-clipboard-target="textToCopy">30023:{{ article.pubkey }}:{{ article.slug }}</span>
<button type="button" <button type="button"
data-copy-to-clipboard-target="copyButton" data-copy-to-clipboard-target="copyButton"
data-action="click->copy-to-clipboard#copyToClipboard" data-action="click->copy-to-clipboard#copyToClipboard">Copy to Clipboard</button>
style="margin-left: 0.5em;">Copy to Clipboard</button>
</span> </span>
<form method="post" action="{{ path('admin_article_add_to_index') }}" style="display:inline;"> <form method="post" action="{{ path('admin_article_add_to_index') }}">
<input type="hidden" name="slug" value="{{ article.slug }}"> <input type="hidden" name="slug" value="{{ article.slug }}">
<label> <label>
<select name="index_key" required> <select name="index_key" required>

29
templates/admin/magazine_editor.html.twig

@ -2,32 +2,32 @@
{% block body %} {% block body %}
<h1>Edit Index: {{ title }}</h1> <h1>Edit Index: {{ title }}</h1>
<div class="d-flex gap-4"> <div class="magazine-editor-layout">
<!-- Left: Search articles --> <!-- Left: Search articles -->
<section style="flex:1;min-width:320px;"> <section class="magazine-editor-section">
<form method="get" action="{{ path('admin_magazine_edit', {slug: slug}) }}" class="mb-3 d-flex gap-2"> <form method="get" action="{{ path('admin_magazine_edit', {slug: slug}) }}" class="magazine-search-form">
<input type="text" name="q" value="{{ q }}" placeholder="Search articles…" style="flex:1;"> <input type="text" name="q" value="{{ q }}" placeholder="Search articles…">
<button class="btn btn-primary" type="submit">Search</button> <button class="btn btn-primary" type="submit">Search</button>
</form> </form>
{% if q %} {% if q %}
<h3 class="mb-2">Results for “{{ q }}”</h3> <h3 class="mb-2">Results for "{{ q }}"</h3>
{% if results is empty %} {% if results is empty %}
<div class="text-muted">No results.</div> <div class="text-muted">No results.</div>
{% else %} {% else %}
<table style="width:100%;border-collapse:collapse;"> <table class="magazine-table">
<tbody> <tbody>
{% for article in results %} {% for article in results %}
<tr> <tr>
<td style="padding:.25rem .5rem;vertical-align:top;"> <td>
📄 <a href="{{ path('article-slug', {slug: article.slug|url_encode}) }}">{{ article.title }}</a> 📄 <a href="{{ path('article-slug', {slug: article.slug|url_encode}) }}">{{ article.title }}</a>
<div class="small text-muted">slug: {{ article.slug }}</div> <div class="small text-muted">slug: {{ article.slug }}</div>
</td> </td>
<td style="padding:.25rem .5rem;white-space:nowrap;vertical-align:top;"> <td class="author">
{% set pubkey = article.pubkey %} {% set pubkey = article.pubkey %}
<a href="{{ path('author-redirect', {pubkey: pubkey}) }}">{{ pubkey|slice(0,8) ~ '…' ~ pubkey|slice(-4) }}</a> <a href="{{ path('author-redirect', {pubkey: pubkey}) }}">{{ pubkey|slice(0,8) ~ '…' ~ pubkey|slice(-4) }}</a>
</td> </td>
<td style="padding:.25rem .5rem;text-align:right;vertical-align:top;"> <td class="actions">
<form method="post" action="{{ path('admin_magazine_add_article', {slug: slug}) }}"> <form method="post" action="{{ path('admin_magazine_add_article', {slug: slug}) }}">
<input type="hidden" name="_token" value="{{ csrfToken }}"> <input type="hidden" name="_token" value="{{ csrfToken }}">
<input type="hidden" name="article_slug" value="{{ article.slug }}"> <input type="hidden" name="article_slug" value="{{ article.slug }}">
@ -45,16 +45,16 @@
</section> </section>
<!-- Right: Current index contents --> <!-- Right: Current index contents -->
<section style="flex:1;min-width:320px;"> <section class="magazine-editor-section">
<h3 class="mb-2">Current entries</h3> <h3 class="mb-2">Current entries</h3>
{% if current is empty %} {% if current is empty %}
<div class="text-muted">No entries yet.</div> <div class="text-muted">No entries yet.</div>
{% else %} {% else %}
<table style="width:100%;border-collapse:collapse;"> <table class="magazine-table">
<tbody> <tbody>
{% for item in current %} {% for item in current %}
<tr> <tr>
<td style="padding:.25rem .5rem;vertical-align:top;"> <td>
{% if item.kind == '30023' %} {% if item.kind == '30023' %}
📄 <a href="{{ path('article-slug', {slug: item.slug|url_encode}) }}">{{ item.slug }}</a> 📄 <a href="{{ path('article-slug', {slug: item.slug|url_encode}) }}">{{ item.slug }}</a>
{% elseif item.kind == '30040' %} {% elseif item.kind == '30040' %}
@ -64,10 +64,10 @@
{% endif %} {% endif %}
<div class="small text-muted">coord: {{ item.coord }}</div> <div class="small text-muted">coord: {{ item.coord }}</div>
</td> </td>
<td style="padding:.25rem .5rem;white-space:nowrap;vertical-align:top;"> <td class="author">
<a href="{{ path('author-redirect', {pubkey: item.pubkey}) }}">{{ item.pubkey|slice(0,8) ~ '…' ~ item.pubkey|slice(-4) }}</a> <a href="{{ path('author-redirect', {pubkey: item.pubkey}) }}">{{ item.pubkey|slice(0,8) ~ '…' ~ item.pubkey|slice(-4) }}</a>
</td> </td>
<td style="padding:.25rem .5rem;text-align:right;vertical-align:top;"> <td class="actions">
{% if item.kind == '30023' %} {% if item.kind == '30023' %}
<form method="post" action="{{ path('admin_magazine_remove_article', {slug: slug}) }}"> <form method="post" action="{{ path('admin_magazine_remove_article', {slug: slug}) }}">
<input type="hidden" name="_token" value="{{ csrfToken }}"> <input type="hidden" name="_token" value="{{ csrfToken }}">
@ -84,4 +84,3 @@
</section> </section>
</div> </div>
{% endblock %} {% endblock %}

18
templates/admin/magazines.html.twig

@ -27,30 +27,30 @@
</summary> </summary>
{% if cat.files is not empty %} {% if cat.files is not empty %}
<div class="ms-3 mt-2"> <div class="ms-3 mt-2">
<table class="file-table" style="width:100%;border-collapse:collapse;"> <table class="magazine-table">
<thead> <thead>
<tr> <tr>
<th style="padding:.25rem .5rem;text-align:left;border-bottom:1px solid #dee2e6;">Article</th> <th>Article</th>
<th style="padding:.25rem .5rem;text-align:left;border-bottom:1px solid #dee2e6;">Author</th> <th>Author</th>
<th style="padding:.25rem .5rem;text-align:left;border-bottom:1px solid #dee2e6;">Date</th> <th>Date</th>
<th style="padding:.25rem .5rem;text-align:left;border-bottom:1px solid #dee2e6;">Coordinate</th> <th>Coordinate</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for file in cat.files %} {% for file in cat.files %}
<tr> <tr>
<td style="padding:.25rem .5rem;vertical-align:top;"> <td>
📄 <a href="{{ path('article-slug', {slug: file.slug|url_encode}) }}">{{ file.name }}</a> 📄 <a href="{{ path('article-slug', {slug: file.slug|url_encode}) }}">{{ file.name }}</a>
<div class="small text-muted">slug: {{ file.slug }}</div> <div class="small text-muted">slug: {{ file.slug }}</div>
</td> </td>
<td style="padding:.25rem .5rem;vertical-align:top;white-space:nowrap;"> <td class="author">
{% if file.authorPubkey %} {% if file.authorPubkey %}
<twig:Molecules:UserFromNpub :ident="file.authorPubkey" /> <twig:Molecules:UserFromNpub :ident="file.authorPubkey" />
{% else %} {% else %}
<span class="text-muted">unknown author</span> <span class="text-muted">unknown author</span>
{% endif %} {% endif %}
</td> </td>
<td style="padding:.25rem .5rem;vertical-align:top;white-space:nowrap;"> <td class="author">
{% if file.date %} {% if file.date %}
<span class="small text-muted">{{ file.date|date('M j, Y') }}</span> <span class="small text-muted">{{ file.date|date('M j, Y') }}</span>
<div class="small text-muted">{{ file.date|date('H:i') }}</div> <div class="small text-muted">{{ file.date|date('H:i') }}</div>
@ -58,7 +58,7 @@
<span class="small text-muted">—</span> <span class="small text-muted">—</span>
{% endif %} {% endif %}
</td> </td>
<td class="small text-muted" style="padding:.25rem .5rem;vertical-align:top;">{{ file.coordinate }}</td> <td class="small text-muted">{{ file.coordinate }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

4
templates/components/SearchComponent.html.twig

@ -15,7 +15,7 @@
><twig:ux:icon name="iconoir:search" class="icon" /></button> ><twig:ux:icon name="iconoir:search" class="icon" /></button>
</label> </label>
{% if is_granted('IS_AUTHENTICATED_FULLY') %} {% if is_granted('IS_AUTHENTICATED_FULLY') %}
<div style="text-align: right"> <div class="search-credits">
<small class="help-text"> <small class="help-text">
<em>{{ 'credit.balance'|trans({'%count%': credits, 'count': credits}) }}</em> <em>{{ 'credit.balance'|trans({'%count%': credits, 'count': credits}) }}</em>
</small> </small>
@ -38,7 +38,7 @@
{% endif %} {% endif %}
<!-- Loading Indicator --> <!-- Loading Indicator -->
<div style="text-align: center"> <div class="search-loading">
<div class="spinner" data-loading> <div class="spinner" data-loading>
<div class="lds-dual-ring"></div> <div class="lds-dual-ring"></div>
</div> </div>

142
templates/event/_kind20_picture.html.twig

@ -0,0 +1,142 @@
{# NIP-68 Picture Event (kind 20) #}
<div class="picture-event">
{# Title tag #}
{% set title = null %}
{% for tag in event.tags %}
{% if tag[0] == 'title' %}
{% set title = tag[1] %}
{% endif %}
{% endfor %}
{% if title %}
<h2 class="picture-title">{{ title }}</h2>
{% endif %}
{# Content warning #}
{% set contentWarning = null %}
{% for tag in event.tags %}
{% if tag[0] == 'content-warning' %}
{% set contentWarning = tag[1] %}
{% endif %}
{% endfor %}
{% if contentWarning %}
<div class="content-warning">
<strong>⚠ Content Warning:</strong> {{ contentWarning }}
<button class="btn-show-nsfw" onclick="this.parentElement.nextElementSibling.classList.remove('hidden'); this.parentElement.style.display='none';">Show Content</button>
</div>
<div class="picture-gallery hidden">
{% else %}
<div class="picture-gallery">
{% endif %}
{# Display images from imeta tags #}
{% for tag in event.tags %}
{% if tag[0] == 'imeta' %}
{% set imageUrl = null %}
{% set mimeType = null %}
{% set blurhash = null %}
{% set dimensions = null %}
{% set altText = null %}
{% set fallbacks = [] %}
{% set annotatedUsers = [] %}
{# Parse imeta tag parameters #}
{% for i in 1..(tag|length - 1) %}
{% set param = tag[i] %}
{% if param starts with 'url ' %}
{% set imageUrl = param[4:] %}
{% elseif param starts with 'm ' %}
{% set mimeType = param[2:] %}
{% elseif param starts with 'blurhash ' %}
{% set blurhash = param[9:] %}
{% elseif param starts with 'dim ' %}
{% set dimensions = param[4:] %}
{% elseif param starts with 'alt ' %}
{% set altText = param[4:] %}
{% elseif param starts with 'fallback ' %}
{% set fallbacks = fallbacks|merge([param[9:]]) %}
{% elseif param starts with 'annotate-user ' %}
{% set annotatedUsers = annotatedUsers|merge([param[14:]]) %}
{% endif %}
{% endfor %}
{% if imageUrl %}
<div class="picture-item">
<picture>
{% for fallback in fallbacks %}
<source srcset="{{ fallback }}" />
{% endfor %}
<img src="{{ imageUrl }}"
alt="{{ altText|default('Picture') }}"
{% if dimensions %}data-dimensions="{{ dimensions }}"{% endif %}
{% if blurhash %}data-blurhash="{{ blurhash }}"{% endif %}
class="picture-image"
onerror="if(this.nextSibling) this.src=this.nextSibling.srcset; else this.parentElement.innerHTML='<p class=error>Image failed to load</p>';" />
</picture>
{# Display annotated users #}
{% if annotatedUsers|length > 0 %}
<div class="annotated-users">
{% for userAnnotation in annotatedUsers %}
{% set parts = userAnnotation|split(':') %}
{% if parts|length == 3 %}
<div class="user-tag" data-left="{{ parts[1] }}" data-top="{{ parts[2] }}" style="left: {{ parts[1] }}%; top: {{ parts[2] }}%;">
<twig:Molecules:UserFromNpub ident="{{ parts[0] }}" />
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if altText %}
<p class="picture-alt">{{ altText }}</p>
{% endif %}
</div>
{% endif %}
{% endif %}
{% endfor %}
</div>
{# Description from content #}
{% if event.content %}
<div class="picture-description mt-1">
<twig:Atoms:Content :content="event.content" />
</div>
{% endif %}
{# Location data #}
{% set location = null %}
{% set geohash = null %}
{% for tag in event.tags %}
{% if tag[0] == 'location' %}
{% set location = tag[1] %}
{% elseif tag[0] == 'g' %}
{% set geohash = tag[1] %}
{% endif %}
{% endfor %}
{% if location or geohash %}
<div class="picture-location">
<span class="location-icon">📍</span>
{% if location %}{{ location }}{% endif %}
{% if geohash %}<span class="geohash" title="{{ geohash }}">{{ geohash[:6] }}...</span>{% endif %}
</div>
{% endif %}
{# Hashtags #}
{% set hashtags = [] %}
{% for tag in event.tags %}
{% if tag[0] == 't' %}
{% set hashtags = hashtags|merge([tag[1]]) %}
{% endif %}
{% endfor %}
{% if hashtags|length > 0 %}
<div class="picture-hashtags">
{% for hashtag in hashtags %}
<span class="hashtag">#{{ hashtag }}</span>
{% endfor %}
</div>
{% endif %}
</div>

201
templates/event/index.html.twig

@ -18,9 +18,16 @@
<span class="event-date">{{ event.created_at|date('F j, Y - H:i') }}</span> <span class="event-date">{{ event.created_at|date('F j, Y - H:i') }}</span>
</div> </div>
</div> </div>
{# NIP-68 Picture Event (kind 20) #}
{% if event.kind == 20 %}
{% include 'event/_kind20_picture.html.twig' %}
{% else %}
{# Regular event content for non-picture events #}
<div class="event-content"> <div class="event-content">
<twig:Atoms:Content :content="event.content" /> <twig:Atoms:Content :content="event.content" />
</div> </div>
{% endif %}
{% if nostrLinks is defined and nostrLinks|length > 0 %} {% if nostrLinks is defined and nostrLinks|length > 0 %}
<div class="nostr-links"> <div class="nostr-links">
@ -37,6 +44,24 @@
{% endif %} {% endif %}
<div class="event-footer"> <div class="event-footer">
{# Source link from r tag #}
{% set sourceUrl = null %}
{% for tag in event.tags %}
{% if tag[0] == 'r' and tag[2] == 'source' %}
{% set sourceUrl = tag[1] %}
{% endif %}
{% endfor %}
{% if sourceUrl %}
<div class="picture-source">
<span class="source-label">Source:</span>
<a href="{{ sourceUrl }}" target="_blank" rel="noopener noreferrer" class="source-link">
{{ sourceUrl }}
</a>
</div>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
<div class="event-tags"> <div class="event-tags">
{% if event.tags is defined and event.tags|length > 0 %} {% if event.tags is defined and event.tags|length > 0 %}
<ul> <ul>
@ -54,6 +79,7 @@
</ul> </ul>
{% endif %} {% endif %}
</div> </div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -84,6 +110,163 @@
line-height: 1.6; line-height: 1.6;
} }
/* NIP-68 Picture Event Styles */
.picture-event {
padding: 1rem;
}
.picture-title {
font-size: 1.8rem;
font-weight: bold;
margin-bottom: 1rem;
color: #333;
}
.content-warning {
background-color: #fff3cd;
border: 2px solid #ffc107;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
text-align: center;
}
.btn-show-nsfw {
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background-color: #ffc107;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.btn-show-nsfw:hover {
background-color: #e0a800;
}
.picture-gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.picture-item {
position: relative;
border-radius: 8px;
overflow: hidden;
background-color: #f5f5f5;
}
.picture-image {
width: 100%;
height: auto;
display: block;
object-fit: cover;
border-radius: 8px;
}
.annotated-users {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.user-tag {
position: absolute;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
pointer-events: all;
cursor: pointer;
transform: translate(-50%, -50%);
}
.picture-alt {
font-size: 0.9rem;
color: #666;
margin-top: 0.5rem;
font-style: italic;
}
.picture-description {
font-size: 1.1rem;
line-height: 1.6;
margin-bottom: 1rem;
color: #333;
}
.picture-location {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
color: #555;
font-size: 0.95rem;
}
.location-icon {
font-size: 1.2rem;
}
.geohash {
background-color: #e9ecef;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
cursor: help;
}
.picture-hashtags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.hashtag {
background-color: #e7f3ff;
color: #0066cc;
padding: 0.3rem 0.8rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
}
.hashtag:hover {
background-color: #d0e7ff;
}
.picture-source {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.95rem;
}
.source-label {
font-weight: 600;
color: #495057;
}
.source-link {
color: #0066cc;
text-decoration: none;
word-break: break-all;
}
.source-link:hover {
text-decoration: underline;
}
.nostr-links { .nostr-links {
margin: 1.5rem 0; margin: 1.5rem 0;
padding: 1rem; padding: 1rem;
@ -108,6 +291,7 @@
.event-footer { .event-footer {
display: flex; display: flex;
flex-direction: column;
justify-content: space-between; justify-content: space-between;
padding: 1rem; padding: 1rem;
border-top: 1px solid #eee; border-top: 1px solid #eee;
@ -125,5 +309,22 @@
.event-tags li, .event-references li { .event-tags li, .event-references li {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.error {
color: #dc3545;
padding: 1rem;
text-align: center;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.picture-gallery {
grid-template-columns: 1fr;
}
.picture-title {
font-size: 1.5rem;
}
}
</style> </style>
{% endblock %} {% endblock %}

2
templates/pages/article.html.twig

@ -78,8 +78,6 @@
</div> </div>
<hr class="divider" />
<twig:Organisms:Comments current="30023:{{ article.pubkey }}:{{ article.slug|e }}"></twig:Organisms:Comments> <twig:Organisms:Comments current="30023:{{ article.pubkey }}:{{ article.slug|e }}"></twig:Organisms:Comments>
</div> </div>
{% endblock %} {% endblock %}

92
templates/pages/author-media.html.twig

@ -0,0 +1,92 @@
{% extends 'layout.html.twig' %}
{% block body %}
{% if author.image is defined %}
<img src="{{ author.image }}" class="avatar" alt="{{ author.name }}" onerror="this.style.display = 'none'" />
{% endif %}
<h1><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></h1>
<div>
{% if author.about is defined %}
{{ author.about|markdown_to_html|mentionify|linkify }}
{% endif %}
</div>
<div class="profile-tabs">
<a href="{{ path('author-profile', {'npub': npub}) }}" class="tab-link">Articles</a>
<a href="{{ path('author-media', {'npub': npub}) }}" class="tab-link active">Media</a>
</div>
<hr />
<div class="w-container">
{% if pictureEvents|length > 0 %}
<div class="masonry-grid">
{% for event in pictureEvents %}
<div class="masonry-item">
{# Extract title #}
{% set title = null %}
{% for tag in event.tags %}
{% if tag[0] == 'title' %}
{% set title = tag[1] %}
{% endif %}
{% endfor %}
{# Extract first image from imeta tags #}
{% set firstImageUrl = null %}
{% set imageAlt = null %}
{% for tag in event.tags %}
{% if tag[0] == 'imeta' and firstImageUrl is null %}
{% for i in 1..(tag|length - 1) %}
{% set param = tag[i] %}
{% if param starts with 'url ' and firstImageUrl is null %}
{% set firstImageUrl = param[4:] %}
{% elseif param starts with 'alt ' %}
{% set imageAlt = param[4:] %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{# Generate nevent for linking #}
{% set eventId = event.id %}
{% set noteId = event.noteId %}
<a href="/e/{{ noteId }}" class="masonry-link">
{% if firstImageUrl %}
<div class="masonry-image-container">
<img src="{{ firstImageUrl }}"
alt="{{ imageAlt|default(title|default('Picture')) }}"
class="masonry-image"
loading="lazy" />
</div>
{% endif %}
{% if title %}
<div class="masonry-caption">
<h3>{{ title }}</h3>
</div>
{% endif %}
{% if event.content %}
<div class="masonry-description mt-1">
{{ event.content|length > 100 ? event.content[:100] ~ '...' : event.content }}
</div>
{% endif %}
<div class="masonry-meta">
<span class="event-date">{{ event.created_at|date('M j, Y') }}</span>
</div>
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-media">
<p>No media found for this author.</p>
</div>
{% endif %}
</div>
{% endblock %}

5
templates/pages/author.html.twig

@ -13,6 +13,11 @@
{% endif %} {% endif %}
</div> </div>
<div class="profile-tabs">
<a href="{{ path('author-profile', {'npub': npub}) }}" class="tab-link active">Articles</a>
<a href="{{ path('author-media', {'npub': npub}) }}" class="tab-link">Media</a>
</div>
<hr /> <hr />
{# {% if relays|length > 0 %}#} {# {% if relays|length > 0 %}#}

14
templates/pages/editor.html.twig

@ -40,7 +40,7 @@
Upload Image Upload Image
</button> </button>
<div data-image-upload-target="dialog" style="display:none;"> <div data-image-upload-target="dialog" class="iu-dialog">
<div class="iu-backdrop" data-action="click->image-upload#closeDialog"></div> <div class="iu-backdrop" data-action="click->image-upload#closeDialog"></div>
<div class="iu-modal"> <div class="iu-modal">
<div class="modal-header"> <div class="modal-header">
@ -48,7 +48,7 @@
<button type="button" class="close" data-action="click->image-upload#closeDialog">&times;</button> <button type="button" class="close" data-action="click->image-upload#closeDialog">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div style="margin-bottom:1em;"> <div>
<label for="upload-provider">Upload to</label> <label for="upload-provider">Upload to</label>
<select id="upload-provider" data-image-upload-target="provider"> <select id="upload-provider" data-image-upload-target="provider">
<option value="sovbit">files.sovbit.host</option> <option value="sovbit">files.sovbit.host</option>
@ -57,15 +57,13 @@
</select> </select>
</div> </div>
<div data-image-upload-target="dropArea" <div data-image-upload-target="dropArea" class="upload-area">
class="upload-area"
style="border:2px dashed #ccc;padding:2em;text-align:center;cursor:pointer;min-height:4em;">
<span>Drag &amp; drop or click to select an image</span> <span>Drag &amp; drop or click to select an image</span>
<input type="file" accept="image/*" style="display:none;" data-image-upload-target="fileInput"> <input type="file" accept="image/*" data-image-upload-target="fileInput">
</div> </div>
<div data-image-upload-target="progress" style="display:none;margin-top:1em;"></div> <div data-image-upload-target="progress" class="upload-progress"></div>
<div data-image-upload-target="error" style="color:red;margin-top:1em;"></div> <div data-image-upload-target="error" class="upload-error"></div>
</div> </div>
</div> </div>
</div> </div>

Loading…
Cancel
Save