({
+ name: 'video-placeholder',
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ onDrop: () => {},
+ onDropRejected: () => {},
+ onEmbed: () => {}
+ }
+ },
+ parseHTML() {
+ return [{ tag: `div[data-type="${this.name}"]` }]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', mergeAttributes(HTMLAttributes)]
+ },
+ group: 'block',
+ draggable: true,
+ atom: true,
+ content: 'inline*',
+ isolating: true,
+
+ addNodeView() {
+ return SvelteNodeViewRenderer(content)
+ },
+ addCommands() {
+ return {
+ insertVideoPlaceholder: () => (props: CommandProps) => {
+ return props.commands.insertContent({
+ type: 'video-placeholder'
+ })
+ }
+ }
+ }
+ })
diff --git a/src/lib/components/edra/headless/components/AudioExtended.svelte b/src/lib/components/edra/headless/components/AudioExtended.svelte
new file mode 100644
index 0000000..b4f90bf
--- /dev/null
+++ b/src/lib/components/edra/headless/components/AudioExtended.svelte
@@ -0,0 +1,226 @@
+
+
+
+
+
diff --git a/src/lib/components/edra/headless/components/AudioPlaceholder.svelte b/src/lib/components/edra/headless/components/AudioPlaceholder.svelte
new file mode 100644
index 0000000..b5c1046
--- /dev/null
+++ b/src/lib/components/edra/headless/components/AudioPlaceholder.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+ Insert An Audio
+
+
diff --git a/src/lib/components/edra/headless/components/CodeExtended.svelte b/src/lib/components/edra/headless/components/CodeExtended.svelte
new file mode 100644
index 0000000..6490aab
--- /dev/null
+++ b/src/lib/components/edra/headless/components/CodeExtended.svelte
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/edra/headless/components/EdraToolBarIcon.svelte b/src/lib/components/edra/headless/components/EdraToolBarIcon.svelte
new file mode 100644
index 0000000..0e1208f
--- /dev/null
+++ b/src/lib/components/edra/headless/components/EdraToolBarIcon.svelte
@@ -0,0 +1,30 @@
+
+
+
diff --git a/src/lib/components/edra/headless/components/GalleryExtended.svelte b/src/lib/components/edra/headless/components/GalleryExtended.svelte
new file mode 100644
index 0000000..ad63ad8
--- /dev/null
+++ b/src/lib/components/edra/headless/components/GalleryExtended.svelte
@@ -0,0 +1,340 @@
+
+
+
+
+ {#if images.length === 0}
+
+
+ Gallery is empty
+
+ {:else}
+
+ {#each images as image}
+
+

+ {#if editor?.isEditable}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
+ {#if editor?.isEditable}
+
+ {/if}
+
+
+
+
+
+
+
diff --git a/src/lib/components/edra/headless/components/GalleryPlaceholder.svelte b/src/lib/components/edra/headless/components/GalleryPlaceholder.svelte
new file mode 100644
index 0000000..fbf812c
--- /dev/null
+++ b/src/lib/components/edra/headless/components/GalleryPlaceholder.svelte
@@ -0,0 +1,247 @@
+
+
+
+
+ {#if isUploading}
+
+
+
Uploading images...
+
+ {:else}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/edra/headless/components/IFrameExtended.svelte b/src/lib/components/edra/headless/components/IFrameExtended.svelte
new file mode 100644
index 0000000..d984e23
--- /dev/null
+++ b/src/lib/components/edra/headless/components/IFrameExtended.svelte
@@ -0,0 +1,220 @@
+
+
+
+
+
diff --git a/src/lib/components/edra/headless/components/IFramePlaceholder.svelte b/src/lib/components/edra/headless/components/IFramePlaceholder.svelte
new file mode 100644
index 0000000..8535812
--- /dev/null
+++ b/src/lib/components/edra/headless/components/IFramePlaceholder.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+ Insert An IFrame
+
+
diff --git a/src/lib/components/edra/headless/components/ImageExtended.svelte b/src/lib/components/edra/headless/components/ImageExtended.svelte
new file mode 100644
index 0000000..934ea5b
--- /dev/null
+++ b/src/lib/components/edra/headless/components/ImageExtended.svelte
@@ -0,0 +1,223 @@
+
+
+
+
+
diff --git a/src/lib/components/edra/headless/components/ImagePlaceholder.svelte b/src/lib/components/edra/headless/components/ImagePlaceholder.svelte
new file mode 100644
index 0000000..8f6c9bb
--- /dev/null
+++ b/src/lib/components/edra/headless/components/ImagePlaceholder.svelte
@@ -0,0 +1,249 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/edra/headless/components/SearchAndReplace.svelte b/src/lib/components/edra/headless/components/SearchAndReplace.svelte
new file mode 100644
index 0000000..66bce00
--- /dev/null
+++ b/src/lib/components/edra/headless/components/SearchAndReplace.svelte
@@ -0,0 +1,114 @@
+
+
+
+
+ {#if show}
+
+
updateSearchTerm()} />
+
{searchCount > 0 ? searchIndex + 1 : 0}/{searchCount}
+
+
+
+
+
+
updateSearchTerm()} />
+
+
+
+ {/if}
+
diff --git a/src/lib/components/edra/headless/components/SlashCommandList.svelte b/src/lib/components/edra/headless/components/SlashCommandList.svelte
new file mode 100644
index 0000000..60cc0c3
--- /dev/null
+++ b/src/lib/components/edra/headless/components/SlashCommandList.svelte
@@ -0,0 +1,116 @@
+
+
+
+
+{#if items.length}
+
+ {#each items as grp, groupIndex}
+ {grp.title}
+
+ {#each grp.commands as command, commandIndex}
+ {@const Icon = icons[command.iconName]}
+ {@const isActive =
+ selectedGroupIndex === groupIndex && selectedCommandIndex === commandIndex}
+
+ {/each}
+ {/each}
+
+{/if}
diff --git a/src/lib/components/edra/headless/components/VideoExtended.svelte b/src/lib/components/edra/headless/components/VideoExtended.svelte
new file mode 100644
index 0000000..faaaf9b
--- /dev/null
+++ b/src/lib/components/edra/headless/components/VideoExtended.svelte
@@ -0,0 +1,226 @@
+
+
+
+
+
diff --git a/src/lib/components/edra/headless/components/VideoPlaceholder.svelte b/src/lib/components/edra/headless/components/VideoPlaceholder.svelte
new file mode 100644
index 0000000..bcf39a7
--- /dev/null
+++ b/src/lib/components/edra/headless/components/VideoPlaceholder.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+ Insert A Video
+
+
diff --git a/src/lib/components/edra/headless/editor.svelte b/src/lib/components/edra/headless/editor.svelte
new file mode 100644
index 0000000..4ca53ae
--- /dev/null
+++ b/src/lib/components/edra/headless/editor.svelte
@@ -0,0 +1,144 @@
+
+
+
+ {@render children?.()}
+ {#if editor}
+ {#if showLinkBubbleMenu}
+
+ {/if}
+ {#if showTableBubbleMenu}
+
+
+ {/if}
+ {/if}
+ {#if !editor}
+
+ Loading...
+
+ {/if}
+
focusEditor(editor, event)}
+ onkeydown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ focusEditor(editor, event)
+ }
+ }}
+ class="edra-editor"
+ >
+
+
+
diff --git a/src/lib/components/edra/headless/index.ts b/src/lib/components/edra/headless/index.ts
new file mode 100644
index 0000000..89230c3
--- /dev/null
+++ b/src/lib/components/edra/headless/index.ts
@@ -0,0 +1,3 @@
+export { default as Edra } from './editor.svelte'
+export { default as EdraToolbar } from './toolbar.svelte'
+export { default as EdraBubbleMenu } from './menus/bubble-menu.svelte'
diff --git a/src/lib/components/edra/headless/menus/bubble-menu.svelte b/src/lib/components/edra/headless/menus/bubble-menu.svelte
new file mode 100644
index 0000000..ceec1cd
--- /dev/null
+++ b/src/lib/components/edra/headless/menus/bubble-menu.svelte
@@ -0,0 +1,162 @@
+
+
+
diff --git a/src/lib/components/edra/headless/menus/link-menu.svelte b/src/lib/components/edra/headless/menus/link-menu.svelte
new file mode 100644
index 0000000..b12d527
--- /dev/null
+++ b/src/lib/components/edra/headless/menus/link-menu.svelte
@@ -0,0 +1,120 @@
+
+
+ {
+ if (!props.editor.isEditable) return false
+ if (props.editor.isActive('link')) {
+ return true
+ } else {
+ isEditing = false
+ linkInput = ''
+ isLinkValid = true
+ return false
+ }
+ }}
+ class="bubble-menu-wrapper"
+>
+ {#if isEditing}
+
+ {:else}
+ {link}
+ {/if}
+
+ {#if !isEditing}
+
+
+
+ {:else}
+
+ {/if}
+
diff --git a/src/lib/components/edra/headless/menus/table/table-col-menu.svelte b/src/lib/components/edra/headless/menus/table/table-col-menu.svelte
new file mode 100644
index 0000000..cf82181
--- /dev/null
+++ b/src/lib/components/edra/headless/menus/table/table-col-menu.svelte
@@ -0,0 +1,54 @@
+
+
+ {
+ if (!props.editor.isEditable) return false
+ if (!props.state) {
+ return false
+ }
+ return isColumnGripSelected({
+ editor: props.editor,
+ view: props.view,
+ state: props.state,
+ from: props.from
+ })
+ }}
+ class="edra-menu-wrapper"
+>
+
+
+
+
diff --git a/src/lib/components/edra/headless/menus/table/table-row-menu.svelte b/src/lib/components/edra/headless/menus/table/table-row-menu.svelte
new file mode 100644
index 0000000..a01e971
--- /dev/null
+++ b/src/lib/components/edra/headless/menus/table/table-row-menu.svelte
@@ -0,0 +1,54 @@
+
+
+ {
+ if (!props.editor.isEditable) return false
+ if (!props.state) {
+ return false
+ }
+ return isRowGripSelected({
+ editor: props.editor,
+ view: props.view,
+ state: props.state,
+ from: props.from
+ })
+ }}
+ class="edra-menu-wrapper"
+>
+
+
+
+
diff --git a/src/lib/components/edra/headless/style.css b/src/lib/components/edra/headless/style.css
new file mode 100644
index 0000000..b46f2f8
--- /dev/null
+++ b/src/lib/components/edra/headless/style.css
@@ -0,0 +1,383 @@
+:root {
+ /* Color Variables */
+ --edra-border-color: #80808050;
+ --edra-button-bg-color: #80808025;
+ --edra-button-hover-bg-color: #80808075;
+ --edra-button-active-bg-color: #80808090;
+ --edra-icon-color: currentColor; /* Default, can be customized */
+ --edra-separator-color: currentColor; /* Default, can be customized */
+
+ /* Size and Spacing Variables */
+ --edra-gap: 0.25rem;
+ --edra-border-radius: 0.5rem;
+ --edra-button-border-radius: 0.5rem;
+ --edra-padding: 0.5rem;
+ --edra-button-padding: 0.25rem;
+ --edra-button-size: 2rem;
+ --edra-icon-size: 1rem;
+ --edra-separator-width: 0.25rem;
+}
+
+/** Editor Styles */
+:root {
+ --border-color: rgba(128, 128, 128, 0.3);
+ --border-color-hover: rgba(128, 128, 128, 0.5);
+ --blockquote-border: rgba(128, 128, 128, 0.7);
+ --code-color: rgb(255, 68, 0);
+ --code-bg: rgba(128, 128, 128, 0.3);
+ --code-border: rgba(128, 128, 128, 0.4);
+ --table-border: rgba(128, 128, 128, 0.3);
+ --table-bg-selected: rgba(128, 128, 128, 0.1);
+ --table-bg-hover: rgba(128, 128, 128, 0.2);
+ --task-completed-color: rgba(128, 128, 128, 0.7);
+ --code-wrapper-bg: rgba(128, 128, 128, 0.05);
+ --highlight-color: rgba(0, 128, 0, 0.3);
+ --highlight-border: greenyellow;
+ --search-result-bg: yellow;
+ --search-result-current-bg: orange;
+}
+
+.edra {
+ display: flex;
+ flex-direction: column;
+ gap: var(--edra-gap);
+ overflow: auto;
+}
+
+.edra-editor {
+ padding: 0 var(--edra-padding);
+ flex-grow: 1;
+ padding-left: 2rem;
+ overflow: auto;
+ box-sizing: border-box;
+}
+
+.edra-toolbar {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--edra-gap);
+ padding: var(--edra-padding);
+ width: fit-content;
+ overflow: auto;
+}
+
+.edra-loading {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--edra-gap);
+}
+
+.animate-spin {
+ animation: animate-spin 1s linear infinite;
+}
+
+@keyframes animate-spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.edra-command-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ background-color: var(--edra-button-bg-color);
+ border-radius: var(--edra-button-border-radius);
+ cursor: pointer;
+ transition: background-color 0.2s ease-in-out;
+ padding: var(--edra-button-padding);
+ min-width: var(--edra-button-size);
+ min-height: var(--edra-button-size);
+}
+
+.edra-command-button:hover {
+ background-color: var(--edra-button-hover-bg-color);
+}
+
+.edra-command-button.active {
+ background-color: var(--edra-button-active-bg-color);
+}
+
+.edra-toolbar-icon {
+ height: var(--edra-icon-size);
+ width: var(--edra-icon-size);
+ color: var(--edra-icon-color);
+}
+
+.separator {
+ width: var(--edra-separator-width);
+ /* background-color: var(--edra-separator-color); */
+}
+
+.edra-media-placeholder-wrapper {
+ width: 100%;
+ height: fit-content;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0.5rem 0;
+}
+
+.edra-media-placeholder-content {
+ height: 100%;
+ width: 100%;
+ padding: 1rem;
+ padding-right: 0;
+ background-color: var(--edra-button-bg-color);
+ border-radius: var(--edra-button-border-radius);
+ border: 1px solid var(--edra-border-color);
+ display: inline-flex;
+ align-items: center;
+ justify-content: start;
+ gap: 1rem;
+ cursor: pointer;
+}
+
+.edra-media-placeholder-icon {
+ height: var(--edra-icon-size);
+ width: var(--edra-icon-size);
+ color: var(--edra-icon-color);
+}
+
+.edra-media-container {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ border-radius: 0.5rem;
+ border: 2px solid transparent;
+ margin: 1rem 0;
+}
+
+.edra-media-container.selected {
+ border-color: #808080;
+}
+
+.edra-media-container.align-left {
+ left: 0;
+ transform: translateX(0);
+}
+
+.edra-media-container.align-center {
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.edra-media-container.align-right {
+ left: 100%;
+ transform: translateX(-100%);
+}
+
+.edra-media-group {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ border-radius: 0.5rem;
+}
+
+.edra-media-content {
+ margin: 0;
+ object-fit: cover;
+}
+
+.edra-media-caption {
+ margin: 0.125rem 0;
+ width: 100%;
+ background-color: transparent;
+ text-align: center;
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: #808080;
+ outline: none;
+ border: none;
+}
+
+.edra-media-resize-handle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ z-index: 20;
+ display: flex;
+ width: 0.5rem;
+ cursor: col-resize;
+ align-items: center;
+}
+
+.edra-media-resize-handle-left {
+ left: 0;
+ justify-content: flex-start;
+ padding: 0.5rem;
+}
+
+.edra-media-resize-handle-right {
+ right: 0;
+ justify-content: flex-end;
+ padding: 0.5rem;
+}
+
+.edra-media-resize-indicator {
+ z-index: 20;
+ height: 3rem;
+ width: 0.25rem;
+ border-radius: 12px;
+ border: 1px solid #808080;
+ background-color: #808080;
+ opacity: 0;
+ transition: opacity 0.5s;
+}
+
+.edra-media-group:hover .edra-media-resize-indicator {
+ opacity: 0.5;
+}
+
+.edra-media-toolbar {
+ position: absolute;
+ right: 16px;
+ top: 8px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ border-radius: 4px;
+ border: 1px solid #808080;
+ background-color: #80808075;
+ padding: 4px;
+ opacity: 0;
+ transition: opacity 0.2s;
+}
+
+.edra-media-toolbar-audio {
+ top: -32px;
+}
+
+.edra-media-group:hover .edra-media-toolbar,
+.edra-media-toolbar.visible {
+ opacity: 1;
+}
+
+.edra-toolbar-button {
+ width: 1.5rem;
+ height: 1.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ color: currentColor;
+}
+
+.edra-toolbar-button:hover {
+ background-color: #80808030;
+}
+
+.edra-toolbar-button.active {
+ background-color: #80808080;
+}
+
+.edra-destructive {
+ color: red;
+}
+
+.bubble-menu-wrapper {
+ z-index: 100;
+ width: fit-content;
+ padding: 0.25rem;
+ border: 1px solid var(--edra-border-color);
+ border-radius: 0.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ background-color: white;
+ backdrop-filter: blur(8px);
+}
+
+html.dark .bubble-menu-wrapper {
+ background-color: black;
+}
+
+.bubble-menu-wrapper input {
+ padding: 0.5rem;
+ border: none;
+ max-width: 10rem;
+ background: none;
+ margin-right: 0.5rem;
+ width: fit-content;
+}
+
+input.valid {
+ border: 1px solid green;
+}
+input:focus {
+ outline: none;
+}
+
+input.invalid {
+ border: 1px solid red;
+}
+
+.edra-slash-command-list {
+ margin-bottom: 2rem;
+ max-height: min(80vh, 20rem);
+ width: 12rem;
+ overflow: auto;
+ scroll-behavior: smooth;
+ border-radius: 0.5rem;
+ border: 1px solid var(--edra-border-color);
+ padding: 0.5rem;
+ backdrop-filter: blur(8px);
+}
+
+.edra-slash-command-list-title {
+ margin: 0.5rem;
+ user-select: none;
+ font-size: 0.875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.025em;
+}
+
+.edra-slash-command-list-item {
+ display: flex;
+ height: fit-content;
+ width: 100%;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.5rem;
+ padding: 0.5rem;
+ background: none;
+ border: none;
+ margin: 0.25rem 0;
+ border-radius: 0.25rem;
+}
+.edra-slash-command-list-item.active {
+ background-color: var(--edra-border-color);
+}
+
+.edra-search-and-replace {
+ display: flex;
+ align-items: center;
+ gap: var(--edra-gap);
+}
+
+.edra-search-and-replace-content {
+ display: flex;
+ align-items: center;
+ gap: var(--edra-gap);
+}
+
+.edra-search-and-replace-content input {
+ max-width: 10rem;
+ background: none;
+ width: 15rem;
+ border: 1px solid var(--edra-border-color);
+ border-radius: var(--edra-button-border-radius);
+ padding: 0.2rem 0.5rem;
+}
diff --git a/src/lib/components/edra/headless/toolbar.svelte b/src/lib/components/edra/headless/toolbar.svelte
new file mode 100644
index 0000000..ae30cc9
--- /dev/null
+++ b/src/lib/components/edra/headless/toolbar.svelte
@@ -0,0 +1,78 @@
+
+
+
+ {#if children}
+ {@render children()}
+ {:else}
+ {#if !showSearchAndReplace}
+ {#each Object.keys(commands).filter((key) => !excludedCommands.includes(key)) as keys}
+ {@const groups = commands[keys].commands}
+ {#each groups as command}
+
+ {/each}
+
+ {/each}
+
+
+ {editor.getAttributes('textStyle').fontSize ?? '16px'}
+
+
+
+
+ {
+ const color = editor.getAttributes('textStyle').color
+ const hasColor = editor.isActive('textStyle', { color })
+ if (hasColor) {
+ editor.chain().focus().unsetColor().run()
+ } else {
+ const color = prompt('Enter the color of the text:')
+ if (color !== null) {
+ editor.chain().focus().setColor(color).run()
+ }
+ }
+ }}
+ />
+ {
+ const hasHightlight = editor.isActive('highlight')
+ if (hasHightlight) {
+ editor.chain().focus().unsetHighlight().run()
+ } else {
+ const color = prompt('Enter the color of the highlight:')
+ if (color !== null) {
+ editor.chain().focus().setHighlight({ color }).run()
+ }
+ }
+ }}
+ />
+ {/if}
+
+ {/if}
+
diff --git a/src/lib/components/edra/onedark.css b/src/lib/components/edra/onedark.css
new file mode 100644
index 0000000..3399ae4
--- /dev/null
+++ b/src/lib/components/edra/onedark.css
@@ -0,0 +1,176 @@
+/* One Dark and Light Theme for Highlight.js using Pure CSS */
+/* Light Theme (Default) */
+.tiptap pre code {
+ color: #383a42;
+}
+
+/* Comment */
+.hljs-comment,
+.hljs-quote {
+ font-style: italic;
+ color: #a0a1a7;
+}
+
+/* Red */
+.hljs-variable,
+.hljs-template-variable,
+.hljs-tag,
+.hljs-name,
+.hljs-selector-id,
+.hljs-selector-class,
+.hljs-regexp,
+.hljs-deletion {
+ color: #e45649;
+}
+
+/* Orange */
+.hljs-number,
+.hljs-built_in,
+.hljs-literal,
+.hljs-type,
+.hljs-params,
+.hljs-meta,
+.hljs-link {
+ color: #986801;
+}
+
+/* Yellow */
+.hljs-attribute {
+ color: #c18401;
+}
+
+/* Green */
+.hljs-string,
+.hljs-symbol,
+.hljs-bullet,
+.hljs-addition {
+ color: #50a14f;
+}
+
+/* Blue */
+.hljs-title,
+.hljs-section {
+ color: #4078f2;
+}
+
+/* Purple */
+.hljs-keyword,
+.hljs-selector-tag {
+ color: #a626a4;
+}
+
+/* Cyan */
+.hljs-emphasis {
+ font-style: italic;
+ color: #0184bc;
+}
+
+.hljs-strong {
+ font-weight: bold;
+}
+
+/* Base styles */
+.hljs-doctag,
+.hljs-formula {
+ color: #a626a4;
+}
+
+.hljs-attr,
+.hljs-subst {
+ color: #383a42;
+}
+
+/* Line highlights */
+.hljs-addition {
+ background-color: #e6ffed;
+}
+
+.hljs-deletion {
+ background-color: #ffeef0;
+}
+
+/* Dark Theme (All dark styles consolidated in one media query) */
+html.dark {
+ .tiptap pre code {
+ color: #abb2bf;
+ }
+
+ /* Comment */
+ .hljs-comment,
+ .hljs-quote {
+ color: #5c6370;
+ }
+
+ /* Red */
+ .hljs-variable,
+ .hljs-template-variable,
+ .hljs-tag,
+ .hljs-name,
+ .hljs-selector-id,
+ .hljs-selector-class,
+ .hljs-regexp,
+ .hljs-deletion {
+ color: #e06c75;
+ }
+
+ /* Orange */
+ .hljs-number,
+ .hljs-built_in,
+ .hljs-literal,
+ .hljs-type,
+ .hljs-params,
+ .hljs-meta,
+ .hljs-link {
+ color: #d19a66;
+ }
+
+ /* Yellow */
+ .hljs-attribute {
+ color: #e5c07b;
+ }
+
+ /* Green */
+ .hljs-string,
+ .hljs-symbol,
+ .hljs-bullet,
+ .hljs-addition {
+ color: #98c379;
+ }
+
+ /* Blue */
+ .hljs-title,
+ .hljs-section {
+ color: #61afef;
+ }
+
+ /* Purple */
+ .hljs-keyword,
+ .hljs-selector-tag {
+ color: #c678dd;
+ }
+
+ /* Cyan */
+ .hljs-emphasis {
+ color: #56b6c2;
+ }
+
+ /* Base styles */
+ .hljs-doctag,
+ .hljs-formula {
+ color: #c678dd;
+ }
+
+ .hljs-attr,
+ .hljs-subst {
+ color: #abb2bf;
+ }
+
+ /* Line highlights */
+ .hljs-addition {
+ background-color: #283428;
+ }
+
+ .hljs-deletion {
+ background-color: #342828;
+ }
+}
diff --git a/src/lib/components/edra/svelte-renderer.ts b/src/lib/components/edra/svelte-renderer.ts
new file mode 100644
index 0000000..1a088ca
--- /dev/null
+++ b/src/lib/components/edra/svelte-renderer.ts
@@ -0,0 +1,75 @@
+import { flushSync, mount, unmount } from 'svelte'
+import type { Editor, NodeViewProps } from '@tiptap/core'
+
+interface RendererOptions> {
+ editor: Editor
+ props: P
+}
+
+type App = ReturnType
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+class SvelteRenderer = object> {
+ id: string
+ component: App
+ editor: Editor
+ props: P
+ element: HTMLElement
+ ref: R | null = null
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ mnt: Record | null = null
+
+ constructor(component: App, { props, editor }: RendererOptions) {
+ this.id = Math.floor(Math.random() * 0xffffffff).toString()
+ this.component = component
+ this.props = props
+ this.editor = editor
+
+ this.element = document.createElement('div')
+ this.element.classList.add('svelte-renderer')
+
+ if (this.editor.isInitialized) {
+ // On first render, we need to flush the render synchronously
+ // Renders afterwards can be async, but this fixes a cursor positioning issue
+ flushSync(() => {
+ this.render()
+ })
+ } else {
+ this.render()
+ }
+ }
+
+ render(): void {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ this.mnt = mount(this.component as any, {
+ target: this.element,
+ props: {
+ props: this.props
+ }
+ })
+ }
+
+ updateProps(props: Partial): void {
+ Object.assign(this.props, props)
+ this.destroy()
+ this.render()
+ }
+
+ updateAttributes(attributes: Record): void {
+ Object.keys(attributes).forEach((key) => {
+ this.element.setAttribute(key, attributes[key])
+ })
+ this.destroy()
+ this.render()
+ }
+
+ destroy(): void {
+ if (this.mnt) {
+ unmount(this.mnt)
+ } else {
+ unmount(this.component)
+ }
+ }
+}
+
+export default SvelteRenderer
diff --git a/src/lib/components/edra/utils.ts b/src/lib/components/edra/utils.ts
new file mode 100644
index 0000000..4e89bd5
--- /dev/null
+++ b/src/lib/components/edra/utils.ts
@@ -0,0 +1,151 @@
+import type { Content, Editor } from '@tiptap/core'
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+import type { EditorState, Transaction } from '@tiptap/pm/state'
+import type { EditorView } from '@tiptap/pm/view'
+import { browser } from '$app/environment'
+import type { Snippet } from 'svelte'
+
+export interface ShouldShowProps {
+ editor: Editor
+ element: HTMLElement
+ view: EditorView
+ state: EditorState
+ oldState?: EditorState
+ from: number
+ to: number
+}
+
+export const findColors = (doc: Node) => {
+ const hexColor = /(#[0-9a-f]{3,6})\b/gi
+ const decorations: Decoration[] = []
+
+ doc.descendants((node, position) => {
+ if (!node.text) {
+ return
+ }
+
+ Array.from(node.text.matchAll(hexColor)).forEach((match) => {
+ const color = match[0]
+ const index = match.index || 0
+ const from = position + index
+ const to = from + color.length
+ const decoration = Decoration.inline(from, to, {
+ class: 'color',
+ style: `--color: ${color}`
+ })
+
+ decorations.push(decoration)
+ })
+ })
+
+ return DecorationSet.create(doc, decorations)
+}
+
+/**
+ * Check if the current browser is mac or not
+ */
+export const isMac = browser
+ ? navigator.userAgent.includes('Macintosh') || navigator.userAgent.includes('Mac OS X')
+ : false
+
+/**
+ * Dupilcate content at the current selection
+ * @param editor Editor instance
+ * @param node Node to be duplicated
+ */
+export const duplicateContent = (editor: Editor, node: Node) => {
+ const { view } = editor
+ const { state } = view
+ const { selection } = state
+
+ editor
+ .chain()
+ .insertContentAt(selection.to, node.toJSON(), {
+ updateSelection: true
+ })
+ .focus(selection.to)
+ .run()
+}
+
+/**
+ * Function to handle paste event of an image
+ * @param editor Editor - editor instance
+ * @param maxSize number - max size of the image to be pasted in MB, default is 2MB
+ */
+export function getHandlePaste(editor: Editor, maxSize: number = 2) {
+ return (view: EditorView, event: ClipboardEvent) => {
+ const item = event.clipboardData?.items[0]
+
+ if (item?.type.indexOf('image') !== 0) {
+ return
+ }
+
+ const file = item.getAsFile()
+ if (file === null || file.size === undefined) return
+ const filesize = (file?.size / 1024 / 1024).toFixed(4)
+
+ if (filesize && Number(filesize) > maxSize) {
+ window.alert(`too large image! filesize: ${filesize} mb`)
+ return
+ }
+
+ const reader = new FileReader()
+ reader.readAsDataURL(file)
+ reader.onload = (e) => {
+ if (e.target?.result) {
+ editor.commands.setImage({ src: e.target.result as string })
+ }
+ }
+ }
+}
+
+/**
+ * Sets focus on the editor and moves the cursor to the clicked text position,
+ * defaulting to the end of the document if the click is outside any text.
+ *
+ * @param editor - Editor instance
+ * @param event - Optional MouseEvent or KeyboardEvent triggering the focus
+ */
+export function focusEditor(editor: Editor | undefined, event?: MouseEvent | KeyboardEvent) {
+ if (!editor) return
+ // Check if there is a text selection already (i.e. a non-empty selection)
+ const selection = window.getSelection()
+ if (selection && selection.toString().length > 0) {
+ // Focus the editor without modifying selection
+ editor.chain().focus().run()
+ return
+ }
+ if (event instanceof MouseEvent) {
+ const { clientX, clientY } = event
+ const pos = editor.view.posAtCoords({ left: clientX, top: clientY })?.pos
+ if (pos == null) {
+ // If not a valid position, move cursor to the end of the document
+ const endPos = editor.state.doc.content.size
+ editor.chain().focus().setTextSelection(endPos).run()
+ } else {
+ editor.chain().focus().setTextSelection(pos).run()
+ }
+ } else {
+ editor.chain().focus().run()
+ }
+}
+
+/**
+ * Props for Edra's editor component
+ */
+export interface EdraProps {
+ class?: string
+ content?: Content
+ editable?: boolean
+ limit?: number
+ editor?: Editor
+ showSlashCommands?: boolean
+ showLinkBubbleMenu?: boolean
+ showTableBubbleMenu?: boolean
+ /**
+ * Callback function to be called when the content is updated
+ * @param content
+ */
+ onUpdate?: (props: { editor: Editor; transaction: Transaction }) => void
+ children?: Snippet<[]>
+}
diff --git a/src/lib/posts.ts b/src/lib/posts.ts
new file mode 100644
index 0000000..9cb9fdf
--- /dev/null
+++ b/src/lib/posts.ts
@@ -0,0 +1,73 @@
+import fs from 'fs'
+import path from 'path'
+import matter from 'gray-matter'
+import { marked } from 'marked'
+
+export interface Post {
+ title?: string
+ type: 'note' | 'article' | 'image' | 'link'
+ date: string
+ slug: string
+ published: boolean
+ content: string
+ excerpt?: string
+ images?: string[]
+ link?:
+ | {
+ url: string
+ title?: string
+ description?: string
+ image?: string
+ favicon?: string
+ siteName?: string
+ }
+ | string
+}
+
+const postsDirectory = path.join(process.cwd(), 'src/lib/posts')
+
+export async function getAllPosts(): Promise {
+ const fileNames = fs.readdirSync(postsDirectory)
+
+ const posts = fileNames
+ .filter((fileName) => fileName.endsWith('.md'))
+ .map((fileName) => {
+ const filePath = path.join(postsDirectory, fileName)
+ const fileContents = fs.readFileSync(filePath, 'utf8')
+ const { data, content } = matter(fileContents)
+
+ const slug = data.slug || fileName.replace(/\.md$/, '')
+
+ return {
+ ...data,
+ slug,
+ content,
+ excerpt: getExcerpt(content, data.type)
+ } as Post
+ })
+ .filter((post) => post.published)
+ .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
+
+ return posts
+}
+
+export async function getPostBySlug(slug: string): Promise {
+ const posts = await getAllPosts()
+ const post = posts.find((p) => p.slug === slug)
+
+ if (!post) return null
+
+ return {
+ ...post,
+ content: marked(post.content) as string
+ }
+}
+
+function getExcerpt(content: string, type: 'note' | 'article'): string {
+ const plainText = content.replace(/[#*`\[\]]/g, '').trim()
+ const maxLength = type === 'note' ? 280 : 160
+
+ if (plainText.length <= maxLength) return plainText
+
+ return plainText.substring(0, maxLength).trim() + '...'
+}
diff --git a/src/lib/posts/auto-metadata-link.md b/src/lib/posts/auto-metadata-link.md
new file mode 100644
index 0000000..970b55a
--- /dev/null
+++ b/src/lib/posts/auto-metadata-link.md
@@ -0,0 +1,9 @@
+---
+type: 'link'
+date: '2024-01-22T09:00:00Z'
+slug: 'auto-metadata-link'
+published: true
+link: 'https://github.com/sveltejs/kit'
+---
+
+Check out the SvelteKit repository - the framework that powers this blog!
diff --git a/src/lib/posts/beautiful-sunset-gallery.md b/src/lib/posts/beautiful-sunset-gallery.md
new file mode 100644
index 0000000..4d55809
--- /dev/null
+++ b/src/lib/posts/beautiful-sunset-gallery.md
@@ -0,0 +1,15 @@
+---
+title: 'Beautiful Sunset Gallery'
+type: 'image'
+date: '2024-01-19T18:30:00Z'
+slug: 'beautiful-sunset-gallery'
+published: true
+images:
+ - 'https://images.unsplash.com/photo-1495616811223-4d98c6e9c869?w=800&h=600&fit=crop'
+ - 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop'
+ - 'https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?w=800&h=600&fit=crop'
+ - 'https://images.unsplash.com/photo-1475924156734-496f6cac6ec1?w=800&h=600&fit=crop'
+ - 'https://images.unsplash.com/photo-1472214103451-9374bd1c798e?w=800&h=600&fit=crop'
+---
+
+Caught these stunning sunsets during my recent travels. Each one tells its own story of endings and beginnings.
diff --git a/src/lib/posts/interesting-article.md b/src/lib/posts/interesting-article.md
new file mode 100644
index 0000000..b287563
--- /dev/null
+++ b/src/lib/posts/interesting-article.md
@@ -0,0 +1,14 @@
+---
+title: 'Interesting Read'
+type: 'link'
+date: '2024-01-20T10:00:00Z'
+slug: 'interesting-article'
+published: true
+link:
+ url: 'https://example.com/article'
+ title: 'The Future of Web Development'
+ description: 'An in-depth look at emerging trends and technologies shaping the future of web development.'
+ siteName: 'Example Blog'
+---
+
+This article provides great insights into where web development is heading. The discussion about WebAssembly and edge computing is particularly fascinating.
diff --git a/src/lib/posts/minimalist-workspace.md b/src/lib/posts/minimalist-workspace.md
new file mode 100644
index 0000000..ee7fb86
--- /dev/null
+++ b/src/lib/posts/minimalist-workspace.md
@@ -0,0 +1,10 @@
+---
+type: 'image'
+date: '2024-01-17T10:00:00Z'
+slug: 'minimalist-workspace'
+published: true
+images:
+ - 'https://images.unsplash.com/photo-1555212697-194d092e3b8f?w=800&h=600&fit=crop'
+---
+
+My workspace this morning. Sometimes less really is more.
diff --git a/src/lib/posts/quick-thought-about-design-systems.md b/src/lib/posts/quick-thought-about-design-systems.md
new file mode 100644
index 0000000..7a9f474
--- /dev/null
+++ b/src/lib/posts/quick-thought-about-design-systems.md
@@ -0,0 +1,8 @@
+---
+type: 'note'
+date: '2024-01-16T14:20:00Z'
+slug: 'quick-thought-about-design-systems'
+published: true
+---
+
+Been thinking about how the best design systems aren't the ones with the most components, but the ones with the clearest principles. You can have a thousand perfectly crafted components, but if your team doesn't understand the _why_ behind them, you've just built a very pretty prison.
diff --git a/src/lib/posts/svg-animations-are-fun.md b/src/lib/posts/svg-animations-are-fun.md
new file mode 100644
index 0000000..70fd4f5
--- /dev/null
+++ b/src/lib/posts/svg-animations-are-fun.md
@@ -0,0 +1,8 @@
+---
+type: 'note'
+date: '2024-01-20T16:45:00Z'
+slug: 'svg-animations-are-fun'
+published: true
+---
+
+Just spent an hour making a squiggly line animation for my site. Could I have shipped three features in that time? Yes. Did the squiggly line spark more joy? Also yes. Not everything needs to be optimized for productivity.
diff --git a/src/lib/posts/the-perfect-todo-app-doesnt-exist.md b/src/lib/posts/the-perfect-todo-app-doesnt-exist.md
new file mode 100644
index 0000000..7f68bad
--- /dev/null
+++ b/src/lib/posts/the-perfect-todo-app-doesnt-exist.md
@@ -0,0 +1,48 @@
+---
+title: "The Perfect Todo App Doesn't Exist"
+type: 'article'
+date: '2024-01-18T09:00:00Z'
+slug: 'the-perfect-todo-app-doesnt-exist'
+published: true
+---
+
+I've tried them all. Things, Todoist, Notion, Apple Reminders, pen and paper, sticky notes on my monitor. Each promises to be the solution to my productivity woes, and each eventually joins the graveyard of abandoned systems.
+
+## The Cycle
+
+It always starts the same way:
+
+1. Discover new todo app
+2. Feel rush of organizational dopamine
+3. Migrate all tasks with excessive enthusiasm
+4. Use religiously for 2-3 weeks
+5. Gradually abandon as real life intrudes
+6. Feel guilty about abandoned system
+7. Return to step 1
+
+## The Problem Isn't the Tool
+
+After years of this cycle, I've realized something: the perfect todo app doesn't exist because the problem isn't the app. It's the expectation that any tool can magically transform us into productivity machines.
+
+Real productivity isn't about finding the perfect system. It's about:
+
+- Saying no to things that don't matter
+- Being realistic about what you can accomplish
+- Accepting that some days you'll get nothing done
+- Understanding that busy isn't the same as productive
+
+## What Works (For Me)
+
+These days, I use a simple text file. One for today, one for this week, one for "someday maybe." No tags, no priorities, no due dates unless absolutely necessary. Just a list of things I'd like to do.
+
+Some get done. Some don't. The world keeps spinning.
+
+## The Real Secret
+
+The perfect todo app doesn't exist because perfection doesn't exist. The best system is the one you'll actually use, even if it's just a piece of paper or a note on your phone.
+
+Stop optimizing your system. Start doing the work.
+
+---
+
+_What's your relationship with todo apps? Have you found something that works, or are you still searching for the perfect system?_
diff --git a/src/lib/posts/typographica-announcement.md b/src/lib/posts/typographica-announcement.md
new file mode 100644
index 0000000..2be01fd
--- /dev/null
+++ b/src/lib/posts/typographica-announcement.md
@@ -0,0 +1,9 @@
+---
+type: 'link'
+date: '2024-01-21T14:00:00Z'
+slug: 'typographica-announcement'
+published: true
+link: 'https://typographica.org/on-typography/now-open-the-typographica-library/'
+---
+
+Excited about the new Typographica release! The performance improvements and enhanced TypeScript support are game-changers for building modern web applications.
diff --git a/src/lib/posts/welcome-to-my-blog.md b/src/lib/posts/welcome-to-my-blog.md
new file mode 100644
index 0000000..234cdd9
--- /dev/null
+++ b/src/lib/posts/welcome-to-my-blog.md
@@ -0,0 +1,33 @@
+---
+title: 'Welcome to My Blog'
+type: 'article'
+date: '2024-01-15T10:30:00Z'
+slug: 'welcome-to-my-blog'
+published: true
+---
+
+After years of sharing my thoughts across various social platforms, I've decided to bring everything home. This blog will be a space for both quick notes and longer-form thoughts about design, development, and whatever else catches my interest.
+
+## Why Now?
+
+The internet feels different these days. Social media platforms come and go, APIs change, and our content gets scattered across dozens of services. I wanted a simple, permanent place for my writing that I control completely.
+
+## What to Expect
+
+You'll find a mix of content here:
+
+- **Notes**: Quick thoughts, observations, and links I find interesting
+- **Articles**: Deeper dives into topics I'm passionate about
+- **Updates**: What I'm working on and thinking about
+
+The design is intentionally minimal. No comments, no likes, no algorithms—just words on a page, the way blogs used to be.
+
+## Technical Details
+
+This blog is built with SvelteKit and uses markdown files for storage. It's fast, simple, and exactly what I need. No database, no CMS, just files in a folder.
+
+Feel free to view source if you're curious about the implementation. Everything is open and straightforward.
+
+---
+
+_Thanks for reading. Here's to owning our own words._
diff --git a/src/lib/schemas/project.ts b/src/lib/schemas/project.ts
new file mode 100644
index 0000000..957dc8a
--- /dev/null
+++ b/src/lib/schemas/project.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod'
+
+export const projectSchema = z
+ .object({
+ title: z.string().min(1, 'Title is required'),
+ description: z.string().optional(),
+ year: z
+ .number()
+ .min(1990)
+ .max(new Date().getFullYear() + 1),
+ client: z.string().optional(),
+ externalUrl: z.string().url().optional().or(z.literal('')),
+ backgroundColor: z
+ .string()
+ .regex(/^#[0-9A-Fa-f]{6}$/)
+ .optional()
+ .or(z.literal('')),
+ highlightColor: z
+ .string()
+ .regex(/^#[0-9A-Fa-f]{6}$/)
+ .optional()
+ .or(z.literal('')),
+ status: z.enum(['draft', 'published', 'list-only', 'password-protected']),
+ password: z.string().optional()
+ })
+ .refine(
+ (data) => {
+ if (data.status === 'password-protected') {
+ return data.password && data.password.trim().length > 0
+ }
+ return true
+ },
+ {
+ message: 'Password is required when status is password-protected',
+ path: ['password']
+ }
+ )
+
+export type ProjectSchema = z.infer
diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts
new file mode 100644
index 0000000..8273860
--- /dev/null
+++ b/src/lib/server/api-utils.ts
@@ -0,0 +1,99 @@
+import type { RequestEvent } from '@sveltejs/kit'
+
+// Response helpers
+export function jsonResponse(data: any, status = 200): Response {
+ return new Response(JSON.stringify(data), {
+ status,
+ headers: { 'Content-Type': 'application/json' }
+ })
+}
+
+export function errorResponse(message: string, status = 400): Response {
+ return jsonResponse({ error: message }, status)
+}
+
+// Pagination helper
+export interface PaginationParams {
+ page?: number
+ limit?: number
+}
+
+export function getPaginationParams(url: URL): PaginationParams {
+ const page = Math.max(1, parseInt(url.searchParams.get('page') || '1'))
+ const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') || '20')))
+
+ return { page, limit }
+}
+
+export function getPaginationMeta(total: number, page: number, limit: number) {
+ const totalPages = Math.ceil(total / limit)
+
+ return {
+ total,
+ page,
+ limit,
+ totalPages,
+ hasNext: page < totalPages,
+ hasPrev: page > 1
+ }
+}
+
+// Status validation
+export const VALID_STATUSES = ['draft', 'published'] as const
+export type Status = (typeof VALID_STATUSES)[number]
+
+export function isValidStatus(status: any): status is Status {
+ return VALID_STATUSES.includes(status)
+}
+
+// Post type validation
+export const VALID_POST_TYPES = ['post', 'essay'] as const
+export type PostType = (typeof VALID_POST_TYPES)[number]
+
+export function isValidPostType(type: any): type is PostType {
+ return VALID_POST_TYPES.includes(type)
+}
+
+// Request body parser with error handling
+export async function parseRequestBody(request: Request): Promise {
+ try {
+ const body = await request.json()
+ return body as T
+ } catch (error) {
+ return null
+ }
+}
+
+// Date helpers
+export function toISOString(date: Date | string | null | undefined): string | null {
+ if (!date) return null
+ return new Date(date).toISOString()
+}
+
+// Basic auth check (temporary until proper auth is implemented)
+export function checkAdminAuth(event: RequestEvent): boolean {
+ const authHeader = event.request.headers.get('Authorization')
+ if (!authHeader) return false
+
+ const [type, credentials] = authHeader.split(' ')
+ if (type !== 'Basic') return false
+
+ try {
+ const decoded = atob(credentials)
+ const [username, password] = decoded.split(':')
+
+ // For now, simple password check
+ // TODO: Implement proper authentication
+ const adminPassword = process.env.ADMIN_PASSWORD || 'changeme'
+ return username === 'admin' && password === adminPassword
+ } catch {
+ return false
+ }
+}
+
+// CORS headers for API routes
+export const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*', // Update this in production
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
+}
diff --git a/src/lib/server/cloudinary.ts b/src/lib/server/cloudinary.ts
new file mode 100644
index 0000000..53c24d9
--- /dev/null
+++ b/src/lib/server/cloudinary.ts
@@ -0,0 +1,284 @@
+import { v2 as cloudinary } from 'cloudinary'
+import type { UploadApiResponse, UploadApiErrorResponse } from 'cloudinary'
+import { logger } from './logger'
+import { uploadFileLocally } from './local-storage'
+import { dev } from '$app/environment'
+
+// Configure Cloudinary
+cloudinary.config({
+ cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
+ api_key: process.env.CLOUDINARY_API_KEY,
+ api_secret: process.env.CLOUDINARY_API_SECRET,
+ secure: true
+})
+
+// Check if Cloudinary is configured
+export function isCloudinaryConfigured(): boolean {
+ return !!(
+ process.env.CLOUDINARY_CLOUD_NAME &&
+ process.env.CLOUDINARY_API_KEY &&
+ process.env.CLOUDINARY_API_SECRET
+ )
+}
+
+// Upload options for different asset types
+const uploadPresets = {
+ // For general media uploads (blog posts, project images)
+ media: {
+ folder: 'jedmund/media',
+ resource_type: 'auto' as const,
+ // Remove allowed_formats to avoid SVG validation issues
+ transformation: [{ quality: 'auto:good' }, { fetch_format: 'auto' }]
+ },
+
+ // For photo albums
+ photos: {
+ folder: 'jedmund/photos',
+ resource_type: 'image' as const,
+ allowed_formats: ['jpg', 'jpeg', 'png', 'webp'],
+ transformation: [{ quality: 'auto:best' }, { fetch_format: 'auto' }]
+ },
+
+ // For project galleries
+ projects: {
+ folder: 'jedmund/projects',
+ resource_type: 'image' as const,
+ // Remove allowed_formats to avoid SVG validation issues
+ transformation: [{ quality: 'auto:good' }, { fetch_format: 'auto' }]
+ }
+}
+
+// Image size variants (2025-appropriate sizes)
+export const imageSizes = {
+ thumbnail: { width: 800, height: 600, crop: 'fill' as const }, // Much larger thumbnails for modern displays
+ small: { width: 600, quality: 'auto:good' as const },
+ medium: { width: 1200, quality: 'auto:good' as const },
+ large: { width: 1920, quality: 'auto:good' as const },
+ xlarge: { width: 2560, quality: 'auto:good' as const }
+}
+
+export interface UploadResult {
+ success: boolean
+ publicId?: string
+ url?: string
+ secureUrl?: string
+ thumbnailUrl?: string
+ width?: number
+ height?: number
+ format?: string
+ size?: number
+ error?: string
+}
+
+// Upload a single file
+export async function uploadFile(
+ file: File,
+ type: 'media' | 'photos' | 'projects' = 'media',
+ customOptions?: any
+): Promise {
+ try {
+ // TEMPORARY: Force Cloudinary usage for testing
+ const FORCE_CLOUDINARY_IN_DEV = true; // Toggle this to test
+
+ // Use local storage in development or when Cloudinary is not configured
+ if ((dev && !FORCE_CLOUDINARY_IN_DEV) || !isCloudinaryConfigured()) {
+ logger.info('Using local storage for file upload')
+ const localResult = await uploadFileLocally(file, type)
+
+ if (!localResult.success) {
+ return {
+ success: false,
+ error: localResult.error || 'Local upload failed'
+ }
+ }
+
+ return {
+ success: true,
+ publicId: `local/${localResult.filename}`,
+ url: localResult.url,
+ secureUrl: localResult.url,
+ thumbnailUrl: localResult.thumbnailUrl,
+ width: localResult.width,
+ height: localResult.height,
+ format: file.type.split('/')[1],
+ size: localResult.size
+ }
+ }
+
+ // Convert File to buffer
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+
+ // Check if file is SVG for logging purposes
+ const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg')
+
+ // Extract filename without extension
+ const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '')
+ const fileExtension = file.name.split('.').pop()?.toLowerCase()
+
+ // Prepare upload options
+ const uploadOptions = {
+ ...uploadPresets[type],
+ ...customOptions,
+ public_id: `${Date.now()}-${fileNameWithoutExt}`,
+ // For SVG files, explicitly set format to preserve extension
+ ...(isSvg && { format: 'svg' })
+ }
+
+ // Log upload attempt for debugging
+ logger.info('Attempting file upload:', {
+ filename: file.name,
+ mimeType: file.type,
+ size: file.size,
+ isSvg,
+ uploadOptions
+ })
+
+ // Upload to Cloudinary
+ const result = await new Promise((resolve, reject) => {
+ const uploadStream = cloudinary.uploader.upload_stream(
+ uploadOptions,
+ (error, result) => {
+ if (error) reject(error)
+ else if (result) resolve(result)
+ else reject(new Error('Upload failed'))
+ }
+ )
+
+ uploadStream.end(buffer)
+ })
+
+ // Generate thumbnail URL
+ const thumbnailUrl = cloudinary.url(result.public_id, {
+ ...imageSizes.thumbnail,
+ secure: true
+ })
+
+ logger.mediaUpload(file.name, file.size, file.type, true)
+
+ return {
+ success: true,
+ publicId: result.public_id,
+ url: result.url,
+ secureUrl: result.secure_url,
+ thumbnailUrl,
+ width: result.width,
+ height: result.height,
+ format: result.format,
+ size: result.bytes
+ }
+ } catch (error) {
+ logger.error('Cloudinary upload failed', error as Error)
+ logger.mediaUpload(file.name, file.size, file.type, false)
+
+ // Enhanced error logging
+ if (error instanceof Error) {
+ logger.error('Upload error details:', {
+ filename: file.name,
+ mimeType: file.type,
+ size: file.size,
+ errorMessage: error.message,
+ errorStack: error.stack
+ })
+ }
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Upload failed'
+ }
+ }
+}
+
+// Upload multiple files
+export async function uploadFiles(
+ files: File[],
+ type: 'media' | 'photos' | 'projects' = 'media'
+): Promise {
+ const uploadPromises = files.map((file) => uploadFile(file, type))
+ return Promise.all(uploadPromises)
+}
+
+// Delete a file from Cloudinary
+export async function deleteFile(publicId: string): Promise {
+ try {
+ if (!isCloudinaryConfigured()) {
+ throw new Error('Cloudinary is not configured')
+ }
+
+ // Try to delete with auto resource type first
+ const result = await cloudinary.uploader.destroy(publicId, {
+ resource_type: 'auto'
+ })
+
+ return result.result === 'ok'
+ } catch (error) {
+ logger.error('Cloudinary delete failed', error as Error)
+ return false
+ }
+}
+
+// Generate optimized URL for an image
+export function getOptimizedUrl(
+ publicId: string,
+ options?: {
+ width?: number
+ height?: number
+ quality?: string
+ format?: string
+ crop?: string
+ }
+): string {
+ return cloudinary.url(publicId, {
+ secure: true,
+ transformation: [
+ {
+ quality: options?.quality || 'auto:good',
+ fetch_format: options?.format || 'auto',
+ ...(options?.width && { width: options.width }),
+ ...(options?.height && { height: options.height }),
+ ...(options?.crop && { crop: options.crop })
+ }
+ ]
+ })
+}
+
+// Get responsive image URLs for different screen sizes
+export function getResponsiveUrls(publicId: string): Record {
+ return {
+ thumbnail: getOptimizedUrl(publicId, imageSizes.thumbnail),
+ small: getOptimizedUrl(publicId, { width: imageSizes.small.width }),
+ medium: getOptimizedUrl(publicId, { width: imageSizes.medium.width }),
+ large: getOptimizedUrl(publicId, { width: imageSizes.large.width }),
+ xlarge: getOptimizedUrl(publicId, { width: imageSizes.xlarge.width }),
+ original: cloudinary.url(publicId, { secure: true })
+ }
+}
+
+// Smart image size selection based on container width
+export function getSmartImageUrl(publicId: string, containerWidth: number, retina = true): string {
+ // Account for retina displays
+ const targetWidth = retina ? containerWidth * 2 : containerWidth
+
+ // Select appropriate size
+ if (targetWidth <= 600) {
+ return getOptimizedUrl(publicId, { width: imageSizes.small.width })
+ } else if (targetWidth <= 1200) {
+ return getOptimizedUrl(publicId, { width: imageSizes.medium.width })
+ } else if (targetWidth <= 1920) {
+ return getOptimizedUrl(publicId, { width: imageSizes.large.width })
+ } else {
+ return getOptimizedUrl(publicId, { width: imageSizes.xlarge.width })
+ }
+}
+
+// Extract public ID from Cloudinary URL
+export function extractPublicId(url: string): string | null {
+ try {
+ // Cloudinary URLs typically follow this pattern:
+ // https://res.cloudinary.com/{cloud_name}/image/upload/{version}/{public_id}.{format}
+ const match = url.match(/\/v\d+\/(.+)\.[a-zA-Z]+$/)
+ return match ? match[1] : null
+ } catch {
+ return null
+ }
+}
diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts
new file mode 100644
index 0000000..7cca4bc
--- /dev/null
+++ b/src/lib/server/database.ts
@@ -0,0 +1,95 @@
+import { PrismaClient } from '@prisma/client'
+import { dev } from '$app/environment'
+
+// Prevent multiple instances of Prisma Client in development
+const globalForPrisma = globalThis as unknown as {
+ prisma: PrismaClient | undefined
+}
+
+export const prisma =
+ globalForPrisma.prisma ??
+ new PrismaClient({
+ log: dev ? ['query', 'error', 'warn'] : ['error']
+ })
+
+if (dev) globalForPrisma.prisma = prisma
+
+// Utility function to handle database errors
+export function handleDatabaseError(error: unknown): Response {
+ console.error('Database error:', error)
+
+ if (error instanceof Error) {
+ // Check for unique constraint violations
+ if (error.message.includes('Unique constraint')) {
+ return new Response(
+ JSON.stringify({
+ error: 'A record with this identifier already exists'
+ }),
+ {
+ status: 409,
+ headers: { 'Content-Type': 'application/json' }
+ }
+ )
+ }
+
+ // Check for foreign key violations
+ if (error.message.includes('Foreign key constraint')) {
+ return new Response(
+ JSON.stringify({
+ error: 'Related record not found'
+ }),
+ {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' }
+ }
+ )
+ }
+ }
+
+ // Generic error response
+ return new Response(
+ JSON.stringify({
+ error: 'An unexpected database error occurred'
+ }),
+ {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' }
+ }
+ )
+}
+
+// Utility to create slugs from titles
+export function createSlug(title: string): string {
+ return title
+ .toLowerCase()
+ .trim()
+ .replace(/[^\w\s-]/g, '') // Remove special characters
+ .replace(/[\s_-]+/g, '-') // Replace spaces, underscores, hyphens with single hyphen
+ .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
+}
+
+// Ensure unique slug by appending number if needed
+export async function ensureUniqueSlug(
+ slug: string,
+ model: 'project' | 'post' | 'album' | 'photo',
+ excludeId?: number
+): Promise {
+ let uniqueSlug = slug
+ let counter = 1
+
+ while (true) {
+ const existingRecord = await prisma[model].findFirst({
+ where: {
+ slug: uniqueSlug,
+ ...(excludeId ? { NOT: { id: excludeId } } : {})
+ }
+ })
+
+ if (!existingRecord) break
+
+ uniqueSlug = `${slug}-${counter}`
+ counter++
+ }
+
+ return uniqueSlug
+}
diff --git a/src/lib/server/local-storage.ts b/src/lib/server/local-storage.ts
new file mode 100644
index 0000000..9a856ab
--- /dev/null
+++ b/src/lib/server/local-storage.ts
@@ -0,0 +1,149 @@
+import { writeFile, mkdir } from 'fs/promises'
+import { existsSync } from 'fs'
+import path from 'path'
+import sharp from 'sharp'
+import { logger } from './logger'
+
+// Base directory for local uploads
+const UPLOAD_DIR = 'static/local-uploads'
+const PUBLIC_PATH = '/local-uploads'
+
+// Ensure upload directory exists
+async function ensureUploadDir(): Promise {
+ const dirs = [
+ UPLOAD_DIR,
+ path.join(UPLOAD_DIR, 'media'),
+ path.join(UPLOAD_DIR, 'photos'),
+ path.join(UPLOAD_DIR, 'projects'),
+ path.join(UPLOAD_DIR, 'thumbnails')
+ ]
+
+ for (const dir of dirs) {
+ if (!existsSync(dir)) {
+ await mkdir(dir, { recursive: true })
+ }
+ }
+}
+
+// Generate unique filename
+function generateFilename(originalName: string): string {
+ const timestamp = Date.now()
+ const random = Math.random().toString(36).substring(2, 8)
+ const ext = path.extname(originalName)
+ const name = path.basename(originalName, ext)
+ // Sanitize filename
+ const safeName = name.replace(/[^a-z0-9]/gi, '-').toLowerCase()
+ return `${timestamp}-${random}-${safeName}${ext}`
+}
+
+export interface LocalUploadResult {
+ success: boolean
+ filename?: string
+ url?: string
+ thumbnailUrl?: string
+ width?: number
+ height?: number
+ size?: number
+ error?: string
+}
+
+// Upload file locally (for development/testing)
+export async function uploadFileLocally(
+ file: File,
+ type: 'media' | 'photos' | 'projects' = 'media'
+): Promise {
+ try {
+ await ensureUploadDir()
+
+ // Generate unique filename
+ const filename = generateFilename(file.name)
+ const filepath = path.join(UPLOAD_DIR, type, filename)
+ const thumbnailPath = path.join(UPLOAD_DIR, 'thumbnails', `thumb-${filename}`)
+
+ // Convert File to buffer
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+
+ // Process image with sharp to get dimensions
+ let width = 0
+ let height = 0
+
+ try {
+ const image = sharp(buffer)
+ const metadata = await image.metadata()
+ width = metadata.width || 0
+ height = metadata.height || 0
+
+ // Save original
+ await writeFile(filepath, buffer)
+
+ // Create thumbnail (800x600 for modern displays)
+ await image
+ .resize(800, 600, {
+ fit: 'cover',
+ position: 'center'
+ })
+ .jpeg({ quality: 85 }) // Good quality for larger thumbnails
+ .toFile(thumbnailPath)
+ } catch (imageError) {
+ // If sharp fails (e.g., for SVG), just save the original
+ logger.warn('Sharp processing failed, saving original only', imageError as Error)
+ await writeFile(filepath, buffer)
+ }
+
+ // Construct URLs
+ const url = `${PUBLIC_PATH}/${type}/${filename}`
+ const thumbnailUrl = `${PUBLIC_PATH}/thumbnails/thumb-${filename}`
+
+ logger.info('File uploaded locally', {
+ filename,
+ type,
+ size: file.size,
+ dimensions: `${width}x${height}`
+ })
+
+ return {
+ success: true,
+ filename,
+ url,
+ thumbnailUrl,
+ width,
+ height,
+ size: file.size
+ }
+ } catch (error) {
+ logger.error('Local upload failed', error as Error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Upload failed'
+ }
+ }
+}
+
+// Delete local file
+export async function deleteFileLocally(url: string): Promise {
+ try {
+ // Extract path from URL
+ const relativePath = url.replace(PUBLIC_PATH, '')
+ const filepath = path.join(UPLOAD_DIR, relativePath)
+
+ // Check if file exists and delete
+ if (existsSync(filepath)) {
+ const { unlink } = await import('fs/promises')
+ await unlink(filepath)
+
+ // Try to delete thumbnail too
+ const filename = path.basename(filepath)
+ const thumbnailPath = path.join(UPLOAD_DIR, 'thumbnails', `thumb-${filename}`)
+ if (existsSync(thumbnailPath)) {
+ await unlink(thumbnailPath)
+ }
+
+ return true
+ }
+ return false
+ } catch (error) {
+ logger.error('Local delete failed', error as Error)
+ return false
+ }
+}
diff --git a/src/lib/server/logger.ts b/src/lib/server/logger.ts
new file mode 100644
index 0000000..82d297e
--- /dev/null
+++ b/src/lib/server/logger.ts
@@ -0,0 +1,135 @@
+import { dev } from '$app/environment'
+
+export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
+
+interface LogEntry {
+ level: LogLevel
+ message: string
+ timestamp: string
+ context?: Record
+ error?: Error
+}
+
+class Logger {
+ private shouldLog(level: LogLevel): boolean {
+ // In development, log everything
+ if (dev) return true
+
+ // In production, only log warnings and errors
+ return level === 'warn' || level === 'error'
+ }
+
+ private formatLog(entry: LogEntry): string {
+ const parts = [`[${entry.timestamp}]`, `[${entry.level.toUpperCase()}]`, entry.message]
+
+ if (entry.context) {
+ parts.push(JSON.stringify(entry.context, null, 2))
+ }
+
+ if (entry.error) {
+ parts.push(`\nError: ${entry.error.message}`)
+ if (entry.error.stack) {
+ parts.push(`Stack: ${entry.error.stack}`)
+ }
+ }
+
+ return parts.join(' ')
+ }
+
+ private log(level: LogLevel, message: string, context?: Record, error?: Error) {
+ if (!this.shouldLog(level)) return
+
+ const entry: LogEntry = {
+ level,
+ message,
+ timestamp: new Date().toISOString(),
+ context,
+ error
+ }
+
+ const formatted = this.formatLog(entry)
+
+ switch (level) {
+ case 'debug':
+ case 'info':
+ console.log(formatted)
+ break
+ case 'warn':
+ console.warn(formatted)
+ break
+ case 'error':
+ console.error(formatted)
+ break
+ }
+ }
+
+ debug(message: string, context?: Record) {
+ this.log('debug', message, context)
+ }
+
+ info(message: string, context?: Record) {
+ this.log('info', message, context)
+ }
+
+ warn(message: string, context?: Record) {
+ this.log('warn', message, context)
+ }
+
+ error(message: string, error?: Error, context?: Record) {
+ this.log('error', message, context, error)
+ }
+
+ // Log API requests
+ apiRequest(method: string, path: string, context?: Record) {
+ this.info(`API Request: ${method} ${path}`, context)
+ }
+
+ // Log API responses
+ apiResponse(method: string, path: string, status: number, duration: number) {
+ const level = status >= 400 ? 'error' : 'info'
+ this.log(level, `API Response: ${method} ${path} - ${status} (${duration}ms)`, {
+ status,
+ duration
+ })
+ }
+
+ // Log database operations
+ dbQuery(operation: string, model: string, duration?: number, context?: Record) {
+ this.debug(`DB Query: ${operation} on ${model}`, {
+ ...context,
+ duration: duration ? `${duration}ms` : undefined
+ })
+ }
+
+ // Log media operations
+ mediaUpload(filename: string, size: number, mimeType: string, success: boolean) {
+ const level = success ? 'info' : 'error'
+ this.log(level, `Media Upload: ${filename}`, {
+ size: `${(size / 1024 / 1024).toFixed(2)} MB`,
+ mimeType,
+ success
+ })
+ }
+}
+
+export const logger = new Logger()
+
+// Middleware to log API requests
+export function createRequestLogger() {
+ return (event: any) => {
+ const start = Date.now()
+ const { method, url } = event.request
+ const path = new URL(url).pathname
+
+ logger.apiRequest(method, path, {
+ headers: Object.fromEntries(event.request.headers),
+ ip: event.getClientAddress()
+ })
+
+ // Log response after it's sent
+ event.locals.logResponse = (status: number) => {
+ const duration = Date.now() - start
+ logger.apiResponse(method, path, status, duration)
+ }
+ }
+}
diff --git a/src/lib/server/media-usage.ts b/src/lib/server/media-usage.ts
new file mode 100644
index 0000000..d5dfa79
--- /dev/null
+++ b/src/lib/server/media-usage.ts
@@ -0,0 +1,262 @@
+import { prisma } from './database.js'
+
+export interface MediaUsageReference {
+ mediaId: number
+ contentType: 'project' | 'post' | 'album'
+ contentId: number
+ fieldName: string
+}
+
+export interface MediaUsageDisplay {
+ contentType: string
+ contentId: number
+ contentTitle: string
+ fieldName: string
+ fieldDisplayName: string
+ contentUrl?: string
+ createdAt: Date
+}
+
+/**
+ * Track media usage for a piece of content
+ */
+export async function trackMediaUsage(references: MediaUsageReference[]) {
+ if (references.length === 0) return
+
+ // Use upsert to handle duplicates gracefully
+ const operations = references.map((ref) =>
+ prisma.mediaUsage.upsert({
+ where: {
+ mediaId_contentType_contentId_fieldName: {
+ mediaId: ref.mediaId,
+ contentType: ref.contentType,
+ contentId: ref.contentId,
+ fieldName: ref.fieldName
+ }
+ },
+ update: {
+ updatedAt: new Date()
+ },
+ create: {
+ mediaId: ref.mediaId,
+ contentType: ref.contentType,
+ contentId: ref.contentId,
+ fieldName: ref.fieldName
+ }
+ })
+ )
+
+ await prisma.$transaction(operations)
+}
+
+/**
+ * Remove media usage tracking for a piece of content
+ */
+export async function removeMediaUsage(contentType: string, contentId: number, fieldName?: string) {
+ await prisma.mediaUsage.deleteMany({
+ where: {
+ contentType,
+ contentId,
+ ...(fieldName && { fieldName })
+ }
+ })
+}
+
+/**
+ * Update media usage for a piece of content (removes old, adds new)
+ */
+export async function updateMediaUsage(
+ contentType: 'project' | 'post' | 'album',
+ contentId: number,
+ fieldName: string,
+ mediaIds: number[]
+) {
+ await prisma.$transaction(async (tx) => {
+ // Remove existing usage for this field
+ await tx.mediaUsage.deleteMany({
+ where: {
+ contentType,
+ contentId,
+ fieldName
+ }
+ })
+
+ // Add new usage references
+ if (mediaIds.length > 0) {
+ await tx.mediaUsage.createMany({
+ data: mediaIds.map((mediaId) => ({
+ mediaId,
+ contentType,
+ contentId,
+ fieldName
+ }))
+ })
+ }
+ })
+}
+
+/**
+ * Get usage information for a specific media item
+ */
+export async function getMediaUsage(mediaId: number): Promise {
+ const usage = await prisma.mediaUsage.findMany({
+ where: { mediaId },
+ orderBy: { createdAt: 'desc' }
+ })
+
+ const results: MediaUsageDisplay[] = []
+
+ for (const record of usage) {
+ let contentTitle = 'Unknown'
+ let contentUrl = undefined
+
+ // Fetch content details based on type
+ try {
+ switch (record.contentType) {
+ case 'project': {
+ const project = await prisma.project.findUnique({
+ where: { id: record.contentId },
+ select: { title: true, slug: true }
+ })
+ if (project) {
+ contentTitle = project.title
+ contentUrl = `/work/${project.slug}`
+ }
+ break
+ }
+ case 'post': {
+ const post = await prisma.post.findUnique({
+ where: { id: record.contentId },
+ select: { title: true, slug: true, postType: true }
+ })
+ if (post) {
+ contentTitle = post.title || `${post.postType} post`
+ contentUrl = `/universe/${post.slug}`
+ }
+ break
+ }
+ case 'album': {
+ const album = await prisma.album.findUnique({
+ where: { id: record.contentId },
+ select: { title: true, slug: true }
+ })
+ if (album) {
+ contentTitle = album.title
+ contentUrl = `/photos/${album.slug}`
+ }
+ break
+ }
+ }
+ } catch (error) {
+ console.error(`Error fetching ${record.contentType} ${record.contentId}:`, error)
+ }
+
+ results.push({
+ contentType: record.contentType,
+ contentId: record.contentId,
+ contentTitle,
+ fieldName: record.fieldName,
+ fieldDisplayName: getFieldDisplayName(record.fieldName),
+ contentUrl,
+ createdAt: record.createdAt
+ })
+ }
+
+ return results
+}
+
+/**
+ * Get friendly field names for display
+ */
+function getFieldDisplayName(fieldName: string): string {
+ const displayNames: Record = {
+ featuredImage: 'Featured Image',
+ logoUrl: 'Logo',
+ gallery: 'Gallery',
+ content: 'Content',
+ coverPhotoId: 'Cover Photo',
+ photoId: 'Photo',
+ attachments: 'Attachments'
+ }
+
+ return displayNames[fieldName] || fieldName
+}
+
+/**
+ * Extract media IDs from various data structures
+ */
+export function extractMediaIds(data: any, fieldName: string): number[] {
+ const value = data[fieldName]
+ if (!value) return []
+
+ switch (fieldName) {
+ case 'gallery':
+ case 'attachments':
+ // Gallery/attachments are arrays of media objects with id property
+ if (Array.isArray(value)) {
+ return value
+ .map((item) => (typeof item === 'object' ? item.id : parseInt(item)))
+ .filter((id) => !isNaN(id))
+ }
+ return []
+
+ case 'featuredImage':
+ case 'logoUrl':
+ // Single media URL - extract ID from URL or assume it's an ID
+ if (typeof value === 'string') {
+ // Try to extract ID from URL pattern (e.g., /api/media/123/...)
+ const match = value.match(/\/api\/media\/(\d+)/)
+ return match ? [parseInt(match[1])] : []
+ } else if (typeof value === 'number') {
+ return [value]
+ }
+ return []
+
+ case 'content':
+ // Extract from rich text content (Edra editor)
+ return extractMediaFromRichText(value)
+
+ default:
+ return []
+ }
+}
+
+/**
+ * Extract media IDs from rich text content (TipTap/Edra JSON)
+ */
+function extractMediaFromRichText(content: any): number[] {
+ if (!content || typeof content !== 'object') return []
+
+ const mediaIds: number[] = []
+
+ function traverse(node: any) {
+ if (!node) return
+
+ // Handle image nodes
+ if (node.type === 'image' && node.attrs?.src) {
+ const match = node.attrs.src.match(/\/api\/media\/(\d+)/)
+ if (match) {
+ mediaIds.push(parseInt(match[1]))
+ }
+ }
+
+ // Handle gallery nodes
+ if (node.type === 'gallery' && node.attrs?.images) {
+ for (const image of node.attrs.images) {
+ if (image.id) {
+ mediaIds.push(image.id)
+ }
+ }
+ }
+
+ // Recursively traverse child nodes
+ if (node.content) {
+ for (const child of node.content) {
+ traverse(child)
+ }
+ }
+ }
+
+ traverse(content)
+ return [...new Set(mediaIds)] // Remove duplicates
+}
diff --git a/src/lib/types/editor.ts b/src/lib/types/editor.ts
new file mode 100644
index 0000000..a931c89
--- /dev/null
+++ b/src/lib/types/editor.ts
@@ -0,0 +1,43 @@
+import type { JSONContent } from '@tiptap/core'
+
+export interface EditorData extends JSONContent {}
+
+export interface EditorProps {
+ data?: EditorData
+ readOnly?: boolean
+ placeholder?: string
+ onChange?: (data: EditorData) => void
+}
+
+// Legacy EditorJS format support (for migration)
+export interface EditorBlock {
+ id?: string
+ type: string
+ data: {
+ text?: string
+ level?: number
+ style?: string
+ items?: string[]
+ caption?: string
+ url?: string
+ [key: string]: any
+ }
+}
+
+export interface EditorSaveData {
+ time: number
+ blocks: EditorBlock[]
+ version: string
+}
+
+// Tiptap/Edra content nodes
+export interface TiptapNode {
+ type: string
+ attrs?: Record
+ content?: TiptapNode[]
+ marks?: Array<{
+ type: string
+ attrs?: Record
+ }>
+ text?: string
+}
diff --git a/src/lib/types/labs.ts b/src/lib/types/labs.ts
new file mode 100644
index 0000000..2e9f771
--- /dev/null
+++ b/src/lib/types/labs.ts
@@ -0,0 +1,14 @@
+export interface LabProject {
+ id: string
+ title: string
+ description: string
+ status: 'active' | 'maintenance' | 'archived'
+ technologies: string[]
+ url?: string
+ github?: string
+ image?: string
+ featured?: boolean
+ year: number
+}
+
+export type ProjectStatus = 'active' | 'maintenance' | 'archived'
diff --git a/src/lib/types/photos.ts b/src/lib/types/photos.ts
new file mode 100644
index 0000000..fe03a98
--- /dev/null
+++ b/src/lib/types/photos.ts
@@ -0,0 +1,36 @@
+export interface ExifData {
+ camera?: string
+ lens?: string
+ focalLength?: string
+ aperture?: string
+ shutterSpeed?: string
+ iso?: string
+ dateTaken?: string
+ location?: string
+}
+
+export interface Photo {
+ id: string
+ src: string
+ alt: string
+ caption?: string
+ width: number
+ height: number
+ exif?: ExifData
+}
+
+export interface PhotoAlbum {
+ id: string
+ slug: string
+ title: string
+ description?: string
+ coverPhoto: Photo
+ photos: Photo[]
+ createdAt: string
+}
+
+export type PhotoItem = Photo | PhotoAlbum
+
+export function isAlbum(item: PhotoItem): item is PhotoAlbum {
+ return 'photos' in item && Array.isArray(item.photos)
+}
diff --git a/src/lib/types/project.ts b/src/lib/types/project.ts
new file mode 100644
index 0000000..e11df9a
--- /dev/null
+++ b/src/lib/types/project.ts
@@ -0,0 +1,68 @@
+export type ProjectStatus = 'draft' | 'published' | 'list-only' | 'password-protected'
+export type ProjectType = 'work' | 'labs'
+
+export interface Project {
+ id: number
+ slug: string
+ title: string
+ subtitle: string | null
+ description: string | null
+ year: number
+ client: string | null
+ role: string | null
+ featuredImage: string | null
+ logoUrl: string | null
+ gallery: any[] | null
+ externalUrl: string | null
+ caseStudyContent: any | null
+ backgroundColor: string | null
+ highlightColor: string | null
+ projectType: ProjectType
+ displayOrder: number
+ status: ProjectStatus
+ password: string | null
+ createdAt?: string
+ updatedAt?: string
+ publishedAt?: string | null
+}
+
+export interface ProjectFormData {
+ title: string
+ subtitle: string
+ description: string
+ year: number
+ client: string
+ role: string
+ projectType: ProjectType
+ externalUrl: string
+ featuredImage: string | null
+ backgroundColor: string
+ highlightColor: string
+ logoUrl: string
+ gallery: any[] | null
+ status: ProjectStatus
+ password: string
+ caseStudyContent: any
+}
+
+export const defaultProjectFormData: ProjectFormData = {
+ title: '',
+ subtitle: '',
+ description: '',
+ year: new Date().getFullYear(),
+ client: '',
+ role: '',
+ projectType: 'work',
+ externalUrl: '',
+ featuredImage: null,
+ backgroundColor: '',
+ highlightColor: '',
+ logoUrl: '',
+ gallery: null,
+ status: 'draft',
+ password: '',
+ caseStudyContent: {
+ type: 'doc',
+ content: [{ type: 'paragraph' }]
+ }
+}
diff --git a/src/lib/utils/content.ts b/src/lib/utils/content.ts
new file mode 100644
index 0000000..cfebb78
--- /dev/null
+++ b/src/lib/utils/content.ts
@@ -0,0 +1,277 @@
+// Render Edra/BlockNote JSON content to HTML
+export const renderEdraContent = (content: any): string => {
+ if (!content) return ''
+
+ // Handle Tiptap format first (has type: 'doc')
+ if (content.type === 'doc' && content.content) {
+ return renderTiptapContent(content)
+ }
+
+ // Handle both { blocks: [...] } and { content: [...] } formats
+ const blocks = content.blocks || content.content || []
+ if (!Array.isArray(blocks)) return ''
+
+ const renderBlock = (block: any): string => {
+ switch (block.type) {
+ case 'heading': {
+ const level = block.attrs?.level || block.level || 1
+ const headingText = block.content || block.text || ''
+ return `${headingText}`
+ }
+
+ case 'paragraph': {
+ const paragraphText = block.content || block.text || ''
+ if (!paragraphText) return '
'
+ return `${paragraphText}
`
+ }
+
+ case 'bulletList':
+ case 'ul': {
+ const listItems = (block.content || [])
+ .map((item: any) => {
+ const itemText = item.content || item.text || ''
+ return `${itemText}`
+ })
+ .join('')
+ return ``
+ }
+
+ case 'orderedList':
+ case 'ol': {
+ const orderedItems = (block.content || [])
+ .map((item: any) => {
+ const itemText = item.content || item.text || ''
+ return `${itemText}`
+ })
+ .join('')
+ return `${orderedItems}
`
+ }
+
+ case 'blockquote': {
+ const quoteText = block.content || block.text || ''
+ return `${quoteText}
`
+ }
+
+ case 'codeBlock':
+ case 'code': {
+ const codeText = block.content || block.text || ''
+ const language = block.attrs?.language || block.language || ''
+ return `${codeText}
`
+ }
+
+ case 'image': {
+ const src = block.attrs?.src || block.src || ''
+ const alt = block.attrs?.alt || block.alt || ''
+ const caption = block.attrs?.caption || block.caption || ''
+ return `
${caption ? `${caption}` : ''}`
+ }
+
+ case 'hr':
+ case 'horizontalRule':
+ return '
'
+
+ default: {
+ // For simple text content
+ const text = block.content || block.text || ''
+ if (text) {
+ return `${text}
`
+ }
+ return ''
+ }
+ }
+ }
+
+ return blocks.map(renderBlock).join('')
+}
+
+// Render Tiptap JSON content to HTML
+function renderTiptapContent(doc: any): string {
+ if (!doc || !doc.content) return ''
+
+ const renderNode = (node: any): string => {
+ switch (node.type) {
+ case 'paragraph': {
+ const content = renderInlineContent(node.content || [])
+ if (!content) return '
'
+ return `${content}
`
+ }
+
+ case 'heading': {
+ const level = node.attrs?.level || 1
+ const content = renderInlineContent(node.content || [])
+ return `${content}`
+ }
+
+ case 'bulletList': {
+ const items = (node.content || [])
+ .map((item: any) => {
+ const itemContent = item.content?.map(renderNode).join('') || ''
+ return `${itemContent}`
+ })
+ .join('')
+ return ``
+ }
+
+ case 'orderedList': {
+ const items = (node.content || [])
+ .map((item: any) => {
+ const itemContent = item.content?.map(renderNode).join('') || ''
+ return `${itemContent}`
+ })
+ .join('')
+ return `${items}
`
+ }
+
+ case 'listItem': {
+ // List items are handled by their parent list
+ return node.content?.map(renderNode).join('') || ''
+ }
+
+ case 'blockquote': {
+ const content = node.content?.map(renderNode).join('') || ''
+ return `${content}
`
+ }
+
+ case 'codeBlock': {
+ const language = node.attrs?.language || ''
+ const content = node.content?.[0]?.text || ''
+ return `${escapeHtml(content)}
`
+ }
+
+ case 'image': {
+ const src = node.attrs?.src || ''
+ const alt = node.attrs?.alt || ''
+ const title = node.attrs?.title || ''
+ const width = node.attrs?.width
+ const height = node.attrs?.height
+ const widthAttr = width ? ` width="${width}"` : ''
+ const heightAttr = height ? ` height="${height}"` : ''
+ return `
${title ? `${title}` : ''}`
+ }
+
+ case 'horizontalRule': {
+ return '
'
+ }
+
+ case 'hardBreak': {
+ return '
'
+ }
+
+ default: {
+ // For any unknown block types, try to render their content
+ if (node.content) {
+ return node.content.map(renderNode).join('')
+ }
+ return ''
+ }
+ }
+ }
+
+ // Render inline content (text nodes with marks)
+ const renderInlineContent = (content: any[]): string => {
+ return content.map((node: any) => {
+ if (node.type === 'text') {
+ let text = escapeHtml(node.text || '')
+
+ // Apply marks (bold, italic, etc.)
+ if (node.marks) {
+ node.marks.forEach((mark: any) => {
+ switch (mark.type) {
+ case 'bold':
+ text = `${text}`
+ break
+ case 'italic':
+ text = `${text}`
+ break
+ case 'underline':
+ text = `${text}`
+ break
+ case 'strike':
+ text = `${text}`
+ break
+ case 'code':
+ text = `${text}`
+ break
+ case 'link':
+ const href = mark.attrs?.href || '#'
+ const target = mark.attrs?.target || '_blank'
+ text = `${text}`
+ break
+ case 'highlight':
+ text = `${text}`
+ break
+ }
+ })
+ }
+
+ return text
+ }
+
+ // Handle other inline nodes
+ return renderNode(node)
+ }).join('')
+ }
+
+ // Helper to escape HTML
+ const escapeHtml = (str: string): string => {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ }
+
+ return doc.content.map(renderNode).join('')
+}
+
+// Extract text content from Edra JSON for excerpt
+export const getContentExcerpt = (content: any, maxLength = 200): string => {
+ if (!content) return ''
+
+ // Handle Tiptap format first (has type: 'doc')
+ if (content.type === 'doc' && content.content) {
+ return extractTiptapText(content, maxLength)
+ }
+
+ // Handle both { blocks: [...] } and { content: [...] } formats
+ const blocks = content.blocks || content.content || []
+ if (!Array.isArray(blocks)) return ''
+
+ const extractText = (node: any): string => {
+ // For block-level content
+ if (node.type && node.content && typeof node.content === 'string') {
+ return node.content
+ }
+ // For inline content with text property
+ if (node.text) return node.text
+ // For nested content
+ if (node.content && Array.isArray(node.content)) {
+ return node.content.map(extractText).join(' ')
+ }
+ return ''
+ }
+
+ const text = blocks.map(extractText).join(' ').trim()
+ if (text.length <= maxLength) return text
+ return text.substring(0, maxLength).trim() + '...'
+}
+
+// Extract text from Tiptap content
+function extractTiptapText(doc: any, maxLength: number): string {
+ const extractFromNode = (node: any): string => {
+ if (node.type === 'text') {
+ return node.text || ''
+ }
+
+ if (node.content && Array.isArray(node.content)) {
+ return node.content.map(extractFromNode).join(' ')
+ }
+
+ return ''
+ }
+
+ const text = doc.content.map(extractFromNode).join(' ').trim()
+ if (text.length <= maxLength) return text
+ return text.substring(0, maxLength).trim() + '...'
+}
diff --git a/src/lib/utils/date.ts b/src/lib/utils/date.ts
new file mode 100644
index 0000000..a97f509
--- /dev/null
+++ b/src/lib/utils/date.ts
@@ -0,0 +1,8 @@
+export function formatDate(dateString: string): string {
+ const date = new Date(dateString)
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+ })
+}
\ No newline at end of file
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 64dc3e3..52fb3c0 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,4 +1,9 @@
@@ -6,15 +11,22 @@
-
+{#if !isAdminRoute}
+
+{/if}
+
+
+{#if !isAdminRoute}
+
+{/if}
+
+
diff --git a/src/routes/+page.ts b/src/routes/+page.ts
index 5fc27f9..2837328 100644
--- a/src/routes/+page.ts
+++ b/src/routes/+page.ts
@@ -1,36 +1,34 @@
import type { PageLoad } from './$types'
import type { Album } from '$lib/types/lastfm'
+import type { Project } from '$lib/types/project'
export const load: PageLoad = async ({ fetch }) => {
try {
- // const [albums, steamGames, psnGames] = await Promise.all([
- const [albums] = await Promise.all([
- fetchRecentAlbums(fetch)
- // fetchRecentSteamGames(fetch),
- // fetchRecentPSNGames(fetch)
- ])
+ // Fetch albums first
+ let albums: Album[] = []
+ try {
+ albums = await fetchRecentAlbums(fetch)
+ } catch (albumError) {
+ console.error('Error fetching albums:', albumError)
+ }
- // const response = await fetch('/api/giantbomb', {
- // method: 'POST',
- // body: JSON.stringify({ games: psnGames }),
- // headers: {
- // 'Content-Type': 'application/json'
- // }
- // })
+ // Fetch projects
+ let projectsData = { projects: [] as Project[], pagination: null }
+ try {
+ projectsData = await fetchProjects(fetch)
+ } catch (projectError) {
+ console.error('Error fetching projects:', projectError)
+ }
- // const games = await response.json()
return {
- albums
- // games: games,
- // steamGames: steamGames,
- // psnGames: psnGames
+ albums,
+ projects: projectsData.projects || []
}
} catch (err) {
- console.error('Error fetching data:', err)
+ console.error('Error in load function:', err)
return {
albums: [],
- games: [],
- error: err instanceof Error ? err.message : 'An unknown error occurred'
+ projects: []
}
}
}
@@ -57,3 +55,15 @@ async function fetchRecentPSNGames(fetch: typeof window.fetch): Promise {
+ const response = await fetch(
+ '/api/projects?projectType=work&includeListOnly=true&includePasswordProtected=true'
+ )
+ if (!response.ok) {
+ throw new Error(`Failed to fetch projects: ${response.status}`)
+ }
+ return await response.json()
+}
diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte
new file mode 100644
index 0000000..7f77695
--- /dev/null
+++ b/src/routes/about/+page.svelte
@@ -0,0 +1,105 @@
+
+
+
+
+ A little about me
+
+
+
+
+ Hello! My name is Justin Edmund. I'm a software designer and developer living in San
+ Francisco.
+
+
+ Right now, I'm spending my free time building a hobby journaling app called Maitsu. I've spent time at several companies over the last 11 years, but you might know me from
+ Pinterest, where I was the first
+ design hire.
+
+
+ I was born and raised in New York City and spend a lot of time in Tokyo. I graduated from Carnegie Mellon University in 2011 with a Bachelors of Arts in Communication Design.
+
+
+
+
+
+ Notable mentions
+
+
+
+
+
+
+ Now playing
+
+
+
+
+
+
+
+
diff --git a/src/routes/about/+page.ts b/src/routes/about/+page.ts
new file mode 100644
index 0000000..4491e8a
--- /dev/null
+++ b/src/routes/about/+page.ts
@@ -0,0 +1,26 @@
+import type { PageLoad } from './$types'
+import type { Album } from '$lib/types/lastfm'
+
+export const load: PageLoad = async ({ fetch }) => {
+ try {
+ const [albums] = await Promise.all([fetchRecentAlbums(fetch)])
+
+ return {
+ albums
+ }
+ } catch (err) {
+ console.error('Error fetching data:', err)
+ return {
+ albums: [],
+ games: [],
+ error: err instanceof Error ? err.message : 'An unknown error occurred'
+ }
+ }
+}
+
+async function fetchRecentAlbums(fetch: typeof window.fetch): Promise {
+ const response = await fetch('/api/lastfm')
+ if (!response.ok) throw new Error(`Failed to fetch albums: ${response.status}`)
+ const musicData: { albums: Album[] } = await response.json()
+ return musicData.albums
+}
diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte
new file mode 100644
index 0000000..2b98f94
--- /dev/null
+++ b/src/routes/admin/+layout.svelte
@@ -0,0 +1,87 @@
+
+
+{#if isLoading}
+ Loading...
+{:else if !isAuthenticated && currentPath !== '/admin/login'}
+
+ Redirecting to login...
+{:else if currentPath === '/admin/login'}
+
+ {@render children()}
+{:else}
+
+
+
+
+ {#if useCardLayout}
+
+ {@render children()}
+
+ {:else}
+
+ {@render children()}
+
+ {/if}
+
+{/if}
+
+
diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte
new file mode 100644
index 0000000..c3eface
--- /dev/null
+++ b/src/routes/admin/+page.svelte
@@ -0,0 +1,8 @@
+
diff --git a/src/routes/admin/albums/+page.svelte b/src/routes/admin/albums/+page.svelte
new file mode 100644
index 0000000..cc0e006
--- /dev/null
+++ b/src/routes/admin/albums/+page.svelte
@@ -0,0 +1,320 @@
+
+
+
+
+ {#snippet actions()}
+
+ {/snippet}
+
+
+ {#if error}
+ {error}
+ {:else}
+
+
+ {#snippet left()}
+
+ {/snippet}
+
+
+
+ {#if isLoading}
+
+ {:else if filteredAlbums.length === 0}
+
+
+ {#if photographyFilter === 'all'}
+ No albums found. Create your first album!
+ {:else}
+ No albums found matching the current filters. Try adjusting your filters or create a new
+ album.
+ {/if}
+
+
+ {:else}
+
+ {#each filteredAlbums as album}
+
+ {/each}
+
+ {/if}
+ {/if}
+
+
+
+
+
diff --git a/src/routes/admin/albums/[id]/edit/+page.svelte b/src/routes/admin/albums/[id]/edit/+page.svelte
new file mode 100644
index 0000000..e63b98e
--- /dev/null
+++ b/src/routes/admin/albums/[id]/edit/+page.svelte
@@ -0,0 +1,1178 @@
+
+
+
+
+ {#if !isLoading && album}
+
+
+ {/if}
+
+
+ {#if isLoading}
+
+
+
+ {:else if error && !album}
+
+
{error}
+
+
+ {:else if album}
+
+ {/if}
+
+
+
+
+
+
+
+
+
diff --git a/src/routes/admin/albums/new/+page.svelte b/src/routes/admin/albums/new/+page.svelte
new file mode 100644
index 0000000..f730092
--- /dev/null
+++ b/src/routes/admin/albums/new/+page.svelte
@@ -0,0 +1,488 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/routes/admin/buttons/+page.svelte b/src/routes/admin/buttons/+page.svelte
new file mode 100644
index 0000000..b11f284
--- /dev/null
+++ b/src/routes/admin/buttons/+page.svelte
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
diff --git a/src/routes/admin/form-components-test/+page.svelte b/src/routes/admin/form-components-test/+page.svelte
new file mode 100644
index 0000000..2bba19b
--- /dev/null
+++ b/src/routes/admin/form-components-test/+page.svelte
@@ -0,0 +1,293 @@
+
+
+
+
+
+
+ MediaInput Component
+ Generic input component for media selection with preview.
+
+
+
+
+
+
+
+
+
+
+
+
+ ImagePicker Component
+ Specialized image picker with enhanced preview and aspect ratio support.
+
+
+
+
+
+
+
+
+
+
+ GalleryManager Component
+ Multiple image management with drag-and-drop reordering.
+
+
+
+
+
+
+
+
+
+
+ Form Actions
+
+
+
+
+
+
+
+
+ Current Values
+
+
+
Single Media:
+
{JSON.stringify(singleMedia?.filename || null, null, 2)}
+
+
+
+
Multiple Media ({multipleMedia.length}):
+
{JSON.stringify(
+ multipleMedia.map((m) => m.filename),
+ null,
+ 2
+ )}
+
+
+
+
Featured Image:
+
{JSON.stringify(featuredImage?.filename || null, null, 2)}
+
+
+
+
Gallery Images ({galleryImages.length}):
+
{JSON.stringify(
+ galleryImages.map((m) => m.filename),
+ null,
+ 2
+ )}
+
+
+
+
Project Gallery ({projectGallery.length}):
+
{JSON.stringify(
+ projectGallery.map((m) => m.filename),
+ null,
+ 2
+ )}
+
+
+
+
+
+
+
diff --git a/src/routes/admin/image-uploader-test/+page.svelte b/src/routes/admin/image-uploader-test/+page.svelte
new file mode 100644
index 0000000..675a0ed
--- /dev/null
+++ b/src/routes/admin/image-uploader-test/+page.svelte
@@ -0,0 +1,272 @@
+
+
+
+
+
+
+ Basic Image Upload
+ Standard image upload with alt text support.
+
+
+
+
+
+
+ Square Logo Upload
+ Image upload with 1:1 aspect ratio constraint.
+
+
+
+
+
+
+ Banner Image Upload
+ Wide banner image with 16:9 aspect ratio.
+
+
+
+
+
+
+ Actions
+
+
+
+
+
+
+
+
+ Current Values
+
+
+
Single Image:
+
{JSON.stringify(
+ singleImage
+ ? {
+ id: singleImage.id,
+ filename: singleImage.filename,
+ altText: singleImage.altText,
+ description: singleImage.description
+ }
+ : null,
+ null,
+ 2
+ )}
+
+
+
+
Logo Image:
+
{JSON.stringify(
+ logoImage
+ ? {
+ id: logoImage.id,
+ filename: logoImage.filename,
+ altText: logoImage.altText,
+ description: logoImage.description
+ }
+ : null,
+ null,
+ 2
+ )}
+
+
+
+
Banner Image:
+
{JSON.stringify(
+ bannerImage
+ ? {
+ id: bannerImage.id,
+ filename: bannerImage.filename,
+ altText: bannerImage.altText,
+ description: bannerImage.description
+ }
+ : null,
+ null,
+ 2
+ )}
+
+
+
+
+
+
+
diff --git a/src/routes/admin/inputs/+page.svelte b/src/routes/admin/inputs/+page.svelte
new file mode 100644
index 0000000..226f9d3
--- /dev/null
+++ b/src/routes/admin/inputs/+page.svelte
@@ -0,0 +1,257 @@
+
+
+
+
+
+
+
diff --git a/src/routes/admin/login/+page.svelte b/src/routes/admin/login/+page.svelte
new file mode 100644
index 0000000..26025c6
--- /dev/null
+++ b/src/routes/admin/login/+page.svelte
@@ -0,0 +1,122 @@
+
+
+
+
+
diff --git a/src/routes/admin/media-library-test/+page.svelte b/src/routes/admin/media-library-test/+page.svelte
new file mode 100644
index 0000000..a38ccb1
--- /dev/null
+++ b/src/routes/admin/media-library-test/+page.svelte
@@ -0,0 +1,258 @@
+
+
+
+
+
+ Single Selection Mode
+ Test selecting a single media item.
+
+
+
+ {#if selectedSingleMedia}
+
+ {/if}
+
+
+
+ Multiple Selection Mode
+ Test selecting multiple media items.
+
+
+
+ {#if selectedMultipleMedia.length > 0}
+
+ {/if}
+
+
+
+ Image Only Selection
+ Test selecting only image files.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/routes/admin/media/+page.svelte b/src/routes/admin/media/+page.svelte
new file mode 100644
index 0000000..049cc39
--- /dev/null
+++ b/src/routes/admin/media/+page.svelte
@@ -0,0 +1,1106 @@
+
+
+
+
+ {#snippet actions()}
+
+
+
+ {/snippet}
+
+
+ {#if error}
+ {error}
+ {:else}
+
+
+ {#snippet left()}
+
+
+ {/snippet}
+ {#snippet right()}
+ e.key === 'Enter' && handleSearch()}
+ placeholder="Search files..."
+ buttonSize="small"
+ fullWidth={false}
+ pill={true}
+ prefixIcon
+ >
+
+
+ {/snippet}
+
+
+ {#if isMultiSelectMode && media.length > 0}
+
+
+
+
+
+
+ {#if selectedMediaIds.size > 0}
+
+
+
+ {/if}
+
+
+ {/if}
+
+ {#if isLoading}
+ Loading media...
+ {:else if media.length === 0}
+
+
No media files found.
+
+
+ {:else if viewMode === 'grid'}
+
+ {:else}
+
+ {/if}
+
+ {#if totalPages > 1}
+
+ {/if}
+ {/if}
+
+
+
+
+
+
+ (isUploadModalOpen = false)}
+ onUploadComplete={handleUploadComplete}
+/>
+
+
diff --git a/src/routes/admin/media/upload/+page.svelte b/src/routes/admin/media/upload/+page.svelte
new file mode 100644
index 0000000..32514b9
--- /dev/null
+++ b/src/routes/admin/media/upload/+page.svelte
@@ -0,0 +1,571 @@
+
+
+
+
+
+
+
+
0}
+ ondragover={handleDragOver}
+ ondragleave={handleDragLeave}
+ ondrop={handleDrop}
+ >
+
+ {#if files.length === 0}
+
+
Drop images here
+
or click to browse and select files
+
Supports JPG, PNG, GIF, WebP, and SVG files
+ {:else}
+
+
{files.length} file{files.length !== 1 ? 's' : ''} selected
+
Drop more files to add them, or click to browse
+
+ {/if}
+
+
+
+
+
+
+
+
+ {#if files.length > 0}
+
+
+
+
+ {#each files as file, index}
+
+
+ {#if file.type.startsWith('image/')}
+
})
+ {:else}
+
📄
+ {/if}
+
+
+
+
{file.name}
+
{formatFileSize(file.size)}
+
+ {#if uploadProgress[file.name]}
+
+ {/if}
+
+
+ {#if !isUploading}
+
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if successCount > 0 || uploadErrors.length > 0}
+
+ {#if successCount > 0}
+
+ ✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
+ {#if successCount === files.length && uploadErrors.length === 0}
+
Redirecting to media library...
+ {/if}
+
+ {/if}
+
+ {#if uploadErrors.length > 0}
+
+
Upload Errors:
+ {#each uploadErrors as error}
+
❌ {error}
+ {/each}
+
+ {/if}
+
+ {/if}
+
+
+
+
diff --git a/src/routes/admin/posts/+page.svelte b/src/routes/admin/posts/+page.svelte
new file mode 100644
index 0000000..11f641e
--- /dev/null
+++ b/src/routes/admin/posts/+page.svelte
@@ -0,0 +1,264 @@
+
+
+
+
+
+ {#if error}
+ {error}
+ {:else}
+
+ {#if showInlineComposer}
+
+
+
+ {/if}
+
+
+
+ {#snippet left()}
+
+ {/snippet}
+
+
+
+ {#if isLoading}
+
+
+
+ {:else if filteredPosts.length === 0}
+
+
📝
+
No posts found
+
+ {#if selectedFilter === 'all'}
+ Create your first post to get started!
+ {:else}
+ No {selectedFilter}s found. Try a different filter or create a new {selectedFilter}.
+ {/if}
+
+
+ {:else}
+
+ {#each filteredPosts as post}
+
+ {/each}
+
+ {/if}
+ {/if}
+
+
+
diff --git a/src/routes/admin/posts/[id]/edit/+page.svelte b/src/routes/admin/posts/[id]/edit/+page.svelte
new file mode 100644
index 0000000..7432f6c
--- /dev/null
+++ b/src/routes/admin/posts/[id]/edit/+page.svelte
@@ -0,0 +1,680 @@
+
+
+
+
+ {#if !loading && post}
+
+
+ {/if}
+
+
+ {#if loading}
+
+
+
+ {:else if loadError}
+
+
Error Loading Post
+
{loadError}
+
+
+ {:else if post}
+
+
+ {#if config?.showTitle}
+
+ {/if}
+
+ {#if config?.showContent}
+
+
+
+ {/if}
+
+
+ {:else}
+ Post not found
+ {/if}
+
+
+ (showDeleteConfirmation = false)}
+/>
+
+
diff --git a/src/routes/admin/posts/new/+page.svelte b/src/routes/admin/posts/new/+page.svelte
new file mode 100644
index 0000000..a20f5b6
--- /dev/null
+++ b/src/routes/admin/posts/new/+page.svelte
@@ -0,0 +1,342 @@
+
+
+
+
+
+
+
+ {#if config?.showTitle}
+
+ {/if}
+
+ {#if config?.showContent}
+
+
+
+ {/if}
+
+
+
+
+
diff --git a/src/routes/admin/projects/+page.svelte b/src/routes/admin/projects/+page.svelte
new file mode 100644
index 0000000..082bcd0
--- /dev/null
+++ b/src/routes/admin/projects/+page.svelte
@@ -0,0 +1,313 @@
+
+
+
+
+ {#snippet actions()}
+
+ {/snippet}
+
+
+ {#if error}
+ {error}
+ {:else}
+
+
+ {#snippet left()}
+
+
+ {/snippet}
+
+
+
+ {#if isLoading}
+
+
+
Loading projects...
+
+ {:else if filteredProjects.length === 0}
+
+
+ {#if selectedStatusFilter === 'all' && selectedTypeFilter === 'all'}
+ No projects found. Create your first project!
+ {:else}
+ No projects found matching the current filters. Try adjusting your filters or create a
+ new project.
+ {/if}
+
+
+ {:else}
+
+ {#each filteredProjects as project}
+
+ {/each}
+
+ {/if}
+ {/if}
+
+
+
+
+
diff --git a/src/routes/admin/projects/[id]/edit/+page.svelte b/src/routes/admin/projects/[id]/edit/+page.svelte
new file mode 100644
index 0000000..2e98dcb
--- /dev/null
+++ b/src/routes/admin/projects/[id]/edit/+page.svelte
@@ -0,0 +1,66 @@
+
+
+{#if isLoading}
+ Loading project...
+{:else if error}
+ {error}
+{:else if !project}
+ Project not found
+{:else}
+
+{/if}
+
+
diff --git a/src/routes/admin/projects/new/+page.svelte b/src/routes/admin/projects/new/+page.svelte
new file mode 100644
index 0000000..13e9a21
--- /dev/null
+++ b/src/routes/admin/projects/new/+page.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/routes/admin/test-upload/+page.svelte b/src/routes/admin/test-upload/+page.svelte
new file mode 100644
index 0000000..c435e89
--- /dev/null
+++ b/src/routes/admin/test-upload/+page.svelte
@@ -0,0 +1,371 @@
+
+
+
+
+
+
+
+
Image Upload Test
+
This page helps you test that image uploads are working correctly.
+
+ {#if localUploadsExist}
+
✅ Local uploads directory is configured
+ {:else}
+
⚠️ No local uploads found yet
+ {/if}
+
+
+
How to test:
+
+ - Copy an image to your clipboard
+ - Click in the editor below and paste (Cmd+V)
+ - Or click the image placeholder to browse files
+ - Or drag and drop an image onto the placeholder
+
+
+
+
+
+
Editor with Image Upload
+
+
+
+ {#if uploadedImages.length > 0}
+
+
Uploaded Images
+
+ {#each uploadedImages as image}
+
+

+
+ {image.timestamp}
+ {image.url}
+ {#if image.url.includes('/local-uploads/')}
+ Local
+ {:else if image.url.includes('cloudinary')}
+ Cloudinary
+ {:else}
+ Unknown
+ {/if}
+
+
+ {/each}
+
+
+ {/if}
+
+
+
Editor Content (JSON)
+
{JSON.stringify(testContent, null, 2)}
+
+
+
+
+
diff --git a/src/routes/admin/universe/compose/+page.svelte b/src/routes/admin/universe/compose/+page.svelte
new file mode 100644
index 0000000..be51d97
--- /dev/null
+++ b/src/routes/admin/universe/compose/+page.svelte
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/src/routes/api/albums/+server.ts b/src/routes/api/albums/+server.ts
new file mode 100644
index 0000000..6850832
--- /dev/null
+++ b/src/routes/api/albums/+server.ts
@@ -0,0 +1,128 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import {
+ jsonResponse,
+ errorResponse,
+ getPaginationParams,
+ getPaginationMeta,
+ checkAdminAuth,
+ parseRequestBody
+} from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+
+// GET /api/albums - List all albums
+export const GET: RequestHandler = async (event) => {
+ try {
+ const { page, limit } = getPaginationParams(event.url)
+ const skip = (page - 1) * limit
+
+ // Get filter parameters
+ const status = event.url.searchParams.get('status')
+ const isPhotography = event.url.searchParams.get('isPhotography')
+
+ // Build where clause
+ const where: any = {}
+ if (status) {
+ where.status = status
+ }
+
+ if (isPhotography !== null) {
+ where.isPhotography = isPhotography === 'true'
+ }
+
+ // Get total count
+ const total = await prisma.album.count({ where })
+
+ // Get albums with photo count and photos for thumbnails
+ const albums = await prisma.album.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ skip,
+ take: limit,
+ include: {
+ photos: {
+ select: {
+ id: true,
+ url: true,
+ thumbnailUrl: true,
+ caption: true
+ },
+ orderBy: { displayOrder: 'asc' },
+ take: 5 // Only get first 5 photos for thumbnails
+ },
+ _count: {
+ select: { photos: true }
+ }
+ }
+ })
+
+ const pagination = getPaginationMeta(total, page, limit)
+
+ logger.info('Albums list retrieved', { total, page, limit })
+
+ return jsonResponse({
+ albums,
+ pagination
+ })
+ } catch (error) {
+ logger.error('Failed to retrieve albums', error as Error)
+ return errorResponse('Failed to retrieve albums', 500)
+ }
+}
+
+// POST /api/albums - Create a new album
+export const POST: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ try {
+ const body = await parseRequestBody<{
+ slug: string
+ title: string
+ description?: string
+ date?: string
+ location?: string
+ coverPhotoId?: number
+ isPhotography?: boolean
+ status?: string
+ showInUniverse?: boolean
+ }>(event.request)
+
+ if (!body || !body.slug || !body.title) {
+ return errorResponse('Missing required fields: slug, title', 400)
+ }
+
+ // Check if slug already exists
+ const existing = await prisma.album.findUnique({
+ where: { slug: body.slug }
+ })
+
+ if (existing) {
+ return errorResponse('Album with this slug already exists', 409)
+ }
+
+ // Create album
+ const album = await prisma.album.create({
+ data: {
+ slug: body.slug,
+ title: body.title,
+ description: body.description,
+ date: body.date ? new Date(body.date) : null,
+ location: body.location,
+ coverPhotoId: body.coverPhotoId,
+ isPhotography: body.isPhotography ?? false,
+ status: body.status ?? 'draft',
+ showInUniverse: body.showInUniverse ?? false
+ }
+ })
+
+ logger.info('Album created', { id: album.id, slug: album.slug })
+
+ return jsonResponse(album, 201)
+ } catch (error) {
+ logger.error('Failed to create album', error as Error)
+ return errorResponse('Failed to create album', 500)
+ }
+}
diff --git a/src/routes/api/albums/[id]/+server.ts b/src/routes/api/albums/[id]/+server.ts
new file mode 100644
index 0000000..33d6d59
--- /dev/null
+++ b/src/routes/api/albums/[id]/+server.ts
@@ -0,0 +1,202 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import {
+ jsonResponse,
+ errorResponse,
+ checkAdminAuth,
+ parseRequestBody
+} from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+
+// GET /api/albums/[id] - Get a single album
+export const GET: RequestHandler = async (event) => {
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid album ID', 400)
+ }
+
+ try {
+ const album = await prisma.album.findUnique({
+ where: { id },
+ include: {
+ photos: {
+ orderBy: { displayOrder: 'asc' }
+ },
+ _count: {
+ select: { photos: true }
+ }
+ }
+ })
+
+ if (!album) {
+ return errorResponse('Album not found', 404)
+ }
+
+ // Get all media usage records for this album's photos in one query
+ const mediaUsages = await prisma.mediaUsage.findMany({
+ where: {
+ contentType: 'album',
+ contentId: album.id,
+ fieldName: 'photos'
+ },
+ include: {
+ media: true
+ }
+ })
+
+ // Create a map of media by mediaId for efficient lookup
+ const mediaMap = new Map()
+ mediaUsages.forEach((usage) => {
+ if (usage.media) {
+ mediaMap.set(usage.mediaId, usage.media)
+ }
+ })
+
+ // Enrich photos with media information using proper media usage tracking
+ const photosWithMedia = album.photos.map((photo) => {
+ // Find the corresponding media usage record for this photo
+ const usage = mediaUsages.find((u) => u.media && u.media.filename === photo.filename)
+ const media = usage?.media
+
+ return {
+ ...photo,
+ mediaId: media?.id || null,
+ altText: media?.altText || '',
+ description: media?.description || photo.caption || '',
+ isPhotography: media?.isPhotography || false,
+ mimeType: media?.mimeType || 'image/jpeg',
+ size: media?.size || 0
+ }
+ })
+
+ const albumWithEnrichedPhotos = {
+ ...album,
+ photos: photosWithMedia
+ }
+
+ return jsonResponse(albumWithEnrichedPhotos)
+ } catch (error) {
+ logger.error('Failed to retrieve album', error as Error)
+ return errorResponse('Failed to retrieve album', 500)
+ }
+}
+
+// PUT /api/albums/[id] - Update an album
+export const PUT: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid album ID', 400)
+ }
+
+ try {
+ const body = await parseRequestBody<{
+ slug?: string
+ title?: string
+ description?: string
+ date?: string
+ location?: string
+ coverPhotoId?: number
+ isPhotography?: boolean
+ status?: string
+ showInUniverse?: boolean
+ }>(event.request)
+
+ if (!body) {
+ return errorResponse('Invalid request body', 400)
+ }
+
+ // Check if album exists
+ const existing = await prisma.album.findUnique({
+ where: { id }
+ })
+
+ if (!existing) {
+ return errorResponse('Album not found', 404)
+ }
+
+ // If slug is being updated, check for conflicts
+ if (body.slug && body.slug !== existing.slug) {
+ const slugExists = await prisma.album.findUnique({
+ where: { slug: body.slug }
+ })
+
+ if (slugExists) {
+ return errorResponse('Album with this slug already exists', 409)
+ }
+ }
+
+ // Update album
+ const album = await prisma.album.update({
+ where: { id },
+ data: {
+ slug: body.slug ?? existing.slug,
+ title: body.title ?? existing.title,
+ description: body.description !== undefined ? body.description : existing.description,
+ date: body.date !== undefined ? (body.date ? new Date(body.date) : null) : existing.date,
+ location: body.location !== undefined ? body.location : existing.location,
+ coverPhotoId: body.coverPhotoId !== undefined ? body.coverPhotoId : existing.coverPhotoId,
+ isPhotography: body.isPhotography ?? existing.isPhotography,
+ status: body.status ?? existing.status,
+ showInUniverse: body.showInUniverse ?? existing.showInUniverse
+ }
+ })
+
+ logger.info('Album updated', { id, slug: album.slug })
+
+ return jsonResponse(album)
+ } catch (error) {
+ logger.error('Failed to update album', error as Error)
+ return errorResponse('Failed to update album', 500)
+ }
+}
+
+// DELETE /api/albums/[id] - Delete an album
+export const DELETE: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid album ID', 400)
+ }
+
+ try {
+ // Check if album exists
+ const album = await prisma.album.findUnique({
+ where: { id },
+ include: {
+ _count: {
+ select: { photos: true }
+ }
+ }
+ })
+
+ if (!album) {
+ return errorResponse('Album not found', 404)
+ }
+
+ // Check if album has photos
+ if (album._count.photos > 0) {
+ return errorResponse('Cannot delete album that contains photos', 409)
+ }
+
+ // Delete album
+ await prisma.album.delete({
+ where: { id }
+ })
+
+ logger.info('Album deleted', { id, slug: album.slug })
+
+ return new Response(null, { status: 204 })
+ } catch (error) {
+ logger.error('Failed to delete album', error as Error)
+ return errorResponse('Failed to delete album', 500)
+ }
+}
diff --git a/src/routes/api/albums/[id]/photos/+server.ts b/src/routes/api/albums/[id]/photos/+server.ts
new file mode 100644
index 0000000..57fe259
--- /dev/null
+++ b/src/routes/api/albums/[id]/photos/+server.ts
@@ -0,0 +1,254 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import {
+ jsonResponse,
+ errorResponse,
+ checkAdminAuth,
+ parseRequestBody
+} from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+
+// POST /api/albums/[id]/photos - Add a photo to an album
+export const POST: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ const albumId = parseInt(event.params.id)
+ if (isNaN(albumId)) {
+ return errorResponse('Invalid album ID', 400)
+ }
+
+ try {
+ const body = await parseRequestBody<{
+ mediaId: number
+ displayOrder?: number
+ }>(event.request)
+
+ if (!body || !body.mediaId) {
+ return errorResponse('Media ID is required', 400)
+ }
+
+ // Check if album exists
+ const album = await prisma.album.findUnique({
+ where: { id: albumId }
+ })
+
+ if (!album) {
+ return errorResponse('Album not found', 404)
+ }
+
+ // Check if media exists
+ const media = await prisma.media.findUnique({
+ where: { id: body.mediaId }
+ })
+
+ if (!media) {
+ return errorResponse('Media not found', 404)
+ }
+
+ // Check if media is already an image type
+ if (!media.mimeType.startsWith('image/')) {
+ return errorResponse('Only images can be added to albums', 400)
+ }
+
+ // Get the next display order if not provided
+ let displayOrder = body.displayOrder
+ if (displayOrder === undefined) {
+ const lastPhoto = await prisma.photo.findFirst({
+ where: { albumId },
+ orderBy: { displayOrder: 'desc' }
+ })
+ displayOrder = (lastPhoto?.displayOrder || 0) + 1
+ }
+
+ // Create photo record from media
+ const photo = await prisma.photo.create({
+ data: {
+ albumId,
+ filename: media.filename,
+ url: media.url,
+ thumbnailUrl: media.thumbnailUrl,
+ width: media.width,
+ height: media.height,
+ caption: media.description, // Use media description as initial caption
+ displayOrder,
+ status: 'published', // Photos in albums are published by default
+ showInPhotos: true
+ }
+ })
+
+ // Track media usage
+ await prisma.mediaUsage.create({
+ data: {
+ mediaId: body.mediaId,
+ contentType: 'album',
+ contentId: albumId,
+ fieldName: 'photos'
+ }
+ })
+
+ logger.info('Photo added to album', {
+ albumId,
+ photoId: photo.id,
+ mediaId: body.mediaId
+ })
+
+ // Return photo with media information for frontend compatibility
+ const photoWithMedia = {
+ ...photo,
+ mediaId: body.mediaId,
+ altText: media.altText,
+ description: media.description,
+ isPhotography: media.isPhotography,
+ mimeType: media.mimeType,
+ size: media.size
+ }
+
+ return jsonResponse(photoWithMedia)
+ } catch (error) {
+ logger.error('Failed to add photo to album', error as Error)
+ return errorResponse('Failed to add photo to album', 500)
+ }
+}
+
+// PUT /api/albums/[id]/photos - Update photo order in album
+export const PUT: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ const albumId = parseInt(event.params.id)
+ if (isNaN(albumId)) {
+ return errorResponse('Invalid album ID', 400)
+ }
+
+ try {
+ const body = await parseRequestBody<{
+ photoId: number
+ displayOrder: number
+ }>(event.request)
+
+ if (!body || !body.photoId || body.displayOrder === undefined) {
+ return errorResponse('Photo ID and display order are required', 400)
+ }
+
+ // Check if album exists
+ const album = await prisma.album.findUnique({
+ where: { id: albumId }
+ })
+
+ if (!album) {
+ return errorResponse('Album not found', 404)
+ }
+
+ // Update photo display order
+ const photo = await prisma.photo.update({
+ where: {
+ id: body.photoId,
+ albumId // Ensure photo belongs to this album
+ },
+ data: {
+ displayOrder: body.displayOrder
+ }
+ })
+
+ logger.info('Photo order updated', {
+ albumId,
+ photoId: body.photoId,
+ displayOrder: body.displayOrder
+ })
+
+ return jsonResponse(photo)
+ } catch (error) {
+ logger.error('Failed to update photo order', error as Error)
+ return errorResponse('Failed to update photo order', 500)
+ }
+}
+
+// DELETE /api/albums/[id]/photos - Remove a photo from an album (without deleting the media)
+export const DELETE: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ const albumId = parseInt(event.params.id)
+ if (isNaN(albumId)) {
+ return errorResponse('Invalid album ID', 400)
+ }
+
+ try {
+ const url = new URL(event.request.url)
+ const photoId = url.searchParams.get('photoId')
+
+ logger.info('DELETE photo request', { albumId, photoId })
+
+ if (!photoId || isNaN(parseInt(photoId))) {
+ return errorResponse('Photo ID is required as query parameter', 400)
+ }
+
+ const photoIdNum = parseInt(photoId)
+
+ // Check if album exists
+ const album = await prisma.album.findUnique({
+ where: { id: albumId }
+ })
+
+ if (!album) {
+ logger.error('Album not found', { albumId })
+ return errorResponse('Album not found', 404)
+ }
+
+ // Check if photo exists in this album
+ const photo = await prisma.photo.findFirst({
+ where: {
+ id: photoIdNum,
+ albumId: albumId // Ensure photo belongs to this album
+ }
+ })
+
+ logger.info('Photo lookup result', { photoIdNum, albumId, found: !!photo })
+
+ if (!photo) {
+ logger.error('Photo not found in album', { photoIdNum, albumId })
+ return errorResponse('Photo not found in this album', 404)
+ }
+
+ // Find and remove the specific media usage record for this photo
+ // We need to find the media ID associated with this photo to remove the correct usage record
+ const mediaUsage = await prisma.mediaUsage.findFirst({
+ where: {
+ contentType: 'album',
+ contentId: albumId,
+ fieldName: 'photos',
+ media: {
+ filename: photo.filename // Match by filename since that's how they're linked
+ }
+ }
+ })
+
+ if (mediaUsage) {
+ await prisma.mediaUsage.delete({
+ where: { id: mediaUsage.id }
+ })
+ }
+
+ // Delete the photo record (this removes it from the album but keeps the media)
+ await prisma.photo.delete({
+ where: { id: photoIdNum }
+ })
+
+ logger.info('Photo removed from album', {
+ photoId: photoIdNum,
+ albumId: albumId
+ })
+
+ return new Response(null, { status: 204 })
+ } catch (error) {
+ logger.error('Failed to remove photo from album', error as Error)
+ return errorResponse('Failed to remove photo from album', 500)
+ }
+}
diff --git a/src/routes/api/albums/by-slug/[slug]/+server.ts b/src/routes/api/albums/by-slug/[slug]/+server.ts
new file mode 100644
index 0000000..8937091
--- /dev/null
+++ b/src/routes/api/albums/by-slug/[slug]/+server.ts
@@ -0,0 +1,57 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { jsonResponse, errorResponse } from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+
+// GET /api/albums/by-slug/[slug] - Get album by slug including photos
+export const GET: RequestHandler = async (event) => {
+ const slug = event.params.slug
+
+ if (!slug) {
+ return errorResponse('Invalid album slug', 400)
+ }
+
+ try {
+ const album = await prisma.album.findUnique({
+ where: { slug },
+ include: {
+ photos: {
+ where: {
+ status: 'published',
+ showInPhotos: true
+ },
+ orderBy: { displayOrder: 'asc' },
+ select: {
+ id: true,
+ filename: true,
+ url: true,
+ thumbnailUrl: true,
+ width: true,
+ height: true,
+ caption: true,
+ displayOrder: true
+ }
+ },
+ _count: {
+ select: {
+ photos: {
+ where: {
+ status: 'published',
+ showInPhotos: true
+ }
+ }
+ }
+ }
+ }
+ })
+
+ if (!album) {
+ return errorResponse('Album not found', 404)
+ }
+
+ return jsonResponse(album)
+ } catch (error) {
+ logger.error('Failed to retrieve album by slug', error as Error)
+ return errorResponse('Failed to retrieve album', 500)
+ }
+}
diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts
new file mode 100644
index 0000000..60d1737
--- /dev/null
+++ b/src/routes/api/health/+server.ts
@@ -0,0 +1,28 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { isCloudinaryConfigured } from '$lib/server/cloudinary'
+import { jsonResponse, errorResponse } from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+
+export const GET: RequestHandler = async () => {
+ try {
+ // Test database connection
+ await prisma.$queryRaw`SELECT 1`
+
+ const health = {
+ status: 'ok',
+ timestamp: new Date().toISOString(),
+ services: {
+ database: 'connected',
+ redis: 'not configured', // We'll add this later
+ cloudinary: isCloudinaryConfigured() ? 'configured' : 'not configured'
+ }
+ }
+
+ logger.info('Health check passed', health)
+ return jsonResponse(health)
+ } catch (error) {
+ logger.error('Health check failed', error as Error)
+ return errorResponse('Service unavailable', 503)
+ }
+}
diff --git a/src/routes/api/media/+server.ts b/src/routes/api/media/+server.ts
new file mode 100644
index 0000000..86f520c
--- /dev/null
+++ b/src/routes/api/media/+server.ts
@@ -0,0 +1,84 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import {
+ jsonResponse,
+ errorResponse,
+ getPaginationParams,
+ getPaginationMeta,
+ checkAdminAuth
+} from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+
+// GET /api/media - List all media with pagination and filters
+export const GET: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ try {
+ const { page, limit } = getPaginationParams(event.url)
+ const skip = (page - 1) * limit
+
+ // Get filter parameters
+ const mimeType = event.url.searchParams.get('mimeType')
+ const unused = event.url.searchParams.get('unused') === 'true'
+ const search = event.url.searchParams.get('search')
+ const isPhotography = event.url.searchParams.get('isPhotography')
+
+ // Build where clause
+ const where: any = {}
+
+ if (mimeType) {
+ where.mimeType = { startsWith: mimeType }
+ }
+
+ if (unused) {
+ where.usedIn = { equals: [] }
+ }
+
+ if (search) {
+ where.filename = { contains: search, mode: 'insensitive' }
+ }
+
+ if (isPhotography !== null) {
+ where.isPhotography = isPhotography === 'true'
+ }
+
+ // Get total count
+ const total = await prisma.media.count({ where })
+
+ // Get media items
+ const media = await prisma.media.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ skip,
+ take: limit,
+ select: {
+ id: true,
+ filename: true,
+ mimeType: true,
+ size: true,
+ url: true,
+ thumbnailUrl: true,
+ width: true,
+ height: true,
+ usedIn: true,
+ isPhotography: true,
+ createdAt: true
+ }
+ })
+
+ const pagination = getPaginationMeta(total, page, limit)
+
+ logger.info('Media list retrieved', { total, page, limit })
+
+ return jsonResponse({
+ media,
+ pagination
+ })
+ } catch (error) {
+ logger.error('Failed to retrieve media', error as Error)
+ return errorResponse('Failed to retrieve media', 500)
+ }
+}
diff --git a/src/routes/api/media/[id]/+server.ts b/src/routes/api/media/[id]/+server.ts
new file mode 100644
index 0000000..4c59496
--- /dev/null
+++ b/src/routes/api/media/[id]/+server.ts
@@ -0,0 +1,134 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { deleteFile, extractPublicId } from '$lib/server/cloudinary'
+import {
+ jsonResponse,
+ errorResponse,
+ checkAdminAuth,
+ parseRequestBody
+} from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+
+// GET /api/media/[id] - Get a single media item
+export const GET: RequestHandler = async (event) => {
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid media ID', 400)
+ }
+
+ try {
+ const media = await prisma.media.findUnique({
+ where: { id }
+ })
+
+ if (!media) {
+ return errorResponse('Media not found', 404)
+ }
+
+ return jsonResponse(media)
+ } catch (error) {
+ logger.error('Failed to retrieve media', error as Error)
+ return errorResponse('Failed to retrieve media', 500)
+ }
+}
+
+// PUT /api/media/[id] - Update a media item
+export const PUT: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid media ID', 400)
+ }
+
+ try {
+ const body = await parseRequestBody<{
+ altText?: string
+ description?: string
+ isPhotography?: boolean
+ }>(event.request)
+
+ if (!body) {
+ return errorResponse('Invalid request body', 400)
+ }
+
+ // Check if media exists
+ const existing = await prisma.media.findUnique({
+ where: { id }
+ })
+
+ if (!existing) {
+ return errorResponse('Media not found', 404)
+ }
+
+ // Update media
+ const media = await prisma.media.update({
+ where: { id },
+ data: {
+ altText: body.altText ?? existing.altText,
+ description: body.description ?? existing.description,
+ isPhotography: body.isPhotography ?? existing.isPhotography
+ }
+ })
+
+ logger.info('Media updated', { id, filename: media.filename })
+
+ return jsonResponse(media)
+ } catch (error) {
+ logger.error('Failed to update media', error as Error)
+ return errorResponse('Failed to update media', 500)
+ }
+}
+
+// DELETE /api/media/[id] - Delete a media item
+export const DELETE: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid media ID', 400)
+ }
+
+ try {
+ // Get the media item
+ const media = await prisma.media.findUnique({
+ where: { id }
+ })
+
+ if (!media) {
+ return errorResponse('Media not found', 404)
+ }
+
+ // Check if media is in use
+ if (media.usedIn && (media.usedIn as any[]).length > 0) {
+ return errorResponse('Cannot delete media that is in use', 409)
+ }
+
+ // Delete from Cloudinary
+ const publicId = extractPublicId(media.url)
+ if (publicId) {
+ const deleted = await deleteFile(publicId)
+ if (!deleted) {
+ logger.warn('Failed to delete from Cloudinary', { publicId, mediaId: id })
+ }
+ }
+
+ // Delete from database
+ await prisma.media.delete({
+ where: { id }
+ })
+
+ logger.info('Media deleted', { id, filename: media.filename })
+
+ return new Response(null, { status: 204 })
+ } catch (error) {
+ logger.error('Failed to delete media', error as Error)
+ return errorResponse('Failed to delete media', 500)
+ }
+}
diff --git a/src/routes/api/media/[id]/metadata/+server.ts b/src/routes/api/media/[id]/metadata/+server.ts
new file mode 100644
index 0000000..d879cac
--- /dev/null
+++ b/src/routes/api/media/[id]/metadata/+server.ts
@@ -0,0 +1,76 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+
+// Update media metadata (alt text, description)
+export const PATCH: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ try {
+ const { id } = event.params
+ const mediaId = parseInt(id)
+
+ if (isNaN(mediaId)) {
+ return errorResponse('Invalid media ID', 400)
+ }
+
+ const body = await event.request.json()
+ const { altText, description } = body
+
+ // Validate input
+ if (typeof altText !== 'string' && typeof description !== 'string') {
+ return errorResponse('Either altText or description must be provided', 400)
+ }
+
+ // Check if media exists
+ const existingMedia = await prisma.media.findUnique({
+ where: { id: mediaId }
+ })
+
+ if (!existingMedia) {
+ return errorResponse('Media not found', 404)
+ }
+
+ // Update media metadata
+ const updatedMedia = await prisma.media.update({
+ where: { id: mediaId },
+ data: {
+ ...(typeof altText === 'string' && { altText: altText.trim() || null }),
+ ...(typeof description === 'string' && { description: description.trim() || null })
+ }
+ })
+
+ logger.info('Media metadata updated', {
+ id: mediaId,
+ filename: updatedMedia.filename,
+ hasAltText: !!updatedMedia.altText,
+ hasDescription: !!updatedMedia.description
+ })
+
+ return jsonResponse({
+ id: updatedMedia.id,
+ altText: updatedMedia.altText,
+ description: updatedMedia.description,
+ updatedAt: updatedMedia.updatedAt
+ })
+ } catch (error) {
+ logger.error('Media metadata update error', error as Error)
+ return errorResponse('Failed to update media metadata', 500)
+ }
+}
+
+// Handle preflight requests
+export const OPTIONS: RequestHandler = async () => {
+ return new Response(null, {
+ status: 204,
+ headers: {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'PATCH, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
+ }
+ })
+}
diff --git a/src/routes/api/media/[id]/usage/+server.ts b/src/routes/api/media/[id]/usage/+server.ts
new file mode 100644
index 0000000..e715977
--- /dev/null
+++ b/src/routes/api/media/[id]/usage/+server.ts
@@ -0,0 +1,55 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+import { getMediaUsage } from '$lib/server/media-usage.js'
+
+// GET /api/media/[id]/usage - Check where media is used
+export const GET: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid media ID', 400)
+ }
+
+ try {
+ const media = await prisma.media.findUnique({
+ where: { id },
+ select: {
+ id: true,
+ filename: true,
+ url: true,
+ altText: true,
+ description: true,
+ isPhotography: true
+ }
+ })
+
+ if (!media) {
+ return errorResponse('Media not found', 404)
+ }
+
+ // Get detailed usage information using our new tracking system
+ const usage = await getMediaUsage(id)
+
+ return jsonResponse({
+ media: {
+ id: media.id,
+ filename: media.filename,
+ url: media.url,
+ altText: media.altText,
+ description: media.description,
+ isPhotography: media.isPhotography
+ },
+ usage: usage,
+ isUsed: usage.length > 0
+ })
+ } catch (error) {
+ logger.error('Failed to check media usage', error as Error)
+ return errorResponse('Failed to check media usage', 500)
+ }
+}
diff --git a/src/routes/api/media/backfill-usage/+server.ts b/src/routes/api/media/backfill-usage/+server.ts
new file mode 100644
index 0000000..4b365cc
--- /dev/null
+++ b/src/routes/api/media/backfill-usage/+server.ts
@@ -0,0 +1,147 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+import {
+ trackMediaUsage,
+ extractMediaIds,
+ removeMediaUsage,
+ type MediaUsageReference
+} from '$lib/server/media-usage.js'
+
+// POST /api/media/backfill-usage - Backfill media usage tracking for all content
+export const POST: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ try {
+ let totalTracked = 0
+ const usageReferences: MediaUsageReference[] = []
+
+ // Clear all existing usage tracking
+ await prisma.mediaUsage.deleteMany({})
+
+ // Backfill projects
+ const projects = await prisma.project.findMany({
+ select: {
+ id: true,
+ featuredImage: true,
+ logoUrl: true,
+ gallery: true,
+ caseStudyContent: true
+ }
+ })
+
+ for (const project of projects) {
+ // Track featured image
+ const featuredImageIds = extractMediaIds(project, 'featuredImage')
+ featuredImageIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'project',
+ contentId: project.id,
+ fieldName: 'featuredImage'
+ })
+ })
+
+ // Track logo
+ const logoIds = extractMediaIds(project, 'logoUrl')
+ logoIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'project',
+ contentId: project.id,
+ fieldName: 'logoUrl'
+ })
+ })
+
+ // Track gallery images
+ const galleryIds = extractMediaIds(project, 'gallery')
+ galleryIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'project',
+ contentId: project.id,
+ fieldName: 'gallery'
+ })
+ })
+
+ // Track media in case study content
+ const contentIds = extractMediaIds(project, 'caseStudyContent')
+ contentIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'project',
+ contentId: project.id,
+ fieldName: 'content'
+ })
+ })
+ }
+
+ // Backfill posts
+ const posts = await prisma.post.findMany({
+ select: {
+ id: true,
+ featuredImage: true,
+ content: true,
+ attachments: true
+ }
+ })
+
+ for (const post of posts) {
+ // Track featured image
+ const featuredImageIds = extractMediaIds(post, 'featuredImage')
+ featuredImageIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'post',
+ contentId: post.id,
+ fieldName: 'featuredImage'
+ })
+ })
+
+ // Track attachments
+ const attachmentIds = extractMediaIds(post, 'attachments')
+ attachmentIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'post',
+ contentId: post.id,
+ fieldName: 'attachments'
+ })
+ })
+
+ // Track media in post content
+ const contentIds = extractMediaIds(post, 'content')
+ contentIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'post',
+ contentId: post.id,
+ fieldName: 'content'
+ })
+ })
+ }
+
+ // Save all usage references
+ if (usageReferences.length > 0) {
+ await trackMediaUsage(usageReferences)
+ totalTracked = usageReferences.length
+ }
+
+ logger.info('Media usage backfill completed', { totalTracked })
+
+ return jsonResponse({
+ success: true,
+ message: 'Media usage tracking backfilled successfully',
+ totalTracked,
+ projectsProcessed: projects.length,
+ postsProcessed: posts.length
+ })
+ } catch (error) {
+ logger.error('Failed to backfill media usage', error as Error)
+ return errorResponse('Failed to backfill media usage', 500)
+ }
+}
diff --git a/src/routes/api/media/bulk-delete/+server.ts b/src/routes/api/media/bulk-delete/+server.ts
new file mode 100644
index 0000000..815739f
--- /dev/null
+++ b/src/routes/api/media/bulk-delete/+server.ts
@@ -0,0 +1,239 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import {
+ jsonResponse,
+ errorResponse,
+ checkAdminAuth,
+ parseRequestBody
+} from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+import { removeMediaUsage, extractMediaIds } from '$lib/server/media-usage.js'
+
+// DELETE /api/media/bulk-delete - Delete multiple media files and clean up references
+export const DELETE: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ try {
+ const body = await parseRequestBody<{ mediaIds: number[] }>(event.request)
+ if (!body || !Array.isArray(body.mediaIds) || body.mediaIds.length === 0) {
+ return errorResponse('Invalid request body. Expected array of media IDs.', 400)
+ }
+
+ const mediaIds = body.mediaIds.filter((id) => typeof id === 'number' && !isNaN(id))
+ if (mediaIds.length === 0) {
+ return errorResponse('No valid media IDs provided', 400)
+ }
+
+ // Get media records before deletion to extract URLs for cleanup
+ const mediaRecords = await prisma.media.findMany({
+ where: { id: { in: mediaIds } },
+ select: { id: true, url: true, thumbnailUrl: true, filename: true }
+ })
+
+ if (mediaRecords.length === 0) {
+ return errorResponse('No media files found with the provided IDs', 404)
+ }
+
+ // Remove media usage tracking for all affected media
+ for (const mediaId of mediaIds) {
+ await prisma.mediaUsage.deleteMany({
+ where: { mediaId }
+ })
+ }
+
+ // Clean up references in content that uses these media files
+ await cleanupMediaReferences(mediaIds)
+
+ // Delete the media records from database
+ const deleteResult = await prisma.media.deleteMany({
+ where: { id: { in: mediaIds } }
+ })
+
+ logger.info('Bulk media deletion completed', {
+ deletedCount: deleteResult.count,
+ mediaIds,
+ filenames: mediaRecords.map((m) => m.filename)
+ })
+
+ return jsonResponse({
+ success: true,
+ message: `Successfully deleted ${deleteResult.count} media file${deleteResult.count > 1 ? 's' : ''}`,
+ deletedCount: deleteResult.count,
+ deletedFiles: mediaRecords.map((m) => ({ id: m.id, filename: m.filename }))
+ })
+ } catch (error) {
+ logger.error('Failed to bulk delete media files', error as Error)
+ return errorResponse('Failed to delete media files', 500)
+ }
+}
+
+/**
+ * Clean up references to deleted media in all content types
+ */
+async function cleanupMediaReferences(mediaIds: number[]) {
+ const mediaUrls = await prisma.media.findMany({
+ where: { id: { in: mediaIds } },
+ select: { url: true }
+ })
+ const urlsToRemove = mediaUrls.map((m) => m.url)
+
+ // Clean up projects
+ const projects = await prisma.project.findMany({
+ select: {
+ id: true,
+ featuredImage: true,
+ logoUrl: true,
+ gallery: true,
+ caseStudyContent: true
+ }
+ })
+
+ for (const project of projects) {
+ let needsUpdate = false
+ const updateData: any = {}
+
+ // Check featured image
+ if (project.featuredImage && urlsToRemove.includes(project.featuredImage)) {
+ updateData.featuredImage = null
+ needsUpdate = true
+ }
+
+ // Check logo URL
+ if (project.logoUrl && urlsToRemove.includes(project.logoUrl)) {
+ updateData.logoUrl = null
+ needsUpdate = true
+ }
+
+ // Check gallery
+ if (project.gallery && Array.isArray(project.gallery)) {
+ const filteredGallery = project.gallery.filter((item: any) => {
+ const itemId = typeof item === 'object' ? item.id : parseInt(item)
+ return !mediaIds.includes(itemId)
+ })
+ if (filteredGallery.length !== project.gallery.length) {
+ updateData.gallery = filteredGallery.length > 0 ? filteredGallery : null
+ needsUpdate = true
+ }
+ }
+
+ // Check case study content
+ if (project.caseStudyContent) {
+ const cleanedContent = cleanContentFromMedia(project.caseStudyContent, mediaIds, urlsToRemove)
+ if (cleanedContent !== project.caseStudyContent) {
+ updateData.caseStudyContent = cleanedContent
+ needsUpdate = true
+ }
+ }
+
+ if (needsUpdate) {
+ await prisma.project.update({
+ where: { id: project.id },
+ data: updateData
+ })
+ logger.info('Cleaned up media references in project', { projectId: project.id })
+ }
+ }
+
+ // Clean up posts
+ const posts = await prisma.post.findMany({
+ select: {
+ id: true,
+ featuredImage: true,
+ content: true,
+ attachments: true
+ }
+ })
+
+ for (const post of posts) {
+ let needsUpdate = false
+ const updateData: any = {}
+
+ // Check featured image
+ if (post.featuredImage && urlsToRemove.includes(post.featuredImage)) {
+ updateData.featuredImage = null
+ needsUpdate = true
+ }
+
+ // Check attachments
+ if (post.attachments && Array.isArray(post.attachments)) {
+ const filteredAttachments = post.attachments.filter((item: any) => {
+ const itemId = typeof item === 'object' ? item.id : parseInt(item)
+ return !mediaIds.includes(itemId)
+ })
+ if (filteredAttachments.length !== post.attachments.length) {
+ updateData.attachments = filteredAttachments.length > 0 ? filteredAttachments : null
+ needsUpdate = true
+ }
+ }
+
+ // Check post content
+ if (post.content) {
+ const cleanedContent = cleanContentFromMedia(post.content, mediaIds, urlsToRemove)
+ if (cleanedContent !== post.content) {
+ updateData.content = cleanedContent
+ needsUpdate = true
+ }
+ }
+
+ if (needsUpdate) {
+ await prisma.post.update({
+ where: { id: post.id },
+ data: updateData
+ })
+ logger.info('Cleaned up media references in post', { postId: post.id })
+ }
+ }
+}
+
+/**
+ * Remove media references from rich text content
+ */
+function cleanContentFromMedia(content: any, mediaIds: number[], urlsToRemove: string[]): any {
+ if (!content || typeof content !== 'object') return content
+
+ function cleanNode(node: any): any {
+ if (!node) return node
+
+ // Remove image nodes that reference deleted media
+ if (node.type === 'image' && node.attrs?.src) {
+ const shouldRemove = urlsToRemove.some((url) => node.attrs.src.includes(url))
+ if (shouldRemove) {
+ return null // Mark for removal
+ }
+ }
+
+ // Clean gallery nodes
+ if (node.type === 'gallery' && node.attrs?.images) {
+ const filteredImages = node.attrs.images.filter((image: any) => !mediaIds.includes(image.id))
+
+ if (filteredImages.length === 0) {
+ return null // Remove empty gallery
+ } else if (filteredImages.length !== node.attrs.images.length) {
+ return {
+ ...node,
+ attrs: {
+ ...node.attrs,
+ images: filteredImages
+ }
+ }
+ }
+ }
+
+ // Recursively clean child nodes
+ if (node.content) {
+ const cleanedContent = node.content.map(cleanNode).filter((child: any) => child !== null)
+
+ return {
+ ...node,
+ content: cleanedContent
+ }
+ }
+
+ return node
+ }
+
+ return cleanNode(content)
+}
diff --git a/src/routes/api/media/bulk-upload/+server.ts b/src/routes/api/media/bulk-upload/+server.ts
new file mode 100644
index 0000000..066c697
--- /dev/null
+++ b/src/routes/api/media/bulk-upload/+server.ts
@@ -0,0 +1,131 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { uploadFiles, isCloudinaryConfigured } from '$lib/server/cloudinary'
+import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+
+export const POST: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ // Check if Cloudinary is configured
+ if (!isCloudinaryConfigured()) {
+ return errorResponse('Media upload service not configured', 503)
+ }
+
+ try {
+ const formData = await event.request.formData()
+ const files = formData.getAll('files') as File[]
+ const context = (formData.get('context') as string) || 'media'
+
+ if (!files || files.length === 0) {
+ return errorResponse('No files provided', 400)
+ }
+
+ // Validate all files
+ const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
+ const maxSize = 10 * 1024 * 1024 // 10MB per file
+ const maxFiles = 50 // Maximum 50 files at once
+
+ if (files.length > maxFiles) {
+ return errorResponse(`Too many files. Maximum ${maxFiles} files allowed`, 400)
+ }
+
+ for (const file of files) {
+ if (!(file instanceof File)) {
+ return errorResponse('Invalid file format', 400)
+ }
+
+ if (!allowedTypes.includes(file.type)) {
+ return errorResponse(
+ `Invalid file type for ${file.name}. Allowed types: JPEG, PNG, WebP`,
+ 400
+ )
+ }
+
+ if (file.size > maxSize) {
+ return errorResponse(`File ${file.name} is too large. Maximum size is 10MB`, 400)
+ }
+ }
+
+ logger.info(`Starting bulk upload of ${files.length} files`)
+
+ // Upload all files to Cloudinary
+ const uploadResults = await uploadFiles(files, context as 'media' | 'photos' | 'projects')
+
+ // Process results and save to database
+ const mediaRecords = []
+ const errors = []
+
+ for (let i = 0; i < uploadResults.length; i++) {
+ const result = uploadResults[i]
+ const file = files[i]
+
+ if (result.success) {
+ try {
+ const media = await prisma.media.create({
+ data: {
+ filename: file.name,
+ mimeType: file.type,
+ size: file.size,
+ url: result.secureUrl!,
+ thumbnailUrl: result.thumbnailUrl,
+ width: result.width,
+ height: result.height,
+ usedIn: []
+ }
+ })
+
+ mediaRecords.push({
+ id: media.id,
+ url: media.url,
+ thumbnailUrl: media.thumbnailUrl,
+ width: media.width,
+ height: media.height,
+ filename: media.filename
+ })
+ } catch (dbError) {
+ errors.push({
+ filename: file.name,
+ error: 'Failed to save to database'
+ })
+ }
+ } else {
+ errors.push({
+ filename: file.name,
+ error: result.error || 'Upload failed'
+ })
+ }
+ }
+
+ logger.info(`Bulk upload completed: ${mediaRecords.length} successful, ${errors.length} failed`)
+
+ return jsonResponse(
+ {
+ success: mediaRecords.length,
+ failed: errors.length,
+ total: files.length,
+ media: mediaRecords,
+ errors: errors.length > 0 ? errors : undefined
+ },
+ 201
+ )
+ } catch (error) {
+ logger.error('Bulk upload error', error as Error)
+ return errorResponse('Failed to upload files', 500)
+ }
+}
+
+// Handle preflight requests
+export const OPTIONS: RequestHandler = async () => {
+ return new Response(null, {
+ status: 204,
+ headers: {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
+ }
+ })
+}
diff --git a/src/routes/api/media/upload/+server.ts b/src/routes/api/media/upload/+server.ts
new file mode 100644
index 0000000..9eac994
--- /dev/null
+++ b/src/routes/api/media/upload/+server.ts
@@ -0,0 +1,217 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { uploadFile, isCloudinaryConfigured } from '$lib/server/cloudinary'
+import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+import { dev } from '$app/environment'
+import exifr from 'exifr'
+
+// Helper function to extract and format EXIF data
+async function extractExifData(file: File): Promise {
+ try {
+ const buffer = await file.arrayBuffer()
+ const exif = await exifr.parse(buffer, {
+ pick: [
+ 'Make',
+ 'Model',
+ 'LensModel',
+ 'FocalLength',
+ 'FNumber',
+ 'ExposureTime',
+ 'ISO',
+ 'DateTime',
+ 'DateTimeOriginal',
+ 'CreateDate',
+ 'GPSLatitude',
+ 'GPSLongitude',
+ 'GPSAltitude',
+ 'Orientation',
+ 'ColorSpace'
+ ]
+ })
+
+ if (!exif) return null
+
+ // Format the data into a more usable structure
+ const formattedExif: any = {}
+
+ if (exif.Make && exif.Model) {
+ formattedExif.camera = `${exif.Make} ${exif.Model}`.trim()
+ }
+
+ if (exif.LensModel) {
+ formattedExif.lens = exif.LensModel
+ }
+
+ if (exif.FocalLength) {
+ formattedExif.focalLength = `${exif.FocalLength}mm`
+ }
+
+ if (exif.FNumber) {
+ formattedExif.aperture = `f/${exif.FNumber}`
+ }
+
+ if (exif.ExposureTime) {
+ if (exif.ExposureTime < 1) {
+ formattedExif.shutterSpeed = `1/${Math.round(1 / exif.ExposureTime)}`
+ } else {
+ formattedExif.shutterSpeed = `${exif.ExposureTime}s`
+ }
+ }
+
+ if (exif.ISO) {
+ formattedExif.iso = `ISO ${exif.ISO}`
+ }
+
+ // Use the most reliable date field available
+ const dateField = exif.DateTimeOriginal || exif.CreateDate || exif.DateTime
+ if (dateField) {
+ formattedExif.dateTaken = dateField.toISOString()
+ }
+
+ // GPS coordinates
+ if (exif.GPSLatitude && exif.GPSLongitude) {
+ formattedExif.coordinates = {
+ latitude: exif.GPSLatitude,
+ longitude: exif.GPSLongitude,
+ altitude: exif.GPSAltitude || null
+ }
+ }
+
+ // Additional metadata
+ if (exif.Orientation) {
+ formattedExif.orientation = exif.Orientation
+ }
+
+ if (exif.ColorSpace) {
+ formattedExif.colorSpace = exif.ColorSpace
+ }
+
+ return Object.keys(formattedExif).length > 0 ? formattedExif : null
+ } catch (error) {
+ logger.warn('Failed to extract EXIF data', {
+ error: error instanceof Error ? error.message : 'Unknown error'
+ })
+ return null
+ }
+}
+
+export const POST: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ // Check if Cloudinary is configured (skip in dev mode)
+ if (!dev && !isCloudinaryConfigured()) {
+ return errorResponse('Media upload service not configured', 503)
+ }
+
+ try {
+ const formData = await event.request.formData()
+ const file = formData.get('file') as File
+ const context = (formData.get('context') as string) || 'media'
+ const altText = (formData.get('altText') as string) || null
+ const description = (formData.get('description') as string) || null
+ const isPhotography = formData.get('isPhotography') === 'true'
+
+ if (!file || !(file instanceof File)) {
+ return errorResponse('No file provided', 400)
+ }
+
+ // Validate file type
+ const allowedTypes = [
+ 'image/jpeg',
+ 'image/jpg',
+ 'image/png',
+ 'image/webp',
+ 'image/gif',
+ 'image/svg+xml'
+ ]
+ if (!allowedTypes.includes(file.type)) {
+ return errorResponse('Invalid file type. Allowed types: JPEG, PNG, WebP, GIF, SVG', 400)
+ }
+
+ // Validate file size (max 10MB)
+ const maxSize = 10 * 1024 * 1024 // 10MB
+ if (file.size > maxSize) {
+ return errorResponse('File too large. Maximum size is 10MB', 400)
+ }
+
+ // Extract EXIF data for image files (but don't block upload if it fails)
+ let exifData = null
+ if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
+ exifData = await extractExifData(file)
+ }
+
+ // Upload to Cloudinary
+ const uploadResult = await uploadFile(file, context as 'media' | 'photos' | 'projects')
+
+ if (!uploadResult.success) {
+ return errorResponse(uploadResult.error || 'Upload failed', 500)
+ }
+
+ // Save to database
+ const media = await prisma.media.create({
+ data: {
+ filename: file.name,
+ originalName: file.name,
+ mimeType: file.type,
+ size: file.size,
+ url: uploadResult.secureUrl!,
+ thumbnailUrl: uploadResult.thumbnailUrl,
+ width: uploadResult.width,
+ height: uploadResult.height,
+ exifData: exifData,
+ altText: altText?.trim() || null,
+ description: description?.trim() || null,
+ isPhotography: isPhotography,
+ usedIn: []
+ }
+ })
+
+ logger.info('Media uploaded successfully', {
+ id: media.id,
+ filename: media.filename,
+ size: media.size
+ })
+
+ return jsonResponse(
+ {
+ id: media.id,
+ url: media.url,
+ thumbnailUrl: media.thumbnailUrl,
+ width: media.width,
+ height: media.height,
+ filename: media.filename,
+ originalName: media.originalName,
+ mimeType: media.mimeType,
+ size: media.size,
+ altText: media.altText,
+ description: media.description,
+ createdAt: media.createdAt,
+ updatedAt: media.updatedAt
+ },
+ 201
+ )
+ } catch (error) {
+ logger.error('Media upload error', error as Error)
+ console.error('Detailed upload error:', error)
+ return errorResponse(
+ `Upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ 500
+ )
+ }
+}
+
+// Handle preflight requests
+export const OPTIONS: RequestHandler = async () => {
+ return new Response(null, {
+ status: 204,
+ headers: {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
+ }
+ })
+}
diff --git a/src/routes/api/og-metadata/+server.ts b/src/routes/api/og-metadata/+server.ts
new file mode 100644
index 0000000..44e31c3
--- /dev/null
+++ b/src/routes/api/og-metadata/+server.ts
@@ -0,0 +1,165 @@
+import { json } from '@sveltejs/kit'
+import type { RequestHandler } from './$types'
+
+export const GET: RequestHandler = async ({ url }) => {
+ const targetUrl = url.searchParams.get('url')
+
+ if (!targetUrl) {
+ return json({ error: 'URL parameter is required' }, { status: 400 })
+ }
+
+ try {
+ // Fetch the HTML content
+ const response = await fetch(targetUrl, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (compatible; OpenGraphBot/1.0)'
+ }
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch URL: ${response.status}`)
+ }
+
+ const html = await response.text()
+
+ // Parse OpenGraph tags
+ const ogData = {
+ url: targetUrl,
+ title: extractMetaContent(html, 'og:title') || extractTitle(html),
+ description:
+ extractMetaContent(html, 'og:description') || extractMetaContent(html, 'description'),
+ image: extractMetaContent(html, 'og:image'),
+ siteName: extractMetaContent(html, 'og:site_name'),
+ favicon: extractFavicon(targetUrl, html)
+ }
+
+ return json(ogData)
+ } catch (error) {
+ console.error('Error fetching OpenGraph data:', error)
+ return json(
+ {
+ error: 'Failed to fetch metadata',
+ url: targetUrl
+ },
+ { status: 500 }
+ )
+ }
+}
+
+function extractMetaContent(html: string, property: string): string | null {
+ // Try property attribute first (for og: tags)
+ const propertyMatch = html.match(
+ new RegExp(`]*property=["']${property}["'][^>]*content=["']([^"']+)["']`, 'i')
+ )
+ if (propertyMatch) return propertyMatch[1]
+
+ // Try name attribute (for description)
+ const nameMatch = html.match(
+ new RegExp(`]*name=["']${property}["'][^>]*content=["']([^"']+)["']`, 'i')
+ )
+ if (nameMatch) return nameMatch[1]
+
+ // Try content first pattern
+ const contentFirstMatch = html.match(
+ new RegExp(`]*content=["']([^"']+)["'][^>]*(?:property|name)=["']${property}["']`, 'i')
+ )
+ if (contentFirstMatch) return contentFirstMatch[1]
+
+ return null
+}
+
+function extractTitle(html: string): string | null {
+ const match = html.match(/]*>([^<]+)<\/title>/i)
+ return match ? match[1].trim() : null
+}
+
+function extractFavicon(baseUrl: string, html: string): string | null {
+ // Special case for GitHub
+ if (baseUrl.includes('github.com')) {
+ return 'https://github.githubassets.com/favicons/favicon.svg'
+ }
+
+ // Try various favicon patterns
+ const patterns = [
+ /]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']/i,
+ /]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']/i,
+ /]*rel=["']apple-touch-icon["'][^>]*href=["']([^"']+)["']/i
+ ]
+
+ for (const pattern of patterns) {
+ const match = html.match(pattern)
+ if (match) {
+ const favicon = match[1]
+ // Convert relative URLs to absolute
+ if (favicon.startsWith('http')) {
+ return favicon
+ } else if (favicon.startsWith('//')) {
+ return 'https:' + favicon
+ } else if (favicon.startsWith('/')) {
+ const url = new URL(baseUrl)
+ return `${url.protocol}//${url.host}${favicon}`
+ } else {
+ const url = new URL(baseUrl)
+ return `${url.protocol}//${url.host}/${favicon}`
+ }
+ }
+ }
+
+ // Default favicon path
+ try {
+ const url = new URL(baseUrl)
+ return `${url.protocol}//${url.host}/favicon.ico`
+ } catch {
+ return null
+ }
+}
+
+// Add POST handler for Editor.js link tool
+export const POST: RequestHandler = async ({ request }) => {
+ const { url: targetUrl } = await request.json()
+
+ if (!targetUrl) {
+ return json({ success: 0 }, { status: 400 })
+ }
+
+ try {
+ // Fetch the HTML content
+ const response = await fetch(targetUrl, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (compatible; OpenGraphBot/1.0)'
+ }
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch URL: ${response.status}`)
+ }
+
+ const html = await response.text()
+
+ // Parse OpenGraph tags and return in Editor.js format
+ const title = extractMetaContent(html, 'og:title') || extractTitle(html)
+ const description =
+ extractMetaContent(html, 'og:description') || extractMetaContent(html, 'description')
+ const image = extractMetaContent(html, 'og:image')
+
+ return json({
+ success: 1,
+ link: targetUrl,
+ meta: {
+ title: title || '',
+ description: description || '',
+ image: {
+ url: image || ''
+ }
+ }
+ })
+ } catch (error) {
+ console.error('Error fetching OpenGraph data:', error)
+ return json(
+ {
+ success: 0
+ },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/routes/api/photos/+server.ts b/src/routes/api/photos/+server.ts
new file mode 100644
index 0000000..9f646cc
--- /dev/null
+++ b/src/routes/api/photos/+server.ts
@@ -0,0 +1,129 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { jsonResponse, errorResponse } from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+import type { PhotoItem, PhotoAlbum, Photo } from '$lib/types/photos'
+
+// GET /api/photos - Get published photography albums and individual photos
+export const GET: RequestHandler = async (event) => {
+ try {
+ const url = new URL(event.request.url)
+ const limit = parseInt(url.searchParams.get('limit') || '50')
+ const offset = parseInt(url.searchParams.get('offset') || '0')
+
+ // Fetch published photography albums
+ const albums = await prisma.album.findMany({
+ where: {
+ status: 'published',
+ isPhotography: true
+ },
+ include: {
+ photos: {
+ where: {
+ status: 'published'
+ },
+ orderBy: { displayOrder: 'asc' },
+ select: {
+ id: true,
+ filename: true,
+ url: true,
+ thumbnailUrl: true,
+ width: true,
+ height: true,
+ caption: true,
+ displayOrder: true
+ }
+ }
+ },
+ orderBy: { createdAt: 'desc' },
+ skip: offset,
+ take: limit
+ })
+
+ // Fetch individual published photos (not in albums, marked for photography)
+ const individualPhotos = await prisma.photo.findMany({
+ where: {
+ status: 'published',
+ showInPhotos: true,
+ albumId: null // Only photos not in albums
+ },
+ select: {
+ id: true,
+ slug: true,
+ filename: true,
+ url: true,
+ thumbnailUrl: true,
+ width: true,
+ height: true,
+ caption: true,
+ title: true,
+ description: true
+ },
+ orderBy: { createdAt: 'desc' },
+ skip: offset,
+ take: limit
+ })
+
+ // Transform albums to PhotoAlbum format
+ const photoAlbums: PhotoAlbum[] = albums
+ .filter((album) => album.photos.length > 0) // Only include albums with published photos
+ .map((album) => ({
+ id: `album-${album.id}`,
+ slug: album.slug, // Add slug for navigation
+ title: album.title,
+ description: album.description || undefined,
+ coverPhoto: {
+ id: `cover-${album.photos[0].id}`,
+ src: album.photos[0].url,
+ alt: album.photos[0].caption || album.title,
+ caption: album.photos[0].caption || undefined,
+ width: album.photos[0].width || 400,
+ height: album.photos[0].height || 400
+ },
+ photos: album.photos.map((photo) => ({
+ id: `photo-${photo.id}`,
+ src: photo.url,
+ alt: photo.caption || photo.filename,
+ caption: photo.caption || undefined,
+ width: photo.width || 400,
+ height: photo.height || 400
+ })),
+ createdAt: album.createdAt.toISOString()
+ }))
+
+ // Transform individual photos to Photo format
+ const photos: Photo[] = individualPhotos.map((photo) => ({
+ id: `photo-${photo.id}`,
+ src: photo.url,
+ alt: photo.title || photo.caption || photo.filename,
+ caption: photo.caption || undefined,
+ width: photo.width || 400,
+ height: photo.height || 400
+ }))
+
+ // Combine albums and individual photos
+ const photoItems: PhotoItem[] = [...photoAlbums, ...photos]
+
+ // Sort by creation date (albums use createdAt, individual photos would need publishedAt or createdAt)
+ photoItems.sort((a, b) => {
+ const dateA = 'createdAt' in a ? new Date(a.createdAt) : new Date()
+ const dateB = 'createdAt' in b ? new Date(b.createdAt) : new Date()
+ return dateB.getTime() - dateA.getTime()
+ })
+
+ const response = {
+ photoItems,
+ pagination: {
+ total: photoItems.length,
+ limit,
+ offset,
+ hasMore: photoItems.length === limit // Simple check, could be more sophisticated
+ }
+ }
+
+ return jsonResponse(response)
+ } catch (error) {
+ logger.error('Failed to fetch photos', error as Error)
+ return errorResponse('Failed to fetch photos', 500)
+ }
+}
diff --git a/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts b/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts
new file mode 100644
index 0000000..53942f2
--- /dev/null
+++ b/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts
@@ -0,0 +1,80 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { jsonResponse, errorResponse } from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+
+// GET /api/photos/[albumSlug]/[photoId] - Get individual photo with album context
+export const GET: RequestHandler = async (event) => {
+ const albumSlug = event.params.albumSlug
+ const photoId = parseInt(event.params.photoId)
+
+ if (!albumSlug || isNaN(photoId)) {
+ return errorResponse('Invalid album slug or photo ID', 400)
+ }
+
+ try {
+ // First find the album
+ const album = await prisma.album.findUnique({
+ where: {
+ slug: albumSlug,
+ status: 'published',
+ isPhotography: true
+ },
+ include: {
+ photos: {
+ orderBy: { displayOrder: 'asc' },
+ select: {
+ id: true,
+ filename: true,
+ url: true,
+ thumbnailUrl: true,
+ width: true,
+ height: true,
+ caption: true,
+ title: true,
+ description: true,
+ displayOrder: true,
+ exifData: true
+ }
+ }
+ }
+ })
+
+ if (!album) {
+ return errorResponse('Album not found', 404)
+ }
+
+ // Find the specific photo
+ const photo = album.photos.find((p) => p.id === photoId)
+ if (!photo) {
+ return errorResponse('Photo not found in album', 404)
+ }
+
+ // Get photo index for navigation
+ const photoIndex = album.photos.findIndex((p) => p.id === photoId)
+ const prevPhoto = photoIndex > 0 ? album.photos[photoIndex - 1] : null
+ const nextPhoto = photoIndex < album.photos.length - 1 ? album.photos[photoIndex + 1] : null
+
+ return jsonResponse({
+ photo,
+ album: {
+ id: album.id,
+ slug: album.slug,
+ title: album.title,
+ description: album.description,
+ location: album.location,
+ date: album.date,
+ totalPhotos: album.photos.length
+ },
+ navigation: {
+ currentIndex: photoIndex + 1, // 1-based for display
+ totalCount: album.photos.length,
+ prevPhoto: prevPhoto ? { id: prevPhoto.id, url: prevPhoto.thumbnailUrl } : null,
+ nextPhoto: nextPhoto ? { id: nextPhoto.id, url: nextPhoto.thumbnailUrl } : null
+ }
+ })
+ } catch (error) {
+ logger.error('Failed to retrieve photo', error as Error)
+ return errorResponse('Failed to retrieve photo', 500)
+ }
+}
diff --git a/src/routes/api/photos/[id]/+server.ts b/src/routes/api/photos/[id]/+server.ts
new file mode 100644
index 0000000..3ecccdc
--- /dev/null
+++ b/src/routes/api/photos/[id]/+server.ts
@@ -0,0 +1,129 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+
+// GET /api/photos/[id] - Get a single photo
+export const GET: RequestHandler = async (event) => {
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid photo ID', 400)
+ }
+
+ try {
+ const photo = await prisma.photo.findUnique({
+ where: { id },
+ include: {
+ album: {
+ select: { id: true, title: true, slug: true }
+ }
+ }
+ })
+
+ if (!photo) {
+ return errorResponse('Photo not found', 404)
+ }
+
+ return jsonResponse(photo)
+ } catch (error) {
+ logger.error('Failed to retrieve photo', error as Error)
+ return errorResponse('Failed to retrieve photo', 500)
+ }
+}
+
+// DELETE /api/photos/[id] - Delete a photo completely (removes photo record and media usage)
+// NOTE: This deletes the photo entirely. Use DELETE /api/albums/[id]/photos to remove from album only.
+export const DELETE: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid photo ID', 400)
+ }
+
+ try {
+ // Check if photo exists
+ const photo = await prisma.photo.findUnique({
+ where: { id }
+ })
+
+ if (!photo) {
+ return errorResponse('Photo not found', 404)
+ }
+
+ // Remove media usage tracking for this photo
+ if (photo.albumId) {
+ await prisma.mediaUsage.deleteMany({
+ where: {
+ contentType: 'album',
+ contentId: photo.albumId,
+ fieldName: 'photos'
+ }
+ })
+ }
+
+ // Delete the photo record
+ await prisma.photo.delete({
+ where: { id }
+ })
+
+ logger.info('Photo deleted from album', {
+ photoId: id,
+ albumId: photo.albumId
+ })
+
+ return new Response(null, { status: 204 })
+ } catch (error) {
+ logger.error('Failed to delete photo', error as Error)
+ return errorResponse('Failed to delete photo', 500)
+ }
+}
+
+// PUT /api/photos/[id] - Update photo metadata
+export const PUT: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid photo ID', 400)
+ }
+
+ try {
+ const body = await event.request.json()
+
+ // Check if photo exists
+ const existing = await prisma.photo.findUnique({
+ where: { id }
+ })
+
+ if (!existing) {
+ return errorResponse('Photo not found', 404)
+ }
+
+ // Update photo
+ const photo = await prisma.photo.update({
+ where: { id },
+ data: {
+ caption: body.caption !== undefined ? body.caption : existing.caption,
+ title: body.title !== undefined ? body.title : existing.title,
+ description: body.description !== undefined ? body.description : existing.description,
+ displayOrder: body.displayOrder !== undefined ? body.displayOrder : existing.displayOrder,
+ status: body.status !== undefined ? body.status : existing.status,
+ showInPhotos: body.showInPhotos !== undefined ? body.showInPhotos : existing.showInPhotos
+ }
+ })
+
+ logger.info('Photo updated', { photoId: id })
+
+ return jsonResponse(photo)
+ } catch (error) {
+ logger.error('Failed to update photo', error as Error)
+ return errorResponse('Failed to update photo', 500)
+ }
+}
diff --git a/src/routes/api/posts/+server.ts b/src/routes/api/posts/+server.ts
new file mode 100644
index 0000000..406930e
--- /dev/null
+++ b/src/routes/api/posts/+server.ts
@@ -0,0 +1,202 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import {
+ jsonResponse,
+ errorResponse,
+ getPaginationParams,
+ getPaginationMeta,
+ checkAdminAuth
+} from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+import {
+ trackMediaUsage,
+ extractMediaIds,
+ type MediaUsageReference
+} from '$lib/server/media-usage.js'
+
+// GET /api/posts - List all posts
+export const GET: RequestHandler = async (event) => {
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ try {
+ const { page, limit } = getPaginationParams(event.url)
+ const skip = (page - 1) * limit
+
+ // Get filter parameters
+ const status = event.url.searchParams.get('status')
+ const postType = event.url.searchParams.get('postType')
+
+ // Build where clause
+ const where: any = {}
+ if (status) {
+ where.status = status
+ }
+ if (postType) {
+ where.postType = postType
+ }
+
+ // Get total count
+ const total = await prisma.post.count({ where })
+
+ // Get posts
+ const posts = await prisma.post.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ skip,
+ take: limit
+ })
+
+ const pagination = getPaginationMeta(total, page, limit)
+
+ logger.info('Posts list retrieved', { total, page, limit })
+
+ return jsonResponse({
+ posts,
+ pagination
+ })
+ } catch (error) {
+ logger.error('Failed to retrieve posts', error as Error)
+ return errorResponse('Failed to retrieve posts', 500)
+ }
+}
+
+// POST /api/posts - Create a new post
+export const POST: RequestHandler = async (event) => {
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ try {
+ const data = await event.request.json()
+
+ // Generate slug if not provided
+ if (!data.slug) {
+ if (data.title) {
+ // Generate slug from title
+ data.slug = data.title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ } else {
+ // Generate timestamp-based slug for posts without titles
+ data.slug = `post-${Date.now()}`
+ }
+ }
+
+ // Set publishedAt if status is published
+ if (data.status === 'published') {
+ data.publishedAt = new Date()
+ }
+
+ // Handle photo attachments for posts
+ let featuredImageId = data.featuredImage
+ if (data.attachedPhotos && data.attachedPhotos.length > 0 && !featuredImageId) {
+ // Use first attached photo as featured image for photo posts
+ featuredImageId = data.attachedPhotos[0]
+ }
+
+ // Handle album gallery - use first image as featured image
+ if (data.gallery && data.gallery.length > 0 && !featuredImageId) {
+ // Get the media URL for the first gallery item
+ const firstMedia = await prisma.media.findUnique({
+ where: { id: data.gallery[0] },
+ select: { url: true }
+ })
+ if (firstMedia) {
+ featuredImageId = firstMedia.url
+ }
+ }
+
+ // For albums, store gallery IDs in content field as a special structure
+ let postContent = data.content
+ if (data.type === 'album' && data.gallery) {
+ postContent = {
+ type: 'album',
+ gallery: data.gallery,
+ description: data.content
+ }
+ }
+
+ const post = await prisma.post.create({
+ data: {
+ title: data.title,
+ slug: data.slug,
+ postType: data.type,
+ status: data.status,
+ content: postContent,
+ linkUrl: data.link_url,
+ linkDescription: data.linkDescription,
+ featuredImage: featuredImageId,
+ attachments:
+ data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
+ tags: data.tags,
+ publishedAt: data.publishedAt
+ }
+ })
+
+ // Track media usage
+ try {
+ const usageReferences: MediaUsageReference[] = []
+
+ // Track featured image
+ const featuredImageIds = extractMediaIds({ featuredImage: featuredImageId }, 'featuredImage')
+ featuredImageIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'post',
+ contentId: post.id,
+ fieldName: 'featuredImage'
+ })
+ })
+
+ // Track attached photos (for photo posts)
+ if (data.attachedPhotos && Array.isArray(data.attachedPhotos)) {
+ data.attachedPhotos.forEach((mediaId: number) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'post',
+ contentId: post.id,
+ fieldName: 'attachments'
+ })
+ })
+ }
+
+ // Track gallery (for album posts)
+ if (data.gallery && Array.isArray(data.gallery)) {
+ data.gallery.forEach((mediaId: number) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'post',
+ contentId: post.id,
+ fieldName: 'gallery'
+ })
+ })
+ }
+
+ // Track media in post content
+ const contentIds = extractMediaIds({ content: postContent }, 'content')
+ contentIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'post',
+ contentId: post.id,
+ fieldName: 'content'
+ })
+ })
+
+ if (usageReferences.length > 0) {
+ await trackMediaUsage(usageReferences)
+ }
+ } catch (error) {
+ logger.warn('Failed to track media usage for post', { postId: post.id, error })
+ }
+
+ logger.info('Post created', { id: post.id, title: post.title })
+ return jsonResponse(post)
+ } catch (error) {
+ logger.error('Failed to create post', error as Error)
+ return errorResponse('Failed to create post', 500)
+ }
+}
diff --git a/src/routes/api/posts/[id]/+server.ts b/src/routes/api/posts/[id]/+server.ts
new file mode 100644
index 0000000..37f5f60
--- /dev/null
+++ b/src/routes/api/posts/[id]/+server.ts
@@ -0,0 +1,191 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+import {
+ updateMediaUsage,
+ removeMediaUsage,
+ extractMediaIds,
+ trackMediaUsage,
+ type MediaUsageReference
+} from '$lib/server/media-usage.js'
+
+// GET /api/posts/[id] - Get a single post
+export const GET: RequestHandler = async (event) => {
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ try {
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid post ID', 400)
+ }
+
+ const post = await prisma.post.findUnique({
+ where: { id }
+ })
+
+ if (!post) {
+ return errorResponse('Post not found', 404)
+ }
+
+ logger.info('Post retrieved', { id })
+ return jsonResponse(post)
+ } catch (error) {
+ logger.error('Failed to retrieve post', error as Error)
+ return errorResponse('Failed to retrieve post', 500)
+ }
+}
+
+// PUT /api/posts/[id] - Update a post
+export const PUT: RequestHandler = async (event) => {
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ try {
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid post ID', 400)
+ }
+
+ const data = await event.request.json()
+
+ // Update publishedAt if status is changing to published
+ if (data.status === 'published') {
+ const currentPost = await prisma.post.findUnique({
+ where: { id },
+ select: { status: true, publishedAt: true }
+ })
+
+ if (currentPost && currentPost.status !== 'published' && !currentPost.publishedAt) {
+ data.publishedAt = new Date()
+ }
+ }
+
+ // Handle album gallery updates
+ let featuredImageId = data.featuredImage
+ if (data.gallery && data.gallery.length > 0 && !featuredImageId) {
+ // Get the media URL for the first gallery item
+ const firstMedia = await prisma.media.findUnique({
+ where: { id: data.gallery[0] },
+ select: { url: true }
+ })
+ if (firstMedia) {
+ featuredImageId = firstMedia.url
+ }
+ }
+
+ // For albums, store gallery IDs in content field as a special structure
+ let postContent = data.content
+ if (data.type === 'album' && data.gallery) {
+ postContent = {
+ type: 'album',
+ gallery: data.gallery,
+ description: data.content
+ }
+ }
+
+ const post = await prisma.post.update({
+ where: { id },
+ data: {
+ title: data.title,
+ slug: data.slug,
+ postType: data.type,
+ status: data.status,
+ content: postContent,
+ linkUrl: data.link_url,
+ linkDescription: data.linkDescription,
+ featuredImage: featuredImageId,
+ attachments:
+ data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
+ tags: data.tags,
+ publishedAt: data.publishedAt
+ }
+ })
+
+ // Update media usage tracking
+ try {
+ // Remove all existing usage for this post first
+ await removeMediaUsage('post', id)
+
+ // Track all current media usage in the updated post
+ const usageReferences: MediaUsageReference[] = []
+
+ // Track featured image
+ const featuredImageIds = extractMediaIds(post, 'featuredImage')
+ featuredImageIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'post',
+ contentId: id,
+ fieldName: 'featuredImage'
+ })
+ })
+
+ // Track attachments
+ const attachmentIds = extractMediaIds(post, 'attachments')
+ attachmentIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'post',
+ contentId: id,
+ fieldName: 'attachments'
+ })
+ })
+
+ // Track media in post content
+ const contentIds = extractMediaIds(post, 'content')
+ contentIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'post',
+ contentId: id,
+ fieldName: 'content'
+ })
+ })
+
+ // Add new usage references
+ if (usageReferences.length > 0) {
+ await trackMediaUsage(usageReferences)
+ }
+ } catch (error) {
+ logger.warn('Failed to update media usage for post', { postId: id, error })
+ }
+
+ logger.info('Post updated', { id })
+ return jsonResponse(post)
+ } catch (error) {
+ logger.error('Failed to update post', error as Error)
+ return errorResponse('Failed to update post', 500)
+ }
+}
+
+// DELETE /api/posts/[id] - Delete a post
+export const DELETE: RequestHandler = async (event) => {
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ try {
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid post ID', 400)
+ }
+
+ // Remove media usage tracking first
+ await removeMediaUsage('post', id)
+
+ // Delete the post
+ await prisma.post.delete({
+ where: { id }
+ })
+
+ logger.info('Post deleted', { id })
+ return jsonResponse({ message: 'Post deleted successfully' })
+ } catch (error) {
+ logger.error('Failed to delete post', error as Error)
+ return errorResponse('Failed to delete post', 500)
+ }
+}
diff --git a/src/routes/api/posts/by-slug/[slug]/+server.ts b/src/routes/api/posts/by-slug/[slug]/+server.ts
new file mode 100644
index 0000000..738cf44
--- /dev/null
+++ b/src/routes/api/posts/by-slug/[slug]/+server.ts
@@ -0,0 +1,64 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { jsonResponse, errorResponse } from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+
+// GET /api/posts/by-slug/[slug] - Get post by slug
+export const GET: RequestHandler = async (event) => {
+ const slug = event.params.slug
+
+ if (!slug) {
+ return errorResponse('Invalid post slug', 400)
+ }
+
+ try {
+ const post = await prisma.post.findUnique({
+ where: { slug },
+ include: {
+ album: {
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ description: true,
+ photos: {
+ orderBy: { displayOrder: 'asc' },
+ select: {
+ id: true,
+ url: true,
+ thumbnailUrl: true,
+ caption: true,
+ width: true,
+ height: true
+ }
+ }
+ }
+ },
+ photo: {
+ select: {
+ id: true,
+ url: true,
+ thumbnailUrl: true,
+ caption: true,
+ width: true,
+ height: true
+ }
+ }
+ }
+ })
+
+ if (!post) {
+ return errorResponse('Post not found', 404)
+ }
+
+ // Only return published posts
+ if (post.status !== 'published' || !post.publishedAt) {
+ return errorResponse('Post not found', 404)
+ }
+
+ return jsonResponse(post)
+ } catch (error) {
+ logger.error('Failed to retrieve post by slug', error as Error)
+ return errorResponse('Failed to retrieve post', 500)
+ }
+}
diff --git a/src/routes/api/projects/+server.ts b/src/routes/api/projects/+server.ts
new file mode 100644
index 0000000..cdf037b
--- /dev/null
+++ b/src/routes/api/projects/+server.ts
@@ -0,0 +1,190 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import {
+ jsonResponse,
+ errorResponse,
+ getPaginationParams,
+ getPaginationMeta,
+ checkAdminAuth,
+ parseRequestBody
+} from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+import { createSlug, ensureUniqueSlug } from '$lib/server/database'
+import {
+ trackMediaUsage,
+ extractMediaIds,
+ type MediaUsageReference
+} from '$lib/server/media-usage.js'
+
+// GET /api/projects - List all projects
+export const GET: RequestHandler = async (event) => {
+ try {
+ const { page, limit } = getPaginationParams(event.url)
+ const skip = (page - 1) * limit
+
+ // Get filter parameters
+ const status = event.url.searchParams.get('status')
+ const projectType = event.url.searchParams.get('projectType')
+ const includeListOnly = event.url.searchParams.get('includeListOnly') === 'true'
+ const includePasswordProtected =
+ event.url.searchParams.get('includePasswordProtected') === 'true'
+
+ // Build where clause
+ const where: any = {}
+
+ if (status) {
+ where.status = status
+ } else {
+ // Default behavior: determine which statuses to include
+ const allowedStatuses = ['published']
+
+ if (includeListOnly) {
+ allowedStatuses.push('list-only')
+ }
+
+ if (includePasswordProtected) {
+ allowedStatuses.push('password-protected')
+ }
+
+ where.status = { in: allowedStatuses }
+ }
+
+ if (projectType) {
+ where.projectType = projectType
+ }
+
+ // Get total count
+ const total = await prisma.project.count({ where })
+
+ // Get projects
+ const projects = await prisma.project.findMany({
+ where,
+ orderBy: [{ displayOrder: 'asc' }, { year: 'desc' }, { createdAt: 'desc' }],
+ skip,
+ take: limit
+ })
+
+ const pagination = getPaginationMeta(total, page, limit)
+
+ logger.info('Projects list retrieved', { total, page, limit })
+
+ return jsonResponse({
+ projects,
+ pagination
+ })
+ } catch (error) {
+ logger.error('Failed to retrieve projects', error as Error)
+ return errorResponse('Failed to retrieve projects', 500)
+ }
+}
+
+// POST /api/projects - Create a new project
+export const POST: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ try {
+ const body = await parseRequestBody(event.request)
+ if (!body) {
+ return errorResponse('Invalid request body', 400)
+ }
+
+ // Validate required fields
+ if (!body.title || !body.year) {
+ return errorResponse('Title and year are required', 400)
+ }
+
+ // Generate slug
+ const baseSlug = body.slug || createSlug(body.title)
+ const slug = await ensureUniqueSlug(baseSlug, 'project')
+
+ // Create project
+ const project = await prisma.project.create({
+ data: {
+ slug,
+ title: body.title,
+ subtitle: body.subtitle,
+ description: body.description,
+ year: body.year,
+ client: body.client,
+ role: body.role,
+ featuredImage: body.featuredImage,
+ logoUrl: body.logoUrl,
+ gallery: body.gallery || [],
+ externalUrl: body.externalUrl,
+ caseStudyContent: body.caseStudyContent,
+ backgroundColor: body.backgroundColor,
+ highlightColor: body.highlightColor,
+ projectType: body.projectType || 'work',
+ displayOrder: body.displayOrder || 0,
+ status: body.status || 'draft',
+ password: body.password || null,
+ publishedAt: body.status === 'published' ? new Date() : null
+ }
+ })
+
+ // Track media usage
+ try {
+ const usageReferences: MediaUsageReference[] = []
+
+ // Track featured image
+ const featuredImageIds = extractMediaIds(body, 'featuredImage')
+ featuredImageIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'project',
+ contentId: project.id,
+ fieldName: 'featuredImage'
+ })
+ })
+
+ // Track logo
+ const logoIds = extractMediaIds(body, 'logoUrl')
+ logoIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'project',
+ contentId: project.id,
+ fieldName: 'logoUrl'
+ })
+ })
+
+ // Track gallery images
+ const galleryIds = extractMediaIds(body, 'gallery')
+ galleryIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'project',
+ contentId: project.id,
+ fieldName: 'gallery'
+ })
+ })
+
+ // Track media in case study content
+ const contentIds = extractMediaIds(body, 'caseStudyContent')
+ contentIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'project',
+ contentId: project.id,
+ fieldName: 'content'
+ })
+ })
+
+ if (usageReferences.length > 0) {
+ await trackMediaUsage(usageReferences)
+ }
+ } catch (error) {
+ logger.warn('Failed to track media usage for project', { projectId: project.id, error })
+ }
+
+ logger.info('Project created', { id: project.id, slug: project.slug })
+
+ return jsonResponse(project, 201)
+ } catch (error) {
+ logger.error('Failed to create project', error as Error)
+ return errorResponse('Failed to create project', 500)
+ }
+}
diff --git a/src/routes/api/projects/[id]/+server.ts b/src/routes/api/projects/[id]/+server.ts
new file mode 100644
index 0000000..702c86a
--- /dev/null
+++ b/src/routes/api/projects/[id]/+server.ts
@@ -0,0 +1,197 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import {
+ jsonResponse,
+ errorResponse,
+ checkAdminAuth,
+ parseRequestBody
+} from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+import { ensureUniqueSlug } from '$lib/server/database'
+import {
+ updateMediaUsage,
+ removeMediaUsage,
+ extractMediaIds,
+ type MediaUsageReference
+} from '$lib/server/media-usage.js'
+
+// GET /api/projects/[id] - Get a single project
+export const GET: RequestHandler = async (event) => {
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid project ID', 400)
+ }
+
+ try {
+ const project = await prisma.project.findUnique({
+ where: { id }
+ })
+
+ if (!project) {
+ return errorResponse('Project not found', 404)
+ }
+
+ return jsonResponse(project)
+ } catch (error) {
+ logger.error('Failed to retrieve project', error as Error)
+ return errorResponse('Failed to retrieve project', 500)
+ }
+}
+
+// PUT /api/projects/[id] - Update a project
+export const PUT: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid project ID', 400)
+ }
+
+ try {
+ const body = await parseRequestBody(event.request)
+ if (!body) {
+ return errorResponse('Invalid request body', 400)
+ }
+
+ // Check if project exists
+ const existing = await prisma.project.findUnique({
+ where: { id }
+ })
+
+ if (!existing) {
+ return errorResponse('Project not found', 404)
+ }
+
+ // Handle slug update
+ let slug = existing.slug
+ if (body.slug && body.slug !== existing.slug) {
+ slug = await ensureUniqueSlug(body.slug, 'project', id)
+ }
+
+ // Update project
+ const project = await prisma.project.update({
+ where: { id },
+ data: {
+ slug,
+ title: body.title ?? existing.title,
+ subtitle: body.subtitle ?? existing.subtitle,
+ description: body.description ?? existing.description,
+ year: body.year ?? existing.year,
+ client: body.client ?? existing.client,
+ role: body.role ?? existing.role,
+ featuredImage: body.featuredImage ?? existing.featuredImage,
+ logoUrl: body.logoUrl ?? existing.logoUrl,
+ gallery: body.gallery ?? existing.gallery,
+ externalUrl: body.externalUrl ?? existing.externalUrl,
+ caseStudyContent: body.caseStudyContent ?? existing.caseStudyContent,
+ backgroundColor: body.backgroundColor ?? existing.backgroundColor,
+ highlightColor: body.highlightColor ?? existing.highlightColor,
+ projectType: body.projectType ?? existing.projectType,
+ displayOrder: body.displayOrder ?? existing.displayOrder,
+ status: body.status ?? existing.status,
+ password: body.password ?? existing.password,
+ publishedAt:
+ body.status === 'published' && !existing.publishedAt ? new Date() : existing.publishedAt
+ }
+ })
+
+ // Update media usage tracking
+ try {
+ // Remove all existing usage for this project first
+ await removeMediaUsage('project', id)
+
+ // Track all current media usage in the updated project
+ const usageReferences: MediaUsageReference[] = []
+
+ // Track featured image
+ const featuredImageIds = extractMediaIds(project, 'featuredImage')
+ featuredImageIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'project',
+ contentId: id,
+ fieldName: 'featuredImage'
+ })
+ })
+
+ // Track logo
+ const logoIds = extractMediaIds(project, 'logoUrl')
+ logoIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'project',
+ contentId: id,
+ fieldName: 'logoUrl'
+ })
+ })
+
+ // Track gallery images
+ const galleryIds = extractMediaIds(project, 'gallery')
+ galleryIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'project',
+ contentId: id,
+ fieldName: 'gallery'
+ })
+ })
+
+ // Track media in case study content
+ const contentIds = extractMediaIds(project, 'caseStudyContent')
+ contentIds.forEach((mediaId) => {
+ usageReferences.push({
+ mediaId,
+ contentType: 'project',
+ contentId: id,
+ fieldName: 'content'
+ })
+ })
+
+ if (usageReferences.length > 0) {
+ await trackMediaUsage(usageReferences)
+ }
+ } catch (error) {
+ logger.warn('Failed to update media usage tracking for project', { projectId: id, error })
+ }
+
+ logger.info('Project updated', { id: project.id, slug: project.slug })
+
+ return jsonResponse(project)
+ } catch (error) {
+ logger.error('Failed to update project', error as Error)
+ return errorResponse('Failed to update project', 500)
+ }
+}
+
+// DELETE /api/projects/[id] - Delete a project
+export const DELETE: RequestHandler = async (event) => {
+ // Check authentication
+ if (!checkAdminAuth(event)) {
+ return errorResponse('Unauthorized', 401)
+ }
+
+ const id = parseInt(event.params.id)
+ if (isNaN(id)) {
+ return errorResponse('Invalid project ID', 400)
+ }
+
+ try {
+ // Remove media usage tracking first
+ await removeMediaUsage('project', id)
+
+ // Delete the project
+ await prisma.project.delete({
+ where: { id }
+ })
+
+ logger.info('Project deleted', { id })
+
+ return new Response(null, { status: 204 })
+ } catch (error) {
+ logger.error('Failed to delete project', error as Error)
+ return errorResponse('Failed to delete project', 500)
+ }
+}
diff --git a/src/routes/api/steam/+server.ts b/src/routes/api/steam/+server.ts
index 8d731f6..72f34c2 100644
--- a/src/routes/api/steam/+server.ts
+++ b/src/routes/api/steam/+server.ts
@@ -1,7 +1,7 @@
import 'dotenv/config'
import { error, json } from '@sveltejs/kit'
import redis from '../redis-client'
-import SteamAPI, { Game, GameInfo, GameInfoExtended, UserPlaytime } from 'steamapi'
+import SteamAPI from 'steamapi'
import type { RequestHandler } from './$types'
diff --git a/src/routes/api/universe/+server.ts b/src/routes/api/universe/+server.ts
new file mode 100644
index 0000000..ebe6511
--- /dev/null
+++ b/src/routes/api/universe/+server.ts
@@ -0,0 +1,146 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { jsonResponse, errorResponse } from '$lib/server/api-utils'
+import { logger } from '$lib/server/logger'
+
+export interface UniverseItem {
+ id: number
+ type: 'post' | 'album'
+ slug: string
+ title?: string
+ content?: any
+ publishedAt: string
+ createdAt: string
+
+ // Post-specific fields
+ postType?: string
+ linkUrl?: string
+ linkDescription?: string
+ attachments?: any
+
+ // Album-specific fields
+ description?: string
+ location?: string
+ date?: string
+ photosCount?: number
+ coverPhoto?: any
+ photos?: any[]
+}
+
+// GET /api/universe - Get mixed feed of published posts and albums
+export const GET: RequestHandler = async (event) => {
+ try {
+ const url = new URL(event.request.url)
+ const limit = parseInt(url.searchParams.get('limit') || '20')
+ const offset = parseInt(url.searchParams.get('offset') || '0')
+
+ // Fetch published posts
+ const posts = await prisma.post.findMany({
+ where: {
+ status: 'published',
+ publishedAt: { not: null }
+ },
+ select: {
+ id: true,
+ slug: true,
+ postType: true,
+ title: true,
+ content: true,
+ linkUrl: true,
+ linkDescription: true,
+ attachments: true,
+ publishedAt: true,
+ createdAt: true
+ },
+ orderBy: { publishedAt: 'desc' }
+ })
+
+ // Fetch published albums marked for Universe
+ const albums = await prisma.album.findMany({
+ where: {
+ status: 'published',
+ showInUniverse: true
+ },
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ description: true,
+ date: true,
+ location: true,
+ createdAt: true,
+ _count: {
+ select: { photos: true }
+ },
+ photos: {
+ take: 6, // Fetch enough for 5 thumbnails + 1 background
+ orderBy: { displayOrder: 'asc' },
+ select: {
+ id: true,
+ url: true,
+ thumbnailUrl: true,
+ caption: true,
+ width: true,
+ height: true
+ }
+ }
+ },
+ orderBy: { createdAt: 'desc' }
+ })
+
+ // Transform posts to universe items
+ const postItems: UniverseItem[] = posts.map((post) => ({
+ id: post.id,
+ type: 'post' as const,
+ slug: post.slug,
+ title: post.title || undefined,
+ content: post.content,
+ postType: post.postType,
+ linkUrl: post.linkUrl || undefined,
+ linkDescription: post.linkDescription || undefined,
+ attachments: post.attachments,
+ publishedAt: post.publishedAt!.toISOString(),
+ createdAt: post.createdAt.toISOString()
+ }))
+
+ // Transform albums to universe items
+ const albumItems: UniverseItem[] = albums.map((album) => ({
+ id: album.id,
+ type: 'album' as const,
+ slug: album.slug,
+ title: album.title,
+ description: album.description || undefined,
+ location: album.location || undefined,
+ date: album.date?.toISOString(),
+ photosCount: album._count.photos,
+ coverPhoto: album.photos[0] || null, // Keep for backward compatibility
+ photos: album.photos, // Add all photos for slideshow
+ publishedAt: album.createdAt.toISOString(), // Albums use createdAt as publishedAt
+ createdAt: album.createdAt.toISOString()
+ }))
+
+ // Combine and sort by publishedAt
+ const allItems = [...postItems, ...albumItems].sort(
+ (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
+ )
+
+ // Apply pagination
+ const paginatedItems = allItems.slice(offset, offset + limit)
+ const hasMore = allItems.length > offset + limit
+
+ const response = {
+ items: paginatedItems,
+ pagination: {
+ total: allItems.length,
+ limit,
+ offset,
+ hasMore
+ }
+ }
+
+ return jsonResponse(response)
+ } catch (error) {
+ logger.error('Failed to fetch universe feed', error as Error)
+ return errorResponse('Failed to fetch universe feed', 500)
+ }
+}
diff --git a/src/routes/labs/+page.svelte b/src/routes/labs/+page.svelte
new file mode 100644
index 0000000..15b3406
--- /dev/null
+++ b/src/routes/labs/+page.svelte
@@ -0,0 +1,74 @@
+
+
+
+ {#if error}
+
+
Unable to load projects
+
{error}
+
+ {:else if projects.length === 0}
+
+
No projects yet
+
Labs projects will appear here once published.
+
+ {:else}
+
+ {#each projects as project}
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/src/routes/labs/+page.ts b/src/routes/labs/+page.ts
new file mode 100644
index 0000000..03fa403
--- /dev/null
+++ b/src/routes/labs/+page.ts
@@ -0,0 +1,23 @@
+import type { PageLoad } from './$types'
+
+export const load: PageLoad = async ({ fetch }) => {
+ try {
+ const response = await fetch(
+ '/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true'
+ )
+ if (!response.ok) {
+ throw new Error('Failed to fetch labs projects')
+ }
+
+ const data = await response.json()
+ return {
+ projects: data.projects || []
+ }
+ } catch (error) {
+ console.error('Error loading labs projects:', error)
+ return {
+ projects: [],
+ error: 'Failed to load projects'
+ }
+ }
+}
diff --git a/src/routes/labs/[slug]/+page.svelte b/src/routes/labs/[slug]/+page.svelte
new file mode 100644
index 0000000..7f4f44e
--- /dev/null
+++ b/src/routes/labs/[slug]/+page.svelte
@@ -0,0 +1,159 @@
+
+
+{#if error}
+
+
+
+
{error}
+
← Back to labs
+
+
+{:else if !project}
+
+ Loading project...
+
+{:else if project.status === 'list-only'}
+
+
+
+
This project is not yet available for viewing. Please check back later.
+
← Back to labs
+
+
+{:else if project.status === 'password-protected'}
+
+
+ {#snippet children()}
+
+
+ {/snippet}
+
+
+{:else}
+
+
+
+
+{/if}
+
+
diff --git a/src/routes/labs/[slug]/+page.ts b/src/routes/labs/[slug]/+page.ts
new file mode 100644
index 0000000..9d74553
--- /dev/null
+++ b/src/routes/labs/[slug]/+page.ts
@@ -0,0 +1,36 @@
+import type { PageLoad } from './$types'
+import type { Project } from '$lib/types/project'
+
+export const load: PageLoad = async ({ params, fetch }) => {
+ try {
+ // Find project by slug - we'll fetch all published, list-only, and password-protected projects
+ const response = await fetch(
+ `/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true`
+ )
+ if (!response.ok) {
+ throw new Error('Failed to fetch projects')
+ }
+
+ const data = await response.json()
+ const project = data.projects.find((p: Project) => p.slug === params.slug)
+
+ if (!project) {
+ throw new Error('Project not found')
+ }
+
+ // Handle different project statuses
+ if (project.status === 'draft') {
+ throw new Error('Project not found')
+ }
+
+ return {
+ project
+ }
+ } catch (error) {
+ console.error('Error loading project:', error)
+ return {
+ project: null,
+ error: error instanceof Error ? error.message : 'Failed to load project'
+ }
+ }
+}
diff --git a/src/routes/photos/+page.svelte b/src/routes/photos/+page.svelte
new file mode 100644
index 0000000..deeb722
--- /dev/null
+++ b/src/routes/photos/+page.svelte
@@ -0,0 +1,75 @@
+
+
+
+ {#if error}
+
+
+
Unable to load photos
+
{error}
+
+
+ {:else if photoItems.length === 0}
+
+
+
No photos yet
+
Photography albums will appear here once published.
+
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/src/routes/photos/+page.ts b/src/routes/photos/+page.ts
new file mode 100644
index 0000000..c5fa813
--- /dev/null
+++ b/src/routes/photos/+page.ts
@@ -0,0 +1,25 @@
+import type { PageLoad } from './$types'
+
+export const load: PageLoad = async ({ fetch }) => {
+ try {
+ const response = await fetch('/api/photos?limit=50')
+ if (!response.ok) {
+ throw new Error('Failed to fetch photos')
+ }
+
+ const data = await response.json()
+ return {
+ photoItems: data.photoItems || [],
+ pagination: data.pagination || null
+ }
+ } catch (error) {
+ console.error('Error loading photos:', error)
+
+ // Fallback to empty array if API fails
+ return {
+ photoItems: [],
+ pagination: null,
+ error: 'Failed to load photos'
+ }
+ }
+}
diff --git a/src/routes/photos/[albumSlug]/[photoId]/+page.svelte b/src/routes/photos/[albumSlug]/[photoId]/+page.svelte
new file mode 100644
index 0000000..ad0d194
--- /dev/null
+++ b/src/routes/photos/[albumSlug]/[photoId]/+page.svelte
@@ -0,0 +1,510 @@
+
+
+
+ {#if photo && album}
+ {photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}
+
+
+
+
+
+
+
+
+
+
+ {#if exif?.dateTaken}
+
+ {/if}
+ {:else}
+ Photo Not Found
+ {/if}
+
+
+{#if error || !photo || !album}
+
+
+
Photo Not Found
+
{error || "The photo you're looking for doesn't exist."}
+
← Back to Photos
+
+
+{:else}
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+{/if}
+
+
diff --git a/src/routes/photos/[albumSlug]/[photoId]/+page.ts b/src/routes/photos/[albumSlug]/[photoId]/+page.ts
new file mode 100644
index 0000000..e22c5c2
--- /dev/null
+++ b/src/routes/photos/[albumSlug]/[photoId]/+page.ts
@@ -0,0 +1,30 @@
+import type { PageLoad } from './$types'
+
+export const load: PageLoad = async ({ params, fetch }) => {
+ try {
+ const response = await fetch(`/api/photos/${params.albumSlug}/${params.photoId}`)
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ throw new Error('Photo not found')
+ }
+ throw new Error('Failed to fetch photo')
+ }
+
+ const data = await response.json()
+
+ return {
+ photo: data.photo,
+ album: data.album,
+ navigation: data.navigation
+ }
+ } catch (error) {
+ console.error('Error loading photo:', error)
+ return {
+ photo: null,
+ album: null,
+ navigation: null,
+ error: error instanceof Error ? error.message : 'Failed to load photo'
+ }
+ }
+}
diff --git a/src/routes/photos/[slug]/+page.svelte b/src/routes/photos/[slug]/+page.svelte
new file mode 100644
index 0000000..4f5b75b
--- /dev/null
+++ b/src/routes/photos/[slug]/+page.svelte
@@ -0,0 +1,199 @@
+
+
+
+ {#if album}
+ {album.title} - Photos
+
+ {:else}
+ Album Not Found - Photos
+ {/if}
+
+
+{#if error}
+
+{:else if album}
+
+
+
+
{album.title}
+
+ {#if album.description}
+
{album.description}
+ {/if}
+
+
+ {#if album.date}
+ 📅 {formatDate(album.date)}
+ {/if}
+ {#if album.location}
+ 📍 {album.location}
+ {/if}
+ 📷 {album.photos?.length || 0} photo{(album.photos?.length || 0) !== 1 ? 's' : ''}
+
+
+
+
+ {#if photoItems.length > 0}
+
+ {:else}
+
+
This album doesn't contain any photos yet.
+
+ {/if}
+
+{/if}
+
+
diff --git a/src/routes/photos/[slug]/+page.ts b/src/routes/photos/[slug]/+page.ts
new file mode 100644
index 0000000..e3ef1b1
--- /dev/null
+++ b/src/routes/photos/[slug]/+page.ts
@@ -0,0 +1,31 @@
+import type { PageLoad } from './$types'
+
+export const load: PageLoad = async ({ params, fetch }) => {
+ try {
+ // Fetch the specific album using the individual album endpoint which includes photos
+ const response = await fetch(`/api/albums/by-slug/${params.slug}`)
+ if (!response.ok) {
+ if (response.status === 404) {
+ throw new Error('Album not found')
+ }
+ throw new Error('Failed to fetch album')
+ }
+
+ const album = await response.json()
+
+ // Check if this is a photography album and published
+ if (!album.isPhotography || album.status !== 'published') {
+ throw new Error('Album not found')
+ }
+
+ return {
+ album
+ }
+ } catch (error) {
+ console.error('Error loading album:', error)
+ return {
+ album: null,
+ error: error instanceof Error ? error.message : 'Failed to load album'
+ }
+ }
+}
diff --git a/src/routes/rss/+server.ts b/src/routes/rss/+server.ts
new file mode 100644
index 0000000..190b379
--- /dev/null
+++ b/src/routes/rss/+server.ts
@@ -0,0 +1,242 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { logger } from '$lib/server/logger'
+
+// Helper function to escape XML special characters
+function escapeXML(str: string): string {
+ if (!str) return ''
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
+// Helper function to convert content to HTML for full content
+function convertContentToHTML(content: any): string {
+ if (!content || !content.blocks) return ''
+
+ return content.blocks
+ .map((block: any) => {
+ switch (block.type) {
+ case 'paragraph':
+ return `${escapeXML(block.content || '')}
`
+ case 'heading':
+ const level = block.level || 2
+ return `${escapeXML(block.content || '')}`
+ case 'list':
+ const items = (block.content || [])
+ .map((item: any) => `${escapeXML(item)}`)
+ .join('')
+ return block.listType === 'ordered' ? `${items}
` : ``
+ default:
+ return `${escapeXML(block.content || '')}
`
+ }
+ })
+ .join('\n')
+}
+
+// Helper function to extract text summary from content
+function extractTextSummary(content: any, maxLength: number = 300): string {
+ if (!content || !content.blocks) return ''
+
+ const text = content.blocks
+ .filter((block: any) => block.type === 'paragraph' && block.content)
+ .map((block: any) => block.content)
+ .join(' ')
+
+ return text.length > maxLength ? text.substring(0, maxLength) + '...' : text
+}
+
+// Helper function to format RFC 822 date
+function formatRFC822Date(date: Date): string {
+ return date.toUTCString()
+}
+
+export const GET: RequestHandler = async (event) => {
+ try {
+ // Get published posts from Universe
+ const posts = await prisma.post.findMany({
+ where: {
+ status: 'published',
+ publishedAt: { not: null }
+ },
+ orderBy: { publishedAt: 'desc' },
+ take: 25
+ })
+
+ // Get published albums that show in universe
+ const universeAlbums = await prisma.album.findMany({
+ where: {
+ status: 'published',
+ showInUniverse: true
+ },
+ include: {
+ photos: {
+ where: {
+ status: 'published',
+ showInPhotos: true
+ },
+ orderBy: { displayOrder: 'asc' },
+ take: 1 // Get first photo for cover image
+ },
+ _count: {
+ select: { photos: true }
+ }
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 15
+ })
+
+ // Get published photography albums
+ const photoAlbums = await prisma.album.findMany({
+ where: {
+ status: 'published',
+ isPhotography: true
+ },
+ include: {
+ photos: {
+ where: {
+ status: 'published',
+ showInPhotos: true
+ },
+ orderBy: { displayOrder: 'asc' },
+ take: 1 // Get first photo for cover image
+ },
+ _count: {
+ select: {
+ photos: {
+ where: {
+ status: 'published',
+ showInPhotos: true
+ }
+ }
+ }
+ }
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 15
+ })
+
+ // Combine all content types
+ const items = [
+ ...posts.map((post) => ({
+ type: 'post',
+ section: 'universe',
+ id: post.id.toString(),
+ title:
+ post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`,
+ description: post.excerpt || extractTextSummary(post.content) || '',
+ content: convertContentToHTML(post.content),
+ link: `${event.url.origin}/universe/${post.slug}`,
+ guid: `${event.url.origin}/universe/${post.slug}`,
+ pubDate: post.publishedAt || post.createdAt,
+ updatedDate: post.updatedAt,
+ postType: post.postType,
+ linkUrl: post.linkUrl || null
+ })),
+ ...universeAlbums.map((album) => ({
+ type: 'album',
+ section: 'universe',
+ id: album.id.toString(),
+ title: album.title,
+ description:
+ album.description ||
+ `Photo album with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
+ content: album.description ? `${escapeXML(album.description)}
` : '',
+ link: `${event.url.origin}/photos/${album.slug}`,
+ guid: `${event.url.origin}/photos/${album.slug}`,
+ pubDate: album.createdAt,
+ updatedDate: album.updatedAt,
+ photoCount: album._count.photos,
+ coverPhoto: album.photos[0],
+ location: album.location
+ })),
+ ...photoAlbums
+ .filter((album) => !universeAlbums.some((ua) => ua.id === album.id)) // Avoid duplicates
+ .map((album) => ({
+ type: 'album',
+ section: 'photos',
+ id: album.id.toString(),
+ title: album.title,
+ description:
+ album.description ||
+ `Photography album${album.location ? ` from ${album.location}` : ''} with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
+ content: album.description ? `${escapeXML(album.description)}
` : '',
+ link: `${event.url.origin}/photos/${album.slug}`,
+ guid: `${event.url.origin}/photos/${album.slug}`,
+ pubDate: album.createdAt,
+ updatedDate: album.updatedAt,
+ photoCount: album._count.photos,
+ coverPhoto: album.photos[0],
+ location: album.location,
+ date: album.date
+ }))
+ ].sort((a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime())
+
+ const now = new Date()
+ const lastBuildDate = formatRFC822Date(now)
+
+ // Build RSS XML following best practices
+ const rssXml = `
+
+
+jedmund.com
+Creative work, thoughts, and photography by Justin Edmund
+${event.url.origin}/
+
+en-us
+${lastBuildDate}
+noreply@jedmund.com (Justin Edmund)
+noreply@jedmund.com (Justin Edmund)
+SvelteKit RSS Generator
+https://cyber.harvard.edu/rss/rss.html
+60
+${items
+ .map(
+ (item) => `
+-
+${escapeXML(item.title)}
+
+${item.content ? `` : ''}
+${item.link}
+${item.guid}
+${formatRFC822Date(new Date(item.pubDate))}
+${item.updatedDate ? `${new Date(item.updatedDate).toISOString()}` : ''}
+${item.section}
+${item.type === 'post' ? item.postType : 'album'}
+${item.type === 'post' && item.linkUrl ? `${item.linkUrl}` : ''}
+${
+ item.type === 'album' && item.coverPhoto
+ ? `
+
+
+`
+ : ''
+}
+${item.location ? `${escapeXML(item.location)}` : ''}
+noreply@jedmund.com (Justin Edmund)
+
`
+ )
+ .join('')}
+
+`
+
+ logger.info('Combined RSS feed generated', { itemCount: items.length })
+
+ return new Response(rssXml, {
+ headers: {
+ 'Content-Type': 'application/rss+xml; charset=utf-8',
+ 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
+ 'Last-Modified': lastBuildDate,
+ ETag: `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
+ 'X-Content-Type-Options': 'nosniff',
+ Vary: 'Accept-Encoding'
+ }
+ })
+ } catch (error) {
+ logger.error('Failed to generate combined RSS feed', error as Error)
+ return new Response('Failed to generate RSS feed', { status: 500 })
+ }
+}
diff --git a/src/routes/rss/photos/+server.ts b/src/routes/rss/photos/+server.ts
new file mode 100644
index 0000000..af13333
--- /dev/null
+++ b/src/routes/rss/photos/+server.ts
@@ -0,0 +1,172 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { logger } from '$lib/server/logger'
+
+// Helper function to escape XML special characters
+function escapeXML(str: string): string {
+ if (!str) return ''
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
+// Helper function to format RFC 822 date
+function formatRFC822Date(date: Date): string {
+ return date.toUTCString()
+}
+
+export const GET: RequestHandler = async (event) => {
+ try {
+ // Get published photography albums
+ const albums = await prisma.album.findMany({
+ where: {
+ status: 'published',
+ isPhotography: true
+ },
+ include: {
+ photos: {
+ where: {
+ status: 'published',
+ showInPhotos: true
+ },
+ orderBy: { displayOrder: 'asc' },
+ take: 1 // Get first photo for cover image
+ },
+ _count: {
+ select: {
+ photos: {
+ where: {
+ status: 'published',
+ showInPhotos: true
+ }
+ }
+ }
+ }
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 50 // Limit to most recent 50 albums
+ })
+
+ // Get individual published photos not in albums
+ const standalonePhotos = await prisma.photo.findMany({
+ where: {
+ status: 'published',
+ showInPhotos: true,
+ albumId: null
+ },
+ orderBy: { publishedAt: 'desc' },
+ take: 25
+ })
+
+ // Combine albums and standalone photos
+ const items = [
+ ...albums.map((album) => ({
+ type: 'album',
+ id: album.id.toString(),
+ title: album.title,
+ description:
+ album.description ||
+ `Photography album${album.location ? ` from ${album.location}` : ''} with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
+ content: album.description ? `${escapeXML(album.description)}
` : '',
+ link: `${event.url.origin}/photos/${album.slug}`,
+ pubDate: album.createdAt,
+ updatedDate: album.updatedAt,
+ guid: `${event.url.origin}/photos/${album.slug}`,
+ photoCount: album._count.photos,
+ coverPhoto: album.photos[0],
+ location: album.location,
+ date: album.date
+ })),
+ ...standalonePhotos.map((photo) => ({
+ type: 'photo',
+ id: photo.id.toString(),
+ title: photo.title || photo.filename,
+ description: photo.description || photo.caption || `Photo: ${photo.filename}`,
+ content: photo.description
+ ? `${escapeXML(photo.description)}
`
+ : photo.caption
+ ? `${escapeXML(photo.caption)}
`
+ : '',
+ link: `${event.url.origin}/photos/photo/${photo.slug || photo.id}`,
+ pubDate: photo.publishedAt || photo.createdAt,
+ updatedDate: photo.updatedAt,
+ guid: `${event.url.origin}/photos/photo/${photo.slug || photo.id}`,
+ url: photo.url,
+ thumbnailUrl: photo.thumbnailUrl
+ }))
+ ].sort((a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime())
+
+ const now = new Date()
+ const lastBuildDate = formatRFC822Date(now)
+
+ // Build RSS XML following best practices
+ const rssXml = `
+
+
+Photos - jedmund.com
+Photography and visual content from jedmund
+${event.url.origin}/photos
+
+en-us
+${lastBuildDate}
+noreply@jedmund.com (Justin Edmund)
+noreply@jedmund.com (Justin Edmund)
+SvelteKit RSS Generator
+https://cyber.harvard.edu/rss/rss.html
+60
+${items
+ .map(
+ (item) => `
+-
+${escapeXML(item.title)}
+
+${item.content ? `` : ''}
+${item.link}
+${item.guid}
+${formatRFC822Date(new Date(item.pubDate))}
+${item.updatedDate ? `${new Date(item.updatedDate).toISOString()}` : ''}
+${item.type}
+${
+ item.type === 'album' && item.coverPhoto
+ ? `
+
+
+`
+ : ''
+}
+${
+ item.type === 'photo'
+ ? `
+
+
+`
+ : ''
+}
+${item.location ? `${escapeXML(item.location)}` : ''}
+noreply@jedmund.com (Justin Edmund)
+
`
+ )
+ .join('')}
+
+`
+
+ logger.info('Photos RSS feed generated', { itemCount: items.length })
+
+ return new Response(rssXml, {
+ headers: {
+ 'Content-Type': 'application/rss+xml; charset=utf-8',
+ 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
+ 'Last-Modified': lastBuildDate,
+ ETag: `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
+ 'X-Content-Type-Options': 'nosniff',
+ Vary: 'Accept-Encoding'
+ }
+ })
+ } catch (error) {
+ logger.error('Failed to generate Photos RSS feed', error as Error)
+ return new Response('Failed to generate RSS feed', { status: 500 })
+ }
+}
diff --git a/src/routes/rss/universe/+server.ts b/src/routes/rss/universe/+server.ts
new file mode 100644
index 0000000..15ecf42
--- /dev/null
+++ b/src/routes/rss/universe/+server.ts
@@ -0,0 +1,170 @@
+import type { RequestHandler } from './$types'
+import { prisma } from '$lib/server/database'
+import { logger } from '$lib/server/logger'
+
+// Helper function to escape XML special characters
+function escapeXML(str: string): string {
+ if (!str) return ''
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
+// Helper function to convert content to HTML for full content
+function convertContentToHTML(content: any): string {
+ if (!content || !content.blocks) return ''
+
+ return content.blocks
+ .map((block: any) => {
+ switch (block.type) {
+ case 'paragraph':
+ return `${escapeXML(block.content || '')}
`
+ case 'heading':
+ const level = block.level || 2
+ return `${escapeXML(block.content || '')}`
+ case 'list':
+ const items = (block.content || [])
+ .map((item: any) => `${escapeXML(item)}`)
+ .join('')
+ return block.listType === 'ordered' ? `${items}
` : ``
+ default:
+ return `${escapeXML(block.content || '')}
`
+ }
+ })
+ .join('\n')
+}
+
+// Helper function to extract text summary from content
+function extractTextSummary(content: any, maxLength: number = 300): string {
+ if (!content || !content.blocks) return ''
+
+ const text = content.blocks
+ .filter((block: any) => block.type === 'paragraph' && block.content)
+ .map((block: any) => block.content)
+ .join(' ')
+
+ return text.length > maxLength ? text.substring(0, maxLength) + '...' : text
+}
+
+// Helper function to format RFC 822 date
+function formatRFC822Date(date: Date): string {
+ return date.toUTCString()
+}
+
+export const GET: RequestHandler = async (event) => {
+ try {
+ // Get published posts from Universe
+ const posts = await prisma.post.findMany({
+ where: {
+ status: 'published',
+ publishedAt: { not: null }
+ },
+ orderBy: { publishedAt: 'desc' },
+ take: 50 // Limit to most recent 50 posts
+ })
+
+ // Get published albums that show in universe
+ const albums = await prisma.album.findMany({
+ where: {
+ status: 'published',
+ showInUniverse: true
+ },
+ include: {
+ _count: {
+ select: { photos: true }
+ }
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 25 // Limit to most recent 25 albums
+ })
+
+ // Combine and sort by date
+ const items = [
+ ...posts.map((post) => ({
+ type: 'post',
+ id: post.id.toString(),
+ title:
+ post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`,
+ description: extractTextSummary(post.content) || '',
+ content: convertContentToHTML(post.content),
+ link: `${event.url.origin}/universe/${post.slug}`,
+ guid: `${event.url.origin}/universe/${post.slug}`,
+ pubDate: post.publishedAt || post.createdAt,
+ updatedDate: post.updatedAt,
+ postType: post.postType,
+ linkUrl: post.linkUrl || null
+ })),
+ ...albums.map((album) => ({
+ type: 'album',
+ id: album.id.toString(),
+ title: album.title,
+ description:
+ album.description ||
+ `Photo album with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
+ content: album.description ? `${escapeXML(album.description)}
` : '',
+ link: `${event.url.origin}/photos/${album.slug}`,
+ guid: `${event.url.origin}/photos/${album.slug}`,
+ pubDate: album.createdAt,
+ updatedDate: album.updatedAt,
+ photoCount: album._count.photos
+ }))
+ ].sort((a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime())
+
+ const now = new Date()
+ const lastBuildDate = formatRFC822Date(now)
+
+ // Build RSS XML following best practices
+ const rssXml = `
+
+
+Universe - jedmund.com
+Posts and photo albums from jedmund's universe
+${event.url.origin}/universe
+
+en-us
+${lastBuildDate}
+noreply@jedmund.com (Justin Edmund)
+noreply@jedmund.com (Justin Edmund)
+SvelteKit RSS Generator
+https://cyber.harvard.edu/rss/rss.html
+60
+${items
+ .map(
+ (item) => `
+-
+${escapeXML(item.title)}
+
+${item.content ? `` : ''}
+${item.link}
+${item.guid}
+${formatRFC822Date(new Date(item.pubDate))}
+${item.updatedDate ? `${new Date(item.updatedDate).toISOString()}` : ''}
+${item.type === 'post' ? item.postType : 'album'}
+${item.type === 'post' && item.linkUrl ? `${item.linkUrl}` : ''}
+noreply@jedmund.com (Justin Edmund)
+
`
+ )
+ .join('')}
+
+`
+
+ logger.info('Universe RSS feed generated', { itemCount: items.length })
+
+ return new Response(rssXml, {
+ headers: {
+ 'Content-Type': 'application/rss+xml; charset=utf-8',
+ 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
+ 'Last-Modified': lastBuildDate,
+ ETag: `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
+ 'X-Content-Type-Options': 'nosniff',
+ Vary: 'Accept-Encoding'
+ }
+ })
+ } catch (error) {
+ logger.error('Failed to generate Universe RSS feed', error as Error)
+ return new Response('Failed to generate RSS feed', { status: 500 })
+ }
+}
diff --git a/src/routes/universe/+page.server.ts b/src/routes/universe/+page.server.ts
new file mode 100644
index 0000000..0f75f1e
--- /dev/null
+++ b/src/routes/universe/+page.server.ts
@@ -0,0 +1,23 @@
+import type { PageServerLoad } from './$types'
+
+export const load: PageServerLoad = async ({ fetch }) => {
+ try {
+ const response = await fetch('/api/universe?limit=20')
+ if (!response.ok) {
+ throw new Error('Failed to fetch universe feed')
+ }
+
+ const data = await response.json()
+ return {
+ universeItems: data.items || [],
+ pagination: data.pagination || null
+ }
+ } catch (error) {
+ console.error('Error loading universe feed:', error)
+ return {
+ universeItems: [],
+ pagination: null,
+ error: 'Failed to load universe feed'
+ }
+ }
+}
diff --git a/src/routes/universe/+page.svelte b/src/routes/universe/+page.svelte
new file mode 100644
index 0000000..4485ce6
--- /dev/null
+++ b/src/routes/universe/+page.svelte
@@ -0,0 +1,53 @@
+
+
+
+ Universe - jedmund
+
+
+
+
+ {#if data.error}
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/src/routes/universe/[slug]/+page.server.ts b/src/routes/universe/[slug]/+page.server.ts
new file mode 100644
index 0000000..63d2f5f
--- /dev/null
+++ b/src/routes/universe/[slug]/+page.server.ts
@@ -0,0 +1,30 @@
+import type { PageServerLoad } from './$types'
+
+export const load: PageServerLoad = async ({ params, fetch }) => {
+ try {
+ // Fetch the specific post by slug from the database
+ const response = await fetch(`/api/posts/by-slug/${params.slug}`)
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ return {
+ post: null,
+ error: 'Post not found'
+ }
+ }
+ throw new Error('Failed to fetch post')
+ }
+
+ const post = await response.json()
+
+ return {
+ post
+ }
+ } catch (error) {
+ console.error('Error loading post:', error)
+ return {
+ post: null,
+ error: 'Failed to load post'
+ }
+ }
+}
diff --git a/src/routes/universe/[slug]/+page.svelte b/src/routes/universe/[slug]/+page.svelte
new file mode 100644
index 0000000..83f613f
--- /dev/null
+++ b/src/routes/universe/[slug]/+page.svelte
@@ -0,0 +1,127 @@
+
+
+
+ {#if post}
+ {pageTitle} - jedmund
+
+
+
+
+
+
+ {#if post.attachments && post.attachments.length > 0}
+
+ {/if}
+
+
+
+
+ {:else}
+ Post Not Found - jedmund
+ {/if}
+
+
+{#if error || !post}
+
+
+
+
Post Not Found
+
{error || "The post you're looking for doesn't exist."}
+
+
+
+
+{:else}
+
+
+
+{/if}
+
+
diff --git a/src/routes/work/[slug]/+page.svelte b/src/routes/work/[slug]/+page.svelte
new file mode 100644
index 0000000..cf5549b
--- /dev/null
+++ b/src/routes/work/[slug]/+page.svelte
@@ -0,0 +1,215 @@
+
+
+{#if error}
+
+
+
+
{error}
+
← Back to home
+
+
+{:else if !project}
+
+ Loading project...
+
+{:else if project.status === 'list-only'}
+
+
+
+
This project is not yet available for viewing. Please check back later.
+
← Back to projects
+
+
+{:else if project.status === 'password-protected' || project.status === 'published'}
+ {#snippet projectLayout()}
+
+
+
+
+ {#if project.status === 'password-protected'}
+
+ {#snippet children()}
+
+ {/snippet}
+
+ {:else}
+
+ {/if}
+
+
+ {/snippet}
+
+ {@render projectLayout()}
+{/if}
+
+
diff --git a/src/routes/work/[slug]/+page.ts b/src/routes/work/[slug]/+page.ts
new file mode 100644
index 0000000..ad65f8f
--- /dev/null
+++ b/src/routes/work/[slug]/+page.ts
@@ -0,0 +1,34 @@
+import type { PageLoad } from './$types'
+import type { Project } from '$lib/types/project'
+
+export const load: PageLoad = async ({ params, fetch }) => {
+ try {
+ // Find project by slug - we'll fetch all published, list-only, and password-protected projects
+ const response = await fetch(`/api/projects?includeListOnly=true&includePasswordProtected=true`)
+ if (!response.ok) {
+ throw new Error('Failed to fetch projects')
+ }
+
+ const data = await response.json()
+ const project = data.projects.find((p: Project) => p.slug === params.slug)
+
+ if (!project) {
+ throw new Error('Project not found')
+ }
+
+ // Handle different project statuses
+ if (project.status === 'draft') {
+ throw new Error('Project not found')
+ }
+
+ return {
+ project
+ }
+ } catch (error) {
+ console.error('Error loading project:', error)
+ return {
+ project: null,
+ error: error instanceof Error ? error.message : 'Failed to load project'
+ }
+ }
+}
diff --git a/src/stories/Button.stories.svelte b/src/stories/Button.stories.svelte
new file mode 100644
index 0000000..672eb36
--- /dev/null
+++ b/src/stories/Button.stories.svelte
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/stories/Button.svelte b/src/stories/Button.svelte
new file mode 100644
index 0000000..5ab6049
--- /dev/null
+++ b/src/stories/Button.svelte
@@ -0,0 +1,30 @@
+
+
+
diff --git a/src/stories/Configure.mdx b/src/stories/Configure.mdx
new file mode 100644
index 0000000..1830134
--- /dev/null
+++ b/src/stories/Configure.mdx
@@ -0,0 +1,369 @@
+import { Meta } from '@storybook/addon-docs/blocks'
+
+import Github from './assets/github.svg'
+import Discord from './assets/discord.svg'
+import Youtube from './assets/youtube.svg'
+import Tutorials from './assets/tutorials.svg'
+import Styling from './assets/styling.png'
+import Context from './assets/context.png'
+import Assets from './assets/assets.png'
+import Docs from './assets/docs.png'
+import Share from './assets/share.png'
+import FigmaPlugin from './assets/figma-plugin.png'
+import Testing from './assets/testing.png'
+import Accessibility from './assets/accessibility.png'
+import Theming from './assets/theming.png'
+import AddonLibrary from './assets/addon-library.png'
+
+export const RightArrow = () => (
+
+)
+
+
+
+
+
+ # Configure your project
+
+ Because Storybook works separately from your app, you'll need to configure it for your specific stack and setup. Below, explore guides for configuring Storybook with popular frameworks and tools. If you get stuck, learn how you can ask for help from our community.
+
+
+
+
+

+
Add styling and CSS
+
Like with web applications, there are many ways to include CSS within Storybook. Learn more about setting up styling within Storybook.
+
Learn more
+
+
+

+
Provide context and mocking
+
Often when a story doesn't render, it's because your component is expecting a specific environment or context (like a theme provider) to be available.
+
Learn more
+
+
+

+
+
Load assets and resources
+
To link static files (like fonts) to your projects and stories, use the
+ `staticDirs` configuration option to specify folders to load when
+ starting Storybook.
+
Learn more
+
+
+
+
+
+
+ # Do more with Storybook
+
+ Now that you know the basics, let's explore other parts of Storybook that will improve your experience. This list is just to get you started. You can customise Storybook in many ways to fit your needs.
+
+
+
+
+
+
+

+
Autodocs
+
Auto-generate living,
+ interactive reference documentation from your components and stories.
+
Learn more
+
+
+

+
Publish to Chromatic
+
Publish your Storybook to review and collaborate with your entire team.
+
Learn more
+
+
+

+
Figma Plugin
+
Embed your stories into Figma to cross-reference the design and live
+ implementation in one place.
+
Learn more
+
+
+

+
Testing
+
Use stories to test a component in all its variations, no matter how
+ complex.
+
Learn more
+
+
+

+
Accessibility
+
Automatically test your components for a11y issues as you develop.
+
Learn more
+
+
+

+
Theming
+
Theme Storybook's UI to personalize it to your project.
+
Learn more
+
+
+
+
+
+
+
+

+
+
+
+
+
+

+ Join our contributors building the future of UI development.
+
+
Star on GitHub
+
+
+

+
+
+
+

+
+
+
+
+
+
+
diff --git a/src/stories/Header.stories.svelte b/src/stories/Header.stories.svelte
new file mode 100644
index 0000000..ba4368b
--- /dev/null
+++ b/src/stories/Header.stories.svelte
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/src/stories/Header.svelte b/src/stories/Header.svelte
new file mode 100644
index 0000000..0939e7a
--- /dev/null
+++ b/src/stories/Header.svelte
@@ -0,0 +1,45 @@
+
+
+
diff --git a/src/stories/Page.stories.svelte b/src/stories/Page.stories.svelte
new file mode 100644
index 0000000..dc86c5a
--- /dev/null
+++ b/src/stories/Page.stories.svelte
@@ -0,0 +1,32 @@
+
+
+ {
+ const canvas = within(canvasElement)
+ const loginButton = canvas.getByRole('button', { name: /Log in/i })
+ await expect(loginButton).toBeInTheDocument()
+ await userEvent.click(loginButton)
+ await waitFor(() => expect(loginButton).not.toBeInTheDocument())
+
+ const logoutButton = canvas.getByRole('button', { name: /Log out/i })
+ await expect(logoutButton).toBeInTheDocument()
+ }}
+/>
+
+
diff --git a/src/stories/Page.svelte b/src/stories/Page.svelte
new file mode 100644
index 0000000..55b3e5a
--- /dev/null
+++ b/src/stories/Page.svelte
@@ -0,0 +1,70 @@
+
+
+
+ (user = { name: 'Jane Doe' })}
+ onLogout={() => (user = undefined)}
+ onCreateAccount={() => (user = { name: 'Jane Doe' })}
+ />
+
+
+ Pages in Storybook
+
+ We recommend building UIs with a
+
+ component-driven
+
+ process starting with atomic components and ending with pages.
+
+
+ Render pages with mock data. This makes it easy to build and review page states without
+ needing to navigate to them in your app. Here are some handy patterns for managing page data
+ in Storybook:
+
+
+ -
+ Use a higher-level connected component. Storybook helps you compose such data from the
+ "args" of child component stories
+
+ -
+ Assemble data in the page component from your services. You can mock these services out
+ using Storybook.
+
+
+
+ Get a guided tutorial on component-driven development at
+
+ Storybook tutorials
+
+ . Read more in the
+ docs
+ .
+
+
+
Tip
+ Adjust the width of the canvas with the
+
+ Viewports addon in the toolbar
+
+
+
diff --git a/src/stories/admin/Button.stories.js b/src/stories/admin/Button.stories.js
new file mode 100644
index 0000000..83b0115
--- /dev/null
+++ b/src/stories/admin/Button.stories.js
@@ -0,0 +1,128 @@
+import Button from '$lib/components/admin/Button.svelte'
+import ButtonShowcase from './ButtonShowcase.svelte'
+
+export default {
+ title: 'Admin/Button',
+ component: Button,
+ tags: ['autodocs'],
+ argTypes: {
+ variant: {
+ control: { type: 'select' },
+ options: ['primary', 'secondary', 'danger', 'ghost', 'text', 'overlay']
+ },
+ size: {
+ control: { type: 'select' },
+ options: ['small', 'medium', 'large', 'icon']
+ },
+ iconPosition: {
+ control: { type: 'select' },
+ options: ['left', 'right']
+ },
+ iconOnly: {
+ control: 'boolean'
+ },
+ pill: {
+ control: 'boolean'
+ },
+ fullWidth: {
+ control: 'boolean'
+ },
+ loading: {
+ control: 'boolean'
+ },
+ active: {
+ control: 'boolean'
+ },
+ disabled: {
+ control: 'boolean'
+ },
+ onclick: { action: 'clicked' }
+ }
+}
+
+// Interactive Playground (this will be the default story for the controls)
+export const Playground = {
+ args: {
+ variant: 'primary',
+ size: 'medium',
+ pill: true,
+ iconOnly: false,
+ fullWidth: false,
+ loading: false,
+ active: false,
+ disabled: false
+ }
+}
+
+// Primary story
+export const Primary = {
+ args: {
+ variant: 'primary'
+ }
+}
+
+// Secondary story
+export const Secondary = {
+ args: {
+ variant: 'secondary'
+ }
+}
+
+// Danger story
+export const Danger = {
+ args: {
+ variant: 'danger'
+ }
+}
+
+// Ghost story
+export const Ghost = {
+ args: {
+ variant: 'ghost'
+ }
+}
+
+// Text story
+export const Text = {
+ args: {
+ variant: 'text'
+ }
+}
+
+// Overlay story
+export const Overlay = {
+ args: {
+ variant: 'overlay'
+ }
+}
+
+// Loading State
+export const Loading = {
+ args: {
+ variant: 'primary',
+ loading: true
+ }
+}
+
+// Disabled State
+export const Disabled = {
+ args: {
+ variant: 'primary',
+ disabled: true
+ }
+}
+
+// Full Width
+export const FullWidth = {
+ args: {
+ variant: 'primary',
+ fullWidth: true
+ }
+}
+
+// All variants showcase
+export const AllVariants = {
+ render: () => ({
+ Component: ButtonShowcase
+ })
+}
diff --git a/src/stories/admin/ButtonShowcase.svelte b/src/stories/admin/ButtonShowcase.svelte
new file mode 100644
index 0000000..8317ec2
--- /dev/null
+++ b/src/stories/admin/ButtonShowcase.svelte
@@ -0,0 +1,164 @@
+
+
+
+
+
diff --git a/src/stories/admin/ImageUploader.stories.js b/src/stories/admin/ImageUploader.stories.js
new file mode 100644
index 0000000..88a5721
--- /dev/null
+++ b/src/stories/admin/ImageUploader.stories.js
@@ -0,0 +1,169 @@
+import ImageUploader from '$lib/components/admin/ImageUploader.svelte'
+
+// Mock Media object for testing
+const mockMedia = {
+ id: 1,
+ filename: 'sample-image.jpg',
+ originalName: 'sample-image.jpg',
+ mimeType: 'image/jpeg',
+ size: 1024000,
+ width: 1920,
+ height: 1080,
+ url: 'https://via.placeholder.com/400x300/0066cc/ffffff?text=Sample+Image',
+ thumbnailUrl: 'https://via.placeholder.com/400x300/0066cc/ffffff?text=Sample+Image',
+ altText: 'A beautiful sample image',
+ description: 'This is a sample image for testing purposes',
+ createdAt: new Date(),
+ updatedAt: new Date()
+}
+
+export default {
+ title: 'Admin/Form Components/ImageUploader',
+ component: ImageUploader,
+ tags: ['autodocs'],
+ argTypes: {
+ aspectRatio: {
+ control: { type: 'select' },
+ options: ['', '1:1', '16:9', '4:3', '3:2']
+ },
+ required: {
+ control: 'boolean'
+ },
+ allowAltText: {
+ control: 'boolean'
+ },
+ showBrowseLibrary: {
+ control: 'boolean'
+ },
+ maxFileSize: {
+ control: 'number'
+ },
+ label: {
+ control: 'text'
+ },
+ placeholder: {
+ control: 'text'
+ },
+ helpText: {
+ control: 'text'
+ },
+ error: {
+ control: 'text'
+ }
+ }
+}
+
+// Empty uploader
+export const Empty = {
+ args: {
+ label: 'Featured Image',
+ placeholder: 'Drag and drop an image here, or click to browse',
+ allowAltText: true,
+ required: false,
+ maxFileSize: 10
+ }
+}
+
+// With uploaded image
+export const WithImage = {
+ args: {
+ label: 'Project Logo',
+ value: mockMedia,
+ allowAltText: true,
+ aspectRatio: '1:1'
+ }
+}
+
+// Square aspect ratio
+export const SquareAspectRatio = {
+ args: {
+ label: 'Square Logo',
+ aspectRatio: '1:1',
+ placeholder: 'Upload a square logo',
+ allowAltText: true,
+ required: true
+ }
+}
+
+// Wide aspect ratio
+export const WideAspectRatio = {
+ args: {
+ label: 'Hero Banner',
+ aspectRatio: '16:9',
+ placeholder: 'Upload a banner image',
+ allowAltText: true,
+ helpText: 'Recommended size: 1920x1080 pixels'
+ }
+}
+
+// Required field
+export const Required = {
+ args: {
+ label: 'Required Image',
+ required: true,
+ allowAltText: true,
+ placeholder: 'This field is required'
+ }
+}
+
+// With error
+export const WithError = {
+ args: {
+ label: 'Image with Error',
+ error: 'Please select a valid image file',
+ allowAltText: true
+ }
+}
+
+// With help text
+export const WithHelpText = {
+ args: {
+ label: 'Profile Picture',
+ helpText: 'Upload a clear photo of yourself. This will be displayed on your profile.',
+ allowAltText: true,
+ aspectRatio: '1:1',
+ maxFileSize: 5
+ }
+}
+
+// Without alt text
+export const WithoutAltText = {
+ args: {
+ label: 'Decorative Image',
+ allowAltText: false,
+ placeholder: 'Upload a decorative image',
+ helpText: 'This image is purely decorative and does not need alt text.'
+ }
+}
+
+// With browse library option
+export const WithBrowseLibrary = {
+ args: {
+ label: 'Featured Image',
+ allowAltText: true,
+ showBrowseLibrary: true,
+ placeholder: 'Upload a new image or browse existing ones'
+ }
+}
+
+// Small file size limit
+export const SmallFileLimit = {
+ args: {
+ label: 'Small Image',
+ maxFileSize: 1,
+ allowAltText: true,
+ helpText: 'Maximum file size: 1MB'
+ }
+}
+
+// Interactive playground
+export const Playground = {
+ args: {
+ label: 'Image Upload',
+ placeholder: 'Drag and drop an image here',
+ allowAltText: true,
+ required: false,
+ maxFileSize: 10,
+ showBrowseLibrary: false
+ }
+}
diff --git a/src/stories/admin/Input.stories.js b/src/stories/admin/Input.stories.js
new file mode 100644
index 0000000..237b0cb
--- /dev/null
+++ b/src/stories/admin/Input.stories.js
@@ -0,0 +1,286 @@
+import Input from '$lib/components/admin/Input.svelte'
+
+export default {
+ title: 'Admin/Input',
+ component: Input,
+ tags: ['autodocs'],
+ argTypes: {
+ type: {
+ control: { type: 'select' },
+ options: [
+ 'text',
+ 'email',
+ 'password',
+ 'url',
+ 'search',
+ 'number',
+ 'tel',
+ 'date',
+ 'time',
+ 'color',
+ 'textarea'
+ ]
+ },
+ size: {
+ control: { type: 'select' },
+ options: ['small', 'medium', 'large']
+ },
+ fullWidth: {
+ control: 'boolean'
+ },
+ required: {
+ control: 'boolean'
+ },
+ disabled: {
+ control: 'boolean'
+ },
+ readonly: {
+ control: 'boolean'
+ },
+ prefixIcon: {
+ control: 'boolean'
+ },
+ suffixIcon: {
+ control: 'boolean'
+ },
+ showCharCount: {
+ control: 'boolean'
+ },
+ label: {
+ control: 'text'
+ },
+ placeholder: {
+ control: 'text'
+ },
+ helpText: {
+ control: 'text'
+ },
+ error: {
+ control: 'text'
+ },
+ maxLength: {
+ control: 'number'
+ }
+ }
+}
+
+// Interactive Playground
+export const Playground = {
+ args: {
+ type: 'text',
+ label: 'Your Name',
+ placeholder: 'Enter your name',
+ size: 'medium',
+ fullWidth: true,
+ required: false,
+ disabled: false,
+ readonly: false
+ }
+}
+
+// Basic text input
+export const Basic = {
+ args: {
+ type: 'text',
+ label: 'Basic Input',
+ placeholder: 'Type something...'
+ }
+}
+
+// Email input
+export const Email = {
+ args: {
+ type: 'email',
+ label: 'Email Address',
+ placeholder: 'you@example.com',
+ required: true
+ }
+}
+
+// Password input
+export const Password = {
+ args: {
+ type: 'password',
+ label: 'Password',
+ placeholder: 'Enter your password',
+ required: true
+ }
+}
+
+// Search input
+export const Search = {
+ args: {
+ type: 'search',
+ label: 'Search',
+ placeholder: 'Search for something...'
+ }
+}
+
+// Number input
+export const Number = {
+ args: {
+ type: 'number',
+ label: 'Age',
+ placeholder: '25',
+ min: 0,
+ max: 120
+ }
+}
+
+// Textarea
+export const Textarea = {
+ args: {
+ type: 'textarea',
+ label: 'Description',
+ placeholder: 'Tell us about yourself...',
+ rows: 4
+ }
+}
+
+// Auto-resizing textarea
+export const AutoResizeTextarea = {
+ args: {
+ type: 'textarea',
+ label: 'Auto-resize Textarea',
+ placeholder: 'This textarea will grow as you type...',
+ autoResize: true,
+ rows: 2
+ }
+}
+
+// With help text
+export const WithHelpText = {
+ args: {
+ type: 'email',
+ label: 'Email',
+ placeholder: 'you@example.com',
+ helpText: 'We will never share your email with anyone else.'
+ }
+}
+
+// With error
+export const WithError = {
+ args: {
+ type: 'email',
+ label: 'Email',
+ value: 'invalid-email',
+ error: 'Please enter a valid email address.',
+ required: true
+ }
+}
+
+// Character count
+export const CharacterCount = {
+ args: {
+ type: 'textarea',
+ label: 'Bio',
+ placeholder: 'Tell us about yourself...',
+ maxLength: 150,
+ showCharCount: true,
+ rows: 3
+ }
+}
+
+// Different sizes
+export const Sizes = {
+ render: () => ({
+ template: `
+
+
+
+
+
+ `,
+ components: { Input }
+ })
+}
+
+// Disabled state
+export const Disabled = {
+ args: {
+ type: 'text',
+ label: 'Disabled Input',
+ value: 'This input is disabled',
+ disabled: true
+ }
+}
+
+// Readonly state
+export const Readonly = {
+ args: {
+ type: 'text',
+ label: 'Readonly Input',
+ value: 'This input is readonly',
+ readonly: true
+ }
+}
+
+// With prefix icon
+export const WithPrefixIcon = {
+ args: {
+ type: 'email',
+ label: 'Email with Icon',
+ placeholder: 'you@example.com',
+ prefixIcon: true
+ },
+ render: (args) => ({
+ Component: Input,
+ props: args,
+ slots: {
+ prefix: () => `
+
+ `
+ }
+ })
+}
+
+// With suffix icon
+export const WithSuffixIcon = {
+ args: {
+ type: 'search',
+ label: 'Search with Icon',
+ placeholder: 'Search...',
+ suffixIcon: true
+ },
+ render: (args) => ({
+ Component: Input,
+ props: args,
+ slots: {
+ suffix: () => `
+
+ `
+ }
+ })
+}
+
+// Color input
+export const ColorInput = {
+ args: {
+ type: 'color',
+ label: 'Pick a Color',
+ value: '#ff6b6b'
+ }
+}
+
+// Date input
+export const DateInput = {
+ args: {
+ type: 'date',
+ label: 'Select Date',
+ required: true
+ }
+}
+
+// Time input
+export const TimeInput = {
+ args: {
+ type: 'time',
+ label: 'Select Time'
+ }
+}
diff --git a/src/stories/admin/MediaInput.stories.js b/src/stories/admin/MediaInput.stories.js
new file mode 100644
index 0000000..90e0962
--- /dev/null
+++ b/src/stories/admin/MediaInput.stories.js
@@ -0,0 +1,207 @@
+import MediaInput from '$lib/components/admin/MediaInput.svelte'
+
+// Mock Media objects for testing
+const mockMedia = {
+ id: '1',
+ filename: 'example-image.jpg',
+ originalName: 'example-image.jpg',
+ mimeType: 'image/jpeg',
+ size: 1024000,
+ width: 1920,
+ height: 1080,
+ url: 'https://via.placeholder.com/300x200/0066cc/ffffff?text=Sample+Image',
+ thumbnailUrl: 'https://via.placeholder.com/300x200/0066cc/ffffff?text=Sample+Image',
+ createdAt: new Date(),
+ updatedAt: new Date()
+}
+
+const mockMediaList = [
+ mockMedia,
+ {
+ id: '2',
+ filename: 'another-image.png',
+ originalName: 'another-image.png',
+ mimeType: 'image/png',
+ size: 512000,
+ width: 800,
+ height: 600,
+ url: 'https://via.placeholder.com/300x200/cc6600/ffffff?text=Image+2',
+ thumbnailUrl: 'https://via.placeholder.com/300x200/cc6600/ffffff?text=Image+2',
+ createdAt: new Date(),
+ updatedAt: new Date()
+ },
+ {
+ id: '3',
+ filename: 'third-image.jpg',
+ originalName: 'third-image.jpg',
+ mimeType: 'image/jpeg',
+ size: 768000,
+ width: 1200,
+ height: 800,
+ url: 'https://via.placeholder.com/300x200/009966/ffffff?text=Image+3',
+ thumbnailUrl: 'https://via.placeholder.com/300x200/009966/ffffff?text=Image+3',
+ createdAt: new Date(),
+ updatedAt: new Date()
+ }
+]
+
+export default {
+ title: 'Admin/Form Components/MediaInput',
+ component: MediaInput,
+ tags: ['autodocs'],
+ argTypes: {
+ mode: {
+ control: { type: 'select' },
+ options: ['single', 'multiple']
+ },
+ fileType: {
+ control: { type: 'select' },
+ options: ['image', 'video', 'all']
+ },
+ required: {
+ control: 'boolean'
+ },
+ label: {
+ control: 'text'
+ },
+ placeholder: {
+ control: 'text'
+ },
+ error: {
+ control: 'text'
+ }
+ }
+}
+
+// Single media input (empty)
+export const SingleEmpty = {
+ args: {
+ label: 'Featured Image',
+ mode: 'single',
+ fileType: 'image',
+ placeholder: 'No image selected',
+ value: null
+ }
+}
+
+// Single media input (with value)
+export const SingleWithValue = {
+ args: {
+ label: 'Featured Image',
+ mode: 'single',
+ fileType: 'image',
+ value: mockMedia
+ }
+}
+
+// Multiple media input (empty)
+export const MultipleEmpty = {
+ args: {
+ label: 'Gallery Images',
+ mode: 'multiple',
+ fileType: 'image',
+ placeholder: 'No images selected',
+ value: []
+ }
+}
+
+// Multiple media input (with values)
+export const MultipleWithValues = {
+ args: {
+ label: 'Gallery Images',
+ mode: 'multiple',
+ fileType: 'image',
+ value: mockMediaList
+ }
+}
+
+// Required field
+export const Required = {
+ args: {
+ label: 'Logo Image',
+ mode: 'single',
+ fileType: 'image',
+ required: true,
+ placeholder: 'Choose a logo image'
+ }
+}
+
+// With error state
+export const WithError = {
+ args: {
+ label: 'Profile Picture',
+ mode: 'single',
+ fileType: 'image',
+ required: true,
+ error: 'Please select a profile picture'
+ }
+}
+
+// All file types
+export const AllFileTypes = {
+ args: {
+ label: 'Any Media File',
+ mode: 'single',
+ fileType: 'all',
+ placeholder: 'Choose any media file'
+ }
+}
+
+// Video only
+export const VideoOnly = {
+ args: {
+ label: 'Video File',
+ mode: 'single',
+ fileType: 'video',
+ placeholder: 'Choose a video file'
+ }
+}
+
+// Multiple with many files
+export const MultipleWithManyFiles = {
+ args: {
+ label: 'Project Assets',
+ mode: 'multiple',
+ fileType: 'all',
+ value: [
+ ...mockMediaList,
+ {
+ id: '4',
+ filename: 'video-file.mp4',
+ originalName: 'video-file.mp4',
+ mimeType: 'video/mp4',
+ size: 5120000,
+ width: null,
+ height: null,
+ url: 'https://via.placeholder.com/300x200/990066/ffffff?text=Video',
+ thumbnailUrl: 'https://via.placeholder.com/300x200/990066/ffffff?text=Video',
+ createdAt: new Date(),
+ updatedAt: new Date()
+ },
+ {
+ id: '5',
+ filename: 'document.pdf',
+ originalName: 'document.pdf',
+ mimeType: 'application/pdf',
+ size: 1024000,
+ width: null,
+ height: null,
+ url: 'https://via.placeholder.com/300x200/666666/ffffff?text=PDF',
+ thumbnailUrl: null,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ }
+ ]
+ }
+}
+
+// Interactive playground
+export const Playground = {
+ args: {
+ label: 'Media Input',
+ mode: 'single',
+ fileType: 'image',
+ required: false,
+ placeholder: 'Select a file'
+ }
+}
diff --git a/src/stories/assets/accessibility.png b/src/stories/assets/accessibility.png
new file mode 100644
index 0000000..6ffe6fe
Binary files /dev/null and b/src/stories/assets/accessibility.png differ
diff --git a/src/stories/assets/accessibility.svg b/src/stories/assets/accessibility.svg
new file mode 100644
index 0000000..107e93f
--- /dev/null
+++ b/src/stories/assets/accessibility.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/stories/assets/addon-library.png b/src/stories/assets/addon-library.png
new file mode 100644
index 0000000..95deb38
Binary files /dev/null and b/src/stories/assets/addon-library.png differ
diff --git a/src/stories/assets/assets.png b/src/stories/assets/assets.png
new file mode 100644
index 0000000..cfba681
Binary files /dev/null and b/src/stories/assets/assets.png differ
diff --git a/src/stories/assets/avif-test-image.avif b/src/stories/assets/avif-test-image.avif
new file mode 100644
index 0000000..530709b
Binary files /dev/null and b/src/stories/assets/avif-test-image.avif differ
diff --git a/src/stories/assets/context.png b/src/stories/assets/context.png
new file mode 100644
index 0000000..e5cd249
Binary files /dev/null and b/src/stories/assets/context.png differ
diff --git a/src/stories/assets/discord.svg b/src/stories/assets/discord.svg
new file mode 100644
index 0000000..d638958
--- /dev/null
+++ b/src/stories/assets/discord.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/stories/assets/docs.png b/src/stories/assets/docs.png
new file mode 100644
index 0000000..a749629
Binary files /dev/null and b/src/stories/assets/docs.png differ
diff --git a/src/stories/assets/figma-plugin.png b/src/stories/assets/figma-plugin.png
new file mode 100644
index 0000000..8f79b08
Binary files /dev/null and b/src/stories/assets/figma-plugin.png differ
diff --git a/src/stories/assets/github.svg b/src/stories/assets/github.svg
new file mode 100644
index 0000000..dc51352
--- /dev/null
+++ b/src/stories/assets/github.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/stories/assets/share.png b/src/stories/assets/share.png
new file mode 100644
index 0000000..8097a37
Binary files /dev/null and b/src/stories/assets/share.png differ
diff --git a/src/stories/assets/styling.png b/src/stories/assets/styling.png
new file mode 100644
index 0000000..d341e82
Binary files /dev/null and b/src/stories/assets/styling.png differ
diff --git a/src/stories/assets/testing.png b/src/stories/assets/testing.png
new file mode 100644
index 0000000..d4ac39a
Binary files /dev/null and b/src/stories/assets/testing.png differ
diff --git a/src/stories/assets/theming.png b/src/stories/assets/theming.png
new file mode 100644
index 0000000..1535eb9
Binary files /dev/null and b/src/stories/assets/theming.png differ
diff --git a/src/stories/assets/tutorials.svg b/src/stories/assets/tutorials.svg
new file mode 100644
index 0000000..b492a9c
--- /dev/null
+++ b/src/stories/assets/tutorials.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/stories/assets/youtube.svg b/src/stories/assets/youtube.svg
new file mode 100644
index 0000000..a7515d7
--- /dev/null
+++ b/src/stories/assets/youtube.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/stories/button.css b/src/stories/button.css
new file mode 100644
index 0000000..4fc38a2
--- /dev/null
+++ b/src/stories/button.css
@@ -0,0 +1,30 @@
+.storybook-button {
+ display: inline-block;
+ cursor: pointer;
+ border: 0;
+ border-radius: 3em;
+ font-weight: 700;
+ line-height: 1;
+ font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+}
+.storybook-button--primary {
+ background-color: #555ab9;
+ color: white;
+}
+.storybook-button--secondary {
+ box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
+ background-color: transparent;
+ color: #333;
+}
+.storybook-button--small {
+ padding: 10px 16px;
+ font-size: 12px;
+}
+.storybook-button--medium {
+ padding: 11px 20px;
+ font-size: 14px;
+}
+.storybook-button--large {
+ padding: 12px 24px;
+ font-size: 16px;
+}
diff --git a/src/stories/header.css b/src/stories/header.css
new file mode 100644
index 0000000..d511c66
--- /dev/null
+++ b/src/stories/header.css
@@ -0,0 +1,32 @@
+.storybook-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+ padding: 15px 20px;
+ font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+}
+
+.storybook-header svg {
+ display: inline-block;
+ vertical-align: top;
+}
+
+.storybook-header h1 {
+ display: inline-block;
+ vertical-align: top;
+ margin: 6px 0 6px 10px;
+ font-weight: 700;
+ font-size: 20px;
+ line-height: 1;
+}
+
+.storybook-header button + button {
+ margin-left: 10px;
+}
+
+.storybook-header .welcome {
+ margin-right: 10px;
+ color: #333;
+ font-size: 14px;
+}
diff --git a/src/stories/page.css b/src/stories/page.css
new file mode 100644
index 0000000..67f2bb2
--- /dev/null
+++ b/src/stories/page.css
@@ -0,0 +1,68 @@
+.storybook-page {
+ margin: 0 auto;
+ padding: 48px 20px;
+ max-width: 600px;
+ color: #333;
+ font-size: 14px;
+ line-height: 24px;
+ font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+}
+
+.storybook-page h2 {
+ display: inline-block;
+ vertical-align: top;
+ margin: 0 0 4px;
+ font-weight: 700;
+ font-size: 32px;
+ line-height: 1;
+}
+
+.storybook-page p {
+ margin: 1em 0;
+}
+
+.storybook-page a {
+ color: inherit;
+}
+
+.storybook-page ul {
+ margin: 1em 0;
+ padding-left: 30px;
+}
+
+.storybook-page li {
+ margin-bottom: 8px;
+}
+
+.storybook-page .tip {
+ display: inline-block;
+ vertical-align: top;
+ margin-right: 10px;
+ border-radius: 1em;
+ background: #e7fdd8;
+ padding: 4px 12px;
+ color: #357a14;
+ font-weight: 700;
+ font-size: 11px;
+ line-height: 12px;
+}
+
+.storybook-page .tip-wrapper {
+ margin-top: 40px;
+ margin-bottom: 40px;
+ font-size: 13px;
+ line-height: 20px;
+}
+
+.storybook-page .tip-wrapper svg {
+ display: inline-block;
+ vertical-align: top;
+ margin-top: 3px;
+ margin-right: 4px;
+ width: 12px;
+ height: 12px;
+}
+
+.storybook-page .tip-wrapper svg path {
+ fill: #1ea7fd;
+}
diff --git a/svelte.config.js b/svelte.config.js
index f27eebb..1998181 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -11,14 +11,23 @@ const config = {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
- adapter: adapter(),
+ adapter: adapter({
+ // Increase body size limit to 10MB for file uploads
+ bodyParser: {
+ sizeLimit: 10485760 // 10MB in bytes
+ }
+ }),
+
+ csrf: {
+ checkOrigin: false
+ },
alias: {
$icons: 'src/assets/icons',
$illos: 'src/assets/illos',
$components: 'src/lib/components',
$utils: 'src/lib/utils',
- $styles: 'src/styles'
+ $styles: 'src/assets/styles'
}
}
}
diff --git a/test-form-loading.js b/test-form-loading.js
new file mode 100644
index 0000000..7bef28a
--- /dev/null
+++ b/test-form-loading.js
@@ -0,0 +1,35 @@
+// Simple test to check if project edit page loads correctly
+const puppeteer = require('puppeteer')
+
+;(async () => {
+ const browser = await puppeteer.launch({ headless: false })
+ const page = await browser.newPage()
+
+ try {
+ // Go to admin login first (might be needed)
+ await page.goto('http://localhost:5173/admin/login')
+ await page.waitForTimeout(1000)
+
+ // Try to go directly to edit page
+ await page.goto('http://localhost:5173/admin/projects/8/edit')
+ await page.waitForTimeout(2000)
+
+ // Check if title field is populated
+ const titleValue = await page.$eval(
+ 'input[placeholder*="title" i], input[name*="title" i], #title',
+ (el) => el.value
+ )
+
+ console.log('Title field value:', titleValue)
+
+ if (titleValue === 'Maitsu') {
+ console.log('✅ Form loading works correctly!')
+ } else {
+ console.log('❌ Form loading failed - title not populated')
+ }
+ } catch (error) {
+ console.error('Test failed:', error.message)
+ } finally {
+ await browser.close()
+ }
+})()
diff --git a/vite.config.ts b/vite.config.ts
index b3b3e18..97fee1e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -4,6 +4,11 @@ import autoprefixer from 'autoprefixer'
import svg from '@poppanator/sveltekit-svg'
export default defineConfig({
+ server: {
+ watch: {
+ usePolling: true
+ }
+ },
plugins: [
sveltekit(),
svg({
@@ -59,7 +64,8 @@ export default defineConfig({
@import './src/assets/styles/fonts.scss';
@import './src/assets/styles/themes.scss';
@import './src/assets/styles/globals.scss';
- `
+ `,
+ api: 'modern-compiler'
}
},
postcss: {