diff --git a/.editorconfig b/.editorconfig index f9eaa9e..8b89f14 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,7 +6,7 @@ indent_style = tab indent_size = 4 [*.{yml,md}] indent_style = space -indent_size = 4 +indent_size = 2 [package.json] indent_style = space indent_size = 2 diff --git a/.eslintrc.yml b/.eslintrc.yml index dff5676..b6bb240 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -1,63 +1,75 @@ extends: - - eslint:recommended - - plugin:@typescript-eslint/recommended - - plugin:react/recommended - - plugin:react/jsx-runtime - - next/core-web-vitals + - eslint:recommended + - plugin:@typescript-eslint/recommended + - plugin:react/recommended + - plugin:react/jsx-runtime + - next/core-web-vitals ignorePatterns: - - tmp - - dist - - "**/lib/*.js" + - out + - app/*.js + - md-compiler/out/*.js parser: "@typescript-eslint/parser" plugins: - - "@typescript-eslint" + - "@typescript-eslint" rules: - no-shadow: off - "@typescript-eslint/no-shadow": - - error - no-use-before-define: off - "@typescript-eslint/no-use-before-define": - - error - react/jsx-filename-extension: - - warn - - extensions: - - .tsx - react/jsx-key: - - off - quotes: - - warn - - single - indent: - - warn - - tab - - flatTernaryExpressions: true - react/forbid-component-props: - - error - - forbid: - - key - "@typescript-eslint/no-explicit-any": - - off - semi: - - warn - - always - no-trailing-spaces: - - warn - comma-dangle: - - warn - - always-multiline - space-before-blocks: - - warn - - always - keyword-spacing: - - warn - - before: true - after: true - jsx-quotes: - - warn - - prefer-double + no-shadow: off + "@typescript-eslint/no-shadow": + - error + no-use-before-define: off + "@typescript-eslint/no-use-before-define": + - error + react/jsx-filename-extension: + - warn + - extensions: + - .tsx + react/jsx-key: + - off + quotes: + - warn + - single + indent: + - warn + - tab + - flatTernaryExpressions: true + react/forbid-component-props: + - error + - forbid: + - key + "@typescript-eslint/no-explicit-any": + - off + semi: + - warn + - always + no-trailing-spaces: + - warn + comma-dangle: + - warn + - always-multiline + space-before-blocks: + - warn + - always + keyword-spacing: + - warn + - before: true + after: true + jsx-quotes: + - warn + - prefer-double + "@next/next/no-img-element": + - off + react/forbid-elements: + - error + - forbid: + - img + "@typescript-eslint/no-restricted-imports": + - error + - name: "next/image" + message: "Please change the import to `@/_components/Image`" + "allowTypeImports": true + - "path" env: - browser: true + browser: true settings: - react: - pragma: React - version: '17.0' + react: + pragma: React + version: "17.0" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..33874c3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,50 @@ +name: Run tests +on: + workflow_call: + push: + branches-ignore: + - $default-branch +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fix timestamps + run: bash .github/scripts/fix-timestamps + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + #- uses: actions/cache@v4 + # with: + # path: | + # ${{ github.workspace }}/.next/cache + # # Generate a new cache whenever packages or source files change. + # key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} + # # If source files changed but packages didn't, rebuild from a prior cache. + # restore-keys: | + # ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v5 + with: + context: . + target: export + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + outputs: type=local,dest=/tmp/files + tags: www.ferrybig.me:latest + env: + SOURCE_DATE_EPOCH: 0 + - name: Move cache + if: github.event_name == 'push' + run: + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: files + path: /tmp/files diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 20a2a1b..5d18b4b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,53 +3,28 @@ name: Deploy app on: - push: - branches: [$default-branch] - workflow_dispatch: + push: + branches: [$default-branch] + workflow_dispatch: jobs: # push, pull_request - tests: - uses: ./.github/workflows/test.yml - build: - needs: [tests] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Fix timestamps - run: bash .github/scripts/fix-timestamps - - name: Use Node.js 20 - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - run: npm ci - - uses: actions/cache@v4 - with: - path: | - ~/.npm - ${{ github.workspace }}/.next/cache - # Generate a new cache whenever packages or source files change. - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} - # If source files changed but packages didn't, rebuild from a prior cache. - restore-keys: | - ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- - - run: npm run build - - name: CompressFiles - uses: stefh/ghaction-CompressFiles@v2 - with: - path: dist - extensions: ".js,.css,.html,.map,.stl,.xml,.scad,.svg,.txt,.bmp" - tools: "brotli,gzip" - deterministicCompression: true - - name: ssh publish - uses: easingthemes/ssh-deploy@c711f2c3391cac2876bf4c833590077f02e4bcb8 - with: - SSH_PRIVATE_KEY: ${{ secrets.ssh_private_key }} - REMOTE_HOST: ${{ secrets.ssh_host }} - REMOTE_USER: ${{ secrets.ssh_user }} - REMOTE_PORT: 22 - SOURCE: out/ - TARGET: ${{ secrets.ssh_target }} - ARGS: -r -l -0 -v --checksum --delete-delay + tests: + uses: ./.github/workflows/build.yml + deploy: + needs: [tests] + runs-on: ubuntu-latest + steps: + - name: Download artifact + uses: actions/download-artifact@v3 + with: + name: files + path: /tmp + - name: ssh publish + uses: easingthemes/ssh-deploy@c711f2c3391cac2876bf4c833590077f02e4bcb8 + with: + SSH_PRIVATE_KEY: ${{ secrets.ssh_private_key }} + REMOTE_HOST: ${{ secrets.ssh_host }} + REMOTE_USER: ${{ secrets.ssh_user }} + REMOTE_PORT: 22 + SOURCE: /tmp/files + TARGET: ${{ secrets.ssh_target }} + ARGS: -r -l -0 -v --checksum --delete-delay diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 4ffc6b7..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Run tests -on: - workflow_call: - push: - branches-ignore: - - $default-branch -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Fix timestamps - run: bash .github/scripts/fix-timestamps - - name: Use Node.js 20 - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - run: npm ci - - uses: actions/cache@v4 - with: - path: | - ~/.npm - ${{ github.workspace }}/.next/cache - # Generate a new cache whenever packages or source files change. - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} - # If source files changed but packages didn't, rebuild from a prior cache. - restore-keys: | - ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- - - run: npm run lint - - run: npm run tsc diff --git a/.vscode/settings.json b/.vscode/settings.json index 32d8529..dfa92e9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,7 @@ "estree", "ferrybig", "gifsicle", + "goatcounter", "hljs", "jpegtran", "jsxs", diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..33f6df9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,98 @@ +# syntax=docker/dockerfile:1 +FROM node:20-alpine as node-base + +FROM node-base as md-compiler-deps +COPY /md-compiler/package.json /md-compiler/package-lock.json /app/md-compiler/ +RUN cd /app/md-compiler && npm ci --ignore-scripts + +FROM md-compiler-deps as md-compiler-build +COPY /md-compiler /app/md-compiler/ +RUN cd /app/md-compiler && npm run compiler-build + +FROM md-compiler-deps as md-compiler +#RUN apk add --no-cache git +COPY --from=md-compiler-build /app/md-compiler/out /app/md-compiler/out +COPY /content /app/content +COPY /app /app/app +#COPY /app/.git /app/.git +RUN cd /app/md-compiler && npm run compiler-run + +FROM node-base as deps +COPY /package.json /package-lock.json /app/ +RUN cd /app && npm ci --ignore-scripts + +FROM deps as build-env +COPY /public /app/public +COPY /content /app/content +COPY /assets /app/assets +COPY /md-compiler /app/md-compiler +COPY next.config.mjs postcss.config.cjs postcssLightDarkPolyfill.cjs tsconfig.json .eslintrc.yml package.json /app/ +COPY --from=md-compiler /app/app /app/app + +FROM build-env as build +RUN cd /app && npm run lint && npm run tsc +RUN --mount=type=cache,target=/app/.next/cache cd /app && IGNORE_ERRORS=true npm run build + + +FROM node-base as compressor +RUN apk add --no-cache brotli libwebp-tools libavif-apps +COPY --from=build /app/out /srv +RUN cd /srv \ +&& find . -type f -not -name 'sha1sums' -exec sha1sum {} + > /srv/sha1sums \ +&& find /srv -type f \( -name '*.png' -o -name '*.jpg' \) -print0 \ +| xargs -0 sh -c 'for d; do avifenc -- "$d" "${d%.*}.avif"; done' 'sh' \ +&& find /srv -type f \( -name '*.png' \) -print0 \ +| xargs -0 sh -c 'for d; do cwebp -lossless -o "${d%.*}.webp" "$d"; done' 'sh' \ +&& find /srv -type f \ + \( \ + -name '*.png' -o \ + -name '*.jpg' -o \ + -name '*.txt' -o \ + -name '*.html' -o \ + -name '*.js' -o \ + -name '*.map' -o \ + -name '*.css' -o \ + -name '*.json' -o \ + -name '*.xml' -o \ + -name '*.stl' \ + \) -size +512c \ + -print0 \ +| xargs -0 brotli -Z -k + + +FROM scratch as export +COPY --from=compressor /srv / + + +FROM caddy:2.8 +EXPOSE 2999 +COPY < -1) + window.goatcounter[k] = set[k] + } + + var enc = encodeURIComponent + + // Get all data we're going to send off to the counter endpoint. + var get_data = function(vars) { + var data = { + p: (vars.path === undefined ? goatcounter.path : vars.path), + r: (vars.referrer === undefined ? goatcounter.referrer : vars.referrer), + t: (vars.title === undefined ? goatcounter.title : vars.title), + e: !!(vars.event || goatcounter.event), + s: [window.screen.width, window.screen.height, (window.devicePixelRatio || 1)], + b: is_bot(), + q: location.search, + } + + var rcb, pcb, tcb // Save callbacks to apply later. + if (typeof(data.r) === 'function') rcb = data.r + if (typeof(data.t) === 'function') tcb = data.t + if (typeof(data.p) === 'function') pcb = data.p + + if (is_empty(data.r)) data.r = document.referrer + if (is_empty(data.t)) data.t = document.title + if (is_empty(data.p)) data.p = get_path() + + if (rcb) data.r = rcb(data.r) + if (tcb) data.t = tcb(data.t) + if (pcb) data.p = pcb(data.p) + return data + } + + // Check if a value is "empty" for the purpose of get_data(). + var is_empty = function(v) { return v === null || v === undefined || typeof(v) === 'function' } + + // See if this looks like a bot; there is some additional filtering on the + // backend, but these properties can't be fetched from there. + var is_bot = function() { + // Headless browsers are probably a bot. + var w = window, d = document + if (w.callPhantom || w._phantom || w.phantom) + return 150 + if (w.__nightmare) + return 151 + if (d.__selenium_unwrapped || d.__webdriver_evaluate || d.__driver_evaluate) + return 152 + if (navigator.webdriver) + return 153 + return 0 + } + + // Object to urlencoded string, starting with a ?. + var urlencode = function(obj) { + var p = [] + for (var k in obj) + if (obj[k] !== '' && obj[k] !== null && obj[k] !== undefined && obj[k] !== false) + p.push(enc(k) + '=' + enc(obj[k])) + return '?' + p.join('&') + } + + // Show a warning in the console. + var warn = function(msg) { + if (console && 'warn' in console) + console.warn('goatcounter: ' + msg) + } + + // Get the endpoint to send requests to. + var get_endpoint = function() { + var s = document.querySelector('script[data-goatcounter]') + if (s && s.dataset.goatcounter) + return s.dataset.goatcounter + return (goatcounter.endpoint || window.counter) // counter is for compat; don't use. + } + + // Get current path. + var get_path = function() { + var loc = location, + c = document.querySelector('link[rel="canonical"][href]') + if (c) { // May be relative or point to different domain. + var a = document.createElement('a') + a.href = c.href + if (a.hostname.replace(/^www\./, '') === location.hostname.replace(/^www\./, '')) + loc = a + } + return (loc.pathname + loc.search) || '/' + } + + // Run function after DOM is loaded. + var on_load = function(f) { + if (document.body === null) + document.addEventListener('DOMContentLoaded', function() { f() }, false) + else + f() + } + + // Filter some requests that we (probably) don't want to count. + goatcounter.filter = function() { + if ('visibilityState' in document && document.visibilityState === 'prerender') + return 'visibilityState' + if (!goatcounter.allow_frame && location !== parent.location) + return 'frame' + if (!goatcounter.allow_local && location.hostname.match(/(localhost$|^127\.|^10\.|^172\.(1[6-9]|2[0-9]|3[0-1])\.|^192\.168\.|^0\.0\.0\.0$)/)) + return 'localhost' + if (!goatcounter.allow_local && location.protocol === 'file:') + return 'localfile' + if (localStorage && localStorage.getItem('skipgc') === 't') + return 'disabled with #toggle-goatcounter' + return false + } + + // Get URL to send to GoatCounter. + window.goatcounter.url = function(vars) { + var data = get_data(vars || {}) + if (data.p === null) // null from user callback. + return + data.rnd = Math.random().toString(36).substr(2, 5) // Browsers don't always listen to Cache-Control. + + var endpoint = get_endpoint() + if (!endpoint) + return warn('no endpoint found') + + return endpoint + urlencode(data) + } + + // Count a hit. + window.goatcounter.count = function(vars) { + var f = goatcounter.filter() + if (f) + return warn('not counting because of: ' + f) + var url = goatcounter.url(vars) + if (!url) + return warn('not counting because path callback returned null') + navigator.sendBeacon(url) + } + + // Get a query parameter. + window.goatcounter.get_query = function(name) { + var s = location.search.substr(1).split('&') + for (var i = 0; i < s.length; i++) + if (s[i].toLowerCase().indexOf(name.toLowerCase() + '=') === 0) + return s[i].substr(name.length + 1) + } + + // Track click events. + window.goatcounter.bind_events = function() { + if (!document.querySelectorAll) // Just in case someone uses an ancient browser. + return + + var send = function(elem) { + return function() { + goatcounter.count({ + event: true, + path: (elem.dataset.goatcounterClick || elem.name || elem.id || ''), + title: (elem.dataset.goatcounterTitle || elem.title || (elem.innerHTML || '').substr(0, 200) || ''), + referrer: (elem.dataset.goatcounterReferrer || elem.dataset.goatcounterReferral || ''), + }) + } + } + + Array.prototype.slice.call(document.querySelectorAll("*[data-goatcounter-click]")).forEach(function(elem) { + if (elem.dataset.goatcounterBound) + return + var f = send(elem) + elem.addEventListener('click', f, false) + elem.addEventListener('auxclick', f, false) // Middle click. + elem.dataset.goatcounterBound = 'true' + }) + } + + // Add a "visitor counter" frame or image. + window.goatcounter.visit_count = function(opt) { + on_load(function() { + opt = opt || {} + opt.type = opt.type || 'html' + opt.append = opt.append || 'body' + opt.path = opt.path || get_path() + opt.attr = opt.attr || {width: '200', height: (opt.no_branding ? '60' : '80')} + + opt.attr['src'] = get_endpoint() + 'er/' + enc(opt.path) + '.' + enc(opt.type) + '?' + if (opt.no_branding) opt.attr['src'] += '&no_branding=1' + if (opt.style) opt.attr['src'] += '&style=' + enc(opt.style) + if (opt.start) opt.attr['src'] += '&start=' + enc(opt.start) + if (opt.end) opt.attr['src'] += '&end=' + enc(opt.end) + + var tag = {png: 'img', svg: 'img', html: 'iframe'}[opt.type] + if (!tag) + return warn('visit_count: unknown type: ' + opt.type) + + if (opt.type === 'html') { + opt.attr['frameborder'] = '0' + opt.attr['scrolling'] = 'no' + } + + var d = document.createElement(tag) + for (var k in opt.attr) + d.setAttribute(k, opt.attr[k]) + + var p = document.querySelector(opt.append) + if (!p) + return warn('visit_count: append not found: ' + opt.append) + p.appendChild(d) + }) + } + + // Make it easy to skip your own views. + if (location.hash === '#toggle-goatcounter') { + if (localStorage.getItem('skipgc') === 't') { + localStorage.removeItem('skipgc', 't') + alert('GoatCounter tracking is now ENABLED in this browser.') + } + else { + localStorage.setItem('skipgc', 't') + alert('GoatCounter tracking is now DISABLED in this browser until ' + location + ' is loaded again.') + } + } + + if (!goatcounter.no_onload) + on_load(function() { + // 1. Page is visible, count request. + // 2. Page is not yet visible; wait until it switches to 'visible' and count. + // See #487 + if (!('visibilityState' in document) || document.visibilityState === 'visible') + goatcounter.count() + else { + var f = function(e) { + if (document.visibilityState !== 'visible') + return + document.removeEventListener('visibilitychange', f) + goatcounter.count() + } + document.addEventListener('visibilitychange', f) + } + + if (!goatcounter.no_events) + goatcounter.bind_events() + }) +})(); +export default window.goatcounter; diff --git a/app/_components/Analytics.tsx b/app/_components/Analytics.tsx index 46e29df..0ef4ac7 100644 --- a/app/_components/Analytics.tsx +++ b/app/_components/Analytics.tsx @@ -1,168 +1,61 @@ 'use client'; - import { usePathname } from 'next/navigation'; -import { useReportWebVitals } from 'next/web-vitals'; -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; +//import { useReportWebVitals } from 'next/web-vitals'; -type Impression = -| { - eventType: 'load' - referrer: string | null, - type: 'reload' | 'navigate' | 'back_forward' | null, -} -| { - eventType: 'navigate' - page: string, - //visibleTime: number +declare global { + interface Window { + goatcounter: GoatcounterConfig + } } -/*| { - eventType: 'hide' - page: string, - visibleTime: number -}*/ -| { - eventType: 'outbound' - url: string, - //visibleTime: number +interface GoatcounterConfig { + endpoint: string + no_onload: boolean, + allow_local: boolean, } -| { - eventType: 'timings' - metric: string, - value: number +interface Goatcounter extends GoatcounterConfig { + count: (data: { + path: string + event?: boolean, + title?: string + }) => void } - -function findLastEventOfType(events: readonly Impression[], key: T): [number, Extract | null] { - const index = events.findLastIndex(e => e.eventType === key); - if (index >= 0) { - return [index, events[index] as Extract]; - } else { - return [-1, null]; - } +async function analytics(): Promise { + window.goatcounter ??= { + endpoint: 'https://analytics.ferrybig.me/count', + no_onload: true, + allow_local: true, + }; + const { default: goatcounter } = await import('./Analytics.goatcounter'); + return goatcounter as Goatcounter; } -declare global { - interface Document { - prerendering?: boolean - } +let eventPromise = Promise.resolve(); +export function scheduleEvent(event: Parameters[0]) { + eventPromise = eventPromise.then(() => { + analytics().then(goatcounter => { + goatcounter.count(event); + }); + return new Promise((resolve) => setTimeout(resolve, 1000)); + }); } export function Analytics() { - const impressions = useRef([]); - const sessionId = useRef(''); - //const timer = useRef(Date.now()); const path = usePathname(); - const ourPath = useRef(path); useEffect(() => { - { - /*if (document.prerendering) { - document.addEventListener('prerenderingchange', () => { - timer.current = Date.now(); - }, { - once: true, - }); - }*/ - - sessionId.current = crypto.randomUUID(); - const entries = window.performance - .getEntriesByType('navigation') - .map((nav) => (nav as PerformanceNavigationTiming).type); - const navigationType = entries.find((e): e is 'reload' | 'navigate' | 'back_forward' => ['reload', 'navigate', 'back_forward'].includes(e)) ?? null; - - const [index, last] = findLastEventOfType(impressions.current, 'load'); - if (last) { - impressions.current.splice(index, 1); - } - impressions.current.push({ - eventType: 'load', - referrer: document.referrer || null, - type: navigationType, + analytics().then(goatcounter => { + goatcounter.count({ + path, }); - } - function flushQueue() { - if (impressions.current.length == 0) { - return; - } - //const body = JSON.stringify({ - // impressions: impressions.current, - // sessionId: sessionId.current, - //}); - impressions.current.length = 0; - /* - if (navigator.sendBeacon) { - navigator.sendBeacon('https://analytics.ferrybig.me/www.ferrybig.me', body); - } else { - fetch('https://analytics.ferrybig.me/www.ferrybig.me', { - body, - headers: { - 'content-type': 'application/json', - }, - method:'POST', - keepalive: true, - }); - } - */ - } - - const visibilityChange = () => { - if (document.visibilityState === 'hidden') { - /* - impressions.current.push({ - eventType: 'hide', - page: ourPath.current, - visibleTime: Date.now() - timer.current, - }); - */ - flushQueue(); - } else { - //timer.current = Date.now(); - } - }; - - function anchorClick(e: MouseEvent) { - let target: ParentNode| null = e.target as ParentNode; - while (target) { - if (target instanceof HTMLAnchorElement) { - if (new URL(target.href).origin !== window.location.origin) { - impressions.current.push({ - eventType: 'outbound', - url: target.href, - //visibleTime: Date.now() - timer.current, - }); - } - return; - } - target = target.parentNode; - } - } - addEventListener('visibilitychange', visibilityChange, { passive: true }); - addEventListener('click', anchorClick, { passive: true }); - return () => { - removeEventListener('visibilitychange', visibilityChange); - removeEventListener('click', anchorClick); - }; - }, []); - useEffect(() => { - const [index, last] = findLastEventOfType(impressions.current, 'navigate'); - if (last?.page == path) { - impressions.current.splice(index, 1); - } - impressions.current.push({ - eventType: 'navigate', - page: path, - //visibleTime: Math.max(Date.now() - timer.current, last?.visibleTime ?? 0), }); - ourPath.current = path; - //timer.current = Date.now(); }, [path]); + /* useReportWebVitals((metric) => { - const [index, last] = findLastEventOfType(impressions.current, 'timings'); - if (last?.metric == metric.name) { - impressions.current.splice(index, 1); - } - impressions.current.push({ - eventType: 'timings', - metric: metric.name, - value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value), + scheduleEvent({ + path, + event: true, + title: metric.name, }); }); + */ return null; } diff --git a/app/_components/ArticleInfo.module.css b/app/_components/ArticleInfo.module.css index 5abe0c0..6d68a2e 100644 --- a/app/_components/ArticleInfo.module.css +++ b/app/_components/ArticleInfo.module.css @@ -1,5 +1,5 @@ .root { - padding: 8px 0 8px 8px; + padding: 4px 0 8px 8px; font-size: 0.8em; } diff --git a/app/_components/ArticleInfo.tsx b/app/_components/ArticleInfo.tsx index 172703b..ed0e51a 100644 --- a/app/_components/ArticleInfo.tsx +++ b/app/_components/ArticleInfo.tsx @@ -1,24 +1,28 @@ -import { getMetadataBySlug } from '@/content'; import classes from './ArticleInfo.module.css'; import Date from './Date'; -import Link from 'next/link'; +import Share from './Share'; import TagList from './TagList'; import { CONTENT_FOLDER, GIT_BRANCH } from '@/metadata'; + interface ArticleInfo { + slug: string, date: string | null, tags: string[], readingTimeMin: number, readingTimeMax: number, updatedAt: string | null, originalFile: string | null, + feeds: boolean, } function ArticleInfo({ date, tags, + slug, readingTimeMin, readingTimeMax, updatedAt, originalFile, + feeds, }: ArticleInfo) { return (
@@ -48,6 +52,13 @@ function ArticleInfo({ {' '} Suggest edit

+

+ + {feeds && <> + {' '} + Subscribe + } +

); diff --git a/app/_components/ArticleWrapper.module.css b/app/_components/ArticleWrapper.module.css index 7c8f38a..55ba77e 100644 --- a/app/_components/ArticleWrapper.module.css +++ b/app/_components/ArticleWrapper.module.css @@ -1,51 +1,81 @@ .root { display: grid; - grid-template-columns: 256px 1fr; - grid-template-rows: auto 1fr 96px auto; + grid-template: + "info-me content" auto + "info-post content" auto + "toc content" 96px + "toc content" auto + "toc children" 1fr + "toc comments" auto + "toc moreReading" auto + / 256px 1fr; padding: 0 64px; - gap: 16px; + column-gap: 16px; align-items: flex-start; } +.rootShort { + composes: root; + grid-template: + "info-me right" auto + "info-post right" auto + "toc right" 1fr + "toc moreReading" auto + / 256px 1fr; + +} .displayContent { display: contents; } .moreReading { - grid-row: 3 / span 2; - grid-column: 1; - align-self: stretch; + grid-area: moreReading; } -.info { - grid-row: 1 / span 2; - grid-column: 1; - align-self: stretch; +.children { + grid-area: children; +} +.comments { + grid-area: comments; } .meInfo { padding-top: 32px; + grid-area: info-me; +} +.postInfo { + grid-area: info-post; +} +.tocWrapper { + grid-area: toc; + align-self: stretch; } -.content { - grid-row: 1 / span 3; - grid-column: 2; +.markdown { + grid-area: content; min-width: 0; + align-self: stretch; } -.comments { - grid-row: 4; - grid-column: 2; +.rightSide { + display: contents; +} +.rootShort .rightSide{ + grid-area: right; + display: initial; +} +.last { + grid-area: last; } .hero { margin: 0 -16px 16px; - aspect-ratio: 16/9; + aspect-ratio: 32/9; border-top-left-radius: 8px; border-top-right-radius: 8px; background-position: center; background-size: cover; + background-color: gray; display: block; position: relative; line-height: 0; } -.hero img { +.hero.hero img { border-top-left-radius: 8px; border-top-right-radius: 8px; - backdrop-filter: blur(16px); width: 100%; height: 100%; object-fit: contain; @@ -122,47 +152,3 @@ height: auto; } -.heading { - position: relative; -} - -.link { - position: absolute; - opacity: 0; - background: url(../../assets/link.svg); - width: 16px; - height: 16px; - top: calc(50%); - right: 100%; - transform: translateY(-50%); -} - -.heading:hover .link, -.link:focus { - opacity: 1; -} - -.markdown h1 { - margin: 16px 0; - font-size: 2.5em; -} - -.markdown h2 { - margin: 14px 0; -} - -.markdown h3 { - margin: 10px 0; -} - -.markdown h4 { - margin: 7px 0; -} - -.markdown h5 { - margin: 4px 0; -} - -.markdown h6 { - margin: 2px 0; -} diff --git a/app/_components/ArticleWrapper.tsx b/app/_components/ArticleWrapper.tsx index e4f4fd4..a82bf49 100644 --- a/app/_components/ArticleWrapper.tsx +++ b/app/_components/ArticleWrapper.tsx @@ -4,13 +4,14 @@ import Link from 'next/link'; import Column from './Column'; import Comments from './Comments'; import Toc from './Toc'; -import Image from 'next/image'; +import Image from '@/_components/Image'; import Date from './Date'; import classes from './ArticleWrapper.module.css'; import HtmlPreview from './HtmlPreview'; import { Metadata } from 'next'; import { SITE_URL } from '@/metadata'; import ArticleInfo from './ArticleInfo'; +import Heading from './Heading'; function isRelative(href: string): boolean { return !/^(?:[a-z]+:)?\/\//i.test(href); @@ -21,7 +22,7 @@ export function generateFeed(metadata: MetaData | null, children: MetaData[], fo format: format, posts: children, subDirectory: metadata?.slug ?? '', - title: 'Posts under ' + (metadata ? metadata.title : 'www.ferrybig.me'), + title: 'Posts under ' + (metadata?.slug ? metadata.title : 'www.ferrybig.me'), }); } export function generateMetadata({ @@ -70,6 +71,7 @@ export function generateMetadata({ } export default function ArticleWrapper({ metadata: { + title, slug, date, tags, @@ -83,103 +85,106 @@ export default function ArticleWrapper({ factory, originalFile, children, + feeds, }: ArticleWrapperProps) { - + const shortDisplay = readingTimeMin <= 1 && thumbnail == null; return ( -
+
+
+ +

I'm Fernando,
a full stack developer

+
+
-
- -

I'm Fernando,
a full stack developer

-
- +
+ - 1 || thumbnail ? 'right' : undefined} sticky flex> - 0 ? [ +
+ + 0 ? [ + { + lvl: 1, + slug: 'article-pages', + title: 'Articles', + }, + ] : []), { lvl: 1, - slug: 'article-pages', - title: 'Articles under this topic', + slug: 'article-comments', + title: 'Comments', }, - ] : []), - { - lvl: 1, - slug: 'article-comments', - title: 'Comments', - }, - ]}/> - + ]}/> + +
-
- {factory && 0}> +
+ {factory && {factory?.({ components: { - h1: (props) => ( + h1: (props: any) => ( <> {thumbnail ? ( - thumbnail.embed ? ( -
- -