Compare commits

...

10 Commits

26 changed files with 872 additions and 487 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
node_modules node_modules
postgres
# Output # Output
.output .output

@ -1 +1 @@
Subproject commit bcab249efc4bef08205f16f5fcf6a45313868f93 Subproject commit b72a133cdd6acd08d3edf802af2ebfac0a630bf9

View File

@ -1,3 +1,4 @@
FROM postgres:16 FROM postgres:16
COPY ./docker/initdb.d/ /docker-entrypoint-initdb.d/ COPY ./docker/initdb.d/ /docker-entrypoint-initdb.d/
# COPY ./docker/postgres.conf /var/lib/postgresql/data/postgresql.conf

1
docker/postgres.conf Normal file
View File

@ -0,0 +1 @@

721
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,7 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"mdsvex": "^0.12.3",
"pg": "^8.12.0", "pg": "^8.12.0",
"sass": "^1.77.8" "sass": "^1.77.8"
} }

View File

@ -1,4 +1,4 @@
@import 'variables.scss'; @import '../variables.scss';
@font-face { @font-face {
font-family: 'ZenKakuGothicNew-Regular'; font-family: 'ZenKakuGothicNew-Regular';
@ -12,7 +12,7 @@
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
color: #000; color: #ffffff;
font-family: 'ZenKakuGothicNew-Regular', '游ゴシック体', 'Yu Gothic', YuGothic, 'ヒラギノ角ゴシック Pro', font-family: 'ZenKakuGothicNew-Regular', '游ゴシック体', 'Yu Gothic', YuGothic, 'ヒラギノ角ゴシック Pro',
'Hiragino Kaku Gothic Pro', 'メイリオ', Meiryo, Osaka, 'MS PGothic', sans-serif; 'Hiragino Kaku Gothic Pro', 'メイリオ', Meiryo, Osaka, 'MS PGothic', sans-serif;
font-size: 16px; font-size: 16px;

View File

@ -33,7 +33,7 @@
}); });
</script> </script>
<div class="cursor" bind:this={cursor} /> <div class="cursor" bind:this={cursor}></div>
<style lang="scss"> <style lang="scss">
.cursor { .cursor {

15
src/lib/publish.ts Normal file
View File

@ -0,0 +1,15 @@
export type Publish =
| 'public'
| 'limited' // noindex
| 'private'; // noindex nofollow
export function robots(publish: Publish) {
switch (publish) {
case 'public':
return '';
case 'limited':
return 'noindex';
case 'private':
return 'noindex, nofollow';
}
}

View File

@ -0,0 +1,12 @@
export type Article = {
seq: number;
id: string;
title: string;
category: string;
released_at: Date;
updated_at: Date;
tags: string[];
image: string;
publish: string;
content: string;
};

View File

@ -1,7 +1,7 @@
// initialize // initialize
import PG from '$lib/server/database'; import PG from '$lib/server/database';
import fs from 'fs'; import fs from 'fs';
import { compile } from 'mdsvex';
export default async function init() { export default async function init() {
@ -10,21 +10,75 @@ export default async function init() {
{ {
name: 'article', name: 'article',
columns: [ columns: [
{ name: 'id', type: 'serial', constraint: 'primary key' }, { name: 'seq', type: 'serial', constraint: 'primary key' },
{ name: 'id', type: 'text', constraint: 'not null' },
{ name: 'title', type: 'text', constraint: 'not null' }, { name: 'title', type: 'text', constraint: 'not null' },
{ name: 'category', type: 'text', constraint: 'not null' },
{ name: 'released_at', type: 'timestamp', constraint: 'not null' }, { name: 'released_at', type: 'timestamp', constraint: 'not null' },
{ name: 'updated_at', type: 'timestamp', constraint: 'not null' }, { name: 'updated_at', type: 'timestamp', constraint: 'not null' },
{ name: 'tags', type: 'text[]', constraint: 'not null' }, { name: 'tags', type: 'text[]', constraint: 'not null' },
{ name: 'image', type: 'text', constraint: '' },
{ name: 'publish', type: 'text', constraint: 'not null' },
{ name: 'content', type: 'text', constraint: 'not null' },
],
},
{
name: 'article_comment',
columns: [
{ name: 'id', type: 'serial', constraint: 'primary key' },
{ name: 'article', type: 'integer', constraint: 'not null' },
{ name: 'posted_at', type: 'timestamp', constraint: 'not null' },
{ name: 'content', type: 'text', constraint: 'not null' },
],
},
{
name: 'thread',
columns: [
{ name: 'seq', type: 'serial', constraint: 'primary key' },
{ name: 'id', type: 'text', constraint: 'not null' },
{ name: 'title', type: 'text', constraint: 'not null' },
{ name: 'category', type: 'text', constraint: 'not null' },
{ name: 'created_at', type: 'timestamp', constraint: 'not null' },
{ name: 'updated_at', type: 'timestamp', constraint: 'not null' },
{ name: 'tags', type: 'text[]', constraint: 'not null' },
{ name: 'content', type: 'text', constraint: 'not null' }, { name: 'content', type: 'text', constraint: 'not null' },
], ],
}, },
{
name: 'thread_post',
columns: [
{ name: 'seq', type: 'serial', constraint: 'primary key' },
{ name: 'thread_id', type: 'integer', constraint: 'not null' },
{ name: 'title', type: 'text', constraint: 'not null' },
{ name: 'posted_at', type: 'timestamp', constraint: 'not null' },
{ name: 'content', type: 'text', constraint: 'not null' },
],
},
{
name: 'thread_comment',
columns: [
{ name: 'id', type: 'serial', constraint: 'primary key' },
{ name: 'thread', type: 'integer', constraint: 'not null' },
{ name: 'posted_at', type: 'timestamp', constraint: 'not null' },
{ name: 'content', type: 'text', constraint: 'not null' },
],
},
{
name: 'tag',
columns: [
{ name: 'seq', type: 'serial', constraint: 'primary key' },
{ name: 'name', type: 'text', constraint: 'not null' },
{ name: 'ref_count', type: 'integer', constraint: 'not null' },
],
}
]; ];
const db = await PG(); const db = await PG();
try { try {
await db.begin(); await db.begin();
for (const schema of schemas) { for (const schema of schemas) {
const res = await db.query(`select * from information_schema.tables where table_name = '${schema.name}'`) const res = await db.query(`select * from information_schema.tables where table_name = '${schema.name}'`)
if (res.rowCount === null) { if (res.rowCount == 0) {
console.log(`Creating table ${schema.name}`); console.log(`Creating table ${schema.name}`);
const columnStr = schema.columns.map(c => `${c.name} ${c.type} ${c.constraint}`).join(', '); const columnStr = schema.columns.map(c => `${c.name} ${c.type} ${c.constraint}`).join(', ');
await db.query(`create table ${schema.name} (${columnStr})`); await db.query(`create table ${schema.name} (${columnStr})`);
@ -38,7 +92,6 @@ export default async function init() {
await db.rollback(); await db.rollback();
} }
// Check if the article are already in the database
const articleFiles: ArticleFileItem[] = []; const articleFiles: ArticleFileItem[] = [];
function scanDir(path: string) { function scanDir(path: string) {
const files = fs.readdirSync(path); const files = fs.readdirSync(path);
@ -48,13 +101,51 @@ export default async function init() {
if (stat.isDirectory()) { if (stat.isDirectory()) {
scanDir(dir); scanDir(dir);
} else { } else {
// articleFiles.push({ path: `${path}/${file}`, id: file }); articleFiles.push({ path: `${path}/${file}`, id: file });
console.log(`${path}/${file}`);
} }
} }
} }
scanDir('./articles/dist'); scanDir('./articles/dist');
await db.query('update tag set ref_count = 0');
db.commit();
for (const { path, id } of articleFiles) {
const res = await db.query('select * from article where id = $1', [id]);
const md = await compile(fs.readFileSync(path, 'utf-8'));
const title = md.data.fm.title;
const category = path.split('/')[3];
const tags: string[] = md.data.fm.tags;
const released_at = new Date(md.data.fm.released_at);
const updated_at = new Date(md.data.fm.updated_at);
const image = md.data.fm.image;
const publish = md.data.fm.publish;
const content = md.code;
if (res.rowCount == 0) {
console.log(`New article: ${id}`);
await db.query(
'insert into article (id, title, category, released_at, updated_at, tags, image, publish, content) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)',
[id, title, category, released_at, updated_at, tags, image, publish, content]
);
} else if (res.rows[0].updated_at < updated_at) {
console.log(`Update article: ${id}`);
await db.query(
'update article set title = $2, updated_at = $4, tags = $5, content = $6 where id = $1',
[id, title, updated_at, tags, content]
);
} else {
console.log(`Article ${id} is already up-to-date`);
}
for (const tag of tags) {
if ((await db.query('select * from tag where name = $1', [tag])).rowCount == 0) {
db.query('insert into tag (name, ref_count) values ($1, 1)', [tag]);
} else {
db.query('update tag set ref_count = ref_count + 1 where name = $1', [tag]);
}
}
}
await db.commit();
await db.release(); await db.release();
} }

View File

@ -91,15 +91,6 @@
opacity: 0.6; opacity: 0.6;
} }
a { a {
&:first-child {
&::before {
content: '🗀 ';
}
&::after {
content: '|';
margin-left: 15px;
}
}
margin-left: 5px; margin-left: 5px;
padding: 0 5px; padding: 0 5px;
color: inherit; color: inherit;
@ -107,6 +98,15 @@
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
&:first-child {
&::before {
content: '🗀 ';
}
& ::after {
content: '|';
margin-left: 15px;
}
}
} }
} }
} }

View File

@ -1,5 +1,5 @@
<script> <script>
import '../app.scss'; import '$lib/app.scss';
</script> </script>
<slot /> <slot />

View File

@ -1,83 +1,47 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import type { Content } from '$lib/article';
import PG from '$lib/server/database'; import PG from '$lib/server/database';
let data: { let data: {
recent: Content[], recent: {
title: string,
image: string,
date: string,
link: string,
tags: string[]
}[],
tags: string[], tags: string[],
updated: string updated: string,
} = { } = {
recent: [ recent: [],
{ tags: [],
title: "title",
image: "image",
date: "date",
link: "link",
tags: ["tag1", "tag2"],
},
],
tags: ["hoge", "fuga", "piyo"],
updated: "", updated: "",
}; };
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params }) => {
const db = await PG(); const db = await PG();
const now = await db.query('SELECT NOW()'); await db.begin();
try {
const recent_articles = await db.query(
"SELECT * FROM article WHERE publish = 'public' ORDER BY updated_at DESC LIMIT 6"
);
data.recent = recent_articles.rows.map((row) => ({
title: row.title,
image: row.image,
date: row.updated_at.toISOString().slice(0, 10),
link: `/article/${row.category}/${row.id}`,
tags: row.tags,
}));
const tags = await db.query("SELECT * FROM tag ORDER BY ref_count DESC LIMIT 20");
data.tags = tags.rows.map((row) => row.name);
} catch (e) {
await db.rollback();
throw error(500, e as Error);
} finally {
await db.release(); await db.release();
}
data.updated = data.recent[0].date;
data.updated = now.rows[0].now;
return data; return data;
} }
// export const load: PageServerLoad = async ({ params }) => {
// let data = {
// hero: [] as Content[],
// recent: [] as Content[],
// tags: Array.from(index.tags),
// updated: index.updated
// };
// for (const dat of index.articles.filter((article) => article.publish == "public").list.slice(0, 4).reverse()) {
// if (!dat) continue;
// data.hero.push({
// title: dat.title,
// image: dat.image,
// date: ((date) => {
// {
// // YYYY/MM/DD
// return `${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date
// .getDate()
// .toString()
// .padStart(2, '0')}`;
// }
// })(dat.released),
// link: dat.series
// ? `/post/${dat.category}/${dat.series}/${dat.id}`
// : `/post/${dat.category}/${dat.id}`,
// tags: dat.tags
// });
// }
// //recent 10 articles
// for (const dat of index.articles.filter((article) => article.publish == "public").list.slice(0, 10)) {
// if (!dat) continue;
// data.recent.push({
// title: dat.title,
// image: dat.image,
// date: ((date) => {
// {
// // YYYY/MM/DD
// return `${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date
// .getDate()
// .toString()
// .padStart(2, '0')}`;
// }
// })(dat.released),
// link: dat.series
// ? `/post/${dat.category}/${dat.series}/${dat.id}`
// : `/post/${dat.category}/${dat.id}`,
// tags: dat.tags
// });
// }
// return data;
// };

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
// import Cursor from '$lib/components/cursor.svelte'; import Cursor from '$lib/components/cursor.svelte';
import Footer from '$lib/components/footer.svelte'; import Footer from '$lib/components/footer.svelte';
import FormattedDate from '$lib/components/formatted_date.svelte'; import FormattedDate from '$lib/components/formatted_date.svelte';
@ -90,7 +90,14 @@
<ul> <ul>
{#each data.recent as post} {#each data.recent as post}
<li> <li>
<a href={post.link}>{post.title} - {post.date}</a> <a
href={post.link}
on:mouseenter={(e) =>
(e.target as HTMLAnchorElement).style.setProperty(
'--mouse-position',
`${e.offsetX}px`
)}>{post.title}</a
>
</li> </li>
{/each} {/each}
</ul> </ul>
@ -107,6 +114,7 @@
</div> </div>
<Footer /> <Footer />
</main> </main>
<Cursor />
</div> </div>
<style lang="scss"> <style lang="scss">
@ -121,7 +129,7 @@
width: 100vw; width: 100vw;
height: 100dvh; height: 100dvh;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
overflow: auto; overflow: hidden;
} }
.controls { .controls {
position: fixed; position: fixed;
@ -142,6 +150,8 @@
color: white; color: white;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
> div { > div {
margin: 10px 0; margin: 10px 0;
} }
@ -250,6 +260,26 @@
} }
a { a {
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
text-decoration: none;
border-bottom: 1px solid var(--line-primary);
color: white;
box-sizing: border-box;
--mouse-position: 0;
&::after {
content: ' ';
position: absolute;
bottom: 0;
left: var(--mouse-position);
right: calc(100% - var(--mouse-position));
height: 1px;
background-color: white;
transition: 0.2s ease-in-out;
transition-property: left right;
}
&:hover::after {
left: 0px;
right: 0px;
}
} }
button { button {
font-size: 1rem; font-size: 1rem;
@ -276,22 +306,20 @@
.tags { .tags {
ul { ul {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: left;
flex-direction: row;
gap: 3px; gap: 3px;
li { li {
font-size: 1rem; font-size: 1rem;
padding: 3px 5px; padding: 3px 5px;
border: 1px solid var(--line-primary);
border-radius: 5px;
} }
} }
} }
} }
a {
text-decoration: none;
border-bottom: 1px solid var(--line-primary);
color: white;
box-sizing: border-box;
}
@media (max-width: 1000px) { @media (max-width: 1000px) {
main { main {
width: 100%; width: 100%;

View File

@ -0,0 +1,20 @@
import type { Article } from '$lib/server/database/article';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async () => {
return {
props: {
article: {
seq: 0,
id: 'test',
title: 'test',
category: 'test',
released_at: new Date(),
updated_at: new Date(),
tags: ['test'],
image: 'test',
publish: 'test',
content: 'test',
} as Article,
},
};
};

View File

@ -0,0 +1,161 @@
<script lang="ts">
import '$lib/app.scss';
import '$lib/blog.scss';
import Footer from '$lib/components/footer.svelte';
import * as publish from '$lib/publish';
import FormattedDate from '$lib/components/formatted_date.svelte';
import type { LayoutData } from './$types';
export let data: LayoutData;
</script>
<svelte:head>
<title>{data.props.article.title} | Blog | HareWorks</title>
<meta property="og:title" content={data.props.article.title + '- HareWorks'} />
<!-- <meta property="og:description" content={data.props.article.description} /> -->
<meta property="og:image" content={data.props.article.image} />
<meta property="og:type" content="article" />
<meta property="article:published_time" content={data.props.article.released_at.toISOString()} />
{#if data.props.article.updated_at}
<meta property="article:modified_time" content={data.props.article.updated_at.toISOString()} />
{/if}
<meta property="article:author" content="HareWorks" />
<meta property="article:section" content="Blog" />
<meta property="article:tag" content={data.props.article.tags.join(',')} />
<meta name="robots" content={data.props.article.publish as publish.Publish} />
</svelte:head>
<!-- <div class="back">
<img src={data.props.article.image} alt="" />
</div> -->
<div class="container">
<div class="title">
<h1>{data.props.article.title}</h1>
<div class="meta">
<span>
released <FormattedDate date={data.props.article.released_at} />
{#if data.props.article.updated_at}<br />
updated <FormattedDate date={data.props.article.updated_at} />
{/if}
</span>
<span>
<a href="/category/{data.props.article.category}"
>{data.props.article.category[0].toUpperCase() + data.props.article.category.slice(1)}</a
>
{#each data.props.article.tags as tag}
<a href={`/search?tag=${tag}`}>{tag}</a>
{/each}
</span>
</div>
</div>
<div class="panel">
<main>
<div class="document">
<slot />
</div>
</main>
<Footer />
</div>
</div>
<style lang="scss">
:global(body) {
background-color: var(--background-primary);
}
.back {
position: fixed;
z-index: -1;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #000;
color: white;
filter: brightness(0.8) grayscale(0.5);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.container {
color: white;
min-height: 100%;
padding-top: 100px;
width: 1000px;
margin: 0 auto;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.title {
h1 {
font-size: 2rem;
text-align: center;
margin: 0;
padding: 12px 100px 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
.meta {
display: flex;
justify-content: space-between;
padding: 0 20px;
font-size: 0.8rem;
span {
opacity: 0.6;
}
a {
&:first-child {
&::before {
content: '🗀 ';
}
&::after {
content: '|';
margin-left: 15px;
}
}
margin-left: 5px;
padding: 0 5px;
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
.panel {
flex: 1;
background-color: var(--background-primary);
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
}
main {
padding: 20px;
box-sizing: border-box;
width: 1000px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
@media (max-width: 1000px) {
.container {
width: 100%;
}
.title {
h1 {
padding: 8px 20px;
}
}
main {
max-width: 100%;
}
}
</style>

View File

@ -0,0 +1,46 @@
import type { Article } from '$lib/server/database/article';
import type { PageServerLoad } from './$types';
import PG from '$lib/server/database';
export const load: PageServerLoad = async ({ params }) => {
const { id } = params;
const db = await PG();
await db.begin();
try {
const article = await db.query(
"SELECT * FROM article WHERE id = $1",
[id]
);
if (article.rowCount === 0) {
return {
status: 404,
error: new Error('Not found'),
};
}
const row = article.rows[0];
const data: Article = {
seq: row.seq,
id: row.id,
title: row.title,
category: row.category,
released_at: row.released_at,
updated_at: row.updated_at,
tags: row.tags,
image: row.image,
publish: row.publish,
content: row.content,
};
return {
props: { data },
};
} catch (e) {
await db.rollback();
return {
status: 500,
error: e as Error,
};
} finally {
await db.release();
}
};

View File

@ -0,0 +1,3 @@
<script lang="ts">
</script>

View File

@ -1,5 +0,0 @@
<script>
import '../app.scss';
</script>
<slot />

View File

@ -1,5 +1,6 @@
:root { :root {
--background-primary: #343434; --background-primary: #1b1b1b;
--line-primary: rgba(255, 255, 255, 0.1); --line-primary: rgba(255, 255, 255, 0.2);
--line-secondary: rgba(255, 255, 255, 0.1);
--highlight-primary: rgba(255, 115, 0, 0.2); --highlight-primary: rgba(255, 115, 0, 0.2);
} }