├── public ├── icon.png ├── zenn-favicon.png └── icon.svg ├── .env.example ├── next-env.d.ts ├── src ├── styles │ ├── global │ │ ├── index.scss │ │ ├── _utils.scss │ │ ├── _variables.scss │ │ └── _base.scss │ └── components │ │ ├── ContentWrapper.module.scss │ │ ├── SiteFooter.module.scss │ │ ├── HomeHero.module.scss │ │ └── Timeline.module.scss ├── types.ts ├── schema.ts ├── components │ ├── ContentWrapper.tsx │ ├── SiteFooter.tsx │ ├── HomeHero.tsx │ ├── TwitterIcon.tsx │ └── Timeline.tsx ├── pages │ ├── index.tsx │ └── _app.tsx ├── lib │ └── helper.ts └── json-builder.ts ├── .prettierrc.js ├── tsconfig.builder.json ├── Dockerfile ├── .eslintrc.js ├── vercel.json ├── site.config.ts ├── .gitignore ├── tsconfig.json ├── .github └── workflows │ └── deployment.yml ├── package.json ├── .devcontainer └── devcontainer.json └── README.md /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catnose99/timeline/HEAD/public/icon.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SHEET_ID='foo' 2 | GOOGLE_SERVICE_ACCOUNT_EMAIL='foo' 3 | GOOGLE_PRIVATE_KEY='baz' -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /public/zenn-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catnose99/timeline/HEAD/public/zenn-favicon.png -------------------------------------------------------------------------------- /src/styles/global/index.scss: -------------------------------------------------------------------------------- 1 | @import './_utils'; 2 | @import './_variables'; 3 | @import './_base'; 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | singleQuote: true, 4 | semi: true, 5 | }; -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | import { itemSchema } from './schema'; 3 | 4 | export type Item = z.infer; // string 5 | -------------------------------------------------------------------------------- /tsconfig.builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "dist", 6 | "noEmit": false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 2 | 3 | ENV PORT 3000 4 | 5 | WORKDIR /usr/src/app 6 | 7 | COPY package*.json ./ 8 | RUN npm install 9 | 10 | EXPOSE 3000 11 | 12 | CMD ["npm", "run", "dev"] -------------------------------------------------------------------------------- /src/styles/components/ContentWrapper.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | max-width: 680px; 3 | margin: 0 auto; 4 | padding: 0 1.2rem; 5 | } 6 | .undoWrapForScroll { 7 | margin-right: -1.3rem; 8 | } 9 | -------------------------------------------------------------------------------- /src/styles/components/SiteFooter.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | margin-top: 2rem; 3 | padding: 1.5rem 0 3rem; 4 | font-size: 0.9rem; 5 | } 6 | .link { 7 | text-decoration: underline; 8 | text-underline-offset: 4px; 9 | text-decoration-color: var(--c-border); 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | extends: [ 6 | 'prettier', 7 | 'prettier/@typescript-eslint', 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "github": { 4 | "enabled": false, 5 | "autoAlias": false, 6 | "silent": true 7 | }, 8 | "redirects": [ 9 | { 10 | "source": "/", 11 | "destination": "https://catnose.me/timeline", 12 | "permanent": true 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | 3 | export const itemSchema = z.object({ 4 | date: z.string(), 5 | title: z.string(), 6 | action: z.union([z.string(), z.undefined()]), 7 | url: z.union([z.string(), z.undefined()]), 8 | description: z.union([z.string(), z.undefined()]), 9 | excluded: z.boolean(), 10 | }); 11 | 12 | export const itemsSchema = z.array(itemSchema); 13 | -------------------------------------------------------------------------------- /src/styles/global/_utils.scss: -------------------------------------------------------------------------------- 1 | $breakpoints: ( 2 | 'xs': 'screen and (max-width: 400px)', 3 | 'sm': 'screen and (max-width: 576px)', 4 | 'md': 'screen and (max-width: 768px)', 5 | 'lg': 'screen and (max-width: 992px)', 6 | 'xl': 'screen and (max-width: 1200px)', 7 | ); 8 | 9 | @mixin mq($size) { 10 | @media #{map-get($breakpoints, $size)} { 11 | @content; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/styles/global/_variables.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --c-base-background: #1c1c1f; 3 | --c-base-background-lighter: #292c34; 4 | --c-base-text: #fff; 5 | --c-gray: rgba(214, 226, 243, 0.7); 6 | --c-border: rgba(115, 125, 130, 0.4); 7 | --font-family-base: 'Helvetica Neue', Arial, 'Hiragino Kaku Gothic ProN', 8 | 'Hiragino Sans', Meiryo, sans-serif; 9 | --font-family-alphabet: system-ui, -apple-system, 'Segoe UI', Arial, 10 | sans-serif; 11 | } 12 | -------------------------------------------------------------------------------- /site.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | title: "catnose's timeline", 3 | description: 'personal website by catnose.', 4 | siteRoot: 5 | process.env.NODE_ENV === 'production' 6 | ? 'https://times.catnose99.com' 7 | : 'http://localhost:3000', 8 | rssUrlList: [ 9 | 'https://zenn.dev/catnose99/feed', 10 | 'https://catnose.medium.com/feed', 11 | 'https://note.com/catnose/rss', 12 | 'https://catknows.vercel.app/rss.xml', 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /src/components/ContentWrapper.tsx: -------------------------------------------------------------------------------- 1 | import styles from '../styles/components/ContentWrapper.module.scss'; 2 | 3 | export const ContentWrapper: React.FC<{ children: React.ReactNode }> = ( 4 | props 5 | ) => { 6 | return
{props.children}
; 7 | }; 8 | 9 | export const UndoWrapForScroll: React.FC<{ 10 | children: React.ReactNode; 11 | }> = (props) => { 12 | return
{props.children}
; 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/SiteFooter.tsx: -------------------------------------------------------------------------------- 1 | import { ContentWrapper } from '../components/ContentWrapper'; 2 | import styles from '../styles/components/SiteFooter.module.scss'; 3 | 4 | export const SiteFooter = () => { 5 | return ( 6 | 7 |
8 | 9 | Source code is open on GitHub 10 | 11 |
12 |
13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # generated json 38 | .items.json -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "strictNullChecks": true, 9 | "noImplicitAny": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "baseUrl": ".", 19 | "paths": { 20 | "@*": ["./*"] 21 | } 22 | }, 23 | "exclude": ["node_modules"], 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js"] 25 | } 26 | -------------------------------------------------------------------------------- /src/styles/components/HomeHero.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 4rem 0 5rem; 3 | } 4 | .title { 5 | margin-top: 3.3rem; 6 | font-size: 2.7rem; 7 | line-height: 1.2; 8 | font-family: var(--font-family-alphabet); 9 | } 10 | 11 | .description { 12 | margin-top: 2.3rem; 13 | font-size: 1.1rem; 14 | line-height: 1.8; 15 | color: var(--c-gray); 16 | font-family: var(--font-family-alphabet); 17 | a { 18 | display: inline-flex; 19 | align-items: center; 20 | white-space: nowrap; 21 | text-decoration: underline; 22 | text-underline-offset: 4px; 23 | text-decoration-color: var(--c-border); 24 | transition: color 0.2s; 25 | &:hover { 26 | color: var(--c-base-text); 27 | } 28 | } 29 | svg { 30 | margin: 0 1px 0 5px; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/HomeHero.tsx: -------------------------------------------------------------------------------- 1 | import styles from '../styles/components/HomeHero.module.scss'; 2 | import { TwitterIcon } from './TwitterIcon'; 3 | 4 | export const HomeHero = () => { 5 | return ( 6 |
7 | Hello 8 |

Hi, I'm catnose

9 |

10 | Designer, developer, maker, dog & cat lover. Currently working on{' '} 11 | zenn.dev with{' '} 12 | Classmethod. Follow{' '} 13 | 14 | @catnose99 15 | 16 | {' '} 17 | for daily updates. 18 |

19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /.github/workflows/deployment.yml: -------------------------------------------------------------------------------- 1 | # 👏 Thanks: https://zenn.dev/nikaera/articles/vercel-github-actions 2 | 3 | # catnose.me/timelineに移行したのでactionsを停止 4 | 5 | # name: deploy on vercel 6 | 7 | # on: 8 | # push: 9 | # branches: 10 | # - main 11 | # schedule: 12 | # - cron: '0 2 * * *' 13 | # jobs: 14 | # deploy: 15 | # runs-on: ubuntu-latest 16 | # steps: 17 | # - uses: actions/checkout@v2 18 | # with: 19 | # ref: ${{ github.event.inputs.ref }} 20 | # - uses: actions/setup-node@v2 21 | # with: 22 | # node-version: '14' 23 | # - uses: amondnet/vercel-action@v20 24 | # with: 25 | # vercel-token: ${{ secrets.VERCEL_TOKEN }} 26 | # vercel-args: '--prod' 27 | # vercel-org-id: ${{ secrets.ORG_ID}} 28 | # vercel-project-id: ${{ secrets.PROJECT_ID}} 29 | # working-directory: ./ 30 | -------------------------------------------------------------------------------- /src/components/TwitterIcon.tsx: -------------------------------------------------------------------------------- 1 | export const TwitterIcon: React.VFC<{ width: number; height: number }> = ({ 2 | width, 3 | height, 4 | }) => ( 5 | 16 | 24 | 25 | ); 26 | -------------------------------------------------------------------------------- /src/styles/global/_base.scss: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | font-size: 16px; 4 | -ms-text-size-adjust: 100%; 5 | -webkit-text-size-adjust: 100%; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | line-height: 1.6; 9 | @include mq(sm) { 10 | font-size: 15px; 11 | } 12 | } 13 | *, 14 | *:before, 15 | *:after { 16 | box-sizing: inherit; 17 | } 18 | 19 | body { 20 | margin: 0; 21 | color: var(--c-base-text); 22 | background: var(--c-base-background); 23 | word-break: break-word; 24 | word-wrap: break-word; 25 | font-family: var(--font-family-base); 26 | } 27 | 28 | img { 29 | max-width: 100%; 30 | } 31 | p, 32 | blockquote, 33 | dl, 34 | dd, 35 | dt, 36 | section { 37 | margin: 0; 38 | } 39 | 40 | a { 41 | text-decoration: none; 42 | color: inherit; 43 | } 44 | 45 | h1, 46 | h2, 47 | h3, 48 | h4, 49 | h5, 50 | h6 { 51 | margin: 0; 52 | } 53 | 54 | ul, 55 | ol { 56 | margin: 0; 57 | padding: 0; 58 | list-style: none; 59 | } 60 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import jsonItems from '../../.items.json'; 3 | import siteConfig from '../../site.config'; 4 | import { ContentWrapper } from '../components/ContentWrapper'; 5 | import { HomeHero } from '../components/HomeHero'; 6 | import { Timeline } from '../components/Timeline'; 7 | import { itemsSchema } from '../schema'; 8 | 9 | const IndexPage = () => { 10 | const items = itemsSchema.parse(jsonItems); 11 | return ( 12 | <> 13 | 14 | {siteConfig.title} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 |
25 |
26 | 27 | ); 28 | }; 29 | 30 | export default IndexPage; 31 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | // import { SiteFooter } from '@src/components/SiteFooter'; 2 | // import { SiteHeader } from '@src/components/SiteHeader'; 3 | import { AppProps } from 'next/app'; 4 | import Head from 'next/head'; 5 | import siteConfig from '../../site.config'; 6 | import { SiteFooter } from '../components/SiteFooter'; 7 | import '../styles/global/index.scss'; 8 | 9 | export default function MyApp({ Component, pageProps }: AppProps) { 10 | return ( 11 | <> 12 | 13 | 18 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/helper.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import relativeTime from 'dayjs/plugin/relativeTime'; 3 | dayjs.extend(relativeTime); 4 | 5 | export function getHostFromURL(url: string) { 6 | const urlObj = new URL(url); 7 | return urlObj.hostname; 8 | } 9 | 10 | export function getFaviconSrcFromHostname(hostname: string) { 11 | return `https://www.google.com/s2/favicons?sz=128&domain=${hostname}`; 12 | } 13 | 14 | export function formatDate(dateText: string, format = 'YYYY-MM-DD') { 15 | const date = dayjs(dateText); 16 | // conditionally return relative date 17 | const isRecent = Math.abs(date.diff(Date.now(), 'month')) < 6; 18 | 19 | return isRecent ? date.fromNow() : date.format(format); 20 | } 21 | 22 | export const groupByKey = ( 23 | array: readonly V[], 24 | getKeyFunc: (cur: V, idx: number, src: readonly V[]) => K 25 | ): [K, V[]][] => 26 | Array.from( 27 | array.reduce((map, cur, idx, src) => { 28 | const key = getKeyFunc(cur, idx, src); 29 | const items = map.get(key); 30 | if (items) items.push(cur); 31 | else map.set(key, [cur]); 32 | return map; 33 | }, new Map()) 34 | ); 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timeline", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "run-s build:items build:next", 8 | "build:items": "ts-node --project tsconfig.builder.json ./src/json-builder.ts", 9 | "build:next": "next build", 10 | "start": "next start" 11 | }, 12 | "dependencies": { 13 | "dayjs": "^1.10.4", 14 | "next": "^10.0.8", 15 | "react": "17.0.1", 16 | "react-dom": "17.0.1", 17 | "sass": "^1.32.8", 18 | "zod": "^1.11.11" 19 | }, 20 | "devDependencies": { 21 | "@types/fs-extra": "^9.0.7", 22 | "@types/google-spreadsheet": "^3.0.2", 23 | "@types/node": "^14.14.31", 24 | "@types/react-dom": "^17.0.1", 25 | "@typescript-eslint/eslint-plugin": "^4.15.2", 26 | "@typescript-eslint/parser": "^4.15.2", 27 | "dotenv": "^8.2.0", 28 | "eslint": "^7.21.0", 29 | "eslint-config-prettier": "^8.0.0", 30 | "eslint-plugin-react": "^7.22.0", 31 | "eslint-plugin-react-hooks": "^4.2.0", 32 | "fs-extra": "^9.1.0", 33 | "google-spreadsheet": "^3.1.15", 34 | "npm-run-all": "^4.1.5", 35 | "prettier": "^2.2.1", 36 | "rss-parser": "^3.12.0", 37 | "scss": "^0.2.4", 38 | "ts-node": "^9.1.1", 39 | "typescript": "^4.1.5" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/styles/components/Timeline.module.scss: -------------------------------------------------------------------------------- 1 | .year { 2 | position: sticky; 3 | top: 6px; 4 | z-index: 1; 5 | margin-left: -10px; 6 | padding: 0.3rem 0; 7 | width: 78px; 8 | text-align: center; 9 | font-weight: 700; 10 | border-radius: 2.5em; 11 | background: var(--c-base-background-lighter); 12 | } 13 | .itemsContainer { 14 | margin: 1rem 0; 15 | margin-left: 2px; 16 | padding: 1rem 0; 17 | border-left: solid 2px var(--c-border); 18 | } 19 | .itemIcon { 20 | position: absolute; 21 | display: inline-flex; 22 | left: -6px; 23 | top: 4px; 24 | width: 10px; 25 | height: 10px; 26 | border-radius: 50%; 27 | border: solid 2px var(--c-border); 28 | background: var(--c-base-background); 29 | } 30 | 31 | .itemLink { 32 | display: block; 33 | position: relative; 34 | padding-left: 20px; 35 | &:not(:first-child) { 36 | margin-top: 2.2rem; 37 | } 38 | } 39 | 40 | .itemTitle { 41 | margin-top: 0.5rem; 42 | font-size: 1.15rem; 43 | line-height: 1.6; 44 | letter-spacing: 0.01em; 45 | &:hover { 46 | text-decoration: underline; 47 | text-underline-offset: 4px; 48 | text-decoration-color: var(--c-border); 49 | } 50 | } 51 | 52 | .itemMeta { 53 | display: flex; 54 | align-items: center; 55 | color: var(--c-gray); 56 | font-size: 0.9rem; 57 | line-height: 1.2; 58 | } 59 | .itemFavicon { 60 | margin-right: 7px; 61 | border-radius: 4px; 62 | } 63 | 64 | .itemDate { 65 | &:before { 66 | content: '/'; 67 | margin: 0 5px; 68 | color: var(--c-border); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.163.0/containers/docker-existing-dockerfile 3 | { 4 | "name": "Existing Dockerfile", 5 | 6 | // Sets the run context to one level up instead of the .devcontainer folder. 7 | "context": "..", 8 | 9 | // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. 10 | "dockerFile": "../Dockerfile", 11 | 12 | // Set *default* container specific settings.json values on container create. 13 | "settings": { 14 | "terminal.integrated.shell.linux": null, 15 | "editor.tabSize": 2, 16 | "editor.defaultFormatter": "esbenp.prettier-vscode", 17 | "editor.formatOnPaste": true, 18 | "editor.formatOnSave": true, 19 | "editor.formatOnType": true, 20 | "editor.codeActionsOnSave": [ 21 | "source.organizeImports", 22 | "source.fixAll.eslint" 23 | ] 24 | }, 25 | 26 | // Add the IDs of extensions you want installed when the container is created. 27 | "extensions": ["esbenp.prettier-vscode","dbaeumer.vscode-eslint"] 28 | 29 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 30 | // "forwardPorts": [], 31 | 32 | // Uncomment the next line to run commands after the container is created - for example installing curl. 33 | // "postCreateCommand": "apt-get update && apt-get install -y curl", 34 | 35 | // Uncomment when using a ptrace-based debugger like C++, Go, and Rust 36 | // "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], 37 | 38 | // Uncomment to use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker. 39 | // "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ], 40 | 41 | // Uncomment to connect as a non-root user if you've added one. See https://aka.ms/vscode-remote/containers/non-root. 42 | // "remoteUser": "vscode" 43 | } 44 | -------------------------------------------------------------------------------- /src/components/Timeline.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | formatDate, 3 | getFaviconSrcFromHostname, 4 | getHostFromURL, 5 | groupByKey, 6 | } from '../lib/helper'; 7 | import styles from '../styles/components/Timeline.module.scss'; 8 | import { Item } from '../types'; 9 | 10 | const TimelineItem: React.VFC<{ item: Item }> = ({ item }) => { 11 | const hostname = item.url ? getHostFromURL(item.url) : null; 12 | return ( 13 | 14 |
15 |
16 | {hostname && ( 17 | 27 | )} 28 |
29 | {!!item.action?.length && ( 30 | {item.action} 31 | )} 32 | 33 |
34 |
35 |

{item.title}

36 |
37 | ); 38 | }; 39 | 40 | export const Timeline: React.VFC<{ items: Item[] }> = ({ items }) => { 41 | const itemGroups = groupByKey(items, (item) => Number(item.date.slice(0, 4))); 42 | 43 | return ( 44 |
45 | {itemGroups.map((group) => { 46 | const [year, items] = group; 47 | return ( 48 |
49 |
{year}
50 |
51 | {items.map((item, i) => ( 52 | 53 | ))} 54 |
55 |
56 | ); 57 | })} 58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # catnose99/timeline 2 | 3 | [![deploy on vercel](https://github.com/catnose99/timeline/actions/workflows/deployment.yml/badge.svg?branch=main)](https://github.com/catnose99/timeline/actions/workflows/deployment.yml) 4 | 5 | ![screenshot](https://user-images.githubusercontent.com/34590683/110445555-959d5a80-8101-11eb-92f8-f9860bae4ac4.png) 6 | 7 | Personal website gathering my activities on the internet. 8 | 9 | ## Usage 10 | 11 | The feed are regularly updated by fetching data from specified sources: RSS feed or Google spreadsheet. 12 | 13 | ### configuration 14 | 15 | #### Create Google Spreadsheet 16 | 17 | 1. Create a sheet. 18 | 2. Set sheet header (first row in the sheet) exactly as follows. 19 | 20 | ``` 21 | date | title | action | url | description | excluded | from_rss 22 | ``` 23 | 24 | [Real world sheet example](https://docs.google.com/spreadsheets/d/1xMmgneTK_yTE6q8fg-18uLKubh2HHvuV2BKksnWk69s/edit?usp=sharing) 25 | 26 | #### Configure keys 27 | 28 | This is required to access a sheet systematically. 29 | 30 | 1. On [GCP IAM console](https://console.cloud.google.com/iam-admin/iam), create a service account. 31 | 2. Generate a key for the service account with JSON format. 32 | 3. Open the spreadsheet and paste service account mail(`foo@bar.iam.gserviceaccount.com`) on Collaboration settings. 33 | 4. Create `.env` with the following contents on project root dir. 34 | 35 | ```bash 36 | SHEET_ID='foo' # extracted from the sheet url. 37 | GOOGLE_SERVICE_ACCOUNT_EMAIL='bar' # foo@bar.iam.gserviceaccount.com 38 | GOOGLE_PRIVATE_KEY='baz' # copy "private_key" in json (downloaded on step.2) 39 | ``` 40 | 41 | #### Configure scheduled deployments 42 | 43 | You can configure scheduled deployments via Github Actions easily. (See `/.github/workflows`) 44 | 45 | To make actions work, save the following values in [repository secrets](https://docs.github.com/en/actions/reference/encrypted-secrets). 46 | 47 | - `VERCEL_TOKEN` 48 | - `PROJECT_ID` 49 | - `ORG_ID` 50 | 51 | `VERCEL_TOKEN` can be generated on [Vercel dashboard](https://vercel.com/account/tokens). `PROJECT_ID` and `ORG_ID` can be found in `.vercel/project.json` which is generated after running `vercel` command locally. 52 | 53 | ### development 54 | 55 | ```bash 56 | # install packages 57 | $ npm install 58 | # generate .items.json which has the feed sources. 59 | $ npm run build:items 60 | # start local server 61 | $ npm run dev 62 | ``` 63 | 64 | ### deployment 65 | 66 | I recommend you deploy on static website hosting services such as Vercel, Netlify or Cloudflare Pages. Note that build commands are configured properly. 67 | 68 | ```bash 69 | # build commands 70 | $ npm run build 71 | ``` 72 | 73 | To keep the feed fresh, you need to rebuild the app periodically. 74 | 75 | ## Licence 76 | 77 | MIT except for logo (`/public/icon.*`). Be sure to replace them when you fork this repo. 78 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/json-builder.ts: -------------------------------------------------------------------------------- 1 | // fetch rss => update spreadsheets => generate .items.json 2 | require('dotenv').config(); 3 | import dayjs from 'dayjs'; 4 | import fs from 'fs-extra'; 5 | import { GoogleSpreadsheet } from 'google-spreadsheet'; 6 | import Parser from 'rss-parser'; 7 | import siteConfig from '../site.config'; 8 | import { getHostFromURL } from './lib/helper'; 9 | import { itemSchema } from './schema'; 10 | 11 | type RssItem = { 12 | title: string; 13 | url: string; 14 | date: string; 15 | }; 16 | 17 | const parser = new Parser(); 18 | const sheetId = process.env.SHEET_ID; 19 | const clientEmail = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL; 20 | const privateKey = process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/gm, '\n'); 21 | const sheetHeaderValues = [ 22 | 'date', 23 | 'title', 24 | 'action', 25 | 'url', 26 | 'description', 27 | 'excluded', 28 | 'from_rss', 29 | ]; 30 | 31 | (async function () { 32 | // prepare to access spreadsheet 33 | if ( 34 | typeof sheetId === 'undefined' || 35 | typeof clientEmail === 'undefined' || 36 | typeof privateKey === 'undefined' 37 | ) { 38 | console.error( 39 | 'env "SHEET_ID", "GOOGLE_SERVICE_ACCOUNT_EMAIL", "GOOGLE_PRIVATE_KEY" are required.' 40 | ); 41 | process.exit(1); 42 | } 43 | 44 | const doc = new GoogleSpreadsheet(sheetId); 45 | await doc.useServiceAccountAuth({ 46 | client_email: clientEmail, 47 | private_key: privateKey, 48 | }); 49 | 50 | // prepare for getting sheet values 51 | await doc.loadInfo(); 52 | const sheet = doc.sheetsByIndex[0]; 53 | await sheet.loadHeaderRow(); 54 | 55 | // ensure sheet has valid header columns. 56 | if ( 57 | JSON.stringify(sheet.headerValues) !== JSON.stringify(sheetHeaderValues) 58 | ) { 59 | console.error( 60 | `Your sheet must have the following header columns ${sheetHeaderValues 61 | .map((v) => `"${v}"`) 62 | .join(', ')} in the exact same order.` 63 | ); 64 | process.exit(1); 65 | } 66 | 67 | // get urls that already exist on sheets 68 | const rows = await sheet.getRows(); 69 | const existingUrls = rows 70 | .map((r) => r.url.trim()) 71 | .filter((r): r is string => typeof r === 'string'); 72 | 73 | // get feed items from specified rss urls on site.config 74 | const feedItems = await getAllFeedItems(); 75 | 76 | // remove existing urls on sheets 77 | const newItems = feedItems.filter( 78 | (item) => !existingUrls.includes(item.url.trim()) 79 | ); 80 | // write new items to spreadsheets 81 | const newRows = newItems.map((item) => { 82 | return { 83 | date: item.date, 84 | title: item.title, 85 | url: item.url, 86 | action: `Posted on ${getHostFromURL(item.url)}`, 87 | from_rss: '1', 88 | }; 89 | }); 90 | sheet.addRows(newRows); 91 | 92 | const allRows = await sheet.getRows(); 93 | const jsonData = allRows 94 | .map((r) => 95 | itemSchema.parse({ 96 | title: r.title, 97 | url: r.url, 98 | date: r.date, 99 | action: r.action, 100 | description: r.description, 101 | excluded: r.excluded === '1' || r.excluded === 'true', 102 | }) 103 | ) 104 | .filter((r) => !r.excluded) 105 | .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); 106 | 107 | fs.writeJsonSync('.items.json', jsonData); 108 | })(); 109 | 110 | async function fetchFeedItems(url: string) { 111 | try { 112 | const feed = await parser.parseURL(url); 113 | if (!feed?.items?.length) return []; 114 | return feed.items 115 | .map(({ title, contentSnippet, link, isoDate }) => { 116 | return { 117 | title, 118 | url: link, 119 | date: dayjs(isoDate).format('YYYY-MM-DD'), 120 | }; 121 | }) 122 | .filter( 123 | (item): item is RssItem => 124 | typeof item.title === 'string' && 125 | typeof item.url === 'string' && 126 | typeof item.date === 'string' 127 | ); 128 | } catch (err) { 129 | console.error(`🚩 Failed to fetch data from ${url}`); 130 | return []; 131 | } 132 | } 133 | 134 | async function getAllFeedItems() { 135 | const allFeedItems: RssItem[] = []; 136 | for (const url of siteConfig.rssUrlList) { 137 | const items = await fetchFeedItems(url); 138 | allFeedItems.push(...items); 139 | } 140 | return allFeedItems; 141 | } 142 | 143 | export default {}; 144 | --------------------------------------------------------------------------------