This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- Written by retoor@molodetz.nl -->
<!-- This code represents a notes application called "Ada Notes" offering features such as composing, listing, and managing notes with tags and file attachments. The application uses HTML, CSS, and JavaScript with web components for custom note and compose elements. -->
<!-- The code utilizes an external library for markdown parsing: marked.js hosted via jsDelivr CDN. -->
<!-- MIT License -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Ada Notes</title>
<style>
:root {
--sidebar-width: 220px;
--bg: #f5f5f5;
--note-bg: #ffffff;
--note-radius: 8px;
--note-shadow: 0 1px 4px rgba(0,0,0,.1);
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; font-family: system-ui, sans-serif; background: var(--bg); }
#app { display: flex; height: 100%; }
#sidebar {
width: var(--sidebar-width);
background: #fff;
border-right: 1px solid #ddd;
overflow-y: auto;
padding: .75rem 0;
}
#sidebar h2 { margin: .5rem 1rem; font-size: 1rem; }
#sidebar ul { list-style: none; margin: 0; padding: 0; }
#sidebar li {
padding: .5rem 1rem;
cursor: pointer;
border-radius: 4px;
}
#sidebar li:hover,
#sidebar li.active { background: #f0f0f0; }
main { flex: 1; overflow-y: auto; padding: 1rem 1.5rem; }
#note-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
padding-top: 1rem;
}
.note-card {
background: var(--note-bg);
border-radius: var(--note-radius);
box-shadow: var(--note-shadow);
padding: .75rem;
display: flex;
flex-direction: column;
gap: .5rem;
}
.note-card h3 { margin: 0; font-size: 1rem; }
.note-body { flex: 1; font-size: .9rem; }
.attachments { display: flex; flex-wrap: wrap; gap: 4px; }
.attachments img { max-width: 100%; border-radius: 4px; }
.tag-list { display: flex; flex-wrap: wrap; gap: 4px; margin-top: .5rem; }
.tag { background: #eee; padding: 2px 6px; border-radius: 4px; font-size: .75rem; }
note-compose { display: block; }
.compose-wrapper {
background: #fff;
border-radius: var(--note-radius);
box-shadow: var(--note-shadow);
padding: .75rem;
display: flex;
flex-direction: column;
gap: .5rem;
margin-bottom: 1rem;
}
.compose-wrapper textarea { resize: vertical; min-height: 60px; }
input,textarea,tag-input {
padding: .5rem;
border: 1px solid #ccc;
border-radius: 4px;
width: 100%;
margin-bottom: 5px;
}
button {
padding: .5rem 1rem;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
/* Context Menu Styling */
.context-menu {
position: absolute;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
padding: 5px;
z-index: 1000;
display: none; /* Initially hidden */
min-width: 150px;
}
.context-menu-item {
padding: 8px 12px;
cursor: pointer;
user-select: none;
}
.context-menu-item:hover {
background-color: #f0f0f0;
}
.context-menu-item span {
display: block;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.context-menu.show {
animation: fadeIn 0.2s ease-out;
display: block;
}
</style>
</head>
<body>
<div id="app">
<aside id="sidebar">
<!-- New search box -->
<input
type="text"
id="search"
placeholder="Search notes…"
style="width: calc(100% - 2rem); margin: .5rem 1rem; padding: .5rem;"
/>
<h2>Tags</h2>
<ul id="tag-list"></ul>
</aside>
<main>
<note-compose></note-compose>
<div id="note-grid"></div>
</main>
</div>
<div id="context-menu" class="context-menu">
<div class="context-menu-item"><span>Edit Tag</span></div>
<div class="context-menu-item"><span>Rename Tag</span></div>
<div class="context-menu-item"><span>Delete Tag</span></div>
</div>
<script>
class ContextMenu extends HTMLElement {
constructor() {
super();
this.menu = document.createElement('div');
this.menu.classList.add('context-menu');
document.body.appendChild(this.menu);
document.body.addEventListener('contextmenu', (event) => {
event.preventDefault(); // Prevent default context menu
this.showContextMenu(event);
});
document.addEventListener('click', () => {
this.hideContextMenu();
});
}
addItem(text) {
const item = document.createElement('div');
item.classList.add('context-menu-item');
item.innerHTML = `<span>${text}</span>`;
this.menu.appendChild(item);
}
showContextMenu(event) {
let clicked = event.target.closest('.right-clickable');
if (!clicked){
clicked = event.target;
if (!clicked.classList.contains('right-clickable'))
return;
}
//Get position
const x = event.clientX;
const y = event.clientY;
// Set the clicked tag's data to a property so it's available for the menu items.
this.currentElement = clicked;
this.menu.style.left = `${x}px`;
this.menu.style.top = `${y}px`;
this.menu.classList.add('show'); // Add show class for animation
}
hideContextMenu() {
this.menu.classList.remove('show');
}
}
customElements.define('context-menu', ContextMenu);
const contextMenu = new ContextMenu();
contextMenu.addItem('Edit Tag');
</script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!--
<tag-input value="apple, banana, Cherry"></tag-input>
<script type="module" src="tag-input-component.html"></script>
A lightweight native Web Component that provides tagentry functionality.
▸ Prevents duplicates (caseinsensitive)
▸ Trims & lowercases everything
▸ Ignores tags < 3 characters
▸ Accepts comma or Enter to commit
▸ Keeps tags sorted
▸ Simple × button to delete
-->
<!--
<tag-input value="apple, banana, Cherry"></tag-input>
<script type="module" src="tag-input-component.html"></script>
A lightweight native Web Component that provides tag-entry functionality.
▸ Prevents duplicates (case-insensitive)
▸ Trims & lower-cases everything
▸ Ignores tags < 3 characters
▸ Accepts comma or Enter to commit
▸ Keeps tags sorted
▸ Simple × button to delete
-->
<template id="tag-input-template">
<style>
:host {
display: inline-block;
font-family: system-ui, sans-serif;
--bg: #f3f4f6;
--fg: #111827;
--tag-bg: #e5e7eb;
--tag-fg: #111827;
--tag-hover-bg: #d1d5db;
--tag-remove: #6b7280;
--tag-remove-hover: #ef4444;
}
.wrapper {
display: flex;
flex-wrap: wrap;
gap: .25rem;
padding: .25rem .5rem;
border: 1px solid #d1d5db;
border-radius: .5rem;
background: var(--bg);
min-width: 12rem;
cursor: text;
}
.tag {
display: flex;
align-items: center;
background: var(--tag-bg);
color: var(--tag-fg);
font-size: .875rem;
padding: .125rem .5rem;
border-radius: .375rem;
user-select: none;
transition: background .15s ease;
}
.tag:hover { background: var(--tag-hover-bg); }
.remove {
all: unset;
margin-left: .25rem;
font-weight: bold;
cursor: pointer;
color: var(--tag-remove);
}
.remove:hover { color: var(--tag-remove-hover); }
input {
flex: 1 0 6rem;
border: none;
background: transparent;
outline: none;
font-size: .875rem;
min-width: 4rem;
padding: .125rem 0;
color: var(--fg);
}
</style>
<div class="wrapper">
<input type="text" placeholder="add tag…" />
</div>
</template>
<script type="module">
class TagInput extends HTMLElement {
static get observedAttributes() { return ["value"]; }
static formAssociated = true;
constructor() {
super();
const tmpl = document.getElementById("tag-input-template").content.cloneNode(true);
this.attachShadow({ mode: "open" }).appendChild(tmpl);
this._internals = this.attachInternals();
this._input = this.shadowRoot.querySelector("input");
this._wrapper = this.shadowRoot.querySelector(".wrapper");
this._tags = [];
}
connectedCallback() {
// Parse initial value attribute or property
if (this.hasAttribute("value")) {
this._setTagsFromString(this.getAttribute("value"));
}
this._upgradeProperty("value");
/* ────── UI interactions ────── */
this._input.addEventListener("keydown", e => {
if (e.key === "," || e.key === "Enter") {
e.preventDefault();
this._commitInput();
// setTimeout(() =>{
this._input.value = ""
// },100);
}
});
this._input.addEventListener("blur", () => this._commitInput());
// Delegate click to delete buttons
this.shadowRoot.addEventListener("click", e => {
if (e.target.classList.contains("remove")) {
const tag = e.target.parentElement.dataset.tag;
this.removeTag(tag);
}
});
// Clicking on wrapper focuses input
this._wrapper.addEventListener("click", () => this._input.focus());
this._renderTags();
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === "value" && oldVal !== newVal) {
this._setTagsFromString(newVal);
}
}
/* ────── Public API ────── */
get value() { return this._tags.join(", "); }
set value(v) { this.setAttribute("value", v); }
addTag(tag) {
const cleaned = this._cleanTag(tag);
if (!cleaned) return;
if (!this._tags.includes(cleaned)) {
this._tags.push(cleaned);
this._sort();
this._update();
this._input.value = "";
}
}
removeTag(tag) {
const idx = this._tags.indexOf(tag.toLowerCase());
if (idx > -1) {
this._tags.splice(idx, 1);
this._update();
}
}
/* ────── Internal helpers ────── */
_commitInput() {
const raw = this._input.value;
if (!raw) return;
raw.split(",").forEach(t => this.addTag(t));
this._input.value = "";
}
_cleanTag(tag) {
if (!tag) return "";
const t = tag.trim().toLowerCase();
return t.length >= 3 ? t : "";
}
_setTagsFromString(str) {
// Build a fresh, de-duplicated, cleaned array without triggering nested updates
const next = [];
if (str) {
str.split(/,\s*/).forEach(t => {
const cleaned = this._cleanTag(t);
if (cleaned && !next.includes(cleaned)) next.push(cleaned);
});
}
next.sort();
this._tags = next;
this._update(false); // single update, no attribute reflection
}
_sort() { this._tags.sort(); }
_update(reflectAttr = true) {
this._renderTags();
const val = this.value;
if (reflectAttr) this.setAttribute("value", val);
this._internals.setFormValue(val);
// Emit external event
this.dispatchEvent(new CustomEvent("change", { detail: this._tags.slice() }));
}
_renderTags() {
// Remove any existing tag elements
const existing = Array.from(this._wrapper.querySelectorAll("span.tag"));
existing.forEach(el => el.remove());
// Build the new list
this._tags.forEach(tag => {
const el = document.createElement("span");
el.className = "tag";
el.dataset.tag = tag;
el.textContent = tag;
const rm = document.createElement("button");
rm.className = "remove";
rm.textContent = "×";
el.appendChild(rm);
// Insert before the input inside .wrapper
this._wrapper.insertBefore(el, this._input);
});
}
_upgradeProperty(prop) {
if (this.hasOwnProperty(prop)) {
const value = this[prop];
delete this[prop];
this[prop] = value;
}
}
}
customElements.define("tag-input", TagInput);
</script>
<script type="module">
const API_BASE = '/api';
/* NOTE CARD dbl-click emits edit event */
class NoteCard extends HTMLElement {
constructor() {
super();
this.addEventListener('dblclick', () =>
document.dispatchEvent(new CustomEvent('edit-note', { detail: this.note }))
);
}
set data(n) { this.note = n; this.render(); }
render() {
if (!this.note) return;
const { title = '', body = '', attachments = [], tags = [] } = this.note;
this.innerHTML = `
<div class="note-card">
${title ? `<h3>${title}</h3>` : ''}
<div class="note-body">${marked.parse(body)}</div>
${attachments.length ? `<div class="attachments">
${attachments.map(a => a.type === 'image'
? `<img src="${a.url}">`
: `<a href="${a.url}" target="_blank">${a.url}</a>`).join('')}
</div>` : ''}
${tags.length ? `<div class="tag-list">${tags.map(t => `<span class="tag right-clickable" data-tag="${t}">${t}</span>`).join('')}</div>` : ''}
</div>`;
}
}
customElements.define('note-card', NoteCard);
/* COMPOSE same form, now doubles as editor */
class NoteCompose extends HTMLElement {
constructor() {
super();
this.noteId = null; // null = new
this.innerHTML = `
<button class="compose-button">New note</button>
<form class="compose-wrapper" style="display:none;">
<input name="title" placeholder="Title" required>
<textarea name="body" placeholder="Take a note…" required></textarea>
<input name="files" type="file" multiple>
<tag-input name="tags" placeholder="Tags (comma-separated)"></tag-input>
<div style="display:flex;gap:.5rem;">
<button type="submit">Save</button>
<button type="button" class="cancel">Cancel</button>
</div>
</form>`;
}
connectedCallback() {
this.form = this.querySelector('form')
this.form.addEventListener('submit', e => {
e.preventDefault();
this.save();
});
this.composeButton = this.querySelector('.compose-button')
this.composeButton.addEventListener('click', () => this.show());
this.querySelector('.cancel').addEventListener('click', () => this.reset());
document.addEventListener('edit-note', e => this.load(e.detail));
}
show(){
this.form.style.display = 'block';
this.q('title').focus();
this.composeButton.style.display = "none";
}
hide(){
this.form.style.display = 'none';
this.composeButton.style.display = "block";
}
q(s) { return this.querySelector(s); }
load(n) { /* pre-fill for edit */
this.noteId = n.id;
this.q('title').value = n.title;
this.q('body').value = n.body;
this.q('tags').value = (n.tags || []).join(', ');
this.show();
this.scrollIntoView({behavior:'smooth'});
}
async save() {
const fd = new FormData(this.form);
const note = {
title: fd.get('title').trim(),
body: fd.get('body').trim(),
tags: (fd.get('tags')||'').split(',').map(t=>t.trim()).filter(Boolean),
attachments: []
};
/* uploads first */
for (const f of this.q('files').files) {
const up = new FormData(); up.append('file', f);
const r = await fetch(`${API_BASE}/upload`, {method:'POST', body:up});
if (r.ok) note.attachments.push(await r.json());
}
const method = this.noteId ? 'PUT' : 'POST';
const url = this.noteId ? `${API_BASE}/notes/${this.noteId}` : `${API_BASE}/notes`;
const res = await fetch(url, {method,
headers:{'Content-Type':'application/json'},
body: JSON.stringify(note)});
if (res.ok) { this.reset(); document.dispatchEvent(new CustomEvent('notes-changed')); }
else alert('Save failed');
}
reset() { this.noteId=null; this.form.reset(); this.hide(); this.q('tags').value=''; }
q(sel){ return this.querySelector(sel.includes('#') ? sel : `[name=\"${sel}\"]`); }
}
customElements.define('note-compose', NoteCompose);
/* tiny loaders unchanged */
async function loadNotes(tag, search) {
let url = API_BASE + '/notes';
const params = [];
if (search) {
params.push(`search=${encodeURIComponent(search)}`);
} else if (tag) {
params.push(`tag=${encodeURIComponent(tag)}`);
}
if (params.length) {
url += '?' + params.join('&');
}
const res = await fetch(url);
const notes = res.ok ? await res.json() : [];
const grid = document.getElementById('note-grid');
grid.innerHTML = '';
notes.forEach(n => {
const card = document.createElement('note-card');
card.data = n;
grid.appendChild(card);
});
}
async function loadTags() {
const r = await fetch(`${API_BASE}/tags`);
const ts = r.ok ? await r.json() : [];
const ul = document.getElementById('tag-list');
ul.innerHTML = '';
ts.forEach(t => {
const li = document.createElement('li');
li.textContent = t.name || t;
li.onclick = () => {
// clear search box & active class
document.getElementById('search').value = '';
document.querySelectorAll('#tag-list li')
.forEach(x => x.classList.toggle('active', x === li));
loadNotes(t.name || t, null);
};
ul.appendChild(li);
});
}
// Wire up search box
document.getElementById('search').addEventListener('input', e => {
const q = e.target.value.trim();
// clear tag selection
document.querySelectorAll('#tag-list li')
.forEach(x => x.classList.remove('active'));
loadNotes(null, q || null);
});
// When notes change, refresh both lists
document.addEventListener('notes-changed', () => {
loadNotes();
loadTags();
});
// Initial load
loadNotes();
loadTags();
document.addEventListener('notes-changed', () => { loadNotes(); loadTags(); });
loadNotes(); loadTags();
</script>
</body>
</html>