Basic crud works kind of

This commit is contained in:
@s.roertgen 2025-04-15 11:29:44 +02:00
commit c4bde3e147
30 changed files with 5594 additions and 0 deletions

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

4
.prettierignore Normal file
View file

@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

15
.prettierrc Normal file
View file

@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

38
README.md Normal file
View file

@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

24
eslint.config.js Normal file
View file

@ -0,0 +1,24 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import { includeIgnoreFile } from '@eslint/compat';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
/** @type {import('eslint').Linter.Config[]} */
export default [
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
}
];

19
jsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

4779
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "opencard",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-vercel": "^5.5.2",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"autoprefixer": "^10.4.20",
"daisyui": "^4.12.23",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.14.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.10",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-dnd-action": "^0.9.57",
"tailwindcss": "^3.4.17",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"@nostr-dev-kit/ndk": "^2.11.0"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

3
src/app.css Normal file
View file

@ -0,0 +1,3 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

13
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,29 @@
<script>
import { addBoard } from '$lib/ndk';
export let modalRef;
function createBoard() {
const board = {
title
};
addBoard(board);
}
let title;
</script>
<!-- Open the modal using ID.showModal() method -->
<dialog id="add-board" class="modal" bind:this={modalRef}>
<div class="modal-box">
<h3 class="text-lg font-bold">Hello!</h3>
<p class="py-4">Press ESC key or click the button below to close</p>
<label class="input input-bordered">
<input type="text" bind:value={title} />
</label>
<div class="modal-action">
<form method="dialog">
<button class="btn">Close</button>
<button onclick={() => createBoard()}>Create Board!</button>
</form>
</div>
</div>
</dialog>

View file

@ -0,0 +1,30 @@
<script>
import { addCard } from '$lib/ndk';
export let modalRef;
let title;
function handleAddCard() {
const card = {
title
};
addCard(card);
}
</script>
<dialog id="add-card" class="modal" bind:this={modalRef}>
<div class="modal-box">
<h3 class="text-lg font-bold">Add Card</h3>
<label class="input input-bordered"
>Titel
<input type="text" bind:value={title} />
</label>
<p class="py-4">Press ESC key or click the button below to close</p>
<div class="modal-action">
<form method="dialog">
<button class="btn">Close</button>
<button on:click={handleAddCard}>Add card</button>
</form>
</div>
</div>
</dialog>

View file

@ -0,0 +1,30 @@
<script>
import { addColumn } from '$lib/ndk';
export let modalRef;
let title;
function handleAddColumn() {
const column = {
title
};
addColumn(column);
}
</script>
<dialog id="add-column" class="modal" bind:this={modalRef}>
<div class="modal-box">
<h3 class="text-lg font-bold">Add Column</h3>
<label class="input input-bordered"
>Titel
<input type="text" bind:value={title} />
</label>
<p class="py-4">Press ESC key or click the button below to close</p>
<div class="modal-action">
<form method="dialog">
<button class="btn">Close</button>
<button on:click={handleAddColumn}>Add column</button>
</form>
</div>
</div>
</dialog>

View file

@ -0,0 +1,145 @@
<script>
// This is done in a single file for clarity. A more factored version here: https://svelte.dev/repl/288f827275db4054b23c437a572234f6?version=3.38.2
import { flip } from 'svelte/animate';
import { dndzone } from 'svelte-dnd-action';
import AddColumnModal from './AddColumnModal.svelte';
import AddCardModal from './AddCardModal.svelte';
import { cardsForColumn, columnsForBoard, selectedColumn, currentBoard } from '$lib/db';
import { publishBoard, publishCards } from '$lib/ndk';
export let boardId;
let addColumnModal;
let addCardModal;
function openColumnModal() {
addColumnModal.showModal();
}
function openCardModal() {
addCardModal.showModal();
}
$: items = $columnsForBoard.map((col) => {
col.items = cardsForColumn(col.id);
return col;
});
$: console.log('items', items);
const flipDurationMs = 200;
function handleDndConsiderColumns(e) {
items = e.detail.items;
}
function handleDndFinalizeColumns(e) {
items = e.detail.items;
// store current state
publishBoard({ ...$currentBoard, items });
}
function handleDndConsiderCards(cid, e) {
const colIdx = items.findIndex((c) => c.id === cid);
items[colIdx].items = e.detail.items;
items = [...items];
}
function handleDndFinalizeCards(cid, e) {
const colIdx = items.findIndex((c) => c.id === cid);
items[colIdx].items = e.detail.items;
items = [...items];
publishCards(items[colIdx]);
}
function handleClick(e) {
alert('dragabble elements are still clickable :)');
}
function deleteColumn(columnId) {
const updatedBoardItems = items.filter((e) => e.id !== columnId);
publishBoard({ ...$currentBoard, items: updatedBoardItems });
}
function deleteCard(column, itemId) {
const updatedColumnItems = column.items.filter((e) => e.id !== itemId);
publishCards({ ...column, items: updatedColumnItems });
}
</script>
<button class="btn" on:click={openColumnModal}>Add column</button>
<section
class="board flex flex-nowrap"
use:dndzone={{ items, flipDurationMs, type: 'columns' }}
on:consider={handleDndConsiderColumns}
on:finalize={handleDndFinalizeColumns}
>
{#each items as column (column.id)}
<div class="column" animate:flip={{ duration: flipDurationMs }}>
<div class="column-title">{column.dTag}</div>
<button class="btn" on:click={() => deleteColumn(column.id)}>Delete Column</button>
<div>
<button
class="btn"
on:click={() => {
// set selected columns
$selectedColumn = column.dTag;
openCardModal();
}}>Add Card</button
>
</div>
<div
class="column-content"
use:dndzone={{ items: column.items, flipDurationMs }}
on:consider={(e) => handleDndConsiderCards(column.id, e)}
on:finalize={(e) => handleDndFinalizeCards(column.id, e)}
>
{#each column.items as item (item.id)}
<div class="card" animate:flip={{ duration: flipDurationMs }}>
{item.content}
{item.dTag}
<button on:click={() => deleteCard(column, item.id)}>Delete</button>
</div>
{/each}
</div>
</div>
{/each}
</section>
<AddColumnModal bind:modalRef={addColumnModal} />
<AddCardModal bind:modalRef={addCardModal} />
<style>
.board {
height: 90vh;
width: 100%;
padding: 0.5em;
margin-bottom: 40px;
}
.column {
height: 100%;
width: 250px;
padding: 0.5em;
margin: 1em;
float: left;
border: 1px solid #333333;
/*Notice we make sure this container doesn't scroll so that the title stays on top and the dndzone inside is scrollable*/
overflow-y: hidden;
}
.column-content {
height: 100%;
/* Notice that the scroll container needs to be the dndzone if you want dragging near the edge to trigger scrolling */
overflow-y: scroll;
}
.column-title {
margin-bottom: 1em;
display: flex;
justify-content: center;
align-items: center;
}
.card {
height: 15%;
width: 100%;
margin: 0.4em 0;
display: flex;
justify-content: center;
align-items: center;
background-color: #dddddd;
border: 1px solid #333333;
}
</style>

View file

@ -0,0 +1,16 @@
<script>
import { setBoard } from '$lib/db';
import { eventTitle } from '$lib/ndk';
export let board;
</script>
<div class="card w-96 bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">{eventTitle(board)}</h2>
<div class="card-actions justify-end">
<a onclick={() => setBoard(board)} href={`/board/${board.dTag}`} class="btn btn-primary"
>Open Board</a
>
</div>
</div>
</div>

View file

@ -0,0 +1,50 @@
<script>
import { db } from '$lib/db';
import { login } from '$lib/ndk';
</script>
<div class="navbar bg-base-100">
<div class="flex-1">
<a href="/" class="btn btn-ghost text-xl">OPENCARD</a>
</div>
<div class="flex-none gap-2">
<div class="form-control">
<input type="text" placeholder="Search" class="input input-bordered w-24 md:w-auto" />
</div>
{#if $db.user}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full">
<img
alt="Tailwind CSS Navbar component"
src="https://img.daisyui.com/images/stock/photo-1534528741775-53994a69daeb.webp"
/>
</div>
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"
>
<li>
<a class="justify-between">
Profile
<span class="badge">New</span>
</a>
</li>
<li><a>Settings</a></li>
<li><a>Logout</a></li>
</ul>
</div>
{:else}
<div class="dropdown dropdown-end">
<button class="btn">Login</button>
<ul
tabindex="0"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"
>
<li><a onclick={() => login('browser-extension')}>Browser Extension</a></li>
</ul>
</div>
{/if}
</div>
</div>

79
src/lib/db.js Normal file
View file

@ -0,0 +1,79 @@
import { writable, derived, get } from 'svelte/store';
import NDK from '@nostr-dev-kit/ndk';
import { columnsDsFromBoard } from './ndk';
export const events = writable([]);
export const boards = derived(events, ($events) => {
const allBoards = $events.filter((e) => e.kind === 30043);
const deduped = deduplicateKeepMostRecent(allBoards);
return deduped;
});
export const currentBoardId = writable('');
export const currentBoard = derived(currentBoardId, ($currentBoardId) => {
return get(events).find((e) => e.dTag === $currentBoardId);
});
export const columns = derived(events, ($events) => {
const allColumns = $events.filter((e) => e.kind === 30044);
const deduped = deduplicateKeepMostRecent(allColumns);
return deduped;
});
export const columnsForBoard = derived(
[events, boards, columns, currentBoardId],
([$events, $boards, $columns, $currentBoardId]) => {
let currentBoard = $boards.find((e) => e.dTag === $currentBoardId);
let boardColumnIds = columnsDsFromBoard(currentBoard);
const cols = boardColumnIds.map((d) => {
return $columns.find((c) => c.tagAddress() === d);
});
return cols;
}
);
export const selectedColumn = writable('');
export const cards = derived(events, ($events) => {
const allCards = $events.filter((e) => e.kind === 30045);
const deduped = deduplicateKeepMostRecent(allCards);
return deduped;
});
export const cardsForColumn = (columnId) => {
const cols = get(columns);
const column = cols.find((c) => c.id === columnId);
const cardIds = column.tags.filter((e) => e[0] === 'a').map((e) => e[1]);
let cardsForCol = cardIds.map((id) => {
return get(cards).find((e) => e.tagAddress() === id);
});
if (cardsForCol.length === 0) {
return [];
} else {
return cardsForCol;
}
};
export const db = writable({
user: null,
ndk: await initNDK(),
currentBoardId: null
});
async function initNDK() {
const ndk = new NDK({
explicitRelayUrls: ['ws://localhost:10547']
});
await ndk.connect();
return ndk;
}
export function setBoard(board) {
db.update((db) => ({ ...db, board }));
}
function deduplicateKeepMostRecent(array) {
const mostRecentMap = new Map();
array.forEach((obj) => {
const key = obj.deduplicationKey();
if (!mostRecentMap.has(key) || obj.created_at > mostRecentMap.get(key).created_at) {
mostRecentMap.set(key, obj);
}
});
return Array.from(mostRecentMap.values());
}

1
src/lib/index.js Normal file
View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

155
src/lib/ndk.js Normal file
View file

@ -0,0 +1,155 @@
import NDK, { NDKNip07Signer, NDKEvent } from '@nostr-dev-kit/ndk';
import { get } from 'svelte/store';
import { db, events, currentBoardId, currentBoard, selectedColumn } from '$lib/db';
export async function login(method) {
const nip07signer = new NDKNip07Signer();
const ndk = get(db).ndk;
ndk.signer = nip07signer;
switch (method) {
case 'browser-extension': {
console.log('login with extension');
const user = await nip07signer.user();
db.update((db) => {
return { ...db, ndk, user };
});
}
}
}
export function addBoard(board) {
const ndk = get(db).ndk;
const event = new NDKEvent(ndk, { kind: 30043, content: 'Board Event' });
const tags = [['title', board.title]];
event.tags = tags;
event.publish();
}
export async function addColumn(column) {
const ndk = get(db).ndk;
const columnEvent = new NDKEvent(ndk, { kind: 30044, content: 'Column Event' });
columnEvent.tags = [['title', column.title]];
await columnEvent.publish();
// add column to Board
const existingBoard = await ndk.fetchEvent({
kinds: [30043],
authors: [columnEvent.pubkey],
'#d': [get(currentBoardId)]
});
existingBoard.tags.push(['a', `30044:${columnEvent.pubkey}:${columnEvent.dTag}`]);
existingBoard.publishReplaceable();
}
export async function addCard(card) {
const ndk = get(db).ndk;
const cardEvent = new NDKEvent(ndk, { kind: 30045, content: card.title });
await cardEvent.publish();
const columnEvent = await ndk.fetchEvent({
kinds: [30044],
authors: [cardEvent.pubkey],
'#d': [get(selectedColumn)]
});
columnEvent.tags.push(['a', `${cardEvent.kind}:${cardEvent.pubkey}:${cardEvent.dTag}`]);
columnEvent?.publishReplaceable();
}
async function eventTagToCard(eventTag) {
const ndk = get(db).ndk;
const [kind, pubkey, d] = eventTag.split(':');
const event = await ndk.fetchEvent({
kinds: [30045],
pubkey,
d
});
return { id: event.id, content: event.content, event };
}
async function eventTagToColumn(eventTag) {
const ndk = get(db).ndk;
const [kind, pubkey, d] = eventTag.split(':');
const event = await ndk.fetchEvent({
kind,
pubkey,
d
});
const title = event.tags.find((e) => e[0] === 'title')[1];
const itemEventTags = event.tags
.filter((e) => e[0] == 30045) // only card events
.filter((e) => e[0] === 'a' || e[0] === 'e')
.map((e) => e[1]);
let items = [];
if (itemEventTags.length > 0) {
items = await Promise.all(itemEventTags.map(eventTagToCard));
} else {
items = [];
}
return { id: d, title, items, event };
}
export function eventTitle(event) {
const title = event.tags.find((e) => e[0] === 'title')[1];
return title;
}
// Löschen?
async function eventToBoard(event) {
const id = event.tags.find((e) => e[0] === 'd')[1];
const eventId = event.id;
const title = event.tags.find((e) => e[0] === 'title')[1];
const columnEventTags = event.tags.filter((e) => e[0] === 'a').map((e) => e[1]);
let columns;
if (columnEventTags.length) {
columns = await Promise.all(columnEventTags.map(eventTagToColumn));
} else {
columns = [];
}
return { eventId, title, created_at: event.created_at, event };
}
export async function getBoards() {
const ndk = get(db).ndk;
const sub = ndk.subscribe({ kinds: [30043, 30044, 30045] }); // listen for boards, columns indexes
sub.on('event', async (event) => {
events.update((events) => [...events, event]);
});
}
// returns the d tags of the columns from the board
export function columnsDsFromBoard(board) {
const columnDs = board.tags.filter((t) => t[0] === 'a').map((t) => t[1]);
return columnDs;
}
export async function publishBoard(board) {
const ndk = get(db).ndk;
const existingBoard = await ndk.fetchEvent({
kinds: [30043],
authors: [board.pubkey],
'#d': [get(currentBoardId)]
});
const columnIds = board.items.map((e) => e.dTag);
let tags = existingBoard.tags.filter((t) => t[0] !== 'a');
columnIds.forEach((dTag) => {
tags.push(['a', `30044:${board.pubkey}:${dTag}`]);
});
existingBoard.tags = tags;
existingBoard.publishReplaceable();
}
export async function publishCards(column) {
const ndk = get(db).ndk;
const existingColumn = await ndk.fetchEvent({
kinds: [30044],
authors: [column.pubkey],
ids: [column.id]
});
const cardIds = column.items.map((e) => e.dTag);
let tags = existingColumn.tags.filter((t) => t[0] !== 'a');
cardIds.forEach((dTag) => {
tags.push(['a', `30045:${column.pubkey}:${dTag}`]);
});
existingColumn.tags = tags;
existingColumn.publishReplaceable();
}

View file

@ -0,0 +1,8 @@
<script>
import '../app.css';
import Navbar from '$lib/components/Navbar.svelte';
let { children } = $props();
</script>
<Navbar />
{@render children()}

29
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,29 @@
<script>
import { onMount } from 'svelte';
import { db, boards } from '$lib/db';
import { getBoards } from '$lib/ndk';
import AddBoardModal from '$lib/components/AddBoardModal.svelte';
import BoardCard from '$lib/components/BoardCard.svelte';
let addBoardModal;
function openModal() {
addBoardModal.showModal();
}
onMount(async () => {
await getBoards();
});
</script>
<button onclick={openModal}>Add Board</button>
<AddBoardModal bind:modalRef={addBoardModal} />
<h1>Boards</h1>
{#if $boards.length > 0}
<div class="flex flex-wrap gap-2">
{#each $boards as board (board.id)}
<BoardCard {board} />
{/each}
</div>
{/if}

View file

@ -0,0 +1,5 @@
export async function load({ params }) {
return {
id: params.id
};
}

View file

@ -0,0 +1,10 @@
<script>
import Board from '$lib/components/Board.svelte';
import { currentBoardId } from '$lib/db.js';
export let data;
console.log('data.id', data.id);
$currentBoardId = data.id;
</script>
<Board boardId={data.id} />

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

13
svelte.config.js Normal file
View file

@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-vercel';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

10
tailwind.config.js Normal file
View file

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {}
},
plugins: [require('daisyui'),]
};

6
vite.config.js Normal file
View file

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});