BLOG-58 Rewrite frontend with svelte kit #61

Merged
squid merged 15 commits from BLOG-58_rewrite_frontend_with_svelte into main 2025-07-23 05:34:25 +08:00
57 changed files with 2332 additions and 3685 deletions

View File

@ -21,7 +21,7 @@ jobs:
- name: Install node.js - name: Install node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
cache: "pnpm" cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml cache-dependency-path: frontend/pnpm-lock.yaml

11
.vscode/settings.json vendored
View File

@ -1,12 +1,3 @@
{ {
"cSpell.words": [ "typescript.preferences.importModuleSpecifier": "non-relative"
"actix",
"chrono",
"dotenv",
"rustls",
"serde",
"sqlx",
"squidspirit"
]
} }

View File

@ -2,12 +2,12 @@
## Development ## Development
- Frontend: Next.js - Frontend: SvelteKit with Tailwind CSS
- Backend: Rust actix-web - Backend: Rust actix-web
Despite Next.js being a full-stack framework, I still decided to adopt a separate front-end and back-end architecture for this blog project. I believe that this separation makes the project cleaner, reduces coupling, and aligns with modern development practices. Furthermore, I wanted to practice developing a purely back-end API. Despite SvelteKit being a full-stack framework, I still decided to adopt a separate front-end and back-end architecture for this blog project. I believe that this separation makes the project cleaner, reduces coupling, and aligns with modern development practices. Furthermore, I wanted to practice developing a purely back-end API.
As for the more detailed development approach, I plan to use Clean Architecture for the overall structure and ATDD for testing. Of course, such a small project may not necessarily require such complex design patterns, but I want to give myself an opportunity to practice them. As for the more detailed development approach, I plan to use Clean Architecture for the overall structure. Of course, such a small project may not necessarily require such complex design patterns, but I want to give myself an opportunity to practice them.
These will allow me to become more proficient in these modern development practices and leave a lot of flexibility and room for adjustments in the future. These will allow me to become more proficient in these modern development practices and leave a lot of flexibility and room for adjustments in the future.

149
frontend/.gitignore vendored
View File

@ -1,136 +1,23 @@
# Logs node_modules
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Output
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json .output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# Runtime data # OS
pids .DS_Store
*.pid Thumbs.db
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover # Env
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env .env
.env.development.local .env.*
.env.test.local !.env.example
.env.production.local !.env.test
.env.local
# parcel-bundler cache (https://parceljs.org/) # Vite
.cache vite.config.js.timestamp-*
.parcel-cache vite.config.ts.timestamp-*
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

1
frontend/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

9
frontend/.prettierignore Normal file
View File

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

View File

@ -1,11 +1,16 @@
{ {
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"], "useTabs": true,
"printWidth": 120, "singleQuote": true,
"tabWidth": 2, "trailingComma": "none",
"trailingComma": "all", "printWidth": 100,
"singleQuote": false, "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"semi": true, "overrides": [
"importOrder": ["^[\\w-]+(/.*)?$", "^@[\\w-]+(/.*)?$", "^@/(app|lib)(/.*)?$", "^\\..*$"], {
"importOrderSeparation": true, "files": "*.svelte",
"importOrderSortSpecifiers": true "options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
} }

View File

@ -1,27 +1,26 @@
FROM node:20-alpine AS base FROM node:22-alpine AS base
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable pnpm
FROM base AS deps FROM base AS deps
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN apk add --no-cache libc6-compat && \ RUN apk add --no-cache libc6-compat && \
corepack enable pnpm && \
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN corepack enable pnpm && \ RUN pnpm run build
pnpm run build
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production COPY package.json pnpm-lock.yaml ./
COPY --from=builder /app/public ./public COPY --from=builder /app/build ./build
COPY --from=builder /app/.next/standalone ./ RUN pnpm install --prod --frozen-lockfile
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000 EXPOSE 3000
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
ENV PORT=3000 ENV PORT=3000
CMD ["node", "server.js"] CMD ["node", "build"]

View File

@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

40
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,40 @@
import prettier from 'eslint-config-prettier';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

View File

@ -1,16 +0,0 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View File

@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -1,13 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Avoid from rendering twice in development mode
reactStrictMode: false,
output: "standalone",
env: {
APP_VERSION: process.env.npm_package_version,
},
};
export default nextConfig;

View File

@ -1,39 +1,46 @@
{ {
"name": "squidspirit-blog", "name": "squidspirit-blog",
"version": "0.1.1",
"private": true, "private": true,
"license": "MIT", "version": "0.1.1",
"type": "module",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "vite dev",
"build": "next build", "build": "vite build",
"start": "next start", "preview": "vite preview",
"lint": "next lint" "prepare": "svelte-kit sync || echo ''",
}, "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"packageManager": "pnpm@10.0.0", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"dependencies": { "format": "prettier --write .",
"@fortawesome/fontawesome-svg-core": "^6.7.2", "lint": "prettier --check . && eslint ."
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@reduxjs/toolkit": "^2.5.0",
"next": "15.1.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-redux": "^9.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/compat": "^1.2.5",
"@trivago/prettier-plugin-sort-imports": "^5.2.1", "@eslint/js": "^9.18.0",
"@types/node": "^20.17.14", "@fortawesome/fontawesome-free": "^7.0.0",
"@types/react": "^19.0.7", "@sveltejs/adapter-auto": "^6.0.0",
"@types/react-dom": "^19.0.3", "@sveltejs/adapter-node": "^5.2.13",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-next": "15.1.4", "eslint-config-prettier": "^10.0.1",
"postcss": "^8.5.1", "eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.10", "prettier-plugin-svelte": "^3.3.3",
"tailwindcss": "^3.4.17", "prettier-plugin-tailwindcss": "^0.6.11",
"typescript": "^5.7.3" "svelte": "^5.0.0",
} "svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"packageManager": "pnpm@10.12.4"
} }

4453
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

40
frontend/src/app.css Normal file
View File

@ -0,0 +1,40 @@
@import 'tailwindcss';
@font-face {
font-family: 'HackNerdMono';
src: url('/font/HackNerdMono.woff2') format('woff2');
}
@theme {
--font-sans: 'Noto Sans TC', sans-serif;
--font-mono: 'HackNerdMono', 'Noto Sans Mono', monospace;
--color-true-gray-50: #fafafa;
--color-true-gray-100: #f5f5f5;
--color-true-gray-200: #e5e5e5;
--color-true-gray-300: #d4d4d4;
--color-true-gray-400: #a3a3a3;
--color-true-gray-500: #737373;
--color-true-gray-600: #525252;
--color-true-gray-700: #404040;
--color-true-gray-800: #262626;
--color-true-gray-900: #171717;
--spacing-toolbar-height: 4rem;
--spacing-content-height: calc(100vh - var(--spacing-toolbar-height));
}
body {
@apply bg-white font-sans text-base font-normal text-gray-600;
}
pre,
code,
kbd,
samp {
@apply font-mono;
}
.toolbar {
@apply h-[--tool-bar-height];
}

15
frontend/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
declare const __VERSION__: string;
}
}
export {};

29
frontend/src/app.html Normal file
View File

@ -0,0 +1,29 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<link
rel="icon"
media="(prefers-color-scheme: light)"
href="%sveltekit.assets%/icon/logo-light.svg"
/>
<link
rel="icon"
media="(prefers-color-scheme: dark)"
href="%sveltekit.assets%/icon/logo-dark.svg"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>魚之魷魂 SquidSpirit</title>
<meta name="description" content="程式、科技、教學、分享" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono&family=Noto+Sans+TC:wght@100..900&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="antialiased">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -1,10 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-white font-sans text-base font-normal text-gray-600;
--tool-bar-height: 4rem;
--content-height: calc(100vh - var(--tool-bar-height));
}

View File

@ -1,63 +0,0 @@
import type { Metadata } from "next";
import { Noto_Sans_Mono, Noto_Sans_TC } from "next/font/google";
import localFont from "next/font/local";
import Footer from "@/lib/common/framework/ui/Footer";
import Navbar from "@/lib/common/framework/ui/Navbar";
import "./globals.css";
const notoSansTc = Noto_Sans_TC({
variable: "--font-noto-sans-tc",
subsets: ["latin"],
});
const notoSansMono = Noto_Sans_Mono({
variable: "--font-noto-sans-mono",
subsets: ["latin"],
});
const hackNerdMono = localFont({
src: "./_font/HackNerdMono.woff2",
variable: "--font-hack-nerd-mono",
});
export const metadata: Metadata = {
title: "魚之魷魂 SquidSpirit",
description: "程式、科技、教學、分享",
icons: {
icon: [
{
media: "(prefers-color-scheme: light)",
url: "/icon/logo-light.svg",
href: "/icon/logo-light.svg",
},
{
media: "(prefers-color-scheme: dark)",
url: "/icon/logo-dark.svg",
href: "/icon/logo-dark.svg",
},
],
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-Hant">
<head>
<meta name="app-version" content={process.env.APP_VERSION} />
</head>
<body className={`${notoSansTc.variable} ${notoSansMono.variable} ${hackNerdMono.variable} antialiased`}>
<div className="min-h-screen">
<Navbar />
{children}
</div>
<Footer />
</body>
</html>
);
}

View File

@ -1,16 +0,0 @@
export default function NotFoundPage() {
return (
<div className="mx-auto flex min-h-[--content-height] max-w-screen-xl flex-col items-center justify-center px-4 md:px-6">
<div className="flex flex-row items-end gap-x-4 md:gap-x-6">
<h1 className="text-5xl font-extrabold text-gray-800 underline md:text-7xl">404</h1>
<h2 className="flex flex-row items-center gap-x-2 text-2xl font-bold md:gap-x-2.5 md:text-3xl">
<div className="h-7 w-1.5 bg-gray-800 md:h-9 md:w-2" />
<div className="rounded-md bg-blue-600 px-1 py-px md:px-1.5 md:py-0.5">
<span className="text-white">Not</span>
</div>
<span className="text-gray-800">Found.</span>
</h2>
</div>
</div>
);
}

View File

@ -1,13 +0,0 @@
import AboutMe from "@/lib/home/framework/ui/AboutMe";
import FirstView from "@/lib/home/framework/ui/FirstView";
import Motto from "@/lib/home/framework/ui/Motto";
export default function HomePage() {
return (
<div>
<FirstView />
<AboutMe />
<Motto />
</div>
);
}

View File

@ -0,0 +1,21 @@
<div class="border-t border-gray-300">
<div
class="mx-auto flex max-w-screen-xl flex-col items-center justify-center gap-4 px-4 py-12 md:flex-row md:px-6"
>
<div class="flex flex-row items-center justify-center gap-x-4">
<a href="https://www.youtube.com/@squidspirit16" target="_blank" aria-label="YouTube Channel">
<i class="fa-brands fa-youtube size-4" title="YouTube Channel"></i>
</a>
<a href="mailto:squid@squidspirit.com" aria-label="Email">
<i class="fa-solid fa-envelope size-4" title="Email"></i>
</a>
<a href="https://git.squidspirit.com/squid/blog" target="_blank" aria-label="Git Repository">
<i class="fa-brands fa-git-alt size-4" title="Git Repository"></i>
</a>
</div>
<div class="max-md:hidden">
<div class="h-4 w-0.5 bg-gray-300"></div>
</div>
<span class="text-sm">Copyright © 2025 SquidSpirit</span>
</div>
</div>

View File

@ -1,35 +0,0 @@
import Link from "next/link";
import { faGitAlt, faYoutube } from "@fortawesome/free-brands-svg-icons";
import { faEnvelope } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export default function Footer() {
return (
<div className="border-t border-gray-300">
<div className="mx-auto flex max-w-screen-xl flex-col items-center justify-center gap-4 px-4 py-12 md:flex-row md:px-6">
<div className="flex flex-row items-center justify-center gap-x-4">
<Link href="https://www.youtube.com/@squidspirit16" target="_blank">
<FontAwesomeIcon className="size-4" icon={faYoutube} title="YouTube Channel" />
</Link>
<Link href="mailto:squid@squidspirit.com">
<FontAwesomeIcon className="size-4" icon={faEnvelope} title="Email" />
</Link>
<Link href="https://git.squidspirit.com/squid/blog" target="_blank">
<FontAwesomeIcon className="size-4" icon={faGitAlt} title="Git Repository" />
</Link>
</div>
<Devider className="max-md:hidden" />
<span className="text-sm">Copyright © 2025 SquidSpirit</span>
</div>
</div>
);
}
function Devider(props: { className?: string }) {
return (
<div className={props.className}>
<div className="h-4 w-0.5 bg-gray-300" />
</div>
);
}

View File

@ -0,0 +1,18 @@
<script lang="ts">
import { page } from '$app/state';
import NavbarAction from '$lib/common/framework/ui/NavbarAction.svelte';
</script>
<div class="border-b border-gray-300">
<div
class="mx-auto flex h-toolbar-height max-w-screen-xl flex-row items-center justify-between px-4 md:px-6"
>
<a class="flex flex-row items-center gap-x-2" href="/">
<img class="mt-1 size-10" src="/icon/logo-light.svg" alt="SquidSpirit" />
<span class="text-2xl font-black text-gray-800">魚之魷魂</span>
</a>
<div class="flex flex-row items-center gap-x-6">
<NavbarAction label="首頁" link="/" isSelected={page.url.pathname === '/'} />
</div>
</div>
</div>

View File

@ -1,28 +0,0 @@
import Image from "next/image";
import Link from "next/link";
export default function Navbar() {
return (
<div className="border-b border-gray-300">
<div className="mx-auto flex h-[--tool-bar-height] max-w-screen-xl flex-row items-center justify-between px-4 md:px-6">
<Link className="flex flex-row items-center gap-x-2" href="/">
<Image className="mt-1 size-10" src="/icon/logo-light.svg" alt="SquidSpirit" height={40} width={40} />
<span className="text-2xl font-black text-gray-800"></span>
</Link>
<div className="flex flex-row items-center gap-x-6">
<Action label="首頁" link="/" isSelected={true} />
</div>
</div>
</div>
);
}
function Action(props: { label: string; link: string; isSelected: boolean }) {
return (
<div className={`rounded px-1.5 ${props.isSelected ? "bg-blue-600" : "bg-transparent"}`}>
<Link className={`font-extrabold ${props.isSelected ? "text-white" : "text-gray-800"}`} href={props.link}>
{props.label}
</Link>
</div>
);
}

View File

@ -0,0 +1,17 @@
<script lang="ts">
let {
label,
link,
isSelected
}: {
label: string;
link: string;
isSelected: boolean;
} = $props();
</script>
<div class="rounded px-1.5 {isSelected ? 'bg-blue-600' : 'bg-transparent'}">
<a class="font-extrabold {isSelected ? 'text-white' : 'text-gray-800'}" href={link}>
{label}
</a>
</div>

View File

@ -1,9 +0,0 @@
import Terminal from "@/lib/home/framework/ui/Terminal";
export default function AboutMe() {
return (
<div className="mx-auto flex max-w-screen-xl flex-col items-center justify-center gap-y-2.5 px-4 py-32 md:gap-y-8 md:px-24 md:py-32">
<Terminal />
</div>
);
}

View File

@ -1,41 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
export default function AnimatedMark(props: { text: string; direction: "left" | "right" }) {
const [isVisible, setIsVisible] = useState(false);
const element = useRef<HTMLSpanElement | null>(null);
const origin = props.direction === "left" ? "origin-left" : "origin-right";
useEffect(() => {
if (!element.current) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
});
},
{ threshold: 1 },
);
observer.observe(element.current);
return () => observer.disconnect();
}, []);
return (
<span
ref={element}
className={`rounded-md bg-blue-600 px-1 py-0.5 text-white transition-transform delay-500 duration-1000 md:rounded-lg md:px-2.5 md:py-2 ${origin} ${isVisible ? "scale-x-100" : "scale-x-0"} `}
>
<span className="scale-x-100">{props.text}</span>
</span>
);
}

View File

@ -1,17 +0,0 @@
import SelfTags from "@/lib/home/framework/ui/SelfTags";
export default function FirstView() {
return (
<div className="mx-auto flex min-h-[--content-height] max-w-screen-xl flex-col justify-center gap-y-2.5 px-4 md:gap-y-8 md:px-6">
<h2 className="text-3xl font-bold text-gray-800 md:text-6xl">Hello </h2>
<h1 className="flex flex-row items-center gap-x-2 text-4xl font-extrabold text-gray-800 md:text-7xl">
<span></span>
<div className="rounded-lg bg-blue-600 px-1.5 py-1">
<span className="text-white">Squid</span>
</div>
<span></span>
</h1>
<SelfTags />
</div>
);
}

View File

@ -0,0 +1,20 @@
<script>
import MottoAnimatedMark from './MottoAnimatedMark.svelte';
</script>
<div
class="mx-auto flex h-screen max-w-screen-xl flex-col items-center justify-center gap-y-2.5 px-4 md:gap-y-8 md:px-6"
>
<div
class="flex w-[19rem] flex-col gap-y-3 text-3xl font-bold text-gray-800 md:w-[38rem] md:gap-y-4 md:text-6xl"
>
<div class="flex w-full flex-row items-center justify-start gap-x-2.5">
<span>Keep</span>
<MottoAnimatedMark text="Learning" direction="left" />
</div>
<div class="flex w-full flex-row items-center justify-end gap-x-2.5 md:gap-x-4">
<MottoAnimatedMark text="Keep" direction="right" />
<span>Progressing</span>
</div>
</div>
</div>

View File

@ -1,18 +0,0 @@
import AnimatedMark from "@/lib/home/framework/ui/AnimatedMark";
export default function Motto() {
return (
<div className="mx-auto flex h-screen max-w-screen-xl flex-col items-center justify-center gap-y-2.5 px-4 md:gap-y-8 md:px-6">
<div className="flex w-[19rem] flex-col gap-y-3 text-3xl font-bold text-gray-800 md:w-[38rem] md:gap-y-4 md:text-6xl">
<div className="flex w-full flex-row items-center justify-start gap-x-2.5">
<span>Keep</span>
<AnimatedMark text="Learning" direction="left" />
</div>
<div className="flex w-full flex-row items-center justify-end gap-x-2.5 md:gap-x-4">
<AnimatedMark text="Keep" direction="right" />
<span>Progressing</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
const { text, direction }: { text: string; direction: 'left' | 'right' } = $props();
let isReady: boolean = $state(false);
let origin = $derived(direction === 'left' ? 'origin-left' : 'origin-right');
let element: HTMLSpanElement | null = null;
let observer: IntersectionObserver | null = null;
onMount(() => {
if (!element) {
return;
}
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
isReady = true;
observer?.disconnect();
}
});
},
{ threshold: 1 }
);
observer.observe(element);
});
onDestroy(() => observer?.disconnect());
</script>
<span
bind:this={element}
class="rounded-md bg-blue-600 px-1 py-0.5 text-white transition-transform delay-500 duration-1000 md:rounded-lg md:px-2.5 md:py-2 {origin} {isReady
? 'scale-x-100'
: 'scale-x-0'}"
>
<span class="scale-x-100">{text}</span>
</span>

View File

@ -1,66 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Provider } from "react-redux";
import { useTagDispatch, useTagSelector } from "@/lib/home/presenter/tagHook";
import { tagShuffledAction } from "@/lib/home/presenter/tagReducer";
import tagStore from "@/lib/home/presenter/tagStore";
export default function SelfTags() {
return (
<Provider store={tagStore}>
<SelfTagsProvided />
</Provider>
);
}
function SelfTagsProvided() {
const tags = useTagSelector((state) => state.tags);
const dispatch = useTagDispatch();
// Initialize with placeholder to prevent flickering
const [tagsToShow, setTagsToShow] = useState<string[]>([""]);
const [isTagsVisible, setIsTagsVisible] = useState(false);
const timer = useRef<NodeJS.Timeout | undefined>(undefined);
// On mount
useEffect(() => {
timer.current = setInterval(() => {
dispatch(tagShuffledAction());
}, 4000);
return () => {
clearInterval(timer.current);
timer.current = undefined;
};
}, [dispatch]);
// On tags changed
useEffect(() => {
setIsTagsVisible(false);
setTimeout(() => {
setTagsToShow(tags);
setIsTagsVisible(true);
}, 500);
}, [tags]);
return (
<div
className={`relative w-full max-w-screen-md transition-opacity duration-500 ${isTagsVisible ? "opacity-100" : "opacity-0"}`}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent via-60% to-white" />
<div className="flex flex-row items-center gap-x-2 overflow-hidden">
{tagsToShow.map((tag) => (
<Hashtag key={tag} tag={tag} />
))}
</div>
</div>
);
}
function Hashtag(props: { tag: string }) {
return <span className="text-nowrap text-gray-400"># {props.tag}</span>;
}

View File

@ -0,0 +1,74 @@
<script lang="ts">
import TerminalLastLine from '$lib/home/framework/ui/TerminalLastLine.svelte';
import TerminalNormalLine from '$lib/home/framework/ui/TerminalNormalLine.svelte';
import { onDestroy, onMount } from 'svelte';
const lines = [
'大家好,我是 Squid 魷魚',
'身為一位軟體工程師',
'平常最喜歡埋首於程式碼的世界',
'鑽研各種新奇有趣的技術',
'在這裡',
'我會分享我的技術筆記、開發心得',
'還有各式各樣實用工具的評測與介紹',
'一起探索數位世界的無限可能吧!'
];
let isReady: boolean = $state(false);
let currentIndex: number = $state(0);
let element: HTMLDivElement | null = null;
let observer: IntersectionObserver | null = null;
onMount(() => {
if (!element) {
return;
}
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
isReady = true;
observer?.disconnect();
}
});
},
{ threshold: 1 }
);
observer.observe(element);
});
onDestroy(() => {
observer?.disconnect();
observer = null;
});
function onLastLineComplete() {
if (currentIndex < lines.length - 1) {
currentIndex++;
}
}
</script>
<div
class="mx-auto flex max-w-screen-xl flex-col items-center justify-center gap-y-2.5 px-4 py-32 md:gap-y-8 md:px-24 md:py-32"
>
<div
bind:this={element}
class="flex w-full flex-col gap-y-1.5 rounded-2xl border-4 border-true-gray-800 bg-true-gray-700 p-4 pb-28 font-mono font-medium text-gray-50 shadow-lg transition-opacity duration-300 md:gap-y-2.5 md:rounded-3xl md:border-8 md:p-8 md:pb-32 md:text-xl md:shadow-xl {isReady
? 'opacity-100'
: 'opacity-0'}"
>
{#each lines.slice(0, currentIndex) as line, index (index)}
<TerminalNormalLine text={line} />
{/each}
{#if isReady}
{#key currentIndex}
<TerminalLastLine text={lines[currentIndex]} onComplete={onLastLineComplete} />
{/key}
{/if}
</div>
</div>

View File

@ -1,153 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
export default function Terminal() {
const [isReady, setIsReady] = useState(false);
const [currentIndex, setCurrentLineIndex] = useState(0);
const element = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!element.current) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsReady(true);
observer.disconnect();
}
});
},
{ threshold: 1 },
);
observer.observe(element.current);
return () => observer.disconnect();
}, [currentIndex]);
function onLineCompleted() {
if (currentIndex < lines.length - 1) {
setCurrentLineIndex((prev) => prev + 1);
}
}
return (
<div
ref={element}
className={`flex w-full flex-col gap-y-1.5 rounded-2xl border-4 border-true-gray-800 bg-true-gray-700 p-4 pb-28 font-mono font-medium text-gray-50 shadow-lg transition-opacity duration-300 md:gap-y-2.5 md:rounded-3xl md:border-8 md:p-8 md:pb-32 md:text-xl md:shadow-xl ${isReady ? "opacity-100" : "opacity-0"}`}
>
{lines.slice(0, currentIndex).map((line, index) => (
<NormalLine key={index} text={line} />
))}
{isReady ? <LastLine key={currentIndex} text={lines[currentIndex]} onCompleted={onLineCompleted} /> : null}
</div>
);
}
function NormalLine(props: { text: string }) {
return (
<div className="flex w-full flex-row gap-x-1.5 md:gap-x-2">
<span className="text-green-400"></span>
<span>{props.text}</span>
</div>
);
}
function LastLine(props: { text: string; onCompleted?: () => void }) {
const [timeText, setTimeText] = useState("");
const [textToShow, setTextToShow] = useState("");
useEffect(() => {
let interval: NodeJS.Timeout | undefined = undefined;
setTimeout(() => {
interval = setInterval(() => {
setTextToShow((prev) => {
if (prev.length < props.text.length) {
return prev + props.text[prev.length];
} else {
clearInterval(interval);
return prev;
}
});
}, 50);
}, 300);
return () => clearInterval(interval);
}, [props.text]);
useEffect(() => {
if (textToShow.length === props.text.length) {
setTimeout(() => {
props.onCompleted?.();
}, 300);
}
}, [props, textToShow]);
useEffect(() => {
setTimeText(dateToString(new Date()));
const interval = setInterval(() => {
setTimeText(dateToString(new Date()));
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div className="flex w-full flex-col pt-1.5 leading-5 md:pt-2.5 md:leading-7">
<div className="flex w-full flex-row flex-nowrap items-center gap-x-1.5 text-nowrap md:gap-x-2">
<span>
squid{" "}
<span className="text-blue-500">
~<span className="max-md:hidden">/Documents/blog</span>
</span>
</span>
<div className="h-0.5 w-full bg-gray-50" />
<span> {timeText}</span>
</div>
<div className="flex w-full flex-row gap-x-1.5 md:gap-x-2">
<span>
<span className="text-green-400"></span>
</span>
<span>{textToShow}</span>
<Cursor />
</div>
</div>
);
}
function Cursor() {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const interval = setInterval(() => {
setIsVisible((prev) => !prev);
setTimeout(() => {
setIsVisible((prev) => !prev);
}, 500);
}, 1000);
return () => clearInterval(interval);
}, []);
return <span className={isVisible ? "" : "hidden"}></span>;
}
function dateToString(date: Date) {
return date.toLocaleString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
}
const lines = [
"大家好,我是 Squid 魷魚",
"身為一位軟體工程師",
"平常最喜歡埋首於程式碼的世界",
"鑽研各種新奇有趣的技術",
"在這裡",
"我會分享我的技術筆記、開發心得",
"還有各式各樣實用工具的評測與介紹",
"一起探索數位世界的無限可能吧!",
];

View File

@ -0,0 +1,27 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
let isVisible: boolean = $state(true);
let interval: ReturnType<typeof setInterval> | null = null;
onMount(() => {
interval = setInterval(() => {
toggleVisibility();
setTimeout(toggleVisibility, 500);
}, 1000);
});
onDestroy(() => {
if (interval != null) {
clearInterval(interval);
interval = null;
}
});
function toggleVisibility() {
isVisible = !isVisible;
}
</script>
<span class="transition-opacity duration-200 {isVisible ? 'opacity-100' : 'opacity-0'}"></span>

View File

@ -0,0 +1,70 @@
<script lang="ts">
import TerminalCursor from '$lib/home/framework/ui/TerminalCursor.svelte';
import { onDestroy, onMount } from 'svelte';
let { text, onComplete: onCompleted }: { text: string; onComplete: () => void } = $props();
let timeText: string = $state('');
let showingText: string = $state('');
let textUpdateInterval: ReturnType<typeof setInterval> | null = null;
let timeUpdateInterval: ReturnType<typeof setInterval> | null = null;
onMount(() => {
setTimeout(() => {
textUpdateInterval = setInterval(() => {
if (showingText.length < text.length) {
showingText += text[showingText.length];
} else {
clearInterval(textUpdateInterval!);
setTimeout(onCompleted, 300);
}
}, 50);
}, 300);
timeText = dateToLocaleString(new Date());
timeUpdateInterval = setInterval(() => {
timeText = dateToLocaleString(new Date());
}, 1000);
});
onDestroy(() => {
if (textUpdateInterval != null) {
clearInterval(textUpdateInterval);
textUpdateInterval = null;
}
if (timeUpdateInterval != null) {
clearInterval(timeUpdateInterval);
timeUpdateInterval = null;
}
});
function dateToLocaleString(date: Date): string {
return date.toLocaleString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
</script>
<div class="flex w-full flex-col pt-1.5 leading-5 md:pt-2.5 md:leading-7">
<div class="flex w-full flex-row flex-nowrap items-center gap-x-1.5 text-nowrap md:gap-x-2">
<span>
<span>╭─  squid </span>
<span class="text-blue-500">
~<span class="max-md:hidden">/Documents/blog</span>
</span>
</span>
<div class="h-0.5 w-full bg-gray-50"></div>
<span>{timeText}</span>
</div>
<div class="flex w-full flex-row gap-x-1.5 md:gap-x-2">
<span>
╰─<span class="text-green-400"></span>
</span>
<span>{showingText}</span>
<TerminalCursor />
</div>
</div>

View File

@ -0,0 +1,8 @@
<script lang="ts">
const { text }: { text: string } = $props();
</script>
<div class="flex w-full flex-row gap-x-1.5 md:gap-x-2">
<span class="text-green-400"></span>
<span>{text}</span>
</div>

View File

@ -0,0 +1,17 @@
<script lang="ts">
import TitleScreenAnimatedTags from '$lib/home/framework/ui/TitleScreenAnimatedTags.svelte';
</script>
<div
class="mx-auto flex min-h-content-height max-w-screen-xl flex-col justify-center gap-y-2.5 px-4 md:gap-y-8 md:px-6"
>
<h2 class="text-3xl font-bold text-gray-800 md:text-6xl">Hello 大家好!</h2>
<h1 class="flex flex-row items-center gap-x-2 text-4xl font-extrabold text-gray-800 md:text-7xl">
<span>我是</span>
<div class="rounded-lg bg-blue-600 px-1.5 py-1">
<span class="text-white">Squid</span>
</div>
<span>魷魚</span>
</h1>
<TitleScreenAnimatedTags />
</div>

View File

@ -0,0 +1,77 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
const tagsCollection = [
'APP',
'C++',
'Design Pattern',
'Docker',
'Flutter',
'Go',
'Java',
'LINER',
'Linux',
'Python',
'Squid',
'TypeScript',
'中央大學',
'全端',
'分享',
'前端',
'後端',
'教學',
'暴肝',
'知識',
'碼農',
'科技',
'科普',
'程式設計',
'資工系',
'軟體工程',
'遊戲',
'魷魚'
];
// Initialize with placeholder to prevent flickering
let showingTags: string[] = $state(['']);
let isTagsVisible: boolean = $state(false);
let interval: ReturnType<typeof setInterval> | null = null;
onMount(() => {
shuffleTags();
isTagsVisible = true;
interval = setInterval(() => {
isTagsVisible = false;
setTimeout(() => {
shuffleTags();
isTagsVisible = true;
}, 500);
}, 4000);
});
onDestroy(() => {
if (interval != null) {
clearInterval(interval);
interval = null;
}
});
function shuffleTags() {
showingTags = [...tagsCollection].sort(() => Math.random() - 0.5);
}
</script>
<div
class={`relative w-full max-w-screen-md transition-opacity duration-500 ${isTagsVisible ? 'opacity-100' : 'opacity-0'}`}
>
<div
class="absolute inset-0 bg-gradient-to-r from-transparent via-transparent via-60% to-white"
></div>
<div class="flex flex-row items-center gap-x-2 overflow-hidden">
{#each showingTags as tag (tag)}
<span class="text-nowrap text-gray-400"># {tag}</span>
{/each}
</div>
</div>

View File

@ -1,6 +0,0 @@
import { useDispatch, useSelector } from "react-redux";
import tagStore from "@/lib/home/presenter/tagStore";
export const useTagDispatch = useDispatch.withTypes<typeof tagStore.dispatch>();
export const useTagSelector = useSelector.withTypes<ReturnType<typeof tagStore.getState>>();

View File

@ -1,55 +0,0 @@
import { createAction, createReducer } from "@reduxjs/toolkit";
import shuffleArray from "@/lib/util/shuffleArray";
export const tagShuffledAction = createAction("tag/shuffled");
const tagReducer = createReducer<TagState>(
() => ({
tags: shuffleArray(tagsCollection),
}),
(builder) => {
builder.addCase(tagShuffledAction, (state) => {
state.tags = shuffleArray(tagsCollection);
});
},
);
export default tagReducer;
export type TagAction = ReturnType<typeof tagShuffledAction>;
export interface TagState {
tags: string[];
timer?: NodeJS.Timeout;
}
const tagsCollection = [
"APP",
"C++",
"Design Pattern",
"Docker",
"Flutter",
"Go",
"Java",
"LINER",
"Linux",
"Python",
"Squid",
"TypeScript",
"中央大學",
"全端",
"分享",
"前端",
"後端",
"教學",
"暴肝",
"知識",
"碼農",
"科技",
"科普",
"程式設計",
"資工系",
"軟體工程",
"遊戲",
"魷魚",
];

View File

@ -1,7 +0,0 @@
import { configureStore } from "@reduxjs/toolkit";
import tagReducer, { TagAction, TagState } from "@/lib/home/presenter/tagReducer";
export default configureStore<TagState, TagAction>({
reducer: tagReducer,
});

View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@ -1,8 +0,0 @@
export default function shuffleArray<T>(array: T[]): T[] {
const newArray = [...array];
for (let i = newArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
}
return newArray;
}

View File

@ -0,0 +1,15 @@
<script>
import Footer from '$lib/common/framework/ui/Footer.svelte';
import Navbar from '$lib/common/framework/ui/Navbar.svelte';
import '../app.css';
import '@fortawesome/fontawesome-free/css/all.min.css';
</script>
<svelte:head>
<meta name="app-version" content={App.__VERSION__} />
</svelte:head>
<div class="min-h-screen">
<Navbar />
<slot />
</div>
<Footer />

View File

@ -0,0 +1,11 @@
<script>
import Motto from '$lib/home/framework/ui/Motto.svelte';
import Terminal from '$lib/home/framework/ui/Terminal.svelte';
import TitleScreen from '$lib/home/framework/ui/TitleScreen.svelte';
</script>
<div>
<TitleScreen />
<Terminal />
<Motto />
</div>

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

18
frontend/svelte.config.js Normal file
View File

@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

View File

@ -1,28 +0,0 @@
import type { Config } from "tailwindcss";
export default {
content: ["./src/lib/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {
fontFamily: {
sans: ["var(--font-noto-sans-tc)"],
mono: ["var(--font-hack-nerd-mono)", "var(--font-noto-sans-mono)"],
},
colors: {
"true-gray": {
"50": "#fafafa",
"100": "#f5f5f5",
"200": "#e5e5e5",
"300": "#d4d4d4",
"400": "#a3a3a3",
"500": "#737373",
"600": "#525252",
"700": "#404040",
"800": "#262626",
"900": "#171717",
},
},
},
},
plugins: [],
} satisfies Config;

View File

@ -1,27 +1,19 @@
{ {
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "checkJs": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "skipLibCheck": true,
"jsx": "preserve", "sourceMap": true,
"incremental": true, "strict": true,
"plugins": [ "moduleResolution": "bundler"
{
"name": "next"
} }
], // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
"paths": { // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
"@/*": ["./src/*"] //
} // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
}, // from the referenced tsconfig.json - TypeScript does not merge them in
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
} }

12
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,12 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { version } from './package.json';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
define: {
'App.__VERSION__': JSON.stringify(version)
}
});