blog #4
18 changed files with 3164 additions and 30 deletions
|
|
@ -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=
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
48
src/lib/components/BlogCard.svelte
Normal file
48
src/lib/components/BlogCard.svelte
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
89
src/routes/blog/+page.server.ts
Normal file
89
src/routes/blog/+page.server.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
24
src/routes/blog/[slug]/+page.server.ts
Normal file
24
src/routes/blog/[slug]/+page.server.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
30
src/routes/blog/[slug]/+page.svelte
Normal file
30
src/routes/blog/[slug]/+page.svelte
Normal 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>
|
||||||
|
|
@ -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 . .
|
||||||
|
|
|
||||||
36
strapi/src/api/blog/content-types/blog/schema.json
Normal file
36
strapi/src/api/blog/content-types/blog/schema.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
strapi/src/api/blog/controllers/blog.ts
Normal file
7
strapi/src/api/blog/controllers/blog.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* blog controller
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { factories } from '@strapi/strapi';
|
||||||
|
|
||||||
|
export default factories.createCoreController('api::blog.blog');
|
||||||
7
strapi/src/api/blog/routes/blog.ts
Normal file
7
strapi/src/api/blog/routes/blog.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* blog router
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { factories } from '@strapi/strapi';
|
||||||
|
|
||||||
|
export default factories.createCoreRouter('api::blog.blog');
|
||||||
7
strapi/src/api/blog/services/blog.ts
Normal file
7
strapi/src/api/blog/services/blog.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* blog service
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { factories } from '@strapi/strapi';
|
||||||
|
|
||||||
|
export default factories.createCoreService('api::blog.blog');
|
||||||
14
strapi/src/components/shared/tag.json
Normal file
14
strapi/src/components/shared/tag.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"collectionName": "components_shared_tags",
|
||||||
|
"info": {
|
||||||
|
"displayName": "Tag",
|
||||||
|
"icon": "priceTag"
|
||||||
|
},
|
||||||
|
"options": {},
|
||||||
|
"attributes": {
|
||||||
|
"Topic": {
|
||||||
|
"type": "enumeration",
|
||||||
|
"enum": ["development", "work", "life"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
strapi/types/generated/components.d.ts
vendored
12
strapi/types/generated/components.d.ts
vendored
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
strapi/types/generated/contentTypes.d.ts
vendored
28
strapi/types/generated/contentTypes.d.ts
vendored
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue