Add markdown rendering and improve styling a bit

This commit is contained in:
@s.roertgen 2025-04-23 10:22:21 +02:00
parent 218dfdd5c6
commit cd23a61b90
9 changed files with 251 additions and 120 deletions

112
package-lock.json generated
View file

@ -8,9 +8,11 @@
"name": "educards",
"version": "0.0.1",
"dependencies": {
"@cartamd/plugin-emoji": "^4.3.0",
"@nostr-dev-kit/ndk": "^2.11.0",
"@tailwindcss/vite": "^4.1.4",
"carta-md": "^4.9.0",
"marked": "^15.0.9",
"tailwindcss": "^4.1.4"
},
"devDependencies": {
@ -46,6 +48,19 @@
"node": ">=6.0.0"
}
},
"node_modules/@cartamd/plugin-emoji": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@cartamd/plugin-emoji/-/plugin-emoji-4.3.0.tgz",
"integrity": "sha512-D73qZP/55er1b08CVmBA385omOY9/88zwB1JKBdhEFmOZzYfwr42GiyGHcHpcRinKe/MiaWV4k8vEYj461/wOQ==",
"dependencies": {
"bezier-easing": "^2.1.0",
"node-emoji": "^2.1.3",
"remark-emoji": "^5.0.0"
},
"peerDependencies": {
"carta-md": "^4.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
@ -1215,6 +1230,17 @@
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="
},
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@sveltejs/adapter-vercel": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-vercel/-/adapter-vercel-5.6.1.tgz",
@ -1766,6 +1792,11 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/bezier-easing": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz",
"integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig=="
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@ -1837,6 +1868,14 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/char-regex": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
"integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
"engines": {
"node": ">=10"
}
},
"node_modules/character-entities": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@ -2084,6 +2123,20 @@
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="
},
"node_modules/emojilib": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz",
"integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="
},
"node_modules/emoticon": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/emoticon/-/emoticon-4.1.0.tgz",
"integrity": "sha512-VWZfnxqwNcc51hIy/sbOdEem6D+cVtpPzEEtVAFdaas30+1dgkyaOQ4sQ6Bp0tOMqWO1v+HQfYaoodOkdhK6SQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
@ -3133,6 +3186,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/marked": {
"version": "15.0.9",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.9.tgz",
"integrity": "sha512-9AW/bn9DxQeZVjR52l5jsc0W2pwuhP04QaQewPvylil12Cfr2GBfWmgp6mu8i9Jy8UlBjqDZ9uMTDuJ8QOGZJA==",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
@ -3972,6 +4036,20 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"node_modules/node-emoji": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz",
"integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==",
"dependencies": {
"@sindresorhus/is": "^4.6.0",
"char-regex": "^1.0.2",
"emojilib": "^2.4.0",
"skin-tone": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@ -4541,6 +4619,21 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-emoji": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/remark-emoji/-/remark-emoji-5.0.1.tgz",
"integrity": "sha512-QCqTSvcZ65Ym+P+VyBKd4JfJfh7icMl7cIOGVmPMzWkDtdD8pQ0nQG7yxGolVIiMzSx90EZ7SwNiVpYpfTxn7w==",
"dependencies": {
"@types/mdast": "^4.0.4",
"emoticon": "^4.0.1",
"mdast-util-find-and-replace": "^3.0.1",
"node-emoji": "^2.1.3",
"unified": "^11.0.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@ -4800,6 +4893,17 @@
"node": ">=18"
}
},
"node_modules/skin-tone": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz",
"integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==",
"dependencies": {
"unicode-emoji-modifier-base": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -5176,6 +5280,14 @@
"resolved": "https://registry.npmjs.org/typescript-lru-cache/-/typescript-lru-cache-2.0.0.tgz",
"integrity": "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA=="
},
"node_modules/unicode-emoji-modifier-base": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz",
"integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==",
"engines": {
"node": ">=4"
}
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",

View file

@ -34,9 +34,11 @@
"vite": "^6.0.0"
},
"dependencies": {
"@cartamd/plugin-emoji": "^4.3.0",
"@nostr-dev-kit/ndk": "^2.11.0",
"@tailwindcss/vite": "^4.1.4",
"carta-md": "^4.9.0",
"marked": "^15.0.9",
"tailwindcss": "^4.1.4"
}
}

View file

@ -1,23 +1,18 @@
<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, user } from '$lib/db';
import { eventTitle, publishBoard, publishCards } from '$lib/ndk';
import { cardsForColumn, columnsForBoard, currentBoard, user, userAndBoardMatch } from '$lib/db';
import { eventTitle, publishBoard } from '$lib/ndk';
import Column from './Column.svelte';
export let boardAddress;
let addColumnModal;
let addCardModal;
function openColumnModal() {
addColumnModal.showModal();
}
function openCardModal() {
addCardModal.showModal();
}
$: items = $columnsForBoard.map((col) => {
col.items = cardsForColumn(col.id);
@ -35,95 +30,30 @@
// 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 });
}
function userAndBoardMatch(user, board) {
return user !== undefined && board && user?.pubkey === board?.pubkey;
}
</script>
<div class="flex flex-row items-center justify-between">
<h1 class="ml-5 text-lg">{eventTitle($currentBoard)}</h1>
{#if userAndBoardMatch($user, $currentBoard)}
<h1 class="ml-5 text-lg font-bold">{eventTitle($currentBoard)}</h1>
{#if $user && userAndBoardMatch($user, $currentBoard)}
<button class="btn mr-5" on:click={openColumnModal}>Add column</button>
{/if}
</div>
<section
class="board flex flex-nowrap"
class="board flex flex-nowrap gap-2"
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 }}>
{#if column !== undefined}
<div class="flex">
<div class="column-title">{column.dTag}</div>
{#if userAndBoardMatch($user, $currentBoard)}
<button class="btn btn-error ml-auto mr-0" on:click={() => deleteColumn(column.id)}
>🗑️</button
>
{/if}
</div>
<div class="flex">
{#if userAndBoardMatch($user, $currentBoard)}
<button
class="btn mx-auto"
on:click={() => {
// set selected columns
$selectedColumn = column.dTag;
openCardModal();
}}>Add Card</button
>
{/if}
</div>
{#if column.items.every((e) => e !== undefined)}
<div
class="column-content"
use:dndzone={{ items: column.items, flipDurationMs }}
on:consider={(e) => handleDndConsiderCards(column.id, e)}
on:finalize={(e) => handleDndFinalizeCards(column.id, e)}
>
<!-- {@debug column} -->
{#each column.items as item (item?.id ?? item)}
<div class="card" animate:flip={{ duration: flipDurationMs }}>
{eventTitle(item)}
{item.content}
<button on:click={() => deleteCard(column, item.id)}>Delete</button>
</div>
{/each}
</div>
{/if}
{/if}
<div class="w-96" animate:flip={{ duration: flipDurationMs }}>
<Column {column} {flipDurationMs} bind:items />
</div>
{/each}
</section>
<AddColumnModal bind:modalRef={addColumnModal} />
<AddCardModal bind:modalRef={addCardModal} />
<style>
.board {
@ -132,35 +62,4 @@
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,17 @@
<script>
import { eventTitle } from '$lib/ndk';
import { marked } from 'marked';
export let card;
function deleteCard(column, itemId) {
const updatedColumnItems = column.items.filter((e) => e.id !== itemId);
publishCards({ ...column, items: updatedColumnItems });
}
</script>
<div class="flex flex-col gap-1 rounded-xl border border-none bg-orange-300">
<h1 class="p-1 text-xl font-bold">{eventTitle(card)}</h1>
<p class="p-1">{@html marked.parse(card.content)}</p>
<button class="btn btn-warning m-1" on:click={() => deleteCard(column, item.id)}>Delete</button>
</div>

View file

@ -0,0 +1,76 @@
<script>
import { dndzone } from 'svelte-dnd-action';
import Card from './Card.svelte';
import PlusCircleFill from '$lib/icons/PlusCircleFill.svelte';
import AddCardModal from './AddCardModal.svelte';
import { selectedColumn, currentBoard, user, userAndBoardMatch, deleteColumn } from '$lib/db';
import { eventTitle, publishCards } from '$lib/ndk';
export let column;
export let flipDurationMs;
export let items;
let addCardModal;
function openCardModal() {
addCardModal.showModal();
}
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]);
}
</script>
{#if column !== undefined}
<div class="flex">
<div
class="flex w-full justify-center rounded-xl border border-none bg-orange-300 p-1 text-lg font-bold"
>
<p>{eventTitle(column)}</p>
</div>
{#if userAndBoardMatch($user, $currentBoard)}
<button class="btn btn-error mr-0 ml-auto" on:click={() => deleteColumn(column.id)}>🗑️</button
>
{/if}
</div>
<div class="flex">
{#if userAndBoardMatch($user, $currentBoard)}
<button
class="btn bg-base-300 my-1 w-full border-none"
on:click={() => {
// set selected columns
$selectedColumn = column.dTag;
openCardModal();
}}
>
<PlusCircleFill />
</button>
{/if}
</div>
{#if column.items.every((e) => e !== undefined)}
<div
class="h-full"
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 ?? item)}
<div class="my-1 p-1" animate:flip={{ duration: flipDurationMs }}>
<Card card={item} />
</div>
{/each}
</div>
{/if}
{/if}
<AddCardModal bind:modalRef={addCardModal} />

View file

@ -1,9 +1,12 @@
<script>
import { Carta, MarkdownEditor } from 'carta-md';
import { emoji } from '@cartamd/plugin-emoji';
// Component default theme
import 'carta-md/default.css';
const carta = new Carta();
const carta = new Carta({
extensions: [emoji()]
});
export let value = '';
</script>

View file

@ -10,12 +10,15 @@ export const boards = derived(events, ($events) => {
return deduped;
});
export const currentBoardAddress = writable('');
export const currentBoard = derived(currentBoardAddress, ($currentBoardAddress) => {
console.log('looking for current board');
const board = get(events).find((e) => e.tagAddress() === $currentBoardAddress);
console.log(board);
return board;
});
export const currentBoard = derived(
[currentBoardAddress, events],
([$currentBoardAddress, $events]) => {
console.log('looking for current board');
const board = get(events).find((e) => e.tagAddress() === $currentBoardAddress);
console.log(board);
return board;
}
);
export const columns = derived(events, ($events) => {
const allColumns = $events.filter((e) => e.kind === 30044);
const deduped = deduplicateKeepMostRecent(allColumns);
@ -87,9 +90,9 @@ const createNDKStore = () => {
// 'wss://relay.damus.io'
// 'wss://relay.nostr.band',
// 'wss://nos.lol',
// 'ws://localhost:10547'
'wss://purplepag.es',
'wss://relay-k12.edufeed.org'
'ws://localhost:10547'
// 'wss://purplepag.es',
// 'wss://relay-k12.edufeed.org'
// Add more default relays here
]
) => {
@ -170,3 +173,12 @@ function deduplicateKeepMostRecent(array) {
});
return Array.from(mostRecentMap.values());
}
export function userAndBoardMatch(user, board) {
return user !== undefined && board && user?.pubkey === board?.pubkey;
}
export function deleteColumn(columnId) {
const updatedBoardItems = items.filter((e) => e.id !== columnId);
publishBoard({ ...$currentBoard, items: updatedBoardItems });
}

View file

@ -0,0 +1,11 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
class="bi bi-plus-circle-fill"
viewBox="0 0 16 16"
>
<path
d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M8.5 4.5a.5.5 0 0 0-1 0v3h-3a.5.5 0 0 0 0 1h3v3a.5.5 0 0 0 1 0v-3h3a.5.5 0 0 0 0-1h-3z"
/>
</svg>

After

Width:  |  Height:  |  Size: 266 B

View file

@ -3,7 +3,6 @@
import { currentBoardAddress } from '$lib/db.js';
export let data;
console.log(data.id);
$currentBoardAddress = data.id;
</script>