initial commit

This commit is contained in:
2025-05-03 23:27:36 +01:00
commit 6840fe515d
25 changed files with 1353 additions and 0 deletions

244
.gitignore vendored Normal file
View File

@@ -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?

50
README.md Normal file
View File

@@ -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,
},
})
```

BIN
bun.lockb Executable file

Binary file not shown.

28
eslint.config.js Normal file
View File

@@ -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 },
],
},
},
)

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MarkOne + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

45
package.json Normal file
View File

@@ -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"
}
}

130
public/favicon.svg Normal file
View File

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="410"
height="404"
viewBox="0 0 410 404"
fill="none"
version="1.1"
id="svg20"
sodipodi:docname="favicon.svg"
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata
id="metadata24">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1001"
id="namedview22"
showgrid="false"
inkscape:zoom="0.51361386"
inkscape:cx="-374.79518"
inkscape:cy="145.0506"
inkscape:window-x="-9"
inkscape:window-y="-9"
inkscape:window-maximized="1"
inkscape:current-layer="g8"
inkscape:document-rotation="0"
inkscape:pagecheckerboard="0" />
<path
d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z"
fill="url(#paint0_linear)"
id="path2" />
<defs
id="defs18">
<linearGradient
id="paint0_linear"
x1="6.00017"
y1="32.9999"
x2="235"
y2="344"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#41D1FF"
id="stop6" />
<stop
offset="1"
stop-color="#BD34FE"
id="stop8" />
</linearGradient>
<linearGradient
id="paint1_linear"
x1="194.651"
y1="8.81818"
x2="236.076"
y2="292.989"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#FFEA83"
id="stop11" />
<stop
offset="0.0833333"
stop-color="#FFDD35"
id="stop13" />
<stop
offset="1"
stop-color="#FFA800"
id="stop15" />
</linearGradient>
</defs>
<path
d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z"
fill="url(#paint1_linear)"
id="path4" />
<g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="PWA">
<g
id="g8"
transform="matrix(0.15789659,0,0,0.15890333,54.892928,275.21638)">
<path
fill="#3d3d3d"
fill-opacity="1"
stroke-width="0.2"
stroke-linejoin="round"
d="m 1436.62,603.304 56.39,-142.599 h 162.82 L 1578.56,244.39 1675.2,5.28336e-4 1952,734.933 h -204.13 l -47.3,-131.629 z"
id="path2-1"
style="fill:#3e3e3e;fill-opacity:1" />
<path
fill="#5a0fc8"
fill-opacity="1"
stroke-width="0.2"
stroke-linejoin="round"
d="M 1262.47,734.935 1558.79,0.00156593 1362.34,0.0025425 1159.64,474.933 1015.5,0.00351906 H 864.499 L 709.731,474.933 600.585,258.517 501.812,562.819 602.096,734.935 h 193.331 l 139.857,-425.91 133.346,425.91 z"
id="path4-4"
style="fill:#2e859c;fill-opacity:1" />
<path
fill="#3d3d3d"
fill-opacity="1"
stroke-width="0.2"
stroke-linejoin="round"
d="m 186.476,482.643 h 121.003 c 36.654,0 69.293,-4.091 97.917,-12.273 l 31.293,-96.408 87.459,-269.446 C 517.484,93.9535 509.876,83.9667 501.324,74.5569 456.419,24.852 390.719,4.06265e-4 304.222,4.06265e-4 H -3.8147e-6 V 734.933 H 186.476 Z M 346.642,169.079 c 17.54,17.653 26.309,41.276 26.309,70.871 0,29.822 -7.713,53.474 -23.138,70.956 -16.91,19.425 -48.047,29.137 -93.409,29.137 H 186.476 V 142.598 h 70.442 c 42.277,0 72.185,8.827 89.724,26.481 z"
id="path6"
style="fill:#3e3e3e;fill-opacity:1" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

12
pwa-assets.config.ts Normal file
View File

@@ -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'],
})

29
src/PWABadge.css Normal file
View File

@@ -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;
}

77
src/PWABadge.tsx Normal file
View File

@@ -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 (
<div className="PWABadge" role="alert" aria-labelledby="toast-message">
{ (offlineReady || needRefresh)
&& (
<div className="PWABadge-toast">
<div className="PWABadge-message">
{ offlineReady
? <span id="toast-message">App ready to work offline</span>
: <span id="toast-message">New content available, click on reload button to update.</span>}
</div>
<div className="PWABadge-buttons">
{ needRefresh && <button className="PWABadge-toast-button" onClick={() => updateServiceWorker(true)}>Reload</button> }
<button className="PWABadge-toast-button" onClick={() => close()}>Close</button>
</div>
</div>
)}
</div>
)
}
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)
}

View File

@@ -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<BookmarkPageState>()((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 (
<div className="flex justify-center h-full">
<Main>
<div className="flex flex-col md:flex-row justify-center py-16 lg:py-32 ">
<header className="mb-4 md:mb-0 md:mr-16 text-start">
<h1 className="font-bold text-start">
<span className="invisible md:hidden">&gt;&nbsp;</span>
YOUR BOOKMARKS
</h1>
</header>
<div className="flex flex-col container max-w-2xl -mt-2">
{testBookmarks.map((bookmark, i) => (
<BookmarkListItem
key={bookmark.id}
index={i}
bookmark={bookmark}
/>
))}
</div>
</div>
<BookmarkPreview />
</Main>
</div>
);
}
function Main({ children }: React.PropsWithChildren) {
const isPreviewOpened = useBookmarkPageStore(
(state) => state.isBookmarkPreviewOpened,
);
const layoutMode = useBookmarkPageStore((state) => state.layoutMode);
return (
<main
className={clsx(
"px-4 lg:px-8 2xl:px-0 grid flex justify-center relative w-full",
isPreviewOpened && layoutMode === LAYOUT_MODE.sideBySide
? "grid-cols-2"
: "grid-cols-1",
)}
>
{children}
</main>
);
}
function BookmarkPreview() {
const isVisible = useBookmarkPageStore(
(state) => state.isBookmarkPreviewOpened,
);
const layoutMode = useBookmarkPageStore((state) => state.layoutMode);
if (!isVisible) {
return null;
}
return (
<div
className={clsx(
"h-screen flex justify-center items-center border-l border-stone-700 dark:border-stone-300 flex dark:bg-stone-900",
{
"absolute inset-0 border-l-0": layoutMode === LAYOUT_MODE.popup,
},
)}
>
<p>Content here</p>
</div>
);
}
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 (
<div
className={clsx("group flex flex-row justify-start py-2", {
"bg-teal-600 text-stone-100": isBookmarkItemExpanded && isSelected,
"text-teal-600": isSelected && !isBookmarkItemExpanded,
})}
onMouseEnter={() => {
if (!isBookmarkItemExpanded) {
selectBookmarkAt(index);
}
}}
>
<button
disabled={!isSelected}
className={clsx(
"select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100",
{
invisible: !isSelected,
},
)}
onClick={() => {
setBookmarkItemExpanded(!isBookmarkItemExpanded);
setBookmarkPreviewOpened(false);
}}
>
<span className="sr-only">Options for this bookmark</span>
<span>&nbsp;</span>
<span className={isBookmarkItemExpanded ? "rotate-90" : ""}>&gt;</span>
<span>&nbsp;</span>
</button>
<div className="flex flex-col w-full">
<button className="text-start font-bold" onClick={expandOrOpenPreview}>
{bookmark.title}
</button>
<p className="opacity-80 text-sm">{url.host}</p>
{isBookmarkItemExpanded && isSelected ? (
<div className="flex flex-col space-y-1 md:flex-row md:space-y-0 md:space-x-2 items-end justify-between pt-2">
<p className="text-sm">
#dev #devops #devops #devops #devops #devops #devops
</p>
<div className="flex space-x-2">
<OpenBookmarkPreviewButton />
<Button className="text-sm">
<span className="underline">E</span>dit
</Button>
<Button className="text-sm">
<span className="underline">D</span>elete
</Button>
<span className="-ml-2">&nbsp;</span>
</div>
</div>
) : null}
</div>
</div>
);
}
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 (
<Button
className="text-sm"
onClick={() => {
if (isBookmarkPreviewOpened) {
closePreview();
} else {
openPreview();
}
}}
>
{isBookmarkPreviewOpened ? (
<>
<span className="underline">C</span>lose
</>
) : (
<>
<span className="underline">O</span>pen
</>
)}
</Button>
);
}
export const Route = createFileRoute("/$username/bookmarks")({
component: Page,
});

111
src/app/-route-tree.gen.ts Normal file
View File

@@ -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<FileRouteTypes>()
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": [
"/",
"/$username/bookmarks"
]
},
"/": {
"filePath": "index.tsx"
},
"/$username/bookmarks": {
"filePath": "$username/bookmarks.tsx"
}
}
}
ROUTE_MANIFEST_END */

15
src/app/__root.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
function Root() {
return (
<>
<Outlet />
<TanStackRouterDevtools />
</>
);
}
export const Route = createRootRoute({
component: Root,
});

54
src/app/index.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { createFileRoute } from "@tanstack/react-router";
import { Link } from "~/components/link";
function Index() {
return (
<main className="p-48 flex flex-row items-start justify-center space-x-24">
<div className="flex flex-col items-start">
<h1 className="text-2xl">
<span className="font-bold">MARKONE</span>
<br />
</h1>
<p className="pb-4 text-2xl uppercase">BOOKMARK MANAGER</p>
<div className="flex flex-col text-lg">
<Link>LOGIN</Link>
<Link>SIGN UP</Link>
<Link href="/demo-user/bookmarks">DEMO</Link>
</div>
</div>
<div>
<p>
<strong>WHAT IS MARKONE?</strong>
<br />
MARKONE is a local-first, self-hostable bookmark manager.
</p>
<ul className="px-2 pt-2">
<li>
* Curate interesting websites you find online, and let MARKONE
archive them.
</li>
<li>* Reference your saved bookmarks anytime, even when offline.</li>
<li>* Share your collections to others with a permalink.</li>
</ul>
<br />
<p>
<strong>WHERE IS MARKONE?</strong>
<br />
MARKONE is available as a web app and a browser extension for now.
</p>
<br />
<p>
<strong>TECHNICAL STUFF</strong>
<br />
Source code, as well as hosting guide for MARKONE is{" "}
<Link href="https://github.com/">available here</Link>.
<br />
</p>
</div>
</main>
);
}
export const Route = createFileRoute("/")({
component: Index,
});

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

18
src/bookmark.ts Normal file
View File

@@ -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 };

21
src/components/button.tsx Normal file
View File

@@ -0,0 +1,21 @@
import clsx from "clsx";
function Button({
className,
...props
}: React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>) {
return (
<button
className={clsx(
"px-4 font-bold border border-2 border-b-4 border-text-inherit active:bg-stone-700 active:text-stone-200 active:border-b-1 active:translate-y-0.5",
className,
)}
{...props}
/>
);
}
export { Button };

21
src/components/link.tsx Normal file
View File

@@ -0,0 +1,21 @@
import clsx from "clsx";
function Link({
className,
...props
}: React.DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>) {
return (
<a
className={clsx(
"underline active:bg-stone-700 active:text-stone-200",
className,
)}
{...props}
/>
);
}
export { Link };

9
src/index.css Normal file
View File

@@ -0,0 +1,9 @@
@import "tailwindcss";
:root {
font-family: monospace;
}
body {
@apply bg-stone-200 dark:bg-stone-900 text-stone-800 dark:text-stone-300;
}

15
src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import "./index.css";
import { routeTree } from "./app/-route-tree.gen";
const router = createRouter({
routeTree,
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);

2
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />

31
tsconfig.app.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"~/*": ["src/*"]
}
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

57
vite.config.ts Normal file
View File

@@ -0,0 +1,57 @@
import { VitePWA } from "vite-plugin-pwa";
import { defineConfig } from "vite";
import path from "path";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
"~": path.resolve(__dirname, "./src"),
},
},
plugins: [
TanStackRouterVite({
target: "react",
autoCodeSplitting: false,
routesDirectory: "./src/app",
generatedRouteTree: "./src/app/-route-tree.gen.ts",
routeFileIgnorePrefix: "-",
quoteStyle: "double",
}),
react(),
VitePWA({
registerType: "prompt",
injectRegister: false,
pwaAssets: {
disabled: false,
config: true,
},
manifest: {
name: "MarkOne",
short_name: "MarkOne",
description: "A minimal bookmark manager",
theme_color: "#ffffff",
},
workbox: {
globPatterns: ["**/*.{js,css,html,svg,png,ico}"],
cleanupOutdatedCaches: true,
clientsClaim: true,
},
devOptions: {
enabled: false,
navigateFallback: "index.html",
suppressWarnings: true,
type: "module",
},
}),
tailwindcss(),
],
});