BLOG-5 feat: self tags animation

This commit is contained in:
SquidSpirit 2025-01-18 03:01:11 +08:00
parent 738008b983
commit 0adf77c411
13 changed files with 251 additions and 37 deletions

View File

@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ reactStrictMode: false,
}; };
export default nextConfig; export default nextConfig;

View File

@ -15,9 +15,11 @@
"@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2", "@fortawesome/react-fontawesome": "^0.2.2",
"@reduxjs/toolkit": "^2.5.0",
"next": "15.1.4", "next": "15.1.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-redux": "^9.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",

View File

@ -23,6 +23,9 @@ importers:
'@fortawesome/react-fontawesome': '@fortawesome/react-fontawesome':
specifier: ^0.2.2 specifier: ^0.2.2
version: 0.2.2(@fortawesome/fontawesome-svg-core@6.7.2)(react@19.0.0) version: 0.2.2(@fortawesome/fontawesome-svg-core@6.7.2)(react@19.0.0)
'@reduxjs/toolkit':
specifier: ^2.5.0
version: 2.5.0(react-redux@9.2.0(@types/react@19.0.7)(react@19.0.0)(redux@5.0.1))(react@19.0.0)
next: next:
specifier: 15.1.4 specifier: 15.1.4
version: 15.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 15.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -32,6 +35,9 @@ importers:
react-dom: react-dom:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.0.0(react@19.0.0) version: 19.0.0(react@19.0.0)
react-redux:
specifier: ^9.2.0
version: 9.2.0(@types/react@19.0.7)(react@19.0.0)(redux@5.0.1)
devDependencies: devDependencies:
'@eslint/eslintrc': '@eslint/eslintrc':
specifier: ^3.2.0 specifier: ^3.2.0
@ -357,6 +363,17 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@reduxjs/toolkit@2.5.0':
resolution: {integrity: sha512-awNe2oTodsZ6LmRqmkFhtb/KH03hUhxOamEQy411m3Njj3BbFvoBovxo4Q1cBWnV1ErprVj9MlF0UPXkng0eyg==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rtsao/scc@1.1.0': '@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@ -389,6 +406,9 @@ packages:
'@types/react@19.0.7': '@types/react@19.0.7':
resolution: {integrity: sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==} resolution: {integrity: sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@typescript-eslint/eslint-plugin@8.20.0': '@typescript-eslint/eslint-plugin@8.20.0':
resolution: {integrity: sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==} resolution: {integrity: sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -979,6 +999,9 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
immer@10.1.1:
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
import-fresh@3.3.0: import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1462,6 +1485,18 @@ packages:
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react@19.0.0: react@19.0.0:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -1473,6 +1508,14 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'} engines: {node: '>=8.10.0'}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1481,6 +1524,9 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -1729,6 +1775,11 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-sync-external-store@1.4.0:
resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@ -2010,6 +2061,16 @@ snapshots:
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
'@reduxjs/toolkit@2.5.0(react-redux@9.2.0(@types/react@19.0.7)(react@19.0.0)(redux@5.0.1))(react@19.0.0)':
dependencies:
immer: 10.1.1
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.0.0
react-redux: 9.2.0(@types/react@19.0.7)(react@19.0.0)(redux@5.0.1)
'@rtsao/scc@1.1.0': {} '@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.10.5': {} '@rushstack/eslint-patch@1.10.5': {}
@ -2038,6 +2099,8 @@ snapshots:
dependencies: dependencies:
csstype: 3.1.3 csstype: 3.1.3
'@types/use-sync-external-store@0.0.6': {}
'@typescript-eslint/eslint-plugin@8.20.0(@typescript-eslint/parser@8.20.0(eslint@9.18.0(jiti@1.21.7))(typescript@5.7.3))(eslint@9.18.0(jiti@1.21.7))(typescript@5.7.3)': '@typescript-eslint/eslint-plugin@8.20.0(@typescript-eslint/parser@8.20.0(eslint@9.18.0(jiti@1.21.7))(typescript@5.7.3))(eslint@9.18.0(jiti@1.21.7))(typescript@5.7.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.1 '@eslint-community/regexpp': 4.12.1
@ -2840,6 +2903,8 @@ snapshots:
ignore@5.3.2: {} ignore@5.3.2: {}
immer@10.1.1: {}
import-fresh@3.3.0: import-fresh@3.3.0:
dependencies: dependencies:
parent-module: 1.0.1 parent-module: 1.0.1
@ -3266,6 +3331,15 @@ snapshots:
react-is@16.13.1: {} react-is@16.13.1: {}
react-redux@9.2.0(@types/react@19.0.7)(react@19.0.0)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.0.0
use-sync-external-store: 1.4.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.7
redux: 5.0.1
react@19.0.0: {} react@19.0.0: {}
read-cache@1.0.0: read-cache@1.0.0:
@ -3276,6 +3350,12 @@ snapshots:
dependencies: dependencies:
picomatch: 2.3.1 picomatch: 2.3.1
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@ -3296,6 +3376,8 @@ snapshots:
gopd: 1.2.0 gopd: 1.2.0
set-function-name: 2.0.2 set-function-name: 2.0.2
reselect@5.1.1: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
@ -3642,6 +3724,10 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
use-sync-external-store@1.4.0(react@19.0.0):
dependencies:
react: 19.0.0
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:

View File

@ -1,8 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Navbar from "@/ui/layout/navbar"; import Navbar from "@/lib/common/presenter/ui/Navbar";
import Footer from "@/ui/layout/footer"; import Footer from "@/lib/common/presenter/ui/Footer";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
@ -21,11 +21,11 @@ export default function RootLayout({
return ( return (
<html lang="zh-Hant"> <html lang="zh-Hant">
<body className={`${inter.className} antialiased`}> <body className={`${inter.className} antialiased`}>
<div className="min-h-screen"> <div className="min-h-screen">
<Navbar /> <Navbar />
{children} {children}
</div> </div>
<Footer /> <Footer />
</body> </body>
</html> </html>
); );

View File

@ -1,4 +1,4 @@
import React from "react"; import SelfTags from "@/lib/home/presenter/ui/SelfTags";
export default function HomePage() { export default function HomePage() {
return ( return (
@ -13,29 +13,7 @@ export default function HomePage() {
</div> </div>
<span></span> <span></span>
</h1> </h1>
<div className="relative w-full max-w-screen-md"> <SelfTags />
<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">
<Hashtag tag="React" />
<Hashtag tag="React" />
<Hashtag tag="React" />
<Hashtag tag="React" />
<Hashtag tag="React" />
<Hashtag tag="React" />
<Hashtag tag="React" />
<Hashtag tag="React" />
<Hashtag tag="React" />
<Hashtag tag="React" />
<Hashtag tag="React" />
<Hashtag tag="React" />
<Hashtag tag="React" />
<Hashtag tag="React" />
</div>
</div>
</div> </div>
); );
} }
function Hashtag(props: { tag: string }) {
return <span className="text-nowrap text-gray-400"># {props.tag}</span>;
}

View File

@ -1,7 +1,6 @@
import { faGithub, faYoutube } from "@fortawesome/free-brands-svg-icons"; import { faGithub, faYoutube } from "@fortawesome/free-brands-svg-icons";
import { faEnvelope } from "@fortawesome/free-solid-svg-icons"; import { faEnvelope } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React from "react";
export default function Footer() { export default function Footer() {
return ( return (

View File

@ -1,5 +1,3 @@
import React from "react";
export default function Navbar() { export default function Navbar() {
return ( return (
<div className="border-b border-gray-300"> <div className="border-b border-gray-300">

View File

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

View File

@ -0,0 +1,72 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import tagStore from "./tagStore";
import shuffleArray from "@/lib/util/shuffleArray";
export interface TagState {
tags: string[];
timer?: NodeJS.Timeout;
}
export interface TagStartedActionPayload {
interval: number;
}
export const tagSlice = createSlice({
name: "tag",
initialState: {
tags: [],
timer: undefined,
} as TagState,
reducers: {
started: (state, action: PayloadAction<TagStartedActionPayload>) => {
state.tags = shuffleArray(tagsCollection);
state.timer = setInterval(() => {
tagStore.dispatch(tagSlice.actions.shuffled());
}, action.payload.interval);
},
shuffled: (state) => {
state.tags = shuffleArray(tagsCollection);
},
stopped: (state) => {
clearInterval(state.timer);
state.timer = undefined;
},
},
});
export const tagStartedAction = tagSlice.actions.started;
export const tagStoppedAction = tagSlice.actions.stopped;
const tagReducer = tagSlice.reducer;
export default tagReducer;
const tagsCollection = [
"APP",
"C++",
"Design Pattern",
"Docker",
"Flutter",
"Go",
"Java",
"LINER",
"Linux",
"Python",
"Squid",
"TypeScript",
"中央大學",
"全端",
"分享",
"前端",
"後端",
"教學",
"暴肝",
"知識",
"碼農",
"科技",
"科普",
"程式設計",
"資工系",
"軟體工程",
"遊戲",
"魷魚",
];

View File

@ -0,0 +1,10 @@
import { configureStore } from "@reduxjs/toolkit";
import tagReducer from "./tagSlice";
const tagStore = configureStore({
reducer: {
tag: tagReducer,
},
});
export default tagStore;

View File

@ -0,0 +1,55 @@
"use client";
import { Provider } from "react-redux";
import tagStore from "../redux/tagStore";
import { useTagDispatch, useTagSelector } from "../redux/tagHooks";
import { useEffect, useState } from "react";
import { tagStartedAction, tagStoppedAction } from "../redux/tagSlice";
export default function SelfTags() {
return (
<Provider store={tagStore}>
<_Tags />
</Provider>
);
}
function _Tags() {
const tags = useTagSelector((state) => state.tag.tags);
const dispatch = useTagDispatch();
const [isTagsVisible, setIsTagsVisible] = useState(false);
useEffect(() => {
dispatch(tagStartedAction({ interval: 4000 }));
return () => {
dispatch(tagStoppedAction());
setIsTagsVisible(false);
};
}, []);
useEffect(() => {
if (tags.length === 0) return;
setIsTagsVisible(true);
setTimeout(() => {
setIsTagsVisible(false);
}, 3500);
}, [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">
{tags.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,8 @@
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

@ -2,7 +2,7 @@ import type { Config } from "tailwindcss";
export default { export default {
content: [ content: [
"./src/ui/**/*.{js,ts,jsx,tsx,mdx}", "./src/lib/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
], ],
theme: { theme: {