BLOG-58 Rewrite frontend with svelte kit (#61)
All checks were successful
Frontend CI / build (push) Successful in 1m1s
All checks were successful
Frontend CI / build (push) Successful in 1m1s
Reviewed-on: #61
This commit is contained in:
commit
3f02eaa568
@ -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
11
.vscode/settings.json
vendored
@ -1,12 +1,3 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": [
|
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||||
"actix",
|
|
||||||
"chrono",
|
|
||||||
"dotenv",
|
|
||||||
"rustls",
|
|
||||||
"serde",
|
|
||||||
"sqlx",
|
|
||||||
"squidspirit"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
149
frontend/.gitignore
vendored
@ -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
1
frontend/.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
9
frontend/.prettierignore
Normal file
9
frontend/.prettierignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/static/
|
@ -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"
|
||||||
}
|
}
|
||||||
|
@ -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"]
|
||||||
|
@ -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
40
frontend/eslint.config.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
@ -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;
|
|
5
frontend/next-env.d.ts
vendored
5
frontend/next-env.d.ts
vendored
@ -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.
|
|
@ -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;
|
|
@ -1,39 +1,46 @@
|
|||||||
{
|
{
|
||||||
"name": "squidspirit-blog",
|
"name": "squidspirit-blog",
|
||||||
"version": "0.1.1",
|
"private": true,
|
||||||
"private": true,
|
"version": "0.1.1",
|
||||||
"license": "MIT",
|
"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",
|
"devDependencies": {
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
"@eslint/compat": "^1.2.5",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
"@eslint/js": "^9.18.0",
|
||||||
"@reduxjs/toolkit": "^2.5.0",
|
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||||
"next": "15.1.4",
|
"@sveltejs/adapter-auto": "^6.0.0",
|
||||||
"react": "^19.0.0",
|
"@sveltejs/adapter-node": "^5.2.13",
|
||||||
"react-dom": "^19.0.0",
|
"@sveltejs/kit": "^2.22.0",
|
||||||
"react-redux": "^9.2.0"
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
},
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"devDependencies": {
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"eslint": "^9.18.0",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^5.2.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"@types/node": "^20.17.14",
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
"@types/react": "^19.0.7",
|
"globals": "^16.0.0",
|
||||||
"@types/react-dom": "^19.0.3",
|
"prettier": "^3.4.2",
|
||||||
"eslint": "^9.18.0",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"eslint-config-next": "15.1.4",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"postcss": "^8.5.1",
|
"svelte": "^5.0.0",
|
||||||
"prettier": "^3.4.2",
|
"svelte-check": "^4.0.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.10",
|
"tailwindcss": "^4.0.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"typescript": "^5.0.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript-eslint": "^8.20.0",
|
||||||
}
|
"vite": "^7.0.4"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.12.4"
|
||||||
}
|
}
|
||||||
|
4453
frontend/pnpm-lock.yaml
generated
4453
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,8 +0,0 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
|
||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
40
frontend/src/app.css
Normal file
40
frontend/src/app.css
Normal 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
15
frontend/src/app.d.ts
vendored
Normal 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
29
frontend/src/app.html
Normal 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>
|
@ -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));
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
21
frontend/src/lib/common/framework/ui/Footer.svelte
Normal file
21
frontend/src/lib/common/framework/ui/Footer.svelte
Normal 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>
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
18
frontend/src/lib/common/framework/ui/Navbar.svelte
Normal file
18
frontend/src/lib/common/framework/ui/Navbar.svelte
Normal 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>
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
17
frontend/src/lib/common/framework/ui/NavbarAction.svelte
Normal file
17
frontend/src/lib/common/framework/ui/NavbarAction.svelte
Normal 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>
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
20
frontend/src/lib/home/framework/ui/Motto.svelte
Normal file
20
frontend/src/lib/home/framework/ui/Motto.svelte
Normal 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>
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
42
frontend/src/lib/home/framework/ui/MottoAnimatedMark.svelte
Normal file
42
frontend/src/lib/home/framework/ui/MottoAnimatedMark.svelte
Normal 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>
|
@ -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>;
|
|
||||||
}
|
|
74
frontend/src/lib/home/framework/ui/Terminal.svelte
Normal file
74
frontend/src/lib/home/framework/ui/Terminal.svelte
Normal 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>
|
@ -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 魷魚",
|
|
||||||
"身為一位軟體工程師",
|
|
||||||
"平常最喜歡埋首於程式碼的世界",
|
|
||||||
"鑽研各種新奇有趣的技術",
|
|
||||||
"在這裡",
|
|
||||||
"我會分享我的技術筆記、開發心得",
|
|
||||||
"還有各式各樣實用工具的評測與介紹",
|
|
||||||
"一起探索數位世界的無限可能吧!",
|
|
||||||
];
|
|
27
frontend/src/lib/home/framework/ui/TerminalCursor.svelte
Normal file
27
frontend/src/lib/home/framework/ui/TerminalCursor.svelte
Normal 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>
|
70
frontend/src/lib/home/framework/ui/TerminalLastLine.svelte
Normal file
70
frontend/src/lib/home/framework/ui/TerminalLastLine.svelte
Normal 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>
|
@ -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>
|
17
frontend/src/lib/home/framework/ui/TitleScreen.svelte
Normal file
17
frontend/src/lib/home/framework/ui/TitleScreen.svelte
Normal 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>
|
@ -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>
|
@ -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>>();
|
|
@ -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",
|
|
||||||
"中央大學",
|
|
||||||
"全端",
|
|
||||||
"分享",
|
|
||||||
"前端",
|
|
||||||
"後端",
|
|
||||||
"教學",
|
|
||||||
"暴肝",
|
|
||||||
"知識",
|
|
||||||
"碼農",
|
|
||||||
"科技",
|
|
||||||
"科普",
|
|
||||||
"程式設計",
|
|
||||||
"資工系",
|
|
||||||
"軟體工程",
|
|
||||||
"遊戲",
|
|
||||||
"魷魚",
|
|
||||||
];
|
|
@ -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,
|
|
||||||
});
|
|
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
@ -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;
|
|
||||||
}
|
|
15
frontend/src/routes/+layout.svelte
Normal file
15
frontend/src/routes/+layout.svelte
Normal 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 />
|
11
frontend/src/routes/+page.svelte
Normal file
11
frontend/src/routes/+page.svelte
Normal 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>
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
18
frontend/svelte.config.js
Normal file
18
frontend/svelte.config.js
Normal 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;
|
@ -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;
|
|
@ -1,27 +1,19 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
"target": "ES2017",
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"allowJs": true,
|
||||||
"allowJs": true,
|
"checkJs": true,
|
||||||
"skipLibCheck": true,
|
"esModuleInterop": true,
|
||||||
"strict": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noEmit": true,
|
"resolveJsonModule": true,
|
||||||
"esModuleInterop": true,
|
"skipLibCheck": true,
|
||||||
"module": "esnext",
|
"sourceMap": true,
|
||||||
"moduleResolution": "bundler",
|
"strict": true,
|
||||||
"resolveJsonModule": true,
|
"moduleResolution": "bundler"
|
||||||
"isolatedModules": true,
|
}
|
||||||
"jsx": "preserve",
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
"incremental": true,
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
"plugins": [
|
//
|
||||||
{
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
"name": "next"
|
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
}
|
||||||
|
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal 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)
|
||||||
|
}
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user