<!-- 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 : 220 px ;
--bg : #f5f5f5 ;
--note-bg : #ffffff ;
--note-radius : 8 px ;
--note-shadow : 0 1 px 4 px 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 : 1 px solid #ddd ;
overflow-y : auto ;
padding : .75 rem 0 ;
}
# sidebar h2 { margin : .5 rem 1 rem ; font-size : 1 rem ; }
# sidebar ul { list-style : none ; margin : 0 ; padding : 0 ; }
# sidebar li {
padding : .5 rem 1 rem ;
cursor : pointer ;
border-radius : 4 px ;
}
# sidebar li : hover ,
# sidebar li . active { background : #f0f0f0 ; }
main { flex : 1 ; overflow-y : auto ; padding : 1 rem 1.5 rem ; }
# note-grid {
display : grid ;
grid-template-columns : repeat ( auto - fill , minmax ( 260 px , 1 fr ) ) ;
gap : 1 rem ;
padding-top : 1 rem ;
}
. note-card {
background : var ( - - note - bg ) ;
border-radius : var ( - - note - radius ) ;
box-shadow : var ( - - note - shadow ) ;
padding : .75 rem ;
display : flex ;
flex-direction : column ;
gap : .5 rem ;
}
. note-card h3 { margin : 0 ; font-size : 1 rem ; }
. note-body { flex : 1 ; font-size : .9 rem ; }
. attachments { display : flex ; flex-wrap : wrap ; gap : 4 px ; }
. attachments img { max-width : 100 % ; border-radius : 4 px ; }
. tag-list { display : flex ; flex-wrap : wrap ; gap : 4 px ; margin-top : .5 rem ; }
. tag { background : #eee ; padding : 2 px 6 px ; border-radius : 4 px ; font-size : .75 rem ; }
note-compose { display : block ; }
. compose-wrapper {
background : #fff ;
border-radius : var ( - - note - radius ) ;
box-shadow : var ( - - note - shadow ) ;
padding : .75 rem ;
display : flex ;
flex-direction : column ;
gap : .5 rem ;
margin-bottom : 1 rem ;
}
. compose-wrapper textarea { resize : vertical ; min-height : 60 px ; }
< / style >
< / head >
< body >
< div id = "app" >
< aside id = "sidebar" >
< h2 > Tags< / h2 >
< ul id = "tag-list" > < / ul >
< / aside >
< main >
< note-compose > < / note-compose >
< div id = "note-grid" > < / div >
< / main >
< / div >
< 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 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
-->
<!--
<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 : .25 rem ;
padding : .25 rem .5 rem ;
border : 1 px solid #d1d5db ;
border-radius : .5 rem ;
background : var ( - - bg ) ;
min-width : 12 rem ;
cursor : text ;
}
. tag {
display : flex ;
align-items : center ;
background : var ( - - tag - bg ) ;
color : var ( - - tag - fg ) ;
font-size : .875 rem ;
padding : .125 rem .5 rem ;
border-radius : .375 rem ;
user-select : none ;
transition : background .15 s ease ;
}
. tag : hover { background : var ( - - tag - hover - bg ) ; }
. remove {
all : unset ;
margin-left : .25 rem ;
font-weight : bold ;
cursor : pointer ;
color : var ( - - tag - remove ) ;
}
. remove : hover { color : var ( - - tag - remove - hover ) ; }
input {
flex : 1 0 6 rem ;
border : none ;
background : transparent ;
outline : none ;
font-size : .875 rem ;
min-width : 4 rem ;
padding : .125 rem 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"> ${ 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 = `
<form class="compose-wrapper">
<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" id="cancel" style="display:none;">Cancel</button>
</div>
</form> ` ;
}
connectedCallback ( ) {
this . form = this . querySelector ( 'form' )
this . form . addEventListener ( 'submit' , e => {
e . preventDefault ( ) ;
this . save ( ) ;
} ) ;
this . querySelector ( '#cancel' ) . addEventListener ( 'click' , ( ) => this . reset ( ) ) ;
document . addEventListener ( 'edit-note' , e => this . load ( e . detail ) ) ;
}
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 . q ( '#cancel' ) . style . display = 'inline-block' ;
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 . q ( '#cancel' ) . style . display = 'none' ; 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 ) { const r = await fetch ( tag ? ` ${ API _BASE } /notes?tag= ${ encodeURIComponent ( tag ) } ` : ` ${ API _BASE } /notes ` ) ; const ns = r . ok ? await r . json ( ) : [ ] ; const g = document . getElementById ( 'note-grid' ) ; g . innerHTML = '' ; ns . forEach ( n => { const c = document . createElement ( 'note-card' ) ; c . data = n ; g . appendChild ( c ) ; } ) ; }
async function loadTags ( ) { const r = await fetch ( ` ${ API _BASE } /tags ` ) ; const ts = r . ok ? await r . json ( ) : [ ] ; const l = document . getElementById ( 'tag-list' ) ; l . innerHTML = '' ; ts . forEach ( t => { const li = document . createElement ( 'li' ) ; li . textContent = t . name || t ; li . onclick = ( ) => { document . querySelectorAll ( '#tag-list li' ) . forEach ( x => x . classList . toggle ( 'active' , x === li ) ) ; loadNotes ( t . name || t ) ; } ; l . appendChild ( li ) ; } ) ; }
document . addEventListener ( 'notes-changed' , ( ) => loadNotes ( ) ) ;
loadNotes ( ) ; loadTags ( ) ;
< / script >
< / body >
< / html >