blog (#4)
Some checks failed
Deployment / Deploy website (push) Failing after 58s
Deployment / Deploy strapi (push) Successful in 3m0s

Co-authored-by: Benjamin Palko <benjaminpalko@hotmail.com>
Reviewed-on: #4
This commit is contained in:
Benjamin Palko 2025-05-05 13:02:22 -04:00
parent b2844e8d76
commit 14fe55bb31
18 changed files with 3164 additions and 30 deletions

View file

@ -1,17 +0,0 @@
NPM_TOKEN=
# Name: Toby Purdy
MAIL_TRANSPORT_HOST=smtp.ethereal.email
MAIL_TRANSPORT_PORT=587
MAIL_TRANSPORT_USER=toby.purdy58@ethereal.email
MAIL_TRANSPORT_PASS=2bh8GWQpB2FWk6Eu9S
STRAPI_HOST=
STRAPI_PORT=
DATABASE_CLIENT=
DATABASE_HOST=
DATABASE_PORT=
DATABASE_NAME=
DATABASE_USERNAME=
DATABASE_PASSWORD=

View file

@ -3,14 +3,14 @@ FROM oven/bun:1.2-alpine AS build
WORKDIR /opt/build WORKDIR /opt/build
COPY ./package.json ./bun.lock ./ COPY ./package.json ./bun.lock ./
RUN bun install RUN bun install --filter '!strapi'
COPY postcss.config.js svelte.config.js tailwind.config.ts tsconfig.json vite.config.ts ./ COPY postcss.config.js svelte.config.js tailwind.config.ts tsconfig.json vite.config.ts ./
COPY ./src ./src COPY ./src ./src
COPY ./static ./static COPY ./static ./static
RUN bun run build RUN bun run build
RUN rm -rf ./node_modules/ && bun install --production RUN rm -rf ./node_modules/ && bun install --filter '!strapi' --production
FROM node:18-alpine3.21 FROM node:18-alpine3.21

2833
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -46,6 +46,7 @@
"vitest": "^3.1.2" "vitest": "^3.1.2"
}, },
"dependencies": { "dependencies": {
"dayjs": "^1.11.13",
"lucide-svelte": "^0.503.0", "lucide-svelte": "^0.503.0",
"nodemailer": "^6.10.1" "nodemailer": "^6.10.1"
} }

View file

@ -0,0 +1,48 @@
<script lang="ts">
import dayjs from 'dayjs';
import { Link } from 'lucide-svelte';
interface Props {
documentId: string;
img: string;
'img-alt': string | null;
title: string;
published: string;
topics: { id: number; Topic: string }[];
}
let { documentId, img, 'img-alt': alt, title, published, topics }: Props = $props();
let blogLink = $derived(`/blog/${documentId}`);
</script>
<div class="card">
<figure class="rounded-box group">
<div class="absolute top-4 left-4 z-10 flex gap-2">
{#each topics as topic (topic.id)}
<div class="badge badge-soft badge-info">{topic.Topic}</div>
{/each}
</div>
<div class="absolute z-10 opacity-0 transition-opacity group-hover:opacity-75">
<Link size={32} />
</div>
<a href={blogLink}>
<img
class="z-0 aspect-4/2 w-full object-cover transition-transform group-hover:scale-110 group-hover:blur-xs"
src={img}
{alt}
/>
</a>
</figure>
<div class="card-body">
<div class="card-actions justify-between">
<div>
<p class="text-xs font-light text-gray-500">
Published {dayjs(published).format('DD MMMM YYYY')}
</p>
<h2 class="card-title text-xl">{title}</h2>
</div>
<a href={blogLink} class="btn btn-primary">Read Now</a>
</div>
</div>
</div>

View file

@ -34,7 +34,7 @@
<div class="drawer-content h-screen w-screen lg:p-12"> <div class="drawer-content h-screen w-screen lg:p-12">
<div <div
class="bg-base-200 grid min-h-full grid-rows-[max-content_max-content_auto_max-content] overflow-hidden lg:gap-4 lg:rounded-4xl" class="bg-base-200 mx-auto grid min-h-full max-w-7xl grid-rows-[max-content_max-content_auto_max-content] overflow-hidden shadow-md lg:gap-4 lg:rounded-4xl"
> >
<header class="lg:p-6"> <header class="lg:p-6">
<Navbar drawerId="navigation-drawer" title="Benjamin Palko" {pages} route={page.route} /> <Navbar drawerId="navigation-drawer" title="Benjamin Palko" {pages} route={page.route} />
@ -66,14 +66,14 @@
</div> </div>
</div> </div>
<div class="drawer-side"> <div class="drawer-side z-50">
<label for="navigation-drawer" aria-label="close sidebar" class="drawer-overlay"></label> <label for="navigation-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<div class="menu bg-base-200 flex min-h-full w-full flex-col justify-between p-4"> <div class="menu bg-base-200 flex min-h-full w-full flex-col justify-between p-4">
<ul class="flex flex-col gap-4"> <ul class="flex flex-col gap-4">
{#each pages as page (page.slug)} {#each pages as page (page.slug)}
<li> <li>
<button <button
class="btn btn-primary btn-soft btn-lg" class="btn btn-primary btn-outline btn-lg"
onclick={() => { onclick={() => {
goto(page.slug); goto(page.slug);
drawerOpen = false; drawerOpen = false;
@ -88,7 +88,7 @@
<span class="text-base-content font-semibold">Benjamin</span> <span class="text-base-content font-semibold">Benjamin</span>
Palko Palko
</h2> </h2>
<label for="navigation-drawer" class="btn btn-error btn-soft btn-lg">Close</label> <label for="navigation-drawer" class="btn btn-accent btn-outline btn-lg">Close</label>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,89 @@
import { env } from '$env/dynamic/private';
import type { PageServerLoad } from './$types';
type Dates = {
createdAt: string;
updateAt: string;
publishedAt: string;
};
interface BlogPost extends Dates {
id: number;
documentId: string;
Title: string;
Header: Image;
Body: string;
Topics: Topic[];
}
interface Topic {
id: number;
Topic: string;
}
interface ImageFormat {
ext: string;
url: string;
hash: string;
mime: string;
name: string;
path: unknown;
size: number;
width: number;
height: number;
sizeInBytes: number;
}
interface Image extends Dates {
id: number;
documentId: string;
name: string;
alternativeText: string | null;
caption: string | null;
width: string;
height: string;
formats: {
large: ImageFormat;
medium: ImageFormat;
small: ImageFormat;
thumbnail: ImageFormat;
};
hash: string;
ext: string;
mime: string;
size: number;
url: string;
previewUrl: string | null;
provider: string;
provider_metadata: unknown | null;
}
interface Meta {
pagingation: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
}
export const load: PageServerLoad = async () => {
const response = await fetch(
`http://${env.STRAPI_HOST}:${env.STRAPI_PORT}/api/blogs?populate=Header&populate=Topics`,
{
method: 'GET',
headers: {
Authorization: `bearer ${env.STRAPI_TOKEN}`
}
}
);
const { data, meta }: { data: BlogPost[]; meta: Meta } = await response.json();
console.log(JSON.stringify(data, null, 2));
return {
posts: data,
meta
};
};

View file

@ -1,5 +1,26 @@
<script> <script lang="ts">
import Wip from '$lib/components/WIP.svelte'; import { env } from '$env/dynamic/public';
import BlogCard from '$lib/components/BlogCard.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let posts = $derived(data.posts);
</script> </script>
<Wip /> {#if posts.length === 0}
<h3 class="text-center text-lg font-semibold">Looks like I haven't posted anything yet!</h3>
{/if}
<div class="mx-auto grid gap-8 px-3 py-3 sm:px-8 sm:py-4 lg:grid-cols-2">
{#each posts as post (post.id)}
<BlogCard
documentId={post.documentId}
img={`${env.PUBLIC_STRAPI_HOST}${post.Header.formats.medium.url}`}
img-alt={post.Header.alternativeText}
title={post.Title}
published={post.publishedAt}
topics={post.Topics}
/>
{/each}
</div>

View file

@ -0,0 +1,24 @@
import { env } from '$env/dynamic/private';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const { slug } = params;
const response = await fetch(
`http://${env.STRAPI_HOST}:${env.STRAPI_PORT}/api/blogs/${slug}?populate=Header&populate=Topics`,
{
method: 'GET',
headers: {
Authorization: `bearer ${env.STRAPI_TOKEN}`
}
}
);
const { data, meta } = await response.json();
console.log(JSON.stringify(data, null, 2));
return {
post: data,
meta
};
};

View file

@ -0,0 +1,30 @@
<script lang="ts">
import dayjs from 'dayjs';
import type { PageProps } from './$types';
import { compile } from 'mdsvex';
import { env } from '$env/dynamic/public';
let { data }: PageProps = $props();
let post = $derived(data.post);
</script>
<div class="flex flex-col items-center gap-4">
<div class="w-full max-w-5xl px-4">
<p class="text-gray-500">{dayjs(post.createdAt).format('DD MMMM YYYY')}</p>
<h1 class="text-4xl font-light">{post.Title}</h1>
</div>
<img
class="aspect-4/2 w-full object-cover"
src={`${env.PUBLIC_STRAPI_HOST}${post.Header.formats.medium.url}`}
alt={post.Header.alternativeText}
/>
<div class="w-full max-w-5xl px-4">
{#await compile(post.Body)}
<div class="skeleton h-48 w-full"></div>
{:then content}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html content?.code}
{/await}
</div>
</div>

View file

@ -6,7 +6,7 @@ ENV NODE_ENV=${NODE_ENV}
WORKDIR /opt/ WORKDIR /opt/
COPY package.json bun.lock ./ COPY package.json bun.lock ./
RUN bun add --global node-gyp && bun install --production --frozen-lockfile RUN bun add --global node-gyp && bun install --filter 'strapi' --production --frozen-lockfile
ENV PATH=/opt/node_modules/.bin:$PATH ENV PATH=/opt/node_modules/.bin:$PATH
WORKDIR /opt/app WORKDIR /opt/app
COPY . . COPY . .

View file

@ -0,0 +1,36 @@
{
"kind": "collectionType",
"collectionName": "blogs",
"info": {
"singularName": "blog",
"pluralName": "blogs",
"displayName": "Blog",
"description": ""
},
"options": {
"draftAndPublish": true
},
"attributes": {
"Title": {
"type": "string",
"required": true,
"unique": true
},
"Body": {
"type": "richtext",
"required": true
},
"Topics": {
"displayName": "Tag",
"type": "component",
"repeatable": true,
"component": "shared.tag"
},
"Header": {
"type": "media",
"multiple": false,
"required": true,
"allowedTypes": ["images"]
}
}
}

View file

@ -0,0 +1,7 @@
/**
* blog controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::blog.blog');

View file

@ -0,0 +1,7 @@
/**
* blog router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::blog.blog');

View file

@ -0,0 +1,7 @@
/**
* blog service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::blog.blog');

View file

@ -0,0 +1,14 @@
{
"collectionName": "components_shared_tags",
"info": {
"displayName": "Tag",
"icon": "priceTag"
},
"options": {},
"attributes": {
"Topic": {
"type": "enumeration",
"enum": ["development", "work", "life"]
}
}
}

View file

@ -50,6 +50,17 @@ export interface SharedSeo extends Struct.ComponentSchema {
}; };
} }
export interface SharedTag extends Struct.ComponentSchema {
collectionName: 'components_shared_tags';
info: {
displayName: 'Tag';
icon: 'priceTag';
};
attributes: {
Topic: Schema.Attribute.Enumeration<['development', 'work', 'life']>;
};
}
declare module '@strapi/strapi' { declare module '@strapi/strapi' {
export module Public { export module Public {
export interface ComponentSchemas { export interface ComponentSchemas {
@ -57,6 +68,7 @@ declare module '@strapi/strapi' {
'shared.quote': SharedQuote; 'shared.quote': SharedQuote;
'shared.rich-text': SharedRichText; 'shared.rich-text': SharedRichText;
'shared.seo': SharedSeo; 'shared.seo': SharedSeo;
'shared.tag': SharedTag;
} }
} }
} }

View file

@ -336,6 +336,33 @@ export interface AdminUser extends Struct.CollectionTypeSchema {
}; };
} }
export interface ApiBlogBlog extends Struct.CollectionTypeSchema {
collectionName: 'blogs';
info: {
description: '';
displayName: 'Blog';
pluralName: 'blogs';
singularName: 'blog';
};
options: {
draftAndPublish: true;
};
attributes: {
Body: Schema.Attribute.RichText & Schema.Attribute.Required;
createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private;
Header: Schema.Attribute.Media<'images'> & Schema.Attribute.Required;
locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation<'oneToMany', 'api::blog.blog'> &
Schema.Attribute.Private;
publishedAt: Schema.Attribute.DateTime;
Title: Schema.Attribute.String & Schema.Attribute.Required & Schema.Attribute.Unique;
Topics: Schema.Attribute.Component<'shared.tag', true>;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private;
};
}
export interface PluginContentReleasesRelease extends Struct.CollectionTypeSchema { export interface PluginContentReleasesRelease extends Struct.CollectionTypeSchema {
collectionName: 'strapi_releases'; collectionName: 'strapi_releases';
info: { info: {
@ -763,6 +790,7 @@ declare module '@strapi/strapi' {
'admin::transfer-token': AdminTransferToken; 'admin::transfer-token': AdminTransferToken;
'admin::transfer-token-permission': AdminTransferTokenPermission; 'admin::transfer-token-permission': AdminTransferTokenPermission;
'admin::user': AdminUser; 'admin::user': AdminUser;
'api::blog.blog': ApiBlogBlog;
'plugin::content-releases.release': PluginContentReleasesRelease; 'plugin::content-releases.release': PluginContentReleasesRelease;
'plugin::content-releases.release-action': PluginContentReleasesReleaseAction; 'plugin::content-releases.release-action': PluginContentReleasesReleaseAction;
'plugin::i18n.locale': PluginI18NLocale; 'plugin::i18n.locale': PluginI18NLocale;