commit 6840fe515d2b6e42fef09ebc1f42a87eb598fbda Author: Kenneth Date: Sat May 3 23:27:36 2025 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2adb5db --- /dev/null +++ b/.gitignore @@ -0,0 +1,244 @@ +# Created by https://www.toptal.com/developers/gitignore/api/windows,macos,linux,node +# Edit at https://www.toptal.com/developers/gitignore?templates=windows,macos,linux,node + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Node ### +# Logs +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) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +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.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# 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 + +# 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.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/windows,macos,linux,node + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +dev-dist +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/README.md b/README.md new file mode 100644 index 0000000..74872fd --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..c577741 Binary files /dev/null and b/bun.lockb differ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/index.html b/index.html new file mode 100644 index 0000000..eaf6443 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + MarkOne + TS + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..570fce8 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "markone", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.5", + "@tanstack/react-router": "^1.119.0", + "clsx": "^2.1.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwindcss": "^4.1.5", + "zustand": "^5.0.4" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@tanstack/react-router-devtools": "^1.119.1", + "@tanstack/router-plugin": "^1.119.0", + "@types/node": "^22.15.3", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vite-pwa/assets-generator": "^0.2.6", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.18", + "globals": "^15.14.0", + "typescript": "~5.7.2", + "typescript-eslint": "^8.20.0", + "vite": "^6.0.11", + "vite-plugin-pwa": "^0.21.1", + "workbox-core": "^7.3.0", + "workbox-window": "^7.3.0" + }, + "resolutions": { + "sharp": "0.32.6", + "sharp-ico": "0.1.5" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..733f4fb --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,130 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pwa-assets.config.ts b/pwa-assets.config.ts new file mode 100644 index 0000000..452b31f --- /dev/null +++ b/pwa-assets.config.ts @@ -0,0 +1,12 @@ +import { + defineConfig, + minimal2023Preset as preset, +} from '@vite-pwa/assets-generator/config' + +export default defineConfig({ + headLinkOptions: { + preset: '2023', + }, + preset, + images: ['public/favicon.svg'], +}) diff --git a/src/PWABadge.css b/src/PWABadge.css new file mode 100644 index 0000000..4ed19fa --- /dev/null +++ b/src/PWABadge.css @@ -0,0 +1,29 @@ +.PWABadge-container { + padding: 0; + margin: 0; + width: 0; + height: 0; +} +.PWABadge-toast { + position: fixed; + right: 0; + bottom: 0; + margin: 16px; + padding: 12px; + border: 1px solid #8885; + border-radius: 4px; + z-index: 1; + text-align: left; + box-shadow: 3px 4px 5px 0 #8885; + background-color: white; +} +.PWABadge-toast-message { + margin-bottom: 8px; +} +.PWABadge-toast-button { + border: 1px solid #8885; + outline: none; + margin-right: 5px; + border-radius: 2px; + padding: 3px 10px; +} diff --git a/src/PWABadge.tsx b/src/PWABadge.tsx new file mode 100644 index 0000000..caacb66 --- /dev/null +++ b/src/PWABadge.tsx @@ -0,0 +1,77 @@ +import './PWABadge.css' + +import { useRegisterSW } from 'virtual:pwa-register/react' + +function PWABadge() { + // check for updates every hour + const period = 60 * 60 * 1000 + + const { + offlineReady: [offlineReady, setOfflineReady], + needRefresh: [needRefresh, setNeedRefresh], + updateServiceWorker, + } = useRegisterSW({ + onRegisteredSW(swUrl, r) { + if (period <= 0) return + if (r?.active?.state === 'activated') { + registerPeriodicSync(period, swUrl, r) + } + else if (r?.installing) { + r.installing.addEventListener('statechange', (e) => { + const sw = e.target as ServiceWorker + if (sw.state === 'activated') + registerPeriodicSync(period, swUrl, r) + }) + } + }, + }) + + function close() { + setOfflineReady(false) + setNeedRefresh(false) + } + + return ( +
+ { (offlineReady || needRefresh) + && ( +
+
+ { offlineReady + ? App ready to work offline + : New content available, click on reload button to update.} +
+
+ { needRefresh && } + +
+
+ )} +
+ ) +} + +export default PWABadge + +/** + * This function will register a periodic sync check every hour, you can modify the interval as needed. + */ +function registerPeriodicSync(period: number, swUrl: string, r: ServiceWorkerRegistration) { + if (period <= 0) return + + setInterval(async () => { + if ('onLine' in navigator && !navigator.onLine) + return + + const resp = await fetch(swUrl, { + cache: 'no-store', + headers: { + 'cache': 'no-store', + 'cache-control': 'no-cache', + }, + }) + + if (resp?.status === 200) + await r.update() + }, period) +} diff --git a/src/app/$username/bookmarks.tsx b/src/app/$username/bookmarks.tsx new file mode 100644 index 0000000..c5c9306 --- /dev/null +++ b/src/app/$username/bookmarks.tsx @@ -0,0 +1,339 @@ +import { useEffect } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { create } from "zustand"; +import clsx from "clsx"; +import { Button } from "~/components/button"; +import type { LinkBookmark } from "~/bookmark"; + +const testBookmarks: LinkBookmark[] = [ + { + kind: "link", + id: "1", + title: "Running a Docker container as a non-root user", + url: "https://test.website.com/article/123", + }, + { + kind: "link", + id: "2", + title: "Running a Docker container as a non-root user", + url: "https://test.website.com/article/123", + }, +]; + +const LAYOUT_MODE = { + popup: "popup", + sideBySide: "side-by-side", +} as const; +type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE]; + +interface BookmarkPageState { + bookmarks: LinkBookmark[]; + selectedBookmarkIndex: number; + isBookmarkItemExpanded: boolean; + isBookmarkPreviewOpened: boolean; + layoutMode: LayoutMode; + + setBookmarkItemExpanded: (isExpanded: boolean) => void; + setBookmarkPreviewOpened: (isOpened: boolean) => void; + setLayoutMode: (mode: LayoutMode) => void; + selectBookmarkAt: (index: number) => void; +} + +const useBookmarkPageStore = create()((set, get) => ({ + bookmarks: testBookmarks, + selectedBookmarkIndex: 0, + isBookmarkItemExpanded: false, + isBookmarkPreviewOpened: false, + layoutMode: LAYOUT_MODE.popup, + + setBookmarkItemExpanded(isExpanded: boolean) { + set({ isBookmarkItemExpanded: isExpanded }); + }, + + setBookmarkPreviewOpened(isOpened: boolean) { + set({ isBookmarkPreviewOpened: isOpened }); + }, + + setLayoutMode(mode: LayoutMode) { + set({ layoutMode: mode }); + }, + + selectBookmarkAt(index: number) { + const bookmarks = get().bookmarks; + if (index >= 0 && index < bookmarks.length) { + set({ selectedBookmarkIndex: index }); + } + }, +})); + +function Page() { + const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + const state = useBookmarkPageStore.getState(); + + switch (event.key) { + case "ArrowDown": + state.selectBookmarkAt(state.selectedBookmarkIndex + 1); + break; + case "ArrowUp": + state.selectBookmarkAt(state.selectedBookmarkIndex - 1); + break; + case "ArrowLeft": + state.setBookmarkItemExpanded(false); + break; + case "ArrowRight": + state.setBookmarkItemExpanded(true); + break; + default: + break; + } + } + + window.addEventListener("keydown", onKeyDown); + + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [useBookmarkPageStore]); + + useEffect(() => { + function mediaQueryListener(this: MediaQueryList) { + if (this.matches) { + setLayoutMode(LAYOUT_MODE.sideBySide); + } else { + setLayoutMode(LAYOUT_MODE.popup); + } + } + + const q = window.matchMedia("(width >= 64rem)"); + q.addEventListener("change", mediaQueryListener); + + mediaQueryListener.call(q); + + return () => { + q.removeEventListener("change", mediaQueryListener); + }; + }, []); + + return ( +
+
+
+
+

+ + YOUR BOOKMARKS +

+
+
+ {testBookmarks.map((bookmark, i) => ( + + ))} +
+
+ +
+
+ ); +} + +function Main({ children }: React.PropsWithChildren) { + const isPreviewOpened = useBookmarkPageStore( + (state) => state.isBookmarkPreviewOpened, + ); + const layoutMode = useBookmarkPageStore((state) => state.layoutMode); + + return ( +
+ {children} +
+ ); +} + +function BookmarkPreview() { + const isVisible = useBookmarkPageStore( + (state) => state.isBookmarkPreviewOpened, + ); + const layoutMode = useBookmarkPageStore((state) => state.layoutMode); + + if (!isVisible) { + return null; + } + + return ( +
+

Content here

+
+ ); +} + +function BookmarkListItem({ + bookmark, + index, +}: { bookmark: LinkBookmark; index: number }) { + const url = new URL(bookmark.url); + const selectedBookmark = useBookmarkPageStore( + (state) => state.bookmarks[state.selectedBookmarkIndex], + ); + const isSelected = selectedBookmark.id === bookmark.id; + const isBookmarkItemExpanded = useBookmarkPageStore( + (state) => state.isBookmarkItemExpanded, + ); + const setBookmarkItemExpanded = useBookmarkPageStore( + (state) => state.setBookmarkItemExpanded, + ); + const selectBookmarkAt = useBookmarkPageStore( + (state) => state.selectBookmarkAt, + ); + const setBookmarkPreviewOpened = useBookmarkPageStore( + (state) => state.setBookmarkPreviewOpened, + ); + + function expandOrOpenPreview() { + setBookmarkItemExpanded(true); + if (useBookmarkPageStore.getState().layoutMode === LAYOUT_MODE.sideBySide) { + console.log(useBookmarkPageStore.getState().layoutMode); + setBookmarkPreviewOpened(true); + } + } + + return ( +
{ + if (!isBookmarkItemExpanded) { + selectBookmarkAt(index); + } + }} + > + +
+ +

{url.host}

+ {isBookmarkItemExpanded && isSelected ? ( +
+

+ #dev #devops #devops #devops #devops #devops #devops +

+
+ + + +   +
+
+ ) : null} +
+
+ ); +} + +function OpenBookmarkPreviewButton() { + const isBookmarkPreviewOpened = useBookmarkPageStore( + (state) => state.isBookmarkPreviewOpened, + ); + const setBookmarkPreviewOpened = useBookmarkPageStore( + (state) => state.setBookmarkPreviewOpened, + ); + const setBookmarkItemExpanded = useBookmarkPageStore( + (state) => state.setBookmarkItemExpanded, + ); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (isBookmarkPreviewOpened && event.key === "c") { + closePreview(); + } else if (!isBookmarkPreviewOpened && event.key === "o") { + openPreview(); + } + } + + window.addEventListener("keydown", onKeyDown); + + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [isBookmarkPreviewOpened]); + + function closePreview() { + setBookmarkPreviewOpened(false); + setBookmarkItemExpanded(false); + } + + function openPreview() { + setBookmarkPreviewOpened(true); + } + + return ( + + ); +} + +export const Route = createFileRoute("/$username/bookmarks")({ + component: Page, +}); diff --git a/src/app/-route-tree.gen.ts b/src/app/-route-tree.gen.ts new file mode 100644 index 0000000..fe359f3 --- /dev/null +++ b/src/app/-route-tree.gen.ts @@ -0,0 +1,111 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +// Import Routes + +import { Route as rootRoute } from "./__root" +import { Route as IndexImport } from "./index" +import { Route as UsernameBookmarksImport } from "./$username/bookmarks" + +// Create/Update Routes + +const IndexRoute = IndexImport.update({ + id: "/", + path: "/", + getParentRoute: () => rootRoute, +} as any) + +const UsernameBookmarksRoute = UsernameBookmarksImport.update({ + id: "/$username/bookmarks", + path: "/$username/bookmarks", + getParentRoute: () => rootRoute, +} as any) + +// Populate the FileRoutesByPath interface + +declare module "@tanstack/react-router" { + interface FileRoutesByPath { + "/": { + id: "/" + path: "/" + fullPath: "/" + preLoaderRoute: typeof IndexImport + parentRoute: typeof rootRoute + } + "/$username/bookmarks": { + id: "/$username/bookmarks" + path: "/$username/bookmarks" + fullPath: "/$username/bookmarks" + preLoaderRoute: typeof UsernameBookmarksImport + parentRoute: typeof rootRoute + } + } +} + +// Create and export the route tree + +export interface FileRoutesByFullPath { + "/": typeof IndexRoute + "/$username/bookmarks": typeof UsernameBookmarksRoute +} + +export interface FileRoutesByTo { + "/": typeof IndexRoute + "/$username/bookmarks": typeof UsernameBookmarksRoute +} + +export interface FileRoutesById { + __root__: typeof rootRoute + "/": typeof IndexRoute + "/$username/bookmarks": typeof UsernameBookmarksRoute +} + +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: "/" | "/$username/bookmarks" + fileRoutesByTo: FileRoutesByTo + to: "/" | "/$username/bookmarks" + id: "__root__" | "/" | "/$username/bookmarks" + fileRoutesById: FileRoutesById +} + +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + UsernameBookmarksRoute: typeof UsernameBookmarksRoute +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + UsernameBookmarksRoute: UsernameBookmarksRoute, +} + +export const routeTree = rootRoute + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +/* ROUTE_MANIFEST_START +{ + "routes": { + "__root__": { + "filePath": "__root.tsx", + "children": [ + "/", + "/$username/bookmarks" + ] + }, + "/": { + "filePath": "index.tsx" + }, + "/$username/bookmarks": { + "filePath": "$username/bookmarks.tsx" + } + } +} +ROUTE_MANIFEST_END */ diff --git a/src/app/__root.tsx b/src/app/__root.tsx new file mode 100644 index 0000000..119dfa2 --- /dev/null +++ b/src/app/__root.tsx @@ -0,0 +1,15 @@ +import { createRootRoute, Outlet } from "@tanstack/react-router"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; + +function Root() { + return ( + <> + + + + ); +} + +export const Route = createRootRoute({ + component: Root, +}); diff --git a/src/app/index.tsx b/src/app/index.tsx new file mode 100644 index 0000000..bcab386 --- /dev/null +++ b/src/app/index.tsx @@ -0,0 +1,54 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Link } from "~/components/link"; + +function Index() { + return ( +
+
+

+ MARKONE +
+

+

BOOKMARK MANAGER

+
+ LOGIN + SIGN UP + DEMO +
+
+
+

+ WHAT IS MARKONE? +
+ MARKONE is a local-first, self-hostable bookmark manager. +

+
    +
  • + * Curate interesting websites you find online, and let MARKONE + archive them. +
  • +
  • * Reference your saved bookmarks anytime, even when offline.
  • +
  • * Share your collections to others with a permalink.
  • +
+
+

+ WHERE IS MARKONE? +
+ MARKONE is available as a web app and a browser extension for now. +

+
+

+ TECHNICAL STUFF +
+ Source code, as well as hosting guide for MARKONE is{" "} + available here. +
+

+
+
+ ); +} + +export const Route = createFileRoute("/")({ + component: Index, +}); diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/bookmark.ts b/src/bookmark.ts new file mode 100644 index 0000000..45205b7 --- /dev/null +++ b/src/bookmark.ts @@ -0,0 +1,18 @@ +type BookmarkKind = "link" | "placeholder"; + +interface LinkBookmark { + kind: "link"; + id: string; + title: string; + url: string; +} + +interface PlaceholderBookmark { + id: string; + kind: "placeholder"; +} + +type Bookmark = LinkBookmark | PlaceholderBookmark; +type BookmarkId = Bookmark["id"]; + +export type { Bookmark, BookmarkId, BookmarkKind, LinkBookmark }; diff --git a/src/components/button.tsx b/src/components/button.tsx new file mode 100644 index 0000000..b0a9fb3 --- /dev/null +++ b/src/components/button.tsx @@ -0,0 +1,21 @@ +import clsx from "clsx"; + +function Button({ + className, + ...props +}: React.DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement +>) { + return ( +