Compare commits

..
23 Commits
Author SHA1 Message Date
joshuacoles a0b6c23068 Support multiplatform docker images
Build and Publish Docker Container / build (push) Successful in 3m24s
2024-08-29 17:43:44 +01:00
joshuacoles 8f7b9a1a87 Change backend URL to be root as opposed to localhost
Build and Publish Docker Container / build (push) Successful in 1m10s
2023-12-03 23:32:03 +00:00
joshuacoles 5d146aaf3f Link up omnisearch lookup
Build and Publish Docker Container / build (push) Successful in 1m0s
2023-10-13 21:37:41 +01:00
joshuacoles 87e482e943 Move Omnisearch select component to the tailwind-ui combobox 2023-10-13 21:19:56 +01:00
joshuacoles de6af8c6b5 Stash 2023-10-13 21:13:12 +01:00
joshuacoles 1c0957b7bb Stash 2023-10-13 21:00:11 +01:00
joshuacoles c47d16fa30 Abstract to allow for different select components 2023-10-13 17:56:09 +01:00
joshuacoles fb2951d335 Separate out LinkCollection 2023-10-13 17:54:27 +01:00
joshuacoles 26d5677546 Split out aliases components 2023-10-13 17:17:31 +01:00
joshuacoles a7568c1bb2 Fix some styling
Build and Publish Docker Container / build (push) Successful in 1m6s
2023-09-29 11:20:11 +01:00
joshuacoles 43c81b206f Add readme
Build and Publish Docker Container / build (push) Successful in 1m11s
2023-09-29 10:45:13 +01:00
joshuacoles 1eb540d88e Add readme
Build and Publish Docker Container / build (push) Failing after 18s
2023-09-29 10:43:14 +01:00
joshuacoles e244dd7c3f Add build pipeline 2023-09-29 10:37:07 +01:00
joshuacoles cf6e26b890 Move obsidian reference to a proxy at /metadata and make it env configurable with OBSIDIAN_BEACHHEAD_SERVER. 2023-09-29 10:23:55 +01:00
joshuacoles cfec7982e0 Fix type issue 2023-09-29 10:16:30 +01:00
joshuacoles 7628ccc991 Add dockerfile 2023-09-29 10:14:55 +01:00
joshuacoles 9f4dff0417 Improve alias support 2023-09-29 10:14:49 +01:00
joshuacoles 3e44846e22 Remove old files 2023-09-29 07:57:28 +01:00
joshuacoles b1e9e2ec7d It works! 2023-09-29 07:56:50 +01:00
joshuacoles b615bce8ce Stash, list aliases 2023-09-28 11:32:53 +01:00
joshuacoles 94664892e4 It works! 2023-09-28 10:28:43 +01:00
joshuacoles cf08a4ecf0 Some more attempts 2023-09-28 09:56:14 +01:00
joshuacoles b8cd715d2a Stash 2023-09-27 17:53:11 +01:00
16 changed files with 509 additions and 82 deletions
+2
View File
@@ -0,0 +1,2 @@
/dist
/node_modules
+37
View File
@@ -0,0 +1,37 @@
name: Build and Publish Docker Container
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Docker
uses: docker/login-action@v1
with:
registry: git.joshuacoles.me
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: git.joshuacoles.me/${{ github.repository }}:${{ github.sha }},git.joshuacoles.me/${{ github.repository }}:latest
+1
View File
@@ -22,3 +22,4 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/.env
+30
View File
@@ -0,0 +1,30 @@
# global options
{
admin off # theres no need for the admin api in railway's environment
persist_config off # storage isn't persistent anyway
auto_https off # railway handles https for us, this would cause issues if left enabled
log { # runtime logs
format console # set runtime log format to console mode
}
}
:3000 {
log { # access logs
format console # set access log format to console mode
}
# health check for railway
respond /health 200
# serve from the 'dist' folder (Vite builds into the 'dist' folder)
root * /app/dist
# enable gzipping responses
encode gzip
# serve files from 'dist'
file_server
# if path doesn't exist, redirect it to 'index.html' for client side routing
try_files {path} /index.html
}
+17
View File
@@ -0,0 +1,17 @@
# syntax=docker/dockerfile:1
FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json package.json
COPY bun.lockb bun.lockb
RUN bun install
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN yarn build
FROM caddy AS runner
COPY --from=builder /app/dist /usr/share/caddy
+13
View File
@@ -0,0 +1,13 @@
# Obsidian Note Finder
A simple web UI which allows for easily finding, creating, and copying Obsidian wiki style links from a given vault. I use this to quickly put links to notes inside non Obsidian contexts, which are then exported to obsidian.
This requires an Obsidian Beachhead installation which is a service to provide information and interaction with your obsidian vault over the network. Currently, this comprises a single route `/metadata` which returns the [metadata-extractor](https://github.com/kometenstaub/metadata-extractor) json data file.
## Dev
Set the `OBSIDIAN_BEACHHEAD_SERVER` to point the Obsidian Beachhead installation.
## Prod
Make sure you reverse proxy the `/metadata` route to the Obsidian Beachhead installation.
BIN
View File
Binary file not shown.
+11 -1
View File
@@ -10,12 +10,22 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.0.18",
"@tailwindcss/forms": "^0.5.6",
"@tanstack/react-query": "^4.35.3",
"classnames": "^2.3.2",
"ramda": "^0.29.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0",
"react-select": "^5.7.5",
"react-window": "^1.8.9"
}, },
"devDependencies": { "devDependencies": {
"@types/ramda": "^0.29.5",
"@types/react": "^18.0.37", "@types/react": "^18.0.37",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@types/react-window": "^1.8.6",
"@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/eslint-plugin": "^5.59.0",
"@typescript-eslint/parser": "^5.59.0", "@typescript-eslint/parser": "^5.59.0",
"@vitejs/plugin-react-swc": "^3.0.0", "@vitejs/plugin-react-swc": "^3.0.0",
-44
View File
@@ -1,44 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+150 -31
View File
@@ -1,35 +1,154 @@
import {useState} from 'react' import {useEffect, useState} from "react";
import reactLogo from './assets/react.svg' import classNames from "classnames";
import viteLogo from '/vite.svg'
import './App.css'
function App() { import {useQuery} from "@tanstack/react-query";
const [count, setCount] = useState(0) import {LinkCollection, type Option} from "./aliases";
return ( import {CheckIcon, ChevronUpDownIcon} from '@heroicons/react/20/solid'
<> import {Combobox} from '@headlessui/react'
<div>
<a href="https://vitejs.dev" target="_blank"> type ResultNoteApi = {
<img src={viteLogo} className="logo" alt="Vite logo"/> score: number
</a> vault: string
<a href="https://react.dev" target="_blank"> path: string
<img src={reactLogo} className="logo react" alt="React logo"/> basename: string
</a> foundWords: string[]
</div> matches: SearchMatchApi[]
<h1>Vite + React</h1> excerpt: string
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
} }
export default App type SearchMatchApi = {
match: string
offset: number
}
export function OmnisearchSelect({setSelected}: {
setSelected: (value: Option) => void
}) {
const {
data: metadata,
isLoading,
} = useQuery({
queryKey: ['obsidian-metadata'],
initialData: [],
refetchInterval: false,
queryFn: async () => {
const response = await fetch("/metadata")
const fullData: any[] = await response.json();
return fullData;
},
});
const [query, setQuery] = useState('')
const [selectedPerson, setSelectedPerson] = useState<ResultNoteApi | null>(null);
const {data: filteredPeople} = useQuery({
queryKey: ['obsidian-omnisearch', query],
initialData: [],
queryFn: async () => {
if (query === '') {
return [];
} else {
const response = await fetch(`/search?q=${query}`)
const fullData: ResultNoteApi[] = await response.json();
return fullData;
}
}
});
useEffect(() => {
if (selectedPerson != undefined && metadata.length > 0) {
console.log(selectedPerson);
const data = metadata.find(md => md.relativePath === selectedPerson.path);
if (!data) {
debugger
}
setSelected({
value: selectedPerson.path,
label: selectedPerson.basename,
data,
});
}
}, [selectedPerson, metadata]);
return (<Combobox as="div" value={selectedPerson} onChange={setSelectedPerson}>
<Combobox.Label className="block text-sm font-medium leading-6 text-gray-900">Search for note</Combobox.Label>
<div className="relative mt-2">
<Combobox.Input
aria-disabled={isLoading}
className="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
onChange={(event) => setQuery(event.target.value)}
displayValue={(person: ResultNoteApi) => person?.basename}
/>
<Combobox.Button
className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none">
<ChevronUpDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true"/>
</Combobox.Button>
{filteredPeople.length > 0 && (
<Combobox.Options
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{filteredPeople.map((person) => (
<Combobox.Option
key={person.path}
value={person}
className={({active}) =>
classNames(
'relative cursor-default select-none py-2 pl-3 pr-9',
active ? 'bg-indigo-600 text-white' : 'text-gray-900'
)
}
>
{({
active,
selected
}) => (
<>
<div className="flex">
<span
className={classNames('truncate', selected && 'font-semibold')}>{person?.basename}</span>
<span
className={classNames(
'ml-2 truncate text-gray-500',
active ? 'text-indigo-200' : 'text-gray-500'
)}
>
{person?.path}
</span>
</div>
{selected && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<CheckIcon className="h-5 w-5" aria-hidden="true"/>
</span>
)}
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
)}
</div>
</Combobox>);
}
export function App() {
const [selected, setSelected] = useState<Option | null>(null);
return (
<div className={"m-5 text-lg"}>
{/*<NaiveSelect setSelected={setSelected}/>*/}
<OmnisearchSelect setSelected={setSelected}/>
{selected && <LinkCollection selected={selected}/>}
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
// TODO: Fix this for wrapping items, esp on phones
import Select, {createFilter, MenuListProps} from "react-select";
import {ReactNode, useCallback} from "react";
import {FixedSizeList as List} from "react-window";
import type {Option} from "./aliases.tsx";
import {useQuery} from "@tanstack/react-query";
import * as R from "ramda";
const height = 35;
function MenuList(props: MenuListProps) {
const {
options,
children,
maxHeight,
getValue
} = props as Omit<MenuListProps, 'children'> & {
children: ReactNode[]
};
const [value] = getValue();
const initialOffset = options.indexOf(value) * height;
return (<List
width={'100%'}
height={maxHeight}
itemCount={children?.length ?? 0}
itemSize={height}
initialScrollOffset={initialOffset}
>
{({
index,
style
}) => <div style={style}>{children[index]}</div>}
</List>);
}
export function NaiveSelect({setSelected}: {
setSelected: (value: Option) => void
}) {
const {
data: options,
isLoading,
} = useQuery({
queryKey: ['obsidian-metadata'],
initialData: [],
queryFn: async () => {
const response = await fetch("/metadata")
const fullData: any[] = await response.json();
return R.sortBy(v => -(v.data.backlinks?.length ?? 0), fullData.map(md => ({
value: md.relativePath,
label: md.fileName,
data: md,
}) as Option));
},
});
const onChange = useCallback((value: Option) => {
setSelected(value);
navigator.clipboard.writeText(`[[${value.label}]]`)
}, []);
return (<Select
classNames={{input: () => 'select-input-wrapper'}}
onChange={onChange as any}
components={{MenuList}}
isDisabled={isLoading}
isLoading={isLoading}
isClearable={true}
options={options}
filterOption={createFilter({ignoreAccents: false})}
/>)
}
+140
View File
@@ -0,0 +1,140 @@
import {
ChangeEventHandler,
Dispatch,
KeyboardEventHandler,
MouseEventHandler,
SetStateAction,
useCallback,
useEffect,
useRef,
useState
} from "react";
export interface Option {
label: string,
value: string,
data: {
backlinks: unknown[]
aliases?: string[]
}
}
export function LinkCollection({ selected }: { selected: Option }) {
return (
<div className={"mt-2"}>
<div className="py-1">
<Alias original={selected.label}/>
</div>
{selected.data.aliases?.map(alias => (
<div className="py-1">
<Alias original={selected.label} alias={alias}/>
</div>
))}
<div className="py-1">
<CustomAlias selected={selected}/>
</div>
</div>
);
}
export function CustomAlias({selected}: {
selected: Option
}) {
const [alias, setAlias] = useState('');
// Reset when selection changes
useEffect(() => {
setAlias('');
}, [selected.value]);
const onClick: MouseEventHandler = useCallback((e) => {
if ((e.target as HTMLElement).tagName == 'SPAN' && alias.length > 0) {
navigator.clipboard.writeText(`[[${selected!.label}|${alias}]]`);
}
}, [selected.value, selected.label, alias]);
return (
<span onClick={onClick} className={"rounded-md p-1 hover:bg-slate-100"}>
<span className={"text-slate-300 p-0.5"}>[[</span>
<span>{selected.label}</span>
<span className={"text-slate-300 p-0.5"}>|</span>
<span>
<CustomAliasField content={alias} setContent={setAlias} selected={selected}/>
</span>
<span className={"text-slate-300 p-0.5"}>]]</span>
</span>
)
}
function CustomAliasField({
selected,
content,
setContent
}: {
selected: Option,
content: string,
setContent: Dispatch<SetStateAction<string>>,
}) {
const [width, setWidth] = useState<any>(0);
const span = useRef<HTMLSpanElement>(null);
// Resize on change of content
useEffect(() => {
setWidth(span.current!.offsetWidth);
// setWidth(content.length + 'ch');
}, [content]);
const changeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(evt => {
setContent(evt.target.value);
}, []);
const onCustomElementKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback((e) => {
if (e.key == 'Enter') {
navigator.clipboard.writeText(`[[${selected!.label}|${content}]]`);
(e.target as HTMLInputElement).blur()
}
}, [selected.value, selected.label, content]);
return (
<span>
<span style={{
position: 'absolute',
opacity: 0,
zIndex: -100,
whiteSpace: 'pre',
}} ref={span}>{content}</span>
<input
className={"border-none p-0 px-1"}
type="text" style={{width: `calc(${width}px + 0.25rem)`}} autoFocus onChange={changeHandler}
onKeyDown={onCustomElementKeyDown}/>
</span>
);
}
export function Alias({
original,
alias
}: {
original: string,
alias?: string
}) {
const onClick = useCallback(() => {
if (alias) {
navigator.clipboard.writeText(`[[${original}|${alias}]]`)
} else {
navigator.clipboard.writeText(`[[${original}]]`)
}
}, [original, alias]);
return (
<span className={"rounded-md p-1 hover:bg-slate-100"} onClick={onClick}>
<span className={"text-slate-300 p-0.5"}>[[</span>
<span>{original}</span>
{alias && <><span className={"text-slate-300 p-0.5"}>|</span>
<span>{alias}</span></>}
<span className={"text-slate-300 p-0.5"}>]]</span>
</span>
);
}
+4
View File
@@ -1,3 +1,7 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
.select-input-wrapper > input:focus {
box-shadow: none;
}
+6 -1
View File
@@ -1,10 +1,15 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css' import './index.css'
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {App} from "./App.tsx";
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}>
<App/> <App/>
</QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
) )
+3 -1
View File
@@ -7,6 +7,8 @@ export default {
theme: { theme: {
extend: {}, extend: {},
}, },
plugins: [], plugins: [
require('@tailwindcss/forms')
],
} }
+19 -2
View File
@@ -1,7 +1,24 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react-swc' import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig(({ command, mode }) => {
// Load env file based on `mode` in the current working directory.
// Set the third parameter to '' to load all env regardless of the `VITE_` prefix.
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [react()], plugins: [react()],
server: {
host: '0.0.0.0',
proxy: {
'/metadata': {
target: env['OBSIDIAN_BEACHHEAD_SERVER']
},
'/search': {
target: env['OBSIDIAN_BEACHHEAD_SERVER']
}
}
}
}
}) })