Add tests for featured thumbnail generation; apply Pint formatting and related edits

This commit is contained in:
2026-05-06 18:55:40 +02:00
parent 7a8bc8e22a
commit 82f2b1f660
65 changed files with 11325 additions and 49545 deletions

View File

@@ -458,6 +458,420 @@
-webkit-user-select: text;
user-select: text;
cursor: text;
font-size: 1.05rem;
line-height: 1.85;
}
.news-rich-text-editor .ProseMirror {
color: rgb(226 232 240 / 0.92);
font-family: 'Libre Franklin', 'Inter', sans-serif;
font-size: 1.02rem;
line-height: 1.9;
}
.news-rich-text-editor .ProseMirror p {
padding-top: 0.18rem;
padding-bottom: 0.3rem;
}
.news-rich-text-editor .ProseMirror p:empty {
display: none;
}
.news-rich-text-editor .ProseMirror p + p {
margin-top: 1.5rem;
}
.news-rich-text-editor .ProseMirror h2,
.news-rich-text-editor .ProseMirror h3,
.news-rich-text-editor .ProseMirror h4,
.news-rich-text-editor .ProseMirror h5,
.news-rich-text-editor .ProseMirror h6 {
color: rgb(255 255 255);
font-weight: 600;
letter-spacing: -0.03em;
}
.news-rich-text-editor .ProseMirror h2 {
margin-top: 2.5rem;
margin-bottom: 1rem;
font-size: clamp(1.65rem, 1.2rem + 1vw, 2rem);
}
.news-rich-text-editor .ProseMirror h3 {
margin-top: 2rem;
margin-bottom: 0.75rem;
font-size: clamp(1.35rem, 1.05rem + 0.8vw, 1.6rem);
}
.news-rich-text-editor .ProseMirror h4,
.news-rich-text-editor .ProseMirror h5,
.news-rich-text-editor .ProseMirror h6 {
margin-top: 1.5rem;
margin-bottom: 0.6rem;
}
.news-rich-text-editor .ProseMirror ul,
.news-rich-text-editor .ProseMirror ol {
margin: 1.5rem 0;
padding-left: 1.5rem;
}
.news-rich-text-editor .ProseMirror ul {
list-style-type: disc;
}
.news-rich-text-editor .ProseMirror ol {
list-style-type: decimal;
}
.news-rich-text-editor .ProseMirror li {
color: rgb(255 255 255 / 0.72);
margin-bottom: 0.5rem;
}
.news-rich-text-editor .ProseMirror li::marker {
color: rgb(255 255 255 / 0.45);
}
.news-rich-text-editor .ProseMirror blockquote {
margin: 2rem 0;
border-left: 3px solid rgb(14 165 233 / 0.7);
background: rgb(255 255 255 / 0.03);
padding: 0.6rem 0 0.6rem 1.25rem;
color: rgb(255 255 255 / 0.68);
}
.news-rich-text-editor .ProseMirror hr {
margin: 2rem 0;
}
.rich-text-editor-viewport {
overflow-y: auto;
overscroll-behavior: contain;
border-bottom-left-radius: 0.75rem;
border-bottom-right-radius: 0.75rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
}
.rich-text-editor-viewport .ProseMirror {
min-height: 100%;
}
.rich-text-editor-viewport .ProseMirror:focus {
outline: none;
}
.rich-text-editor-viewport .ProseMirror > :first-child {
margin-top: 0;
}
.rich-text-editor-viewport .ProseMirror > :last-child {
margin-bottom: 0;
}
.rich-text-editor-viewport::-webkit-scrollbar {
width: 10px;
}
.rich-text-editor-viewport::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.34);
}
.rich-text-editor-viewport::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(56, 189, 248, 0.22), rgba(125, 211, 252, 0.35));
border: 2px solid rgba(15, 23, 42, 0.48);
border-radius: 999px;
}
.rich-text-editor-viewport::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(56, 189, 248, 0.36), rgba(125, 211, 252, 0.5));
}
.rich-text-editor-viewport::-webkit-scrollbar-corner {
background: transparent;
}
.tiptap .ProseMirror :is(p, div, h2, h3, hr) {
margin-bottom: 1.05em;
}
.tiptap .ProseMirror p {
font-size: 1.02rem;
line-height: 1.9;
}
.tiptap .ProseMirror h2 {
font-size: 1.75rem;
line-height: 1.18;
margin-top: 1.8em;
margin-bottom: 0.95em;
}
.tiptap .ProseMirror h3 {
font-size: 1.35rem;
line-height: 1.22;
margin-top: 1.5em;
margin-bottom: 0.85em;
}
.tiptap .ProseMirror hr {
margin-top: 1.75em;
margin-bottom: 1.75em;
}
.rich-image-node {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 1.75rem auto;
max-width: 100%;
outline: none;
}
.rich-image-node.is-selected .rich-image-node__frame {
box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.45), 0 0 0 6px rgba(14, 165, 233, 0.12);
}
.rich-image-node__frame {
position: relative;
display: inline-flex;
max-width: 100%;
margin: 0 auto;
border-radius: 1.25rem;
}
.rich-image-node__img {
display: block;
width: 100%;
max-width: 100%;
height: auto;
border-radius: 1.25rem;
object-fit: contain;
}
.rich-image-node__drag-handle,
.rich-image-node__resize-handle {
position: absolute;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
background: rgba(8, 12, 20, 0.9);
color: rgb(241 245 249 / 0.95);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.38);
cursor: grab;
}
.rich-image-node__drag-handle {
top: 0.75rem;
left: 0.75rem;
}
.rich-image-node__resize-handle {
right: 0.75rem;
bottom: 0.75rem;
cursor: nwse-resize;
}
.rich-image-node__caption {
max-width: min(100%, 46rem);
margin: 0 auto;
color: rgb(148 163 184 / 0.9);
font-size: 0.9rem;
line-height: 1.7;
text-align: center;
}
.rich-image-node__editor {
display: grid;
gap: 0.9rem;
padding: 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1.1rem;
background: rgba(8, 12, 20, 0.92);
}
.rich-image-node.is-selected .rich-image-node__editor {
margin-inline: auto;
width: min(100%, 52rem);
}
.rich-compare-node {
position: relative;
display: flex;
flex-direction: column;
gap: 0.8rem;
margin: 1.75rem auto;
max-width: 100%;
outline: none;
}
.rich-compare-node.is-selected {
border-radius: 1.25rem;
}
.rich-compare-node.is-selected .rich-compare-node__grid,
.ProseMirror-selectednode.rich-compare-node .rich-compare-node__grid {
box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.45), 0 0 0 6px rgba(14, 165, 233, 0.12);
}
.rich-compare-node__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
width: 100%;
}
.rich-compare-node__tile {
position: relative;
overflow: hidden;
border-radius: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.92);
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.18);
}
.rich-compare-node__img {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.rich-compare-node__badge {
position: absolute;
left: 0.75rem;
top: 0.75rem;
z-index: 1;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
background: rgba(8, 12, 20, 0.92);
color: rgb(226 232 240 / 0.9);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.12em;
line-height: 1;
padding: 0.45rem 0.65rem;
text-transform: uppercase;
}
.rich-compare-node__subtitle {
max-width: min(100%, 52rem);
margin: 0 auto;
color: rgb(148 163 184 / 0.9);
font-size: 0.92rem;
line-height: 1.7;
text-align: center;
}
.rich-compare-node__editor {
display: grid;
gap: 0.9rem;
padding: 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1.1rem;
background: rgba(8, 12, 20, 0.92);
}
.rich-compare-node.is-selected .rich-compare-node__editor {
margin-inline: auto;
width: min(100%, 72rem);
}
.ProseMirror-selectednode.rich-image-node,
.rich-image-node.is-selected {
border-radius: 1.25rem;
}
.ProseMirror-selectednode.rich-image-node .rich-image-node__img {
outline: 2px solid rgba(125, 211, 252, 0.55);
outline-offset: 4px;
}
.tiptap .tableWrapper,
.rich-text-editor-viewport .tableWrapper {
margin: 1.5rem 0;
overflow-x: auto;
padding: 0.35rem;
border: 1px solid rgba(125, 211, 252, 0.3);
border-radius: 1rem;
background: rgba(8, 12, 20, 0.72);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03), 0 16px 42px rgba(2, 6, 23, 0.22);
}
.tiptap table.rich-table,
.rich-text-editor-viewport .tableWrapper table {
width: 100%;
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
overflow: hidden;
border: 1px solid rgba(125, 211, 252, 0.38);
border-radius: 0.8rem;
}
.tiptap table.rich-table th,
.tiptap table.rich-table td,
.rich-text-editor-viewport .tableWrapper th,
.rich-text-editor-viewport .tableWrapper td {
position: relative;
min-width: 120px;
padding: 0.85rem 0.95rem;
border: 1px solid rgba(148, 163, 184, 0.3) !important;
vertical-align: top;
background: rgba(255, 255, 255, 0.03);
}
.tiptap table.rich-table th,
.rich-text-editor-viewport .tableWrapper th {
background: rgba(14, 165, 233, 0.16);
color: rgb(241 245 249);
font-weight: 700;
}
.tiptap table.rich-table td p,
.tiptap table.rich-table th p,
.rich-text-editor-viewport .tableWrapper td p,
.rich-text-editor-viewport .tableWrapper th p {
margin: 0;
line-height: 1.65;
}
.tiptap table.rich-table .selectedCell,
.rich-text-editor-viewport .tableWrapper .selectedCell {
position: relative;
}
.tiptap table.rich-table .selectedCell::after,
.rich-text-editor-viewport .tableWrapper .selectedCell::after {
content: '';
position: absolute;
inset: 0;
background: rgba(56, 189, 248, 0.14);
pointer-events: none;
}
.tiptap table.rich-table .column-resize-handle,
.rich-text-editor-viewport .tableWrapper .column-resize-handle {
position: absolute;
top: 0;
right: -2px;
width: 4px;
height: 100%;
background: rgba(125, 211, 252, 0.82);
pointer-events: none;
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.45);
}
.tiptap table.rich-table tr:nth-child(even) td,
.rich-text-editor-viewport .tableWrapper tr:nth-child(even) td {
background: rgba(255, 255, 255, 0.035);
}
.tiptap p.is-editor-empty:first-child::before {
@@ -483,6 +897,73 @@
overflow-x: auto;
}
.forum-code-block {
position: relative;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
margin: 1.4rem 0;
border: 1px solid rgba(56, 189, 248, 0.16);
border-radius: 1.15rem;
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.98) 0, rgba(15, 23, 42, 0.98) 3rem, rgba(2, 6, 23, 0.98) 3rem, rgba(2, 6, 23, 0.98) 100%);
box-shadow:
0 22px 58px rgba(2, 6, 23, 0.42),
inset 0 1px 0 rgba(56, 189, 248, 0.1);
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.14) transparent;
}
.forum-code-block::-webkit-scrollbar {
height: 8px;
}
.forum-code-block::-webkit-scrollbar-track {
background: transparent;
}
.forum-code-block::-webkit-scrollbar-thumb {
background-color: rgba(255,255,255,0.16);
border-radius: 999px;
}
.forum-code-block::-webkit-scrollbar-thumb:hover {
background-color: rgba(255,255,255,0.28);
}
.forum-code-block code {
display: block;
min-width: max-content;
padding: 3.75rem 1.25rem 1.25rem;
color: rgb(226 232 240);
font-family: 'JetBrains Mono', 'SFMono-Regular', 'Consolas', 'Liberation Mono', monospace;
font-size: 0.92rem;
line-height: 1.75;
tab-size: 2;
}
.forum-code-block::before {
content: 'Code';
position: absolute;
top: 0.82rem;
left: 1.1rem;
z-index: 1;
border: 1px solid rgba(56, 189, 248, 0.2);
border-radius: 999px;
background: rgba(15, 23, 42, 0.92);
padding: 0.2rem 0.55rem;
color: rgb(125 211 252);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.forum-code-block .story-code-copy-button {
top: 0.78rem;
right: 1.05rem;
}
.tiptap pre code {
display: block;
background: none;
@@ -621,7 +1102,8 @@
.story-prose pre {
position: relative;
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
border-color: rgba(51, 65, 85, 0.95) !important;
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.98) 0, rgba(15, 23, 42, 0.98) 3rem, rgba(2, 6, 23, 0.98) 3rem, rgba(2, 6, 23, 0.98) 100%) !important;
@@ -629,6 +1111,30 @@
0 26px 75px rgba(2, 6, 23, 0.5),
inset 0 1px 0 rgba(56, 189, 248, 0.08);
padding: 4rem 1.5rem 1.5rem !important;
scrollbar-width: thin;
scrollbar-color: rgba(125, 211, 252, 0.34) rgba(15, 23, 42, 0.25);
}
.story-prose pre::-webkit-scrollbar {
height: 10px;
}
.story-prose pre::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.24);
}
.story-prose pre::-webkit-scrollbar-thumb {
border: 2px solid rgba(15, 23, 42, 0.28);
border-radius: 999px;
background: linear-gradient(90deg, rgba(56, 189, 248, 0.28), rgba(125, 211, 252, 0.48));
}
.story-prose pre::-webkit-scrollbar-thumb:hover {
background: linear-gradient(90deg, rgba(56, 189, 248, 0.42), rgba(125, 211, 252, 0.58));
}
.story-prose pre::-webkit-scrollbar-corner {
background: transparent;
}
.story-prose p {
@@ -737,12 +1243,14 @@
.story-prose figure iframe {
display: block;
width: 100%;
min-width: 100%;
max-width: 100%;
height: auto;
aspect-ratio: 16 / 9;
}
.news-rich-text-editor .news-embed,
.story-prose .news-embed {
width: 100%;
margin: 1.75rem 0;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.18);
@@ -798,10 +1306,377 @@
.story-prose .news-embed-video iframe {
display: block;
width: 100%;
max-width: 100%;
height: auto;
border: 0;
aspect-ratio: 16 / 9;
}
.academy-lesson-prose {
color: rgb(226 232 240 / 0.9);
font-family: 'Libre Franklin', 'Inter', sans-serif;
font-size: calc(clamp(0.88rem, 1.02rem + 0.28vw, 1.2rem) * var(--academy-lesson-font-scale, 1));
line-height: 1.74;
}
.academy-lesson-prose > :first-child {
margin-top: 0;
}
.academy-lesson-prose > :last-child {
margin-bottom: 0;
}
.academy-lesson-prose :is(p, li) {
color: rgb(226 232 240 / 0.9);
font-size: inherit;
line-height: inherit;
}
.academy-lesson-prose p {
margin-top: 0;
margin-bottom: 1.0rem;
text-wrap: pretty;
}
.academy-lesson-prose p + p {
margin-top: 0;
}
.academy-lesson-prose h2,
.academy-lesson-prose h3 {
position: relative;
text-wrap: balance;
scroll-margin-top: 7rem;
}
.academy-lesson-prose h2 {
margin-top: 3.35rem;
margin-bottom: 1.05rem;
font-size: clamp(2.08rem, 1.56rem + 1.12vw, 2.8rem);
line-height: 1.06;
font-weight: 800;
letter-spacing: -0.045em;
color: rgb(255 255 255);
}
.academy-lesson-prose h2::before {
content: '';
display: block;
width: 3.25rem;
height: 2px;
margin-bottom: 1rem;
border-radius: 999px;
background: linear-gradient(90deg, rgba(125, 211, 252, 0.95), rgba(125, 211, 252, 0.12));
}
.academy-lesson-prose h3 {
margin-top: 2.45rem;
margin-bottom: 0.9rem;
font-size: clamp(1.48rem, 1.22rem + 0.66vw, 1.9rem);
line-height: 1.18;
font-weight: 750;
letter-spacing: -0.03em;
color: rgb(248 250 252);
}
.academy-lesson-prose ul,
.academy-lesson-prose ol {
margin: 1rem 0 1.15rem;
padding-left: 0;
}
.academy-lesson-prose ul {
list-style: none;
}
.academy-lesson-prose ol {
list-style: none;
counter-reset: lesson-ordered-list;
}
.academy-lesson-prose ul li,
.academy-lesson-prose ol li {
position: relative;
margin: 0;
padding-left: 1.95rem;
line-height: 1.64;
}
.academy-lesson-prose ul li + li,
.academy-lesson-prose ol li + li {
margin-top: 0.14rem;
}
.academy-lesson-prose ul li::before {
content: '';
position: absolute;
top: 0.82em;
left: 0.2rem;
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
background: linear-gradient(180deg, rgba(125, 211, 252, 0.95), rgba(56, 189, 248, 0.65));
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.08);
}
.academy-lesson-prose ol li::before {
counter-increment: lesson-ordered-list;
content: counter(lesson-ordered-list);
position: absolute;
top: 0.16rem;
left: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.2rem;
height: 1.2rem;
border: 1px solid rgba(125, 211, 252, 0.24);
border-radius: 999px;
background: rgba(56, 189, 248, 0.09);
color: rgb(224 242 254);
font-size: 0.72rem;
font-weight: 700;
line-height: 1;
}
.academy-lesson-prose li > p:last-child {
margin-bottom: 0;
}
.academy-lesson-prose li > p:first-child {
margin-top: 0;
}
.academy-lesson-prose li > p + p {
margin-top: 0.25rem;
}
.academy-lesson-prose hr {
position: relative;
height: 1px;
margin: 2.9rem 0;
border: 0;
background: linear-gradient(90deg, rgba(148, 163, 184, 0), rgba(148, 163, 184, 0.34), rgba(148, 163, 184, 0));
}
.academy-lesson-prose hr::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 0.7rem;
height: 0.7rem;
transform: translate(-50%, -50%) rotate(45deg);
border: 1px solid rgba(125, 211, 252, 0.3);
background: rgba(15, 23, 42, 0.9);
box-shadow: 0 0 0 8px rgba(15, 23, 42, 0.9);
}
.academy-lesson-prose table {
width: 100%;
margin: 2rem 0;
border-collapse: separate;
border-spacing: 0;
border: 1px solid rgba(125, 211, 252, 0.28);
border-radius: 1rem;
overflow: hidden;
background: rgba(8, 12, 20, 0.72);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03), 0 16px 42px rgba(2, 6, 23, 0.2);
}
.academy-lesson-prose th,
.academy-lesson-prose td {
min-width: 120px;
padding: 0.9rem 1rem;
border: 1px solid rgba(148, 163, 184, 0.28);
vertical-align: top;
background: rgba(255, 255, 255, 0.03);
}
.academy-lesson-prose th {
background: rgba(14, 165, 233, 0.14);
color: rgb(248 250 252);
font-weight: 700;
}
.academy-lesson-prose td p,
.academy-lesson-prose th p {
margin: 0;
line-height: 1.65;
}
.academy-lesson-prose tbody tr:nth-child(even) td {
background: rgba(255, 255, 255, 0.02);
}
.academy-lesson-prose pre {
margin: .4rem 0;
padding: 0.5rem !important;
border-color: rgba(56, 189, 248, 0.16) !important;
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.98) 0, rgba(15, 23, 42, 0.98) 3rem, rgba(2, 6, 23, 0.98) 3rem, rgba(2, 6, 23, 0.98) 100%) !important;
box-shadow:
0 28px 78px rgba(2, 6, 23, 0.56),
inset 0 1px 0 rgba(56, 189, 248, 0.1);
}
.academy-lesson-prose pre code,
.academy-lesson-prose pre code.hljs,
.academy-lesson-prose pre code[class*='language-'] {
color: rgb(226 232 240);
font-size: 0.92rem;
line-height: 1.8;
tab-size: 2;
}
.academy-lesson-prose pre::after {
inset: 3rem 0 auto 0;
background: linear-gradient(90deg, rgba(56, 189, 248, 0), rgba(56, 189, 248, 0.26), rgba(56, 189, 248, 0));
}
.academy-lesson-prose pre[data-language]::before {
top: 0.8rem;
left: 1.2rem;
}
.academy-lesson-prose > img,
.academy-lesson-prose figure,
.academy-lesson-prose video,
.academy-lesson-prose iframe {
display: block;
width: 100%;
margin: 2.15rem 0;
}
.academy-lesson-prose > img,
.academy-lesson-prose figure img,
.academy-lesson-prose video,
.academy-lesson-prose iframe {
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 1.5rem;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.94), rgba(2, 6, 23, 0.96));
box-shadow: 0 24px 55px rgba(2, 6, 23, 0.34);
}
.academy-lesson-prose iframe,
.academy-lesson-prose video,
.academy-lesson-prose figure iframe,
.academy-lesson-prose figure video {
aspect-ratio: 16 / 9;
}
.academy-code-copy-button {
top: 0.78rem;
right: 1.1rem;
}
.academy-lesson-prose figure {
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 1.75rem;
background: rgba(15, 23, 42, 0.72);
padding: 0.55rem;
}
.academy-lesson-prose figure img,
.academy-lesson-prose figure iframe,
.academy-lesson-prose figure video {
margin: 0;
}
.academy-lesson-prose figcaption {
padding: 0.9rem 0.45rem 0.25rem;
color: rgb(148 163 184 / 0.92);
font-size: 0.92rem;
line-height: 1.7;
text-align: center;
}
.academy-lesson-prose a {
color: rgb(125 211 252);
text-decoration-thickness: 1.5px;
text-underline-offset: 0.18em;
}
.academy-lesson-toc-link {
display: flex;
align-items: flex-start;
gap: 0.8rem;
border-radius: 1rem;
padding: 0.8rem 0.9rem;
color: rgb(226 232 240 / 0.88);
font-size: 0.96rem;
line-height: 1.55;
text-decoration: none;
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease, transform 140ms ease;
}
.academy-lesson-toc-link:hover {
background: rgba(255, 255, 255, 0.05);
color: rgb(255 255 255);
}
.academy-lesson-toc-link-active {
background: rgba(56, 189, 248, 0.12);
color: rgb(255 255 255);
box-shadow: inset 0 0 0 1px rgba(125, 211, 252, 0.18);
}
.academy-lesson-toc-link-active .academy-lesson-toc-link-indicator {
background: linear-gradient(180deg, rgba(125, 211, 252, 1), rgba(56, 189, 248, 0.9));
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12);
}
.academy-lesson-toc-link-subtle {
padding-left: 1.55rem;
color: rgb(148 163 184 / 0.95);
font-size: 0.9rem;
}
.academy-lesson-toc-link-indicator {
width: 0.45rem;
height: 0.45rem;
margin-top: 0.48rem;
flex: 0 0 auto;
border-radius: 999px;
background: linear-gradient(180deg, rgba(125, 211, 252, 0.95), rgba(56, 189, 248, 0.68));
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.08);
}
.academy-lesson-toc-link-subtle .academy-lesson-toc-link-indicator {
width: 0.35rem;
height: 0.35rem;
margin-top: 0.56rem;
background: rgba(148, 163, 184, 0.72);
box-shadow: none;
}
.academy-lesson-prose strong {
color: rgb(255 255 255);
font-weight: 750;
}
.academy-lesson-prose em {
color: rgb(226 232 240 / 0.94);
}
@media (max-width: 768px) {
.academy-lesson-prose {
font-size: calc(clamp(1.02rem, 0.98rem + 0.18vw, 1.1rem) * var(--academy-lesson-font-scale, 1));
line-height: 1.78;
}
.academy-lesson-prose h2 {
margin-top: 2.8rem;
}
.academy-lesson-prose h3 {
margin-top: 2.15rem;
}
}
.news-editor-outline .ProseMirror :is(p, div, figure, blockquote, ul, ol, li, h2, h3, pre, hr) {
position: relative;
outline: 1px dashed rgba(56, 189, 248, 0.24);
@@ -821,7 +1696,7 @@
.news-editor-outline .ProseMirror hr::before {
position: absolute;
top: -0.55rem;
left: 0.1rem;
right: 0.1rem;
z-index: 2;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 0.35rem;
@@ -968,6 +1843,7 @@
.story-prose pre code.hljs,
.story-prose pre code[class*='language-'] {
display: block;
min-width: max-content;
overflow-x: auto;
background: transparent;
padding: 0;

View File

@@ -7,6 +7,7 @@ const buildAdminNavGroups = (isAdmin) => [
items: [
{ label: 'Dashboard', href: '/moderation', icon: 'fa-solid fa-gauge-high', exact: true },
{ label: 'Daily Activity', href: '/moderation/activity', icon: 'fa-solid fa-calendar-day' },
{ label: 'Online Users', href: '/moderation/traffic/online', icon: 'fa-solid fa-user-check' },
],
},
{
@@ -22,10 +23,6 @@ const buildAdminNavGroups = (isAdmin) => [
items: [
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed' },
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images' },
{ label: 'Academy Dashboard', href: '/moderation/academy/dashboard', icon: 'fa-solid fa-graduation-cap' },
{ label: 'Academy Lessons', href: '/moderation/academy/lessons', icon: 'fa-solid fa-book-open' },
{ label: 'Academy Prompts', href: '/moderation/academy/prompts', icon: 'fa-solid fa-wand-magic-sparkles' },
{ label: 'Academy Challenges', href: '/moderation/academy/challenges', icon: 'fa-solid fa-trophy' },
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star' },
{ label: 'Homepage Announcements', href: '/moderation/homepage/announcements', icon: 'fa-solid fa-bullhorn' },
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up' },
@@ -33,6 +30,15 @@ const buildAdminNavGroups = (isAdmin) => [
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles' },
],
},
{
label: 'Academy',
items: [
{ label: 'Academy Dashboard', href: '/moderation/academy/dashboard', icon: 'fa-solid fa-graduation-cap' },
{ label: 'Academy Lessons', href: '/moderation/academy/lessons', icon: 'fa-solid fa-book-open' },
{ label: 'Academy Prompts', href: '/moderation/academy/prompts', icon: 'fa-solid fa-wand-magic-sparkles' },
{ label: 'Academy Challenges', href: '/moderation/academy/challenges', icon: 'fa-solid fa-trophy' },
],
},
{
label: 'System',
items: [
@@ -49,6 +55,18 @@ function NavLink({ item, active }) {
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`
// For some moderation surfaces (traffic/online) we prefer a full-page
// navigation so the server-rendered blade view opens as its own page
// instead of being handled by Inertia or opened inline.
if (item.href === '/moderation/traffic/online') {
return (
<a href={item.href} className={cls}>
<i className={`${item.icon} w-5 text-center text-base`} />
<span>{item.label}</span>
</a>
)
}
return (
<Link href={item.href} className={cls}>
<i className={`${item.icon} w-5 text-center text-base`} />
@@ -66,7 +84,7 @@ function Sidebar({ pathname, isAdmin }) {
}
return (
<aside className="flex h-full w-64 flex-col overflow-y-auto border-r border-white/[0.07] bg-[rgba(10,14,22,0.98)] px-3 py-6">
<aside className="nova-scrollbar flex h-full w-64 flex-col overflow-y-auto border-r border-white/[0.07] bg-[rgba(10,14,22,0.98)] px-3 py-6">
{/* Brand */}
<div className="mb-8 px-3">
<Link href="/moderation" className="flex items-center gap-2.5">

View File

@@ -1,7 +1,53 @@
import React, { useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { Link, router, usePage } from '@inertiajs/react'
import SeoHead from '../../components/seo/SeoHead'
function slugifyHeading(value, fallback = 'section') {
const normalized = String(value || '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return normalized || fallback
}
function formatLessonDate(value) {
if (!value) return 'Recently updated'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Recently updated'
return new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
}
function formatLessonMinutes(minutes) {
const value = Number(minutes || 0)
return value > 0 ? `${value} min read` : 'Quick read'
}
function StatPill({ label, value }) {
return (
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-slate-400">{label}</p>
<p className="mt-2 text-sm font-semibold text-white">{value}</p>
</div>
)
}
function LessonInfoRow({ label, value }) {
return (
<div className="flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">{label}</span>
<span className="text-sm font-semibold text-white">{value}</span>
</div>
)
}
function LockedPanel({ pricingUrl, label }) {
return (
<div className="rounded-[28px] border border-amber-300/20 bg-amber-300/10 p-6 text-amber-50">
@@ -13,10 +59,206 @@ function LockedPanel({ pricingUrl, label }) {
)
}
export default function AcademyShow({ pageType, item, seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl }) {
function copyTextToClipboard(text) {
const source = String(text || '')
if (!source) return Promise.reject(new Error('Nothing to copy'))
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
return navigator.clipboard.writeText(source)
}
const textarea = document.createElement('textarea')
textarea.value = source
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.top = '-1000px'
textarea.style.left = '-1000px'
document.body.appendChild(textarea)
textarea.select()
try {
if (document.execCommand('copy')) {
return Promise.resolve()
}
} finally {
document.body.removeChild(textarea)
}
return Promise.reject(new Error('Clipboard unavailable'))
}
function PromptCopyButton({ prompt }) {
const [status, setStatus] = useState('idle')
const resetTimerRef = useRef(0)
return (
<button
type="button"
onClick={() => {
copyTextToClipboard(prompt)
.then(() => setStatus('copied'))
.catch(() => setStatus('failed'))
.finally(() => {
window.clearTimeout(resetTimerRef.current)
resetTimerRef.current = window.setTimeout(() => setStatus('idle'), 1800)
})
}}
className="inline-flex items-center gap-2 rounded-full border border-[#ffb9ab]/20 bg-[#ffb9ab]/10 px-4 py-2 text-sm font-semibold text-[#ffe2dc] transition hover:border-[#ffb9ab]/35 hover:bg-[#ffb9ab]/16"
aria-label="Copy prompt"
>
<i className={`fa-solid ${status === 'copied' ? 'fa-check' : status === 'failed' ? 'fa-triangle-exclamation' : 'fa-copy'}`} />
<span>{status === 'copied' ? 'Copied' : status === 'failed' ? 'Copy failed' : 'Copy prompt'}</span>
</button>
)
}
function AiComparisonSection({ block }) {
const payload = block?.payload || {}
const criteria = Array.isArray(payload.criteria) ? payload.criteria.filter(Boolean) : []
const results = Array.isArray(block?.comparison_results) ? block.comparison_results.filter((result) => result?.active !== false) : []
const hasPrompt = Boolean(payload.prompt)
const hasNegativePrompt = Boolean(payload.negative_prompt)
const hasUsefulData = Boolean(block?.title || payload.title || payload.intro || hasPrompt || hasNegativePrompt || payload.aspect_ratio || criteria.length || results.length)
if (!hasUsefulData) return null
return (
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(255,151,132,0.14),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-7">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffb8aa]">AI Model Comparison</p>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">{payload.title || block.title || 'Same Prompt, Different AI Models'}</h2>
{payload.intro ? <p className="mt-3 text-sm leading-7 text-slate-300 md:text-base">{payload.intro}</p> : null}
</div>
{payload.aspect_ratio ? <div className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-200">Aspect ratio {payload.aspect_ratio}</div> : null}
</div>
{hasPrompt ? (
<div className="mt-6 rounded-[26px] border border-[#ffb8aa]/15 bg-black/25 p-4 md:p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-[#ffd0c6]">Prompt used</p>
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">Shared source prompt across all compared models</p>
</div>
<PromptCopyButton prompt={payload.prompt} />
</div>
<pre className="mt-4 whitespace-pre-wrap rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100">{payload.prompt}</pre>
{hasNegativePrompt ? (
<div className="mt-4 rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Negative prompt</p>
<pre className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-300">{payload.negative_prompt}</pre>
</div>
) : null}
</div>
) : null}
{criteria.length ? (
<div className="mt-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">What we compare</p>
<div className="mt-3 flex flex-wrap gap-2">
{criteria.map((criterion) => (
<span key={criterion} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-medium text-slate-100">{criterion}</span>
))}
</div>
</div>
) : null}
{results.length ? (
<div className="mt-6 grid gap-5 md:grid-cols-2 2xl:grid-cols-4">
{results.map((result) => {
const imageUrl = result.thumb_url || result.image_url || result.thumb_path || result.image_path || ''
const score = Number(result.score || 0)
const hasScore = Number.isFinite(score) && score > 0
const altText = `${result.model_name || 'AI model'} by ${result.provider || 'unknown provider'} result for ${payload.prompt || 'comparison prompt'}`
return (
<article key={result.id || `${result.provider}-${result.model_name}-${result.sort_order || 0}`} className="overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.04] shadow-[0_16px_40px_rgba(2,6,23,0.18)]">
<div className="aspect-video overflow-hidden bg-slate-950/80">
{imageUrl ? (
<img src={imageUrl} alt={altText} loading="lazy" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-slate-500">No comparison image provided.</div>
)}
</div>
<div className="space-y-4 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-xl font-semibold tracking-[-0.03em] text-white">{result.model_name || result.provider || 'AI model'}</h3>
{result.provider ? <p className="mt-1 text-sm text-slate-400">{result.provider}</p> : null}
</div>
{hasScore ? <div className="rounded-full border border-[#ffb8aa]/20 bg-[#ffb8aa]/10 px-3 py-1 text-sm font-semibold text-[#ffe3dd]">{`Skinbase score ${score}/10`}</div> : null}
</div>
{result.settings ? (
<div className="rounded-[20px] border border-white/10 bg-black/20 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Settings</p>
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-300">{result.settings}</p>
</div>
) : null}
{result.strengths ? (
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-200/75">Strengths</p>
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200">{result.strengths}</p>
</div>
) : null}
{result.weaknesses ? (
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-200/75">Weaknesses</p>
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-300">{result.weaknesses}</p>
</div>
) : null}
{result.best_for ? (
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/75">Best for</p>
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200">{result.best_for}</p>
</div>
) : null}
</div>
</article>
)
})}
</div>
) : null}
</section>
)
}
export default function AcademyShow({ pageType, item, relatedLessons = [], seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl }) {
const flash = usePage().props.flash || {}
const [completed, setCompleted] = useState(Boolean(initialCompleted))
const [saved, setSaved] = useState(Boolean(initialSaved))
const [tableOfContents, setTableOfContents] = useState([])
const [activeHeadingId, setActiveHeadingId] = useState('')
const articleContentRef = useRef(null)
const lessonCover = item?.cover_image_url || item?.cover_image || ''
const lessonCategory = item?.category?.name || 'Academy'
const lessonDifficulty = item?.difficulty || 'Intermediate'
const lessonMinutes = formatLessonMinutes(item?.reading_minutes)
const lessonUpdated = formatLessonDate(item?.published_at)
const lessonBlocks = Array.isArray(item?.blocks) ? item.blocks : []
const relatedLessonList = Array.isArray(relatedLessons) ? relatedLessons : []
const lessonSummary = item.excerpt || item.description || item.prompt_preview || item.content_preview || 'A focused Academy lesson with practical guidance and examples.'
const fontScaleStorageKey = 'academy.lesson.font-scale'
const fontScaleMin = 0.95
const fontScaleMax = 1.12
const fontScaleStep = 0.04
const [lessonFontScale, setLessonFontScale] = useState(() => {
if (typeof window === 'undefined') {
return 1.04
}
const storedValue = Number(window.localStorage.getItem(fontScaleStorageKey))
if (Number.isFinite(storedValue)) {
return Math.min(fontScaleMax, Math.max(fontScaleMin, storedValue))
}
return 1.04
})
const markComplete = () => {
if (!completeUrl || completed) return
@@ -35,84 +277,414 @@ export default function AcademyShow({ pageType, item, seo, pricingUrl, completeU
})
}
const decreaseFontSize = () => {
setLessonFontScale((current) => Math.max(fontScaleMin, Number((current - fontScaleStep).toFixed(2))))
}
const increaseFontSize = () => {
setLessonFontScale((current) => Math.min(fontScaleMax, Number((current + fontScaleStep).toFixed(2))))
}
useEffect(() => {
if (pageType !== 'lesson' || !item?.content || !articleContentRef.current) {
setTableOfContents([])
setActiveHeadingId('')
return
}
const headings = Array.from(articleContentRef.current.querySelectorAll('h2, h3'))
const seenIds = new Map()
const nextTableOfContents = headings.map((heading, index) => {
const baseId = slugifyHeading(heading.textContent, `section-${index + 1}`)
const seenCount = seenIds.get(baseId) ?? 0
const nextId = seenCount > 0 ? `${baseId}-${seenCount + 1}` : baseId
seenIds.set(baseId, seenCount + 1)
heading.id = nextId
return {
id: nextId,
title: heading.textContent?.trim() || `Section ${index + 1}`,
level: heading.tagName.toLowerCase(),
}
})
setTableOfContents(nextTableOfContents)
}, [item?.content, pageType])
useEffect(() => {
if (pageType !== 'lesson' || tableOfContents.length === 0 || !articleContentRef.current) {
setActiveHeadingId('')
return
}
const headingElements = Array.from(articleContentRef.current.querySelectorAll('h2, h3'))
if (!headingElements.length) {
setActiveHeadingId('')
return
}
const observer = new IntersectionObserver((entries) => {
const visibleEntries = entries
.filter((entry) => entry.isIntersecting)
.sort((left, right) => left.boundingClientRect.top - right.boundingClientRect.top)
if (visibleEntries.length) {
setActiveHeadingId((current) => visibleEntries[0].target.id || current)
}
}, {
root: null,
rootMargin: '-18% 0px -68% 0px',
threshold: [0, 1],
})
headingElements.forEach((heading) => observer.observe(heading))
const firstVisibleHeading = headingElements.find((heading) => heading.getBoundingClientRect().top >= 0) || headingElements[0]
if (firstVisibleHeading?.id) {
setActiveHeadingId(firstVisibleHeading.id)
}
return () => observer.disconnect()
}, [pageType, tableOfContents, lessonFontScale])
useEffect(() => {
if (typeof window === 'undefined') {
return
}
window.localStorage.setItem(fontScaleStorageKey, String(lessonFontScale))
}, [lessonFontScale])
useEffect(() => {
if (pageType !== 'lesson' || !item?.content || !articleContentRef.current) {
return
}
const codeBlocks = Array.from(articleContentRef.current.querySelectorAll('pre code'))
if (!codeBlocks.length) {
return
}
const fallbackCopyText = (text) => {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.top = '-1000px'
textarea.style.left = '-1000px'
document.body.appendChild(textarea)
textarea.select()
try {
return document.execCommand('copy')
} catch (_error) {
return false
} finally {
document.body.removeChild(textarea)
}
}
const copyText = (text) => {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
return navigator.clipboard.writeText(text)
}
return fallbackCopyText(text)
? Promise.resolve()
: Promise.reject(new Error('Clipboard unavailable'))
}
codeBlocks.forEach((block) => {
const pre = block.parentElement
if (!pre || pre.dataset.academyCopyButtonMounted === 'true') {
return
}
const button = document.createElement('button')
const icon = document.createElement('span')
const label = document.createElement('span')
button.type = 'button'
button.className = 'story-code-copy-button academy-code-copy-button'
icon.className = 'story-code-copy-icon'
icon.setAttribute('aria-hidden', 'true')
icon.textContent = '⧉'
label.className = 'story-code-copy-label'
label.textContent = 'Copy'
button.appendChild(icon)
button.appendChild(label)
button.dataset.copied = 'idle'
button.setAttribute('aria-label', 'Copy code block')
let resetTimer = 0
button.addEventListener('click', () => {
const source = block.innerText || block.textContent || ''
copyText(source)
.then(() => {
icon.textContent = '✓'
label.textContent = 'Copied'
button.dataset.copied = 'true'
})
.catch(() => {
icon.textContent = '!'
label.textContent = 'Failed'
button.dataset.copied = 'false'
})
.finally(() => {
window.clearTimeout(resetTimer)
resetTimer = window.setTimeout(() => {
icon.textContent = '⧉'
label.textContent = 'Copy'
button.dataset.copied = 'idle'
}, 1800)
})
})
pre.appendChild(button)
pre.dataset.academyCopyButtonMounted = 'true'
})
}, [item?.content, lessonFontScale, pageType])
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
<SeoHead seo={seo || {}} title={item?.title} description={item?.excerpt || item?.description} />
<div className="mx-auto max-w-[1200px] space-y-6">
<section className="rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10">
<div className="flex flex-wrap items-start justify-between gap-5">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Skinbase AI Academy</p>
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{item.title}</h1>
<p className="mt-4 text-base leading-8 text-slate-300">{item.excerpt || item.description || item.prompt_preview || item.content_preview}</p>
</div>
<div className="flex flex-wrap gap-3">
{completeUrl ? <button type="button" onClick={markComplete} className="rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100">{completed ? 'Completed' : 'Mark complete'}</button> : null}
{saveUrl ? <button type="button" onClick={toggleSave} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{saved ? 'Saved' : 'Save prompt'}</button> : null}
{submitUrl ? <Link href={submitUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Submit artwork</Link> : null}
</div>
</div>
</section>
<div className="mx-auto max-w-[1320px] space-y-6">
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
{item.locked ? <LockedPanel pricingUrl={pricingUrl} label={pageType} /> : null}
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
{pageType === 'lesson' ? <div className="whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.content || item.content_preview}</div> : null}
{pageType === 'prompt' ? (
<div className="space-y-6">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompt</p>
<pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.prompt || item.prompt_preview}</pre>
</div>
{item.negative_prompt ? <div><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Negative prompt</p><pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.negative_prompt}</pre></div> : null}
</div>
) : null}
{pageType === 'pack' ? (
<div className="space-y-5">
<p className="text-sm leading-8 text-slate-200">{item.description}</p>
<div className="grid gap-4 md:grid-cols-2">
{(item.prompts || []).map((prompt) => (
<div key={prompt.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
<h3 className="text-lg font-semibold text-white">{prompt.title}</h3>
<p className="mt-2 text-sm leading-7 text-slate-300">{prompt.excerpt || prompt.prompt_preview}</p>
</div>
))}
</div>
</div>
) : null}
{pageType === 'challenge' ? (
<div className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Brief</p>
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.brief || item.description}</div>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Rules</p>
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.rules || 'No special rules posted yet.'}</div>
</div>
</div>
{(item.submissions || []).length ? (
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Approved submissions</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{item.submissions.map((submission) => (
<div key={submission.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
<h3 className="text-lg font-semibold text-white">{submission.artwork?.title || 'Submission'}</h3>
<p className="mt-2 text-sm text-slate-400">{submission.user?.name || 'Unknown creator'}</p>
</div>
))}
{pageType === 'lesson' ? (
<div className="space-y-8">
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-black/20 shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
<div className="grid gap-0 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="relative overflow-hidden p-8 md:p-10 lg:p-12">
{lessonCover ? <img src={lessonCover} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover opacity-15" /> : null}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_34%),linear-gradient(135deg,_rgba(2,6,23,0.96),_rgba(15,23,42,0.78))]" />
<div className="relative z-10 max-w-3xl">
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100">Skinbase AI Academy</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">{lessonCategory}</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">{lessonDifficulty}</span>
</div>
<h1 className="mt-5 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl">{item.title}</h1>
<p className="mt-5 max-w-2xl text-base leading-8 text-slate-300 md:text-lg">{lessonSummary}</p>
<div className="mt-7 flex flex-wrap gap-3">
{completeUrl ? <button type="button" onClick={markComplete} className="rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100">{completed ? 'Completed' : 'Mark complete'}</button> : null}
{saveUrl ? <button type="button" onClick={toggleSave} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{saved ? 'Saved' : 'Save prompt'}</button> : null}
{submitUrl ? <Link href={submitUrl} className="rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-sky-300/25 hover:bg-sky-300/12 hover:text-sky-100">Submit artwork</Link> : null}
</div>
<div className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatPill label="Category" value={lessonCategory} />
<StatPill label="Reading" value={lessonMinutes} />
<StatPill label="Updated" value={lessonUpdated} />
<StatPill label="Access" value={item.access_level || 'free'} />
</div>
</div>
</div>
) : null}
<aside className="border-t border-white/10 bg-white/[0.03] p-6 lg:border-l lg:border-t-0 lg:p-8">
<div className="space-y-5 lg:sticky lg:top-6">
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20">
{lessonCover ? <img src={lessonCover} alt={item.title} className="h-52 w-full object-cover" /> : <div className="flex h-52 items-center justify-center bg-[linear-gradient(135deg,_rgba(14,165,233,0.18),_rgba(17,24,39,0.94))] text-sm font-semibold uppercase tracking-[0.24em] text-slate-300">Lesson cover</div>}
</div>
<div className="space-y-3">
<LessonInfoRow label="Series" value={lessonCategory} />
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
<LessonInfoRow label="Reading time" value={lessonMinutes} />
<LessonInfoRow label="Published" value={lessonUpdated} />
</div>
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Lesson status</p>
<p className="mt-3 text-sm leading-7 text-slate-300">{item.locked ? 'This lesson is partially locked for your account level.' : 'Full lesson content is available below.'}</p>
</div>
</div>
</aside>
</div>
</section>
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
<article className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200 md:p-8">
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-white/10 pb-5">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Article</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Lesson content</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-300">{lessonMinutes}</span>
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-black/20 p-1">
<button
type="button"
onClick={decreaseFontSize}
disabled={lessonFontScale <= fontScaleMin}
aria-label="Decrease article text size"
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-sm font-semibold text-slate-200 transition hover:border-sky-300/30 hover:bg-sky-300/12 hover:text-sky-100 disabled:cursor-not-allowed disabled:opacity-40"
>
-
</button>
<span className="min-w-12 px-1 text-center text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">{Math.round(lessonFontScale * 100)}%</span>
<button
type="button"
onClick={increaseFontSize}
disabled={lessonFontScale >= fontScaleMax}
aria-label="Increase article text size"
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-sm font-semibold text-slate-200 transition hover:border-sky-300/30 hover:bg-sky-300/12 hover:text-sky-100 disabled:cursor-not-allowed disabled:opacity-40"
>
+
</button>
</div>
</div>
</div>
<div className="mt-6">
{item.content ? (
<div className="space-y-8">
<div
ref={articleContentRef}
className="story-prose academy-lesson-prose prose prose-invert max-w-none"
style={{ '--academy-lesson-font-scale': lessonFontScale }}
dangerouslySetInnerHTML={{ __html: item.content }}
/>
{lessonBlocks.map((block) => <AiComparisonSection key={block.id || `${block.type}-${block.sort_order || 0}`} block={block} />)}
</div>
) : (
<div className="space-y-8">
<div className="whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.content_preview}</div>
{lessonBlocks.map((block) => <AiComparisonSection key={block.id || `${block.type}-${block.sort_order || 0}`} block={block} />)}
</div>
)}
</div>
</article>
<aside className="space-y-6 lg:sticky lg:top-6 lg:self-start">
{tableOfContents.length ? (
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">On this page</p>
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Table of contents</h3>
<nav aria-label="Lesson table of contents" className="mt-5 space-y-1.5">
{tableOfContents.map((entry) => (
<a
key={entry.id}
href={`#${entry.id}`}
aria-current={activeHeadingId === entry.id ? 'location' : undefined}
className={`academy-lesson-toc-link ${entry.level === 'h3' ? 'academy-lesson-toc-link-subtle' : ''} ${activeHeadingId === entry.id ? 'academy-lesson-toc-link-active' : ''}`}
>
<span className="academy-lesson-toc-link-indicator" aria-hidden="true" />
<span>{entry.title}</span>
</a>
))}
</nav>
</section>
) : null}
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Series info</p>
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{lessonCategory}</h3>
<div className="mt-5 space-y-3">
<LessonInfoRow label="Category" value={lessonCategory} />
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
<LessonInfoRow label="Reading" value={lessonMinutes} />
<LessonInfoRow label="Updated" value={lessonUpdated} />
</div>
</section>
{relatedLessonList.length ? (
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Continue learning</p>
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">More in {lessonCategory}</h3>
<div className="mt-5 space-y-3">
{relatedLessonList.map((relatedLesson, index) => (
<Link
key={relatedLesson.id}
href={`/academy/lessons/${relatedLesson.slug}`}
className="group flex gap-4 rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-sky-300/25 hover:bg-white/[0.06]"
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/15 bg-sky-300/10 text-sm font-semibold text-sky-100">
{String(index + 1).padStart(2, '0')}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<h4 className="text-sm font-semibold text-white transition group-hover:text-sky-100">{relatedLesson.title}</h4>
<span className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">{formatLessonMinutes(relatedLesson.reading_minutes)}</span>
</div>
<p className="mt-2 text-xs leading-6 text-slate-400">{relatedLesson.excerpt || relatedLesson.content_preview || 'Continue the series with the next lesson.'}</p>
</div>
</Link>
))}
</div>
</section>
) : null}
</aside>
</div>
) : null}
</section>
</div>
) : (
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
{pageType === 'prompt' ? (
<div className="space-y-6">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompt</p>
<pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.prompt || item.prompt_preview}</pre>
</div>
{item.negative_prompt ? <div><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Negative prompt</p><pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.negative_prompt}</pre></div> : null}
</div>
) : null}
{pageType === 'pack' ? (
<div className="space-y-5">
<p className="text-sm leading-8 text-slate-200">{item.description}</p>
<div className="grid gap-4 md:grid-cols-2">
{(item.prompts || []).map((prompt) => (
<div key={prompt.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
<h3 className="text-lg font-semibold text-white">{prompt.title}</h3>
<p className="mt-2 text-sm leading-7 text-slate-300">{prompt.excerpt || prompt.prompt_preview}</p>
</div>
))}
</div>
</div>
) : null}
{pageType === 'challenge' ? (
<div className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Brief</p>
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.brief || item.description}</div>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Rules</p>
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.rules || 'No special rules posted yet.'}</div>
</div>
</div>
{(item.submissions || []).length ? (
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Approved submissions</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{item.submissions.map((submission) => (
<div key={submission.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
<h3 className="text-lg font-semibold text-white">{submission.artwork?.title || 'Submission'}</h3>
<p className="mt-2 text-sm text-slate-400">{submission.user?.name || 'Unknown creator'}</p>
</div>
))}
</div>
</div>
) : null}
</div>
) : null}
</section>
)}
</div>
</main>
)

View File

@@ -1,14 +1,19 @@
import React from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Head, Link, router, useForm } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
import DateTimePicker from '../../../components/ui/DateTimePicker'
import NovaSelect from '../../../components/ui/NovaSelect'
import LessonEditor from './LessonEditor'
function normalizePayload(fields, data) {
const payload = { ...data }
fields.forEach((field) => {
if (field.type === 'csv') {
payload[field.name] = String(payload[field.name] || '').split(',').map((item) => item.trim()).filter(Boolean)
payload[field.name] = String(payload[field.name] || '')
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean)
}
if (field.type === 'json') {
@@ -23,6 +28,57 @@ function normalizePayload(fields, data) {
return payload
}
function getField(fields, name) {
return fields.find((field) => field.name === name) || null
}
function SectionCard({ eyebrow, title, description, children, className = '' }) {
return (
<section className={`w-full min-w-0 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_20px_80px_rgba(15,23,42,0.18)] ${className}`.trim()}>
<div className="mb-5">
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">{eyebrow}</p> : null}
<h2 className="mt-2 text-xl font-semibold tracking-[-0.04em] text-white">{title}</h2>
{description ? <p className="mt-2 text-sm leading-7 text-slate-400">{description}</p> : null}
</div>
<div className="grid gap-5">{children}</div>
</section>
)
}
function TextField({ label, value, onChange, error, ...rest }) {
return (
<label className="grid gap-2 text-sm text-slate-200">
<span>{label}</span>
<input value={value ?? ''} onChange={onChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" {...rest} />
{error ? <p className="text-xs text-rose-300">{error}</p> : null}
</label>
)
}
function TextAreaField({ label, value, onChange, error, rows = 6, hint }) {
return (
<label className="grid gap-2 text-sm text-slate-200">
<span>{label}</span>
<textarea value={value ?? ''} onChange={onChange} rows={rows} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-white outline-none" />
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
{error ? <p className="text-xs text-rose-300">{error}</p> : null}
</label>
)
}
function ToggleField({ label, checked, onChange, help, error }) {
return (
<label className="flex items-start gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200">
<input type="checkbox" checked={Boolean(checked)} onChange={onChange} className="mt-1" />
<span>
<span className="block font-semibold text-white">{label}</span>
{help ? <span className="mt-1 block text-xs leading-5 text-slate-400">{help}</span> : null}
{error ? <span className="mt-2 block text-xs text-rose-300">{error}</span> : null}
</span>
</label>
)
}
function Field({ field, form }) {
const value = form.data[field.name]
@@ -35,18 +91,44 @@ function Field({ field, form }) {
)
}
if (field.type === 'datetime-local') {
return (
<DateTimePicker
label={field.label}
value={value || ''}
onChange={(nextValue) => form.setData(field.name, nextValue || '')}
error={form.errors[field.name]}
clearable
className="bg-black/20"
/>
)
}
if (field.type === 'textarea') {
return <textarea value={value || ''} onChange={(event) => form.setData(field.name, event.target.value)} rows={6} className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" />
return (
<label className="grid gap-2 text-sm text-slate-200">
<span>{field.label}</span>
<textarea
value={value || ''}
onChange={(event) => form.setData(field.name, event.target.value)}
rows={field.rows || 6}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-white outline-none"
/>
{form.errors[field.name] ? <p className="text-xs text-rose-300">{form.errors[field.name]}</p> : null}
</label>
)
}
if (field.type === 'select') {
return (
<NovaSelect
label={field.label}
value={value ?? ''}
onChange={(nextValue) => form.setData(field.name, nextValue ?? '')}
options={field.options || []}
searchable={false}
className="mt-2 rounded-2xl bg-black/20"
className="rounded-2xl bg-black/20"
error={form.errors[field.name]}
/>
)
}
@@ -55,30 +137,329 @@ function Field({ field, form }) {
return (
<NovaSelect
multi
label={field.label}
value={value || []}
onChange={(nextValue) => form.setData(field.name, Array.isArray(nextValue) ? nextValue : [])}
options={field.options || []}
className="mt-2 rounded-2xl bg-black/20"
className="rounded-2xl bg-black/20"
error={form.errors[field.name]}
/>
)
}
return <input type={field.type || 'text'} value={value ?? ''} onChange={(event) => form.setData(field.name, event.target.value)} className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" />
return (
<label className="grid gap-2 text-sm text-slate-200">
<span>{field.label}</span>
<input
type={field.type || 'text'}
value={value ?? ''}
onChange={(event) => form.setData(field.name, event.target.value)}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none"
/>
{form.errors[field.name] ? <p className="text-xs text-rose-300">{form.errors[field.name]}</p> : null}
</label>
)
}
export default function AcademyCrudForm({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method }) {
function PromptPreviewDropzone({ form, previewUrl }) {
const inputRef = useRef(null)
const [dragging, setDragging] = useState(false)
const [localPreviewUrl, setLocalPreviewUrl] = useState('')
const [selectedFileName, setSelectedFileName] = useState('')
const previewSrc = localPreviewUrl || previewUrl || form.data.preview_image || ''
useEffect(() => () => {
if (localPreviewUrl.startsWith('blob:')) {
URL.revokeObjectURL(localPreviewUrl)
}
}, [localPreviewUrl])
const setSelectedFile = (file) => {
if (localPreviewUrl.startsWith('blob:')) {
URL.revokeObjectURL(localPreviewUrl)
}
if (!file) {
setLocalPreviewUrl('')
setSelectedFileName('')
form.setData('preview_image_file', null)
form.clearErrors('preview_image_file')
return
}
const nextPreviewUrl = URL.createObjectURL(file)
setLocalPreviewUrl(nextPreviewUrl)
setSelectedFileName(file.name)
form.setData('preview_image_file', file)
form.clearErrors('preview_image_file')
}
const clearSelection = () => {
if (localPreviewUrl.startsWith('blob:')) {
URL.revokeObjectURL(localPreviewUrl)
}
setLocalPreviewUrl('')
setSelectedFileName('')
form.setData('preview_image_file', null)
form.clearErrors('preview_image_file')
if (inputRef.current) {
inputRef.current.value = ''
}
}
return (
<SectionCard
eyebrow="Visual preview"
title="Preview image"
description="Drag an image here or paste a URL. Uploaded files are converted to WebP and stored on Contabo automatically."
>
<div
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
inputRef.current?.click()
}
}}
onDragOver={(event) => {
event.preventDefault()
setDragging(true)
}}
onDragEnter={(event) => {
event.preventDefault()
setDragging(true)
}}
onDragLeave={(event) => {
event.preventDefault()
setDragging(false)
}}
onDrop={(event) => {
event.preventDefault()
setDragging(false)
setSelectedFile(event.dataTransfer?.files?.[0] || null)
}}
className={[
'w-full min-w-0 rounded-[28px] border border-dashed p-5 outline-none transition',
dragging ? 'border-sky-300/50 bg-sky-400/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]',
].join(' ')}
>
<div className="flex flex-col gap-4">
<div className="flex min-w-0 items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100">
<i className="fa-solid fa-image" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">Drop a preview image or browse</div>
<div className="mt-1 text-xs leading-5 text-slate-400">JPG, PNG, or WEBP. The server re-encodes the final asset to WebP before uploading it to the CDN.</div>
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-400">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">JPG</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">PNG</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">WEBP</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">Max 5 MB</span>
</div>
</div>
</div>
<div className="grid w-full max-w-full gap-3">
<div className="overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">
{previewSrc ? (
<img src={previewSrc} alt="Prompt preview" className="h-40 w-full object-cover" />
) : (
<div className="flex h-40 items-center justify-center px-4 text-center text-sm text-slate-500">No preview image selected</div>
)}
</div>
<div className="flex gap-2">
<button type="button" onClick={() => inputRef.current?.click()} className="flex-1 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Browse</button>
{selectedFileName || localPreviewUrl ? <button type="button" onClick={clearSelection} className="rounded-full border border-white/10 bg-transparent px-4 py-2.5 text-sm font-semibold text-slate-300 transition hover:bg-white/[0.04]">Clear</button> : null}
</div>
</div>
</div>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(event) => {
setSelectedFile(event.target.files?.[0] || null)
event.target.value = ''
}}
/>
<div className="mt-4 grid min-w-0 gap-3 md:grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,220px)]">
<TextField
label="Preview image URL fallback"
value={form.data.preview_image || ''}
onChange={(event) => form.setData('preview_image', event.target.value)}
error={form.errors.preview_image}
placeholder="Paste a URL or leave empty if you upload a file"
/>
<div className="min-w-0 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-300">
<div className="font-semibold text-white">Stored value</div>
<div className="mt-1 break-all text-slate-400">{form.data.preview_image_file?.name || form.data.preview_image || previewUrl || 'None yet'}</div>
</div>
</div>
{form.errors.preview_image_file ? <p className="mt-3 text-sm text-rose-300">{form.errors.preview_image_file}</p> : null}
</div>
</SectionCard>
)
}
function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method }) {
const form = useForm({ ...record, preview_image_file: null })
const categoryField = useMemo(() => getField(fields, 'category_id'), [fields])
const difficultyField = useMemo(() => getField(fields, 'difficulty'), [fields])
const accessField = useMemo(() => getField(fields, 'access_level'), [fields])
const publishedAtField = useMemo(() => getField(fields, 'published_at'), [fields])
const featuredField = useMemo(() => getField(fields, 'featured'), [fields])
const promptOfWeekField = useMemo(() => getField(fields, 'prompt_of_week'), [fields])
const activeField = useMemo(() => getField(fields, 'active'), [fields])
const seoDescriptionField = useMemo(() => getField(fields, 'seo_description'), [fields])
const previewUrl = form.data.preview_image_url || ''
const submit = (event) => {
event.preventDefault()
const payload = normalizePayload(fields, form.data)
form.transform(() => payload)
if (method === 'patch') {
form.patch(submitUrl)
return
}
form.post(submitUrl)
}
const tagCount = String(form.data.tags || '')
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean).length
return (
<AdminLayout title={title} subtitle={subtitle}>
<Head title={`Admin · ${title}`} />
<form onSubmit={submit} className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,340px)]">
<div className="min-w-0 space-y-6">
<SectionCard
eyebrow="Identity"
title="Core prompt details"
description="Set the catalog identity first so the prompt is easy to find, sort, and preview."
>
<div className="grid gap-4 md:grid-cols-2">
{categoryField ? <NovaSelect label={categoryField.label} value={form.data.category_id ?? ''} onChange={(nextValue) => form.setData('category_id', nextValue ?? '')} options={categoryField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.category_id} /> : null}
{difficultyField ? <NovaSelect label={difficultyField.label} value={form.data.difficulty ?? ''} onChange={(nextValue) => form.setData('difficulty', nextValue ?? '')} options={difficultyField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.difficulty} /> : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
{accessField ? <NovaSelect label={accessField.label} value={form.data.access_level ?? ''} onChange={(nextValue) => form.setData('access_level', nextValue ?? '')} options={accessField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.access_level} /> : null}
<TextField label="Aspect ratio" value={form.data.aspect_ratio || ''} onChange={(event) => form.setData('aspect_ratio', event.target.value)} error={form.errors.aspect_ratio} placeholder="1:1, 16:9, 3:2" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<TextField label="Title" value={form.data.title || ''} onChange={(event) => form.setData('title', event.target.value)} error={form.errors.title} maxLength={180} />
<TextField label="Slug" value={form.data.slug || ''} onChange={(event) => form.setData('slug', event.target.value)} error={form.errors.slug} maxLength={180} placeholder="prompt-template-slug" />
</div>
<TextAreaField label="Excerpt" value={form.data.excerpt || ''} onChange={(event) => form.setData('excerpt', event.target.value)} error={form.errors.excerpt} rows={4} hint="Short summary shown in the library and preview cards." />
<TextField label="Tags" value={form.data.tags || ''} onChange={(event) => form.setData('tags', event.target.value)} error={form.errors.tags} placeholder="wallpaper, cinematic, neon, portrait" />
</SectionCard>
<SectionCard
eyebrow="Prompt body"
title="Prompt instructions"
description="Write the instruction stack, guardrails, and production notes in a way that is easy to scan."
>
<TextAreaField label="Prompt" value={form.data.prompt || ''} onChange={(event) => form.setData('prompt', event.target.value)} error={form.errors.prompt} rows={10} hint="This is the main model instruction used by creators." />
<TextAreaField label="Negative prompt" value={form.data.negative_prompt || ''} onChange={(event) => form.setData('negative_prompt', event.target.value)} error={form.errors.negative_prompt} rows={5} hint="Optional exclusions, artifacts, or anti-patterns to avoid." />
<TextAreaField label="Usage notes" value={form.data.usage_notes || ''} onChange={(event) => form.setData('usage_notes', event.target.value)} error={form.errors.usage_notes} rows={5} hint="Explain how to apply the prompt in a practical workflow." />
<TextAreaField label="Workflow notes" value={form.data.workflow_notes || ''} onChange={(event) => form.setData('workflow_notes', event.target.value)} error={form.errors.workflow_notes} rows={5} hint="Internal editorial notes, camera settings, or prompt variants." />
</SectionCard>
<SectionCard
eyebrow="Publishing"
title="Release controls"
description="Choose when the prompt becomes visible and how it behaves in the academy."
>
<div className="grid gap-4 md:grid-cols-2">
{publishedAtField ? <DateTimePicker label={publishedAtField.label} value={form.data.published_at || ''} onChange={(nextValue) => form.setData('published_at', nextValue || '')} error={form.errors.published_at} clearable className="bg-black/20" /> : null}
<TextField label="SEO title" value={form.data.seo_title || ''} onChange={(event) => form.setData('seo_title', event.target.value)} error={form.errors.seo_title} maxLength={180} />
</div>
{seoDescriptionField ? <TextAreaField label={seoDescriptionField.label} value={form.data.seo_description || ''} onChange={(event) => form.setData('seo_description', event.target.value)} error={form.errors.seo_description} rows={4} /> : null}
<div className="grid gap-3 md:grid-cols-3">
{featuredField ? <ToggleField label={featuredField.label} checked={Boolean(form.data.featured)} onChange={(event) => form.setData('featured', event.target.checked)} help="Highlight this prompt in featured rails." error={form.errors.featured} /> : null}
{promptOfWeekField ? <ToggleField label={promptOfWeekField.label} checked={Boolean(form.data.prompt_of_week)} onChange={(event) => form.setData('prompt_of_week', event.target.checked)} help="Promote this prompt as the current weekly pick." error={form.errors.prompt_of_week} /> : null}
{activeField ? <ToggleField label={activeField.label} checked={Boolean(form.data.active)} onChange={(event) => form.setData('active', event.target.checked)} help="Keep draft prompts hidden until they are ready." error={form.errors.active} /> : null}
</div>
</SectionCard>
</div>
<div className="min-w-0 space-y-6 xl:sticky xl:top-6 xl:self-start">
<SectionCard
eyebrow="At a glance"
title="Prompt preview"
description="A compact summary of what editors and visitors will see."
>
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/30">
{previewUrl || form.data.preview_image ? (
<img src={previewUrl || form.data.preview_image} alt="Prompt preview" className="h-56 w-full object-cover" />
) : (
<div className="flex h-56 items-center justify-center px-6 text-center text-sm text-slate-500">No preview image selected yet.</div>
)}
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompt summary</p>
<h3 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white">{form.data.title || 'Untitled prompt'}</h3>
<p className="mt-2 text-sm leading-7 text-slate-400">{form.data.excerpt || 'Add a concise excerpt to give the prompt some context in the library.'}</p>
<dl className="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-400">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Difficulty</dt><dd className="mt-1 text-sm text-white">{form.data.difficulty || '—'}</dd></div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Access</dt><dd className="mt-1 text-sm text-white">{form.data.access_level || '—'}</dd></div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Aspect</dt><dd className="mt-1 text-sm text-white">{form.data.aspect_ratio || '—'}</dd></div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Tags</dt><dd className="mt-1 text-sm text-white">{tagCount}</dd></div>
</dl>
<p className="mt-4 text-xs leading-6 text-slate-500">Uploaded images are converted to WebP and stored on the Contabo S3-backed CDN before the record is saved.</p>
</div>
</SectionCard>
<PromptPreviewDropzone form={form} previewUrl={previewUrl} />
</div>
</div>
<div className="flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save prompt'}</button>
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back</Link>
{destroyUrl ? <button type="button" onClick={() => { if (!window.confirm('Delete this record?')) return; router.delete(destroyUrl) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete</button> : null}
</div>
</form>
</AdminLayout>
)
}
function GenericEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method }) {
const form = useForm(record)
const submit = (event) => {
event.preventDefault()
const payload = normalizePayload(fields, form.data)
form.transform(() => payload)
if (method === 'patch') {
form.transform(() => payload).patch(submitUrl)
form.patch(submitUrl)
return
}
form.transform(() => payload).post(submitUrl)
form.post(submitUrl)
}
return (
@@ -86,13 +467,11 @@ export default function AcademyCrudForm({ title, subtitle, fields, record, submi
<Head title={`Admin · ${title}`} />
<form onSubmit={submit} className="space-y-5 rounded-[30px] border border-white/[0.08] bg-white/[0.03] p-6">
{fields.map((field) => (
<div key={field.name}>
{field.type !== 'checkbox' ? <label className="text-sm font-semibold text-white">{field.label}</label> : null}
<Field field={field} form={form} />
{form.errors[field.name] ? <p className="mt-2 text-sm text-rose-300">{form.errors[field.name]}</p> : null}
</div>
))}
<div className="grid gap-5">
{fields.map((field) => (
<Field key={field.name} field={field} form={form} />
))}
</div>
<div className="flex flex-wrap gap-3">
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save'}</button>
@@ -102,4 +481,50 @@ export default function AcademyCrudForm({ title, subtitle, fields, record, submi
</form>
</AdminLayout>
)
}
export default function AcademyCrudForm({ resource, title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext }) {
if (resource === 'lessons') {
return (
<LessonEditor
title={title}
subtitle={subtitle}
fields={fields}
record={record}
submitUrl={submitUrl}
indexUrl={indexUrl}
destroyUrl={destroyUrl}
method={method}
editorContext={editorContext}
/>
)
}
if (resource === 'prompts') {
return (
<PromptEditor
title={title}
subtitle={subtitle}
fields={fields}
record={record}
submitUrl={submitUrl}
indexUrl={indexUrl}
destroyUrl={destroyUrl}
method={method}
/>
)
}
return (
<GenericEditor
title={title}
subtitle={subtitle}
fields={fields}
record={record}
submitUrl={submitUrl}
indexUrl={indexUrl}
destroyUrl={destroyUrl}
method={method}
/>
)
}

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react'
import axios from 'axios'
import PostActions from './PostActions'
import PostComments from './PostComments'
import ArtworkCard from '../artwork/ArtworkCard'
@@ -68,7 +69,6 @@ export default function PostCard({ post, isLoggedIn = false, viewerUsername = nu
const handleSaveEdit = async () => {
setSaving(true)
try {
const { default: axios } = await import('axios')
const { data } = await axios.patch(`/api/posts/${post.id}`, { body: editBody })
setPostData(data.post)
setEditMode(false)
@@ -82,7 +82,6 @@ export default function PostCard({ post, isLoggedIn = false, viewerUsername = nu
const handleDelete = async () => {
if (!window.confirm('Delete this post?')) return
try {
const { default: axios } = await import('axios')
await axios.delete(`/api/posts/${post.id}`)
onDelete?.(post.id)
} catch {
@@ -91,7 +90,6 @@ export default function PostCard({ post, isLoggedIn = false, viewerUsername = nu
}
const handlePin = async () => {
const { default: axios } = await import('axios')
try {
if (postData.is_pinned) {
await axios.delete(`/api/posts/${post.id}/pin`)
@@ -109,7 +107,6 @@ export default function PostCard({ post, isLoggedIn = false, viewerUsername = nu
const handleSaveToggle = async () => {
if (!isLoggedIn || saveLoading) return
setSaveLoading(true)
const { default: axios } = await import('axios')
try {
if (postData.viewer_saved) {
await axios.delete(`/api/posts/${post.id}/save`)
@@ -130,7 +127,6 @@ export default function PostCard({ post, isLoggedIn = false, viewerUsername = nu
if (!isOwn) return
setAnalyticsOpen(true)
if (!analytics) {
const { default: axios } = await import('axios')
try {
const { data } = await axios.get(`/api/posts/${post.id}/analytics`)
setAnalytics(data)

View File

@@ -4,12 +4,10 @@ import ShareArtworkModal from './ShareArtworkModal'
import LinkPreviewCard from './LinkPreviewCard'
import TagPeopleModal from './TagPeopleModal'
import DateTimePicker from '../ui/DateTimePicker'
import EmojiMartPicker from '../common/EmojiMartPicker'
import extractNativeEmoji from '../common/extractNativeEmoji'
import isEventWithinNode from '../common/isEventWithinNode'
// Lazy-load the heavy emoji picker only when first opened
const EmojiPicker = lazy(() => import('../common/EmojiMartPicker'))
const VISIBILITY_OPTIONS = [
{ value: 'public', icon: 'fa-globe', label: 'Public' },
{ value: 'followers', icon: 'fa-user-friends', label: 'Followers' },
@@ -347,7 +345,7 @@ export default function PostComposer({ user, onPosted }) {
</div>
}>
{emojiData && (
<EmojiPicker
<EmojiMartPicker
data={emojiData}
onEmojiSelect={insertEmoji}
theme="dark"

File diff suppressed because it is too large Load Diff

View File

@@ -220,7 +220,7 @@ mountStorySocial();
mountRememberMeCheckboxes();
function initStorySyntaxHighlighting() {
var codeBlocks = Array.prototype.slice.call(document.querySelectorAll('.story-prose pre code'));
var codeBlocks = Array.prototype.slice.call(document.querySelectorAll('.story-prose pre code, .forum-code-block code'));
if (!codeBlocks.length) return;
function fallbackCopyText(text) {

View File

@@ -3,9 +3,16 @@ import { createInertiaApp } from '@inertiajs/react'
import createServer from '@inertiajs/react/server'
import ReactDOMServer from 'react-dom/server'
// Eagerly import every Inertia page component so the SSR server can resolve
// any page name without async dynamic imports (Node.js + Vite SSR requirement).
const pages = import.meta.glob(['./Pages/**/*.jsx', '!./Pages/**/*.test.jsx', '!./Pages/**/__tests__/**'], { eager: true })
// Eagerly import Inertia page components so the SSR server can resolve any page
// name without async dynamic imports (Node.js + Vite SSR requirement).
// The standalone homepage is Blade-mounted through @vite, so it stays out of
// the SSR graph to avoid duplicate lazy/static imports for its below-fold rails.
const pages = import.meta.glob([
'./Pages/**/*.jsx',
'!./Pages/Home/**/*.jsx',
'!./Pages/**/*.test.jsx',
'!./Pages/**/__tests__/**',
], { eager: true })
// Lightweight server-only placeholder for pages that must remain client-only.
// Returning this prevents an error-level stacktrace while still avoiding

View File

@@ -31,37 +31,34 @@
<input type="text" name="homepage_url" value="" tabindex="-1" autocomplete="off" class="hidden" aria-hidden="true">
<input type="hidden" name="_bot_fingerprint" value="">
@php
$captchaProvider = $captcha['provider'] ?? 'turnstile';
$captchaSiteKey = $captcha['siteKey'] ?? '';
@endphp
<div>
<label class="block text-sm mb-1 text-white/80" for="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@example.com" value="{{ old('email', $prefillEmail ?? '') }}" class="w-full rounded-lg bg-slate-950/70 border border-white/10 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500 text-white" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
@if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && $captchaSiteKey !== '')
@if($captchaProvider === 'recaptcha')
<div class="g-recaptcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@elseif($captchaProvider === 'hcaptcha')
<div class="h-captcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@else
<div class="cf-turnstile" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@endif
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
@if(($turnstile['enabled'] ?? false) && (($turnstile['siteKey'] ?? '') !== ''))
<div class="space-y-2" data-turnstile-container>
<div
class="cf-turnstile"
data-sitekey="{{ $turnstile['siteKey'] }}"
data-theme="dark"
></div>
<p class="text-xs text-white/50" data-turnstile-status>Complete the security check before continuing.</p>
</div>
<x-input-error :messages="$errors->get('turnstile_token')" class="mt-2" />
@endif
<button type="submit" class="w-full rounded-lg py-3 font-medium bg-gradient-to-r from-cyan-500 to-sky-400 hover:from-cyan-400 hover:to-sky-300 text-slate-900 transition">Continue</button>
<button type="submit" class="w-full rounded-lg py-3 font-medium bg-gradient-to-r from-cyan-500 to-sky-400 hover:from-cyan-400 hover:to-sky-300 text-slate-900 transition disabled:cursor-not-allowed disabled:opacity-60" data-turnstile-submit>Continue</button>
<p class="text-sm text-center text-white/60">Already registered? <a href="{{ route('login') }}" class="text-cyan-400 hover:underline">Sign in</a></p>
</form>
</div>
</div>
</div>
@if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== ''))
<script src="{{ $captcha['scriptUrl'] }}" async defer></script>
@if(($turnstile['enabled'] ?? false) && (($turnstile['siteKey'] ?? '') !== '') && (($turnstile['scriptUrl'] ?? '') !== ''))
<script src="{{ $turnstile['scriptUrl'] }}" @if(($turnstile['cspNonce'] ?? '') !== '') nonce="{{ $turnstile['cspNonce'] }}" @endif async defer></script>
<script src="{{ asset('js/register-turnstile.js') }}" @if(($turnstile['cspNonce'] ?? '') !== '') nonce="{{ $turnstile['cspNonce'] }}" @endif defer></script>
@endif
@include('partials.bot-fingerprint-script')
@endsection

View File

@@ -21,7 +21,7 @@
]);
$page_robots = $page_robots ?? ($isAuthSeoRoute ? 'noindex,nofollow' : null);
$shouldRenderBladeSeo = ($useUnifiedSeo ?? ! $isInertiaPage) && (($renderBladeSeo ?? true) || ! $isInertiaPage);
$novaCssEntries = [
$novaCssEntries = $novaCssEntries ?? [
'resources/css/app.css',
'resources/css/nova-grid.css',
'resources/scss/nova.scss',
@@ -70,8 +70,27 @@
@if(!$deferWebManifest)
<link rel="manifest" href="/favicon/site.webmanifest" />
@endif
<style>
html {
background-color: rgb(14, 18, 27);
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: rgb(14, 18, 27);
color: #fff;
}
</style>
@foreach($novaCssEntries as $novaCssEntry)
<link rel="stylesheet" href="{{ Vite::asset($novaCssEntry) }}">
@php
$novaCssHref = Vite::asset($novaCssEntry);
@endphp
<link rel="preload" href="{{ $novaCssHref }}" as="style" onload="this.rel='stylesheet'">
<link rel="stylesheet" href="{{ $novaCssHref }}">
@endforeach
@vite($novaViteEntries)
<script>

View File

@@ -25,7 +25,7 @@
<!-- Logo -->
<a href="/" class="flex items-center gap-2 pr-2 shrink-0">
<img src="https://cdn.skinbase.org/images/sb_logo.webp" alt="" width="289" height="100" class="h-9 w-auto rounded-sm shadow-sm object-contain">
<img src="https://cdn.skinbase.org/images/sb_logo_full.webp" alt="" width="104" height="36" class="h-9 w-auto rounded-sm shadow-sm object-contain">
<span class="sr-only">Skinbase.org</span>
</a>

View File

@@ -2,7 +2,7 @@
<a href="{{ route('news.show', $article->slug) }}" class="block">
<div class="relative aspect-[16/9] overflow-hidden bg-black/20">
@if($article->cover_url)
<img src="{{ $article->cover_url }}" alt="{{ $article->title }}" class="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]">
<img src="{{ $article->cover_url }}" @if($article->cover_srcset) srcset="{{ $article->cover_srcset }}" sizes="(max-width: 767px) 100vw, (max-width: 1279px) 50vw, 390px" @endif alt="{{ $article->title }}" class="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]">
@else
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_45%),linear-gradient(180deg,rgba(15,23,42,0.92),rgba(2,6,23,0.98))]"></div>
@endif

View File

@@ -37,9 +37,14 @@
@if(!empty($tags) && $tags->isNotEmpty())
<section class="rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
<div class="mb-4 flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.18em] text-white/45">
<i class="fa-solid fa-tags text-[11px] text-sky-300"></i>
Topics
<div class="mb-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.18em] text-white/45">
<i class="fa-solid fa-tags text-[11px] text-sky-300"></i>
Popular Topics
</div>
<a href="{{ route('tags.index') }}" class="text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-200/75 transition hover:text-sky-100">
All Tags
</a>
</div>
<div class="flex flex-wrap gap-2">
@foreach($tags as $tag)

View File

@@ -11,6 +11,22 @@
(object) ['name' => 'Announcements', 'url' => route('news.index')],
(object) ['name' => $archiveDate->format('F Y'), 'url' => route('news.archive', ['year' => $archiveDate->year, 'month' => $archiveDate->month])],
]);
$seo = \App\Support\Seo\SeoDataBuilder::fromArray([
'title' => $archiveDate->format('F Y') . ' — News Archive',
'description' => 'News archive for ' . $archiveDate->format('F Y') . ' on Skinbase.',
'canonical' => route('news.archive', ['year' => $archiveDate->year, 'month' => $archiveDate->month]),
'breadcrumbs' => $headerBreadcrumbs,
'structured_data' => [
[
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => $archiveDate->format('F Y') . ' — News Archive',
'description' => 'Published News stories from ' . $archiveDate->format('F Y') . '.',
'url' => route('news.archive', ['year' => $archiveDate->year, 'month' => $archiveDate->month]),
],
],
])->build();
@endphp
<x-nova-page-header

View File

@@ -12,6 +12,22 @@
(object) ['name' => 'Announcements', 'url' => route('news.index')],
(object) ['name' => $authorLabel, 'url' => route('news.author', ['username' => $author->username])],
]);
$seo = \App\Support\Seo\SeoDataBuilder::fromArray([
'title' => $authorLabel . ' — News Author',
'description' => 'News stories and announcements by ' . $authorLabel . '.',
'canonical' => route('news.author', ['username' => $author->username]),
'breadcrumbs' => $headerBreadcrumbs,
'structured_data' => [
[
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => $authorLabel . ' — News Author',
'description' => 'Editorial stories and updates by ' . $authorLabel . '.',
'url' => route('news.author', ['username' => $author->username]),
],
],
])->build();
@endphp
<x-nova-page-header

View File

@@ -6,15 +6,50 @@
@section('news_content')
@php
$articleItems = collect($articles->items());
$headerBreadcrumbs = collect([
(object) ['name' => 'Community', 'url' => route('community.activity')],
(object) ['name' => 'Announcements', 'url' => route('news.index')],
(object) ['name' => 'News', 'url' => route('news.index')],
(object) ['name' => $category->name, 'url' => route('news.category', $category->slug)],
]);
$structuredData = [
[
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => $category->name . ' — News',
'description' => $category->description ?: ('Announcements filed under ' . $category->name . '.'),
'url' => route('news.category', $category->slug),
],
];
if ($articleItems->isNotEmpty()) {
$structuredData[] = [
'@context' => 'https://schema.org',
'@type' => 'ItemList',
'name' => $category->name . ' — News Articles',
'description' => 'Published News stories in the ' . $category->name . ' category.',
'url' => route('news.category', $category->slug),
'numberOfItems' => $articleItems->count(),
'itemListElement' => $articleItems->values()->map(fn ($article, int $index): array => [
'@type' => 'ListItem',
'position' => $index + 1,
'name' => $article->title,
'url' => route('news.show', ['slug' => $article->slug]),
])->all(),
];
}
$seo = \App\Support\Seo\SeoDataBuilder::fromArray([
'title' => $category->name . ' — News',
'description' => $category->description ?: ('Announcements in the ' . $category->name . ' category.'),
'canonical' => route('news.category', $category->slug),
'breadcrumbs' => $headerBreadcrumbs,
'structured_data' => $structuredData,
])->build();
@endphp
<x-nova-page-header
section="Community"
section="News"
:title="$category->name"
icon="fa-folder-open"
:breadcrumbs="$headerBreadcrumbs"

View File

@@ -6,14 +6,55 @@
@section('news_content')
@php
$articleItems = collect([$featured])
->merge($highlights)
->merge($articles->items())
->filter(fn ($article) => $article !== null)
->unique('id')
->values();
$headerBreadcrumbs = collect([
(object) ['name' => 'Community', 'url' => route('community.activity')],
(object) ['name' => 'Announcements', 'url' => route('news.index')],
(object) ['name' => 'News', 'url' => route('news.index')],
]);
$structuredData = [
[
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => config('news.rss_title', 'News'),
'description' => config('news.rss_description', 'Latest news, feature rollouts, and team updates from Skinbase.'),
'url' => route('news.index'),
],
];
if ($articleItems->isNotEmpty()) {
$structuredData[] = [
'@context' => 'https://schema.org',
'@type' => 'ItemList',
'name' => config('news.rss_title', 'News') . ' Articles',
'description' => config('news.rss_description', 'Latest news, feature rollouts, and team updates from Skinbase.'),
'url' => route('news.index'),
'numberOfItems' => $articleItems->count(),
'itemListElement' => $articleItems->map(fn ($article, int $index): array => [
'@type' => 'ListItem',
'position' => $index + 1,
'name' => $article->title,
'url' => route('news.show', $article->slug),
])->all(),
];
}
$seo = \App\Support\Seo\SeoDataBuilder::fromArray([
'title' => config('news.rss_title', 'News'),
'description' => config('news.rss_description', 'Latest news, feature rollouts, and team updates from Skinbase.'),
'canonical' => route('news.index'),
'breadcrumbs' => $headerBreadcrumbs,
'structured_data' => $structuredData,
])->build();
@endphp
<x-nova-page-header
section="Community"
section="News"
title="News"
icon="fa-newspaper"
:breadcrumbs="$headerBreadcrumbs"
@@ -44,7 +85,7 @@
<div class="grid lg:grid-cols-[1.25fr_0.95fr]">
<div class="relative min-h-[280px] overflow-hidden bg-black/20">
@if($featured->cover_url)
<img src="{{ $featured->cover_url }}" alt="{{ $featured->title }}" class="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]">
<img src="{{ $featured->cover_url }}" @if($featured->cover_srcset) srcset="{{ $featured->cover_srcset }}" sizes="(max-width: 1023px) 100vw, 768px" @endif alt="{{ $featured->title }}" class="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]">
@else
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_45%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.98))]"></div>
@endif

View File

@@ -1,5 +1,9 @@
@php
$useUnifiedSeo = true;
$novaCssEntries = [
'resources/css/app.css',
'resources/scss/nova.scss',
];
@endphp
@extends('layouts.nova')

View File

@@ -1,6 +1,11 @@
@php
$isPreview = (bool) ($previewMode ?? false);
$articleUrl = $isPreview ? ($previewCanonical ?? url()->current()) : route('news.show', $article->slug);
$articleSchemaImage = $article->effective_og_image
? url($article->effective_og_image)
: url((string) config('seo.fallback_image_path', '/gfx/skinbase_back_001.webp'));
$articleCoverSizes = '(max-width: 767px) calc(100vw - 3rem), (max-width: 1279px) calc(100vw - 5rem), 768px';
$articleCoverPreloadHref = $article->cover_desktop_url ?: $article->cover_url;
$seo = \App\Support\Seo\SeoDataBuilder::fromArray([
'title' => $article->meta_title ?: $article->title,
'description' => $article->meta_description ?: Str::limit(strip_tags((string) $article->excerpt), 160),
@@ -12,31 +17,55 @@
'og_description' => $article->effective_og_description,
'og_image' => $article->effective_og_image,
'breadcrumbs' => collect([
(object) ['name' => 'Community', 'url' => route('community.activity')],
(object) ['name' => 'Announcements', 'url' => route('news.index')],
(object) ['name' => 'Home', 'url' => url('/')],
(object) ['name' => 'News', 'url' => route('news.index')],
$article->category
? (object) ['name' => $article->category->name, 'url' => route('news.category', $article->category->slug)]
: null,
(object) ['name' => $article->title, 'url' => route('news.show', $article->slug)],
])->filter()->values(),
])
->addJsonLd(array_filter([
'@context' => 'https://schema.org',
'@type' => 'Article',
'@type' => 'NewsArticle',
'headline' => $article->title,
'description' => $article->meta_description ?: Str::limit(strip_tags((string) $article->excerpt), 160),
'image' => $article->effective_og_image,
'image' => $articleSchemaImage
? array_filter([
'@type' => 'ImageObject',
'url' => $articleSchemaImage,
'contentUrl' => $articleSchemaImage,
'thumbnailUrl' => $article->cover_mobile_url,
'caption' => $article->title,
], fn (mixed $value): bool => $value !== null && $value !== '')
: null,
'datePublished' => $article->published_at?->toIso8601String(),
'dateModified' => $article->updated_at?->toIso8601String(),
'articleSection' => $article->category?->name,
'author' => array_filter([
'@type' => 'Person',
'name' => $article->author?->name,
]),
'publisher' => [
'@type' => 'Organization',
'name' => config('seo.site_name', 'Skinbase'),
],
'mainEntityOfPage' => $articleUrl,
], fn (mixed $value): bool => $value !== null && $value !== ''))
->build();
@endphp
@push('head')
@if($articleCoverPreloadHref)
<link
rel="preload"
as="image"
href="{{ $articleCoverPreloadHref }}"
@if($article->cover_srcset) imagesrcset="{{ $article->cover_srcset }}" imagesizes="{{ $articleCoverSizes }}" @endif
fetchpriority="high"
>
@endif
@endpush
@extends('news.layout', [
'metaTitle' => $article->meta_title ?: $article->title,
'metaDescription' => $article->meta_description ?: Str::limit(strip_tags((string)$article->excerpt), 160),
@@ -48,17 +77,16 @@
@php
$headerBreadcrumbs = collect([
(object) ['name' => 'Community', 'url' => route('community.activity')],
(object) ['name' => 'Announcements', 'url' => route('news.index')],
(object) ['name' => 'Home', 'url' => url('/')],
(object) ['name' => 'News', 'url' => route('news.index')],
$article->category
? (object) ['name' => $article->category->name, 'url' => route('news.category', $article->category->slug)]
: null,
(object) ['name' => $article->title, 'url' => $articleUrl],
])->filter()->values();
@endphp
<x-nova-page-header
section="Community"
section="News"
:title="$article->title"
icon="fa-newspaper"
:breadcrumbs="$headerBreadcrumbs"
@@ -105,7 +133,18 @@
<article class="min-w-0">
@if($article->cover_url)
<div class="overflow-hidden rounded-[32px] border border-white/[0.06] bg-black/20 shadow-[0_24px_60px_rgba(0,0,0,0.24)]">
<img src="{{ $article->cover_url }}" alt="{{ $article->title }}" class="h-auto max-h-[520px] w-full object-cover">
<a href="{{ $articleCoverPreloadHref }}" class="group block focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950" aria-label="Open full cover image">
<div class="relative">
<img src="{{ $article->cover_url }}" @if($article->cover_srcset) srcset="{{ $article->cover_srcset }}" sizes="{{ $articleCoverSizes }}" @endif alt="{{ $article->title }}" fetchpriority="high" loading="eager" decoding="async" class="h-auto max-h-[520px] w-full object-cover transition duration-300 group-hover:scale-[1.01]">
<div class="pointer-events-none absolute inset-x-4 bottom-4 flex items-center justify-between gap-3 rounded-full border border-white/10 bg-slate-950/72 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/82 backdrop-blur-sm">
<span>Open Image</span>
<span class="inline-flex items-center gap-2 text-sky-200/90">
<i class="fa-solid fa-magnifying-glass-plus text-[11px]"></i>
Full Image
</span>
</div>
</div>
</a>
</div>
@endif

View File

@@ -11,6 +11,22 @@
(object) ['name' => 'Announcements', 'url' => route('news.index')],
(object) ['name' => '#' . $tag->name, 'url' => route('news.tag', $tag->slug)],
]);
$seo = \App\Support\Seo\SeoDataBuilder::fromArray([
'title' => '#' . $tag->name . ' — News',
'description' => 'Announcements tagged with ' . $tag->name . '.',
'canonical' => route('news.tag', $tag->slug),
'breadcrumbs' => $headerBreadcrumbs,
'structured_data' => [
[
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => '#' . $tag->name . ' — News',
'description' => 'Stories and announcements tagged with #' . $tag->name . '.',
'url' => route('news.tag', $tag->slug),
],
],
])->build();
@endphp
<x-nova-page-header

View File

@@ -16,97 +16,78 @@
$shouldBlur = (bool) ($maturity['should_blur'] ?? false);
$cardImageId = ($idPrefix ?? 'artwork') . '-image-' . ($index ?? 0);
$medalScore = (int) data_get($artwork, 'medals.score_30d', data_get($artwork, 'medals.score', 0));
$cardFrameClass = ($layout ?? 'grid') === 'rail'
? 'aspect-video'
: 'aspect-[4/5] sm:aspect-[5/4] lg:aspect-video';
@endphp
<article class="{{ ($layout ?? 'grid') === 'rail' ? 'min-w-[72%] snap-start sm:min-w-[44%] lg:min-w-0' : 'min-w-0' }}">
<div class="group overflow-hidden rounded-2xl bg-black/20 shadow-lg shadow-black/40 ring-1 ring-white/5 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-within:ring-2 focus-within:ring-sky-300/70">
<a href="{{ $artworkUrl }}" class="relative block overflow-hidden">
<div class="relative aspect-video overflow-hidden bg-neutral-900">
<div class="pointer-events-none absolute inset-0 z-10 bg-gradient-to-br from-white/10 via-white/5 to-transparent"></div>
<img
id="{{ $cardImageId }}"
src="{{ $thumbUrl }}"
@if (!empty($artwork['thumb_srcset']))
srcset="{{ $artwork['thumb_srcset'] }}"
sizes="{{ $sizes ?? '100vw' }}"
@endif
alt="{{ $titleText }}"
width="{{ max(1, (int) ($artwork['width'] ?? 1600)) }}"
height="{{ max(1, (int) ($artwork['height'] ?? 900)) }}"
class="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04] {{ $shouldBlur ? 'blur-2xl scale-[1.03]' : '' }}"
loading="lazy"
decoding="async"
>
@if (!empty($badge))
<div class="absolute left-3 top-3 z-30">
<span class="inline-flex items-center rounded-md px-2 py-1 text-[11px] font-bold ring-1 ring-white/10 backdrop-blur-sm {{ $badgeClass ?? 'bg-sky-500/80 text-white' }}">
{{ $badge }}
</span>
</div>
@elseif ($metricBadge && !empty($metricBadge['label']))
<div class="absolute left-3 top-3 z-30">
<span class="inline-flex items-center rounded-full border border-sky-300/30 bg-sky-500/14 px-2.5 py-1 text-[11px] font-semibold text-sky-100 ring-1 ring-sky-300/20 backdrop-blur-sm">
{{ $metricBadge['label'] }}
</span>
</div>
<a href="{{ $artworkUrl }}" class="group relative block overflow-hidden rounded-2xl bg-black/20 shadow-lg shadow-black/40 ring-1 ring-white/5 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70">
<div class="relative {{ $cardFrameClass }} overflow-hidden bg-neutral-900">
<div class="pointer-events-none absolute inset-0 z-10 bg-gradient-to-br from-white/10 via-white/5 to-transparent"></div>
<img
id="{{ $cardImageId }}"
src="{{ $thumbUrl }}"
@if (!empty($artwork['thumb_srcset']))
srcset="{{ $artwork['thumb_srcset'] }}"
sizes="{{ $sizes ?? '100vw' }}"
@endif
alt="{{ $titleText }}"
width="{{ max(1, (int) ($artwork['width'] ?? 1600)) }}"
height="{{ max(1, (int) ($artwork['height'] ?? 900)) }}"
class="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04] {{ $shouldBlur ? 'blur-2xl scale-[1.03]' : '' }}"
loading="lazy"
decoding="async"
>
@if ($medalScore > 0)
<div class="absolute right-3 top-3 z-30">
<span class="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/12 px-2.5 py-1 text-[11px] font-semibold text-amber-100 ring-1 ring-amber-300/20 backdrop-blur-sm">
Medal {{ number_format($medalScore) }}
</span>
</div>
@endif
@if (!empty($badge))
<div class="absolute left-3 top-3 z-30">
<span class="inline-flex items-center rounded-md px-2 py-1 text-[11px] font-bold ring-1 ring-white/10 backdrop-blur-sm {{ $badgeClass ?? 'bg-sky-500/80 text-white' }}">
{{ $badge }}
</span>
</div>
@elseif ($metricBadge && !empty($metricBadge['label']))
<div class="absolute left-3 top-3 z-30">
<span class="inline-flex items-center rounded-full border border-sky-300/30 bg-sky-500/14 px-2.5 py-1 text-[11px] font-semibold text-sky-100 ring-1 ring-sky-300/20 backdrop-blur-sm">
{{ $metricBadge['label'] }}
</span>
</div>
@endif
@if ($shouldBlur)
<div class="absolute inset-0 z-20 flex items-center justify-center bg-slate-950/55 p-4" data-home-mature-overlay>
<div class="rounded-2xl border border-white/10 bg-black/45 px-4 py-4 text-center shadow-2xl backdrop-blur-md">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-white/70">Mature content</p>
<p class="mt-2 max-w-[16rem] text-sm text-white/90">This artwork may contain mature material.</p>
<button
type="button"
data-home-mature-toggle="{{ $cardImageId }}"
class="mt-4 inline-flex items-center rounded-full border border-white/15 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/20"
>
Reveal image
</button>
</div>
</div>
@endif
@if ($medalScore > 0)
<div class="absolute right-3 top-3 z-30">
<span class="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/12 px-2.5 py-1 text-[11px] font-semibold text-amber-100 ring-1 ring-amber-300/20 backdrop-blur-sm">
Medal {{ number_format($medalScore) }}
</span>
</div>
@endif
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
<div class="truncate text-sm font-semibold text-white">{{ $titleText }}</div>
<div class="mt-1 flex items-center gap-2 text-xs text-white/80">
<img src="{{ $authorAvatar }}" alt="{{ $authorName }}" class="h-6 w-6 shrink-0 rounded-full object-cover" loading="lazy" decoding="async">
<span class="truncate">{{ $authorName }}</span>
@if ($authorUsername !== '')
<span class="shrink-0 text-white/50">@{{ $authorUsername }}</span>
@endif
@if ($shouldBlur)
<div class="absolute inset-0 z-20 flex items-center justify-center bg-slate-950/55 p-4" data-home-mature-overlay>
<div class="rounded-2xl border border-white/10 bg-black/45 px-4 py-4 text-center shadow-2xl backdrop-blur-md">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-white/70">Mature content</p>
<p class="mt-2 max-w-[16rem] text-sm text-white/90">This artwork may contain mature material.</p>
<button
type="button"
data-home-mature-toggle="{{ $cardImageId }}"
class="mt-4 inline-flex items-center rounded-full border border-white/15 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/20"
>
Reveal image
</button>
</div>
</div>
</div>
</a>
@endif
<div class="flex items-start justify-between gap-3 border-t border-white/5 bg-slate-950/40 px-3 py-3">
<div class="min-w-0">
<a href="{{ $artworkUrl }}" class="block truncate text-sm font-semibold text-white transition hover:text-sky-100">{{ $titleText }}</a>
<div class="mt-1 flex items-center gap-2 text-xs text-soft">
@if ($authorUrl)
<a href="{{ $authorUrl }}" class="truncate text-nova-200 transition hover:text-white">{{ $authorName }}</a>
@else
<span class="truncate">{{ $authorName }}</span>
@endif
@if (!empty($artwork['category_name']))
<span class="shrink-0 text-white/35"></span>
<span class="truncate">{{ $artwork['category_name'] }}</span>
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
<div class="truncate text-sm font-semibold text-white">{{ $titleText }}</div>
<div class="mt-1 flex items-center gap-2 text-xs text-white/80">
<img src="{{ $authorAvatar }}" alt="{{ $authorName }}" class="h-6 w-6 shrink-0 rounded-full object-cover" loading="lazy" decoding="async">
<span class="truncate">{{ $authorName }}</span>
@if ($authorUsername !== '')
<span class="shrink-0 text-white/50">{{ $authorUsername }}</span>
@endif
</div>
</div>
<a href="{{ $artworkUrl }}" class="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-white/80 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white">
View
</a>
</div>
</div>
</a>
</article>

View File

@@ -27,7 +27,7 @@
</div>
@if ($sectionItems->isNotEmpty())
<div class="{{ $sectionLayout === 'rail' ? 'flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 ' . $sectionColumns . ' lg:grid lg:overflow-visible' : 'grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 ' . $sectionColumns }}">
<div class="{{ $sectionLayout === 'rail' ? 'flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 ' . $sectionColumns . ' lg:grid lg:overflow-visible' : 'grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 ' . $sectionColumns }}">
@foreach ($sectionItems as $index => $item)
@include('web.home.sections.artwork-card', [
'item' => $item,
@@ -36,7 +36,7 @@
'badgeClass' => $badge_class ?? null,
'sizes' => $sectionLayout === 'rail'
? '(max-width: 640px) 72vw, (max-width: 1024px) 44vw, 240px'
: '(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw',
: '(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw',
'idPrefix' => Str::slug((string) $title, '-'),
'index' => $index,
])