Add note and add list functionality.

This commit is contained in:
Andrew Lalis 2023-08-17 21:38:10 -04:00
parent 69ea579ea3
commit 3a1092d468
15 changed files with 625 additions and 49 deletions

View File

@ -4,6 +4,7 @@ import slf4d.default_provider;
void main() { void main() {
auto provider = new shared DefaultProvider(true, Levels.INFO); auto provider = new shared DefaultProvider(true, Levels.INFO);
provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.WARN);
configureLoggingProvider(provider); configureLoggingProvider(provider);
HttpServer server = initServer(); HttpServer server = initServer();
@ -23,6 +24,7 @@ private HttpServer initServer() {
config.connectionQueueSize = 10; config.connectionQueueSize = 10;
config.defaultHeaders["Access-Control-Allow-Origin"] = "*"; config.defaultHeaders["Access-Control-Allow-Origin"] = "*";
config.defaultHeaders["Access-Control-Allow-Credentials"] = "true"; config.defaultHeaders["Access-Control-Allow-Credentials"] = "true";
config.defaultHeaders["Access-Control-Allow-Methods"] = "*";
config.defaultHeaders["Vary"] = "origin"; config.defaultHeaders["Vary"] = "origin";
config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization"; config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization";
@ -44,7 +46,10 @@ private HttpServer initServer() {
mainHandler.addMapping(Method.GET, "/lists", &getNoteLists); mainHandler.addMapping(Method.GET, "/lists", &getNoteLists);
mainHandler.addMapping(Method.POST, "/lists", &createNoteList); mainHandler.addMapping(Method.POST, "/lists", &createNoteList);
mainHandler.addMapping(Method.GET, "/lists/{id}", &getNoteList);
mainHandler.addMapping(Method.DELETE, "/lists/{id}", &deleteNoteList); mainHandler.addMapping(Method.DELETE, "/lists/{id}", &deleteNoteList);
mainHandler.addMapping(Method.POST, "/lists/{listId}/notes", &createNote);
mainHandler.addMapping(Method.DELETE, "/lists/{listId}/notes/{noteId}", &deleteNote);
return new HttpServer(mainHandler, config); return new HttpServer(mainHandler, config);
} }

View File

@ -14,6 +14,9 @@ static UserDataSource userDataSource;
static this() { static this() {
userDataSource = new FsSqliteDataSource(); userDataSource = new FsSqliteDataSource();
import slf4d;
import d2sqlite3.library;
infoF!"Sqlite version %s"(versionString());
} }
struct User { struct User {
@ -42,9 +45,12 @@ interface UserDataSource {
void deleteUser(string username); void deleteUser(string username);
Nullable!User getUser(string username); Nullable!User getUser(string username);
NoteList[] getLists(string username); NoteList[] getLists(string username);
Nullable!NoteList getList(string username, ulong id);
NoteList createNoteList(string username, string name, string description = null); NoteList createNoteList(string username, string name, string description = null);
void deleteNoteList(string username, ulong id); void deleteNoteList(string username, ulong id);
NoteList updateNoteList(string username, ulong id, NoteList newData);
Note createNote(string username, ulong noteListId, string content); Note createNote(string username, ulong noteListId, string content);
Note updateNote(string username, ulong id, Note newData);
void deleteNote(string username, ulong id); void deleteNote(string username, ulong id);
} }
@ -107,13 +113,26 @@ class FsSqliteDataSource : UserDataSource {
return lists; return lists;
} }
Nullable!NoteList getList(string username, ulong id) {
Database db = getDb(username);
ResultRange results = db.execute("SELECT * FROM note_list WHERE id = ?", id);
if (results.empty()) return Nullable!NoteList.init;
NoteList list = parseNoteList(results.front());
ResultRange noteResults = db.execute("SELECT * FROM note WHERE note_list_id = ? ORDER BY ordinality ASC", id);
foreach (Row row; noteResults) {
list.notes ~= parseNote(row);
}
return nullable(list);
}
NoteList createNoteList(string username, string name, string description = null) { NoteList createNoteList(string username, string name, string description = null) {
Database db = getDb(username); Database db = getDb(username);
Statement existsStatement = db.prepare("SELECT COUNT(name) FROM note_list WHERE name = ?"); Statement existsStatement = db.prepare("SELECT COUNT(name) FROM note_list WHERE name = ?");
existsStatement.bind(1, name); existsStatement.bind(1, name);
ResultRange existsResult = existsStatement.execute(); ResultRange existsResult = existsStatement.execute();
if (existsResult.oneValue!int() > 0) throw new HttpStatusException(HttpStatus.BAD_REQUEST, "List already exists."); if (existsResult.oneValue!int() > 0) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "List already exists.");
}
Nullable!uint ordResult = db.execute("SELECT MAX(ordinality) + 1 FROM note_list").oneValue!(Nullable!uint); Nullable!uint ordResult = db.execute("SELECT MAX(ordinality) + 1 FROM note_list").oneValue!(Nullable!uint);
uint ordinality = 0; uint ordinality = 0;
@ -128,12 +147,47 @@ class FsSqliteDataSource : UserDataSource {
void deleteNoteList(string username, ulong id) { void deleteNoteList(string username, ulong id) {
Database db = getDb(username); Database db = getDb(username);
Statement stmt1 = db.prepare("DELETE FROM note WHERE note_list_id = ?"); db.begin();
stmt1.bind(1, id); db.execute("DELETE FROM note WHERE note_list_id = ?", id);
stmt1.execute(); Nullable!uint ordinality = db.execute(
Statement stmt2 = db.prepare("DELETE FROM note_list WHERE id = ?"); "SELECT ordinality FROM note_list WHERE id = ?", id
stmt2.bind(1, id); ).oneValue!(Nullable!uint)();
stmt2.execute(); db.execute("DELETE FROM note_list WHERE id = ?", id);
if (!ordinality.isNull) {
db.execute("UPDATE note_list SET ordinality = ordinality - 1 WHERE ordinality > ?", ordinality.get);
}
db.commit();
}
NoteList updateNoteList(string username, ulong id, NoteList newData) {
Database db = getDb(username);
ResultRange result = db.execute("SELECT * FROM note_list WHERE id = ?", id);
if (result.empty()) throw new HttpStatusException(HttpStatus.NOT_FOUND);
NoteList list = parseNoteList(result.front());
db.begin();
if (list.ordinality != newData.ordinality) {
if (newData.ordinality > list.ordinality) {
// Decrement all lists between the old index and the new one.
db.execute(
"UPDATE note_list SET ordinality = ordinality - 1 WHERE ordinality > ? AND ordinality <= ?",
list.ordinality,
newData.ordinality
);
} else {
// Increment all lists between the old index and the new one.
db.execute(
"UPDATE note_list SET ordinality = ordinality + 1 WHERE ordinality >= ? AND ordinality < ?",
newData.ordinality,
list.ordinality
);
}
}
db.execute(
"UPDATE note_list SET name = ?, description = ?, ordinality = ? WHERE id = ?",
newData.name, newData.description, newData.ordinality, id
);
db.commit();
return NoteList(id, newData.name, newData.ordinality, newData.description, []);
} }
Note createNote(string username, ulong noteListId, string content) { Note createNote(string username, ulong noteListId, string content) {
@ -158,11 +212,50 @@ class FsSqliteDataSource : UserDataSource {
); );
} }
Note updateNote(string username, ulong id, Note newData) {
Database db = getDb(username);
ResultRange result = db.execute("SELECT * FROM note WHERE id = ?", id);
if (result.empty()) throw new HttpStatusException(HttpStatus.NOT_FOUND);
Note note = parseNote(result.front());
db.begin();
if (note.ordinality != newData.ordinality) {
if (newData.ordinality > note.ordinality) {
// Decrement all notes between the old index and the new one.
db.execute(
"UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ? AND ordinality <= ?",
note.ordinality,
newData.ordinality
);
} else {
// Increment all notes between the old index and the new one.
db.execute(
"UPDATE note SET ordinality = ordinality + 1 WHERE ordinality >= ? AND ordinality < ?",
newData.ordinality,
note.ordinality
);
}
}
db.execute(
"UPDATE note SET ordinality = ?, content = ? WHERE id = ?",
newData.ordinality,
newData.content,
id
);
db.commit();
return Note(id, note.noteListId, newData.ordinality, newData.content);
}
void deleteNote(string username, ulong id) { void deleteNote(string username, ulong id) {
Database db = getDb(username); Database db = getDb(username);
Statement stmt = db.prepare("DELETE FROM note WHERE id = ?"); db.begin();
stmt.bind(1, id); Nullable!uint ordinality = db.execute(
stmt.execute(); "SELECT ordinality FROM note WHERE id = ?", id
).oneValue!(Nullable!uint)();
db.execute("DELETE FROM note WHERE id = ?", id);
if (!ordinality.isNull) {
db.execute("UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ?", ordinality.get);
}
db.commit();
} }
private NoteList parseNoteList(Row row) { private NoteList parseNoteList(Row row) {

View File

@ -2,6 +2,8 @@ module lists;
import handy_httpd; import handy_httpd;
import std.json; import std.json;
import std.typecons;
import std.string;
import auth; import auth;
import data; import data;
@ -17,22 +19,68 @@ void getNoteLists(ref HttpRequestContext ctx) {
ctx.response.writeBodyString(listsArray.toString(), "application/json"); ctx.response.writeBodyString(listsArray.toString(), "application/json");
} }
void getNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return;
AuthContext auth = AuthContextHolder.getOrThrow();
ulong id = ctx.request.getPathParamAs!ulong("id");
Nullable!NoteList optionalList = userDataSource.getList(auth.user.username, id);
if (!optionalList.isNull) {
ctx.response.writeBodyString(serializeList(optionalList.get()).toString(), "application/json");
} else {
ctx.response.setStatus(HttpStatus.NOT_FOUND);
}
}
void createNoteList(ref HttpRequestContext ctx) { void createNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return; if (!validateAuthenticatedRequest(ctx)) return;
AuthContext auth = AuthContextHolder.getOrThrow(); AuthContext auth = AuthContextHolder.getOrThrow();
JSONValue requestBody = ctx.request.readBodyAsJson(); JSONValue requestBody = ctx.request.readBodyAsJson();
string listName = requestBody.object["name"].str; if ("name" !in requestBody.object) {
string description = requestBody.object["description"].str; ctx.response.setStatus(HttpStatus.BAD_REQUEST);
ctx.response.writeBodyString("Missing required name for creating a new list.");
return;
}
string listName = strip(requestBody.object["name"].str);
if (listName.length < 3) {
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
ctx.response.writeBodyString("List name is too short. Should be at least 3 characters.");
}
string description = null;
if ("description" in requestBody.object) {
description = strip(requestBody.object["description"].str);
}
NoteList list = userDataSource.createNoteList(auth.user.username, listName, description); NoteList list = userDataSource.createNoteList(auth.user.username, listName, description);
ctx.response.writeBodyString(serializeList(list).toString(), "application/json"); ctx.response.writeBodyString(serializeList(list).toString(), "application/json");
} }
void createNote(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return;
AuthContext auth = AuthContextHolder.getOrThrow();
ulong listId = ctx.request.getPathParamAs!ulong("listId");
JSONValue requestBody = ctx.request.readBodyAsJson();
if ("content" !in requestBody || requestBody.object["content"].type != JSONType.STRING || requestBody.object["content"].str.length < 1) {
ctx.response.setStatus(HttpStatus.BAD_REQUEST);
ctx.response.writeBodyString("Missing string content.");
return;
}
string content = requestBody.object["content"].str;
Note note = userDataSource.createNote(auth.user.username, listId, content);
ctx.response.writeBodyString(serializeNote(note).toString(), "application/json");
}
void deleteNoteList(ref HttpRequestContext ctx) { void deleteNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return; if (!validateAuthenticatedRequest(ctx)) return;
AuthContext auth = AuthContextHolder.getOrThrow(); AuthContext auth = AuthContextHolder.getOrThrow();
userDataSource.deleteNoteList(auth.user.username, ctx.request.getPathParamAs!ulong("id")); userDataSource.deleteNoteList(auth.user.username, ctx.request.getPathParamAs!ulong("id"));
} }
void deleteNote(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return;
AuthContext auth = AuthContextHolder.getOrThrow();
ulong noteId = ctx.request.getPathParamAs!ulong("noteId");
userDataSource.deleteNote(auth.user.username, noteId);
}
private JSONValue serializeList(NoteList list) { private JSONValue serializeList(NoteList list) {
JSONValue listObj = JSONValue(string[string].init); JSONValue listObj = JSONValue(string[string].init);
listObj.object["id"] = JSONValue(list.id); listObj.object["id"] = JSONValue(list.id);

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router' import { RouterView } from 'vue-router'
</script> </script>
<template> <template>

View File

@ -5,6 +5,10 @@ export interface User {
email: string email: string
} }
export function emptyUser(): User {
return {username: "", email: ""}
}
export interface LoginInfo { export interface LoginInfo {
user: User user: User
token: string token: string

View File

@ -4,16 +4,30 @@ export interface Note {
id: number id: number
ordinality: number ordinality: number
content: string content: string
noteListName: string noteListId: number
} }
export interface NoteList { export interface NoteList {
id: number
name: string name: string
ordinality: number ordinality: number
description: string description: string
notes: Note[] notes: Note[]
} }
export async function createNoteList(token: string, name: string, description: string | null): Promise<NoteList> {
const response = await fetch(API_URL + "/lists", {
method: "POST",
headers: {"Authorization": "Bearer " + token},
body: JSON.stringify({name: name, description: description})
})
if (response.ok) {
return await response.json()
} else {
throw response
}
}
export async function getNoteLists(token: string): Promise<NoteList[]> { export async function getNoteLists(token: string): Promise<NoteList[]> {
const response = await fetch(API_URL + "/lists", { const response = await fetch(API_URL + "/lists", {
headers: {"Authorization": "Bearer " + token} headers: {"Authorization": "Bearer " + token}
@ -25,3 +39,41 @@ export async function getNoteLists(token: string): Promise<NoteList[]> {
return [] return []
} }
} }
export async function getNoteList(token: string, id: number): Promise<NoteList | null> {
const response = await fetch(API_URL + "/lists/" + id, {
headers: {"Authorization": "Bearer " + token}
})
if (response.ok) {
return await response.json()
} else {
return null
}
}
export async function deleteNoteList(token: string, id: number): Promise<void> {
await fetch(API_URL + "/lists/" + id, {
method: "DELETE",
headers: {"Authorization": "Bearer " + token}
})
}
export async function createNote(token: string, listId: number, content: string): Promise<Note> {
const response = await fetch(API_URL + "/lists/" + listId + "/notes", {
method: "POST",
headers: {"Authorization": "Bearer " + token},
body: JSON.stringify({content: content})
})
if (response.ok) {
return await response.json()
} else {
throw response
}
}
export async function deleteNote(token: string, listId: number, id: number): Promise<void> {
await fetch(API_URL + "/lists/" + listId + "/notes/" + id, {
method: "DELETE",
headers: {"Authorization": "Bearer " + token}
})
}

View File

@ -0,0 +1,4 @@
body {
font-family: sans-serif;
margin: 0;
}

View File

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 128 128" style="enable-background:new 0 0 128 128;" xml:space="preserve">
<g>
<ellipse style="fill:#B9E4EA;" cx="63.94" cy="104.89" rx="35" ry="13.61"/>
<path style="fill:#94D1E0;" d="M29.98,110.19c0-7.13,15.2-12.04,33.96-12.04s33.96,4.91,33.96,12.04s-15.2,13.53-33.96,13.53
S29.98,117.32,29.98,110.19z"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="64.1107" y1="89.9664" x2="64.1107" y2="147.6283">
<stop offset="0" style="stop-color:#82AFC1"/>
<stop offset="1" style="stop-color:#2F7889"/>
</linearGradient>
<path style="fill:url(#SVGID_1_);" d="M108.51,32.83l-2.26,12.33l-6.61-6.61l3.44-3.44l-9.75,2.84l0.6,0.6l-8.09,8.09l-6.54-6.54
l-9.63,0.82l-5.72,5.72l-6.2-6.2l-8.96-0.52l-6.72,6.72l-8.09-8.09l0.83-0.83l-9.36-1.98l2.81,2.81l-6.39,6.39L19.63,32.6
l-4.56-2.58l14.51,80.37C30.7,118.02,45.29,124,64.05,124s33.08-5.98,34.51-13.61l14.6-80.45L108.51,32.83z M84.06,110.53
l-6.32-6.32l8.09-8.09l8.09,8.09l0,0l-4.72,4.72C87.58,109.51,85.86,110.04,84.06,110.53z M39.21,109.46l-5.25-5.25l0,0l8.09-8.09
l8.09,8.09l-6.51,6.51C42.09,110.34,40.61,109.91,39.21,109.46z M72.03,104.22l-8.09,8.09l-8.09-8.09l8.09-8.09L72.03,104.22z
M66.8,93.27l8.09-8.09l8.09,8.09l-8.09,8.09L66.8,93.27z M52.99,101.36l-8.09-8.09l8.09-8.09l8.09,8.09L52.99,101.36z
M52.99,107.07l6.13,6.13c-3.65-0.25-7.33-0.75-10.84-1.43L52.99,107.07z M68.76,113.2l6.13-6.13l4.58,4.58
C75.99,112.39,72.36,112.94,68.76,113.2z M96.07,100.65l-7.38-7.38l8.09-8.09l1.8,1.8L96.07,100.65z M100.67,75.57l-3.89,3.89
l-8.09-8.09l8.09-8.09l5.19,5.19L100.67,75.57z M93.92,82.32l-8.09,8.09l-8.09-8.09l8.09-8.09L93.92,82.32z M74.88,79.47
l-8.09-8.09l8.09-8.09l8.09,8.09L74.88,79.47z M72.03,82.32l-8.09,8.09l-8.09-8.09l8.09-8.09L72.03,82.32z M52.99,79.47l-8.09-8.09
l8.09-8.09l8.09,8.09L52.99,79.47z M50.13,82.32l-8.09,8.09l-8.09-8.09l8.09-8.09L50.13,82.32z M31.1,79.47l-3.72-3.72l-1.33-7.4
l5.05-5.05l8.09,8.09L31.1,79.47z M31.1,85.18l8.09,8.09l-7.35,7.35L29.38,86.9L31.1,85.18z M102.85,63.65l-3.22-3.22l4.67-4.67
L102.85,63.65z M96.78,41.4l8.09,8.09l-8.09,8.09l-8.09-8.09L96.78,41.4z M85.83,52.34l8.09,8.09l-8.09,8.09l-8.09-8.09
L85.83,52.34z M74.88,41.4l8.09,8.09l-8.09,8.09l-8.09-8.09L74.88,41.4z M72.03,60.43l-8.09,8.09l-8.09-8.09l8.09-8.09L72.03,60.43
z M52.99,41.4l8.09,8.09l-8.09,8.09l-8.09-8.09L52.99,41.4z M50.13,60.43l-8.09,8.09l-8.09-8.09l8.09-8.09L50.13,60.43z M31.1,41.4
l8.09,8.09l-8.09,8.09l-8.09-8.09L31.1,41.4z M28.24,60.43l-3.06,3.06l-1.34-7.47L28.24,60.43z"/>
<radialGradient id="SVGID_2_" cx="65.5303" cy="12.9983" r="52.279" gradientTransform="matrix(1 0 0 0.4505 0 7.1421)" gradientUnits="userSpaceOnUse">
<stop offset="0.7216" style="stop-color:#94D1E0"/>
<stop offset="1" style="stop-color:#94D1E0;stop-opacity:0"/>
</radialGradient>
<path style="fill:url(#SVGID_2_);" d="M107.47,24.48l-8.06-8.06l2.29-2.29c-1.08-0.97-3.87-1.84-3.87-1.84l-1.27,1.27l-2.07-2.07
c-4.25-1.51-7.07-1.35-7.07-1.35l6.28,6.28l-8.09,8.09l-8.09-8.09l6.66-6.66c-2.61-0.8-5.06-0.66-5.06-0.66l-4.46,4.46L69.5,8.41
l-5.57,0.15l7.86,7.86l-8.09,8.09l-8.09-8.09l7.88-7.88l-5.94,0.22l-4.8,4.8l-4.72-4.72L43,9.51l6.91,6.91l-8.09,8.09l-8.09-8.09
l6.31-6.31c0,0-5.64,0.76-7.28,1.56l-1.89,1.89l-1.18-1.18c0,0-2.25,0.34-4.09,1.63l2.41,2.41l-7.24,7.24c0,0,0.42,1.65,2.81,2.9
l7.29-7.29l8.09,8.09l-4.22,4.22c0,0,2.74,1.55,4.75,0.97l2.33-2.33l5.87,5.87l9.87,0.29l6.15-6.15l5.98,5.98l10.29-0.36l5.62-5.62
l2.5,2.5c2.67,0.26,4.81-0.9,4.81-0.9l-4.45-4.45l8.09-8.09l8.09,8.09C104.64,27.37,107.12,25.86,107.47,24.48z M52.77,35.46
l-8.09-8.09l8.09-8.09l8.09,8.09L52.77,35.46z M74.66,35.46l-8.09-8.09l8.09-8.09l8.09,8.09L74.66,35.46z"/>
<path style="fill:#84B0C1;" d="M64,4C34.17,4,9.99,9.9,9.99,22.74c0,10.24,24.18,18.74,54.01,18.74c29.83,0,54.01-8.5,54.01-18.74
C118.01,11.29,93.83,4,64,4z M64,34.36c-24.01,0-43.47-5.98-43.47-13.35c0-7.37,19.46-11.69,43.47-11.69
c24.01,0,43.47,4.32,43.47,11.69C107.47,28.38,88.01,34.36,64,34.36z"/>
<path style="fill:#A8E3F0;" d="M107.47,15.75c2.07,1.65,3.91,4.42,1.7,6.98c-1.95,2.26-1.41,2.81-0.24,2.51
c2.2-0.56,5.84-3.03,4.61-7.19c-1.25-4.2-8.44-7-13.26-7.99c-1.31-0.27-3.5-0.56-3.89,0C96.01,10.63,102.77,12,107.47,15.75z"/>
<g>
<path style="fill:#A8E3F0;" d="M37.24,35.27c-4.64-0.47-16.02-1.62-22.14-9.69c-2.24-2.96-2.06-7.28,0.44-9.75
c4.34-4.27,10.01-4.41,8.72-3.62c-3.45,2.11-10.3,5.44-4.58,12.31c5.85,7.03,20.26,8.86,22.61,9.22S44.76,36.02,37.24,35.27z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -1,6 +1,7 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import "./assets/base.css"
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'

View File

@ -2,6 +2,12 @@ import { createRouter, createWebHistory } from 'vue-router'
import LoginView from "@/views/LoginView.vue"; import LoginView from "@/views/LoginView.vue";
import ListsView from "@/views/ListsView.vue"; import ListsView from "@/views/ListsView.vue";
import {useAuthStore} from "@/stores/auth"; import {useAuthStore} from "@/stores/auth";
import SingleListView from "@/views/SingleListView.vue";
function checkAuth() {
const authStore = useAuthStore()
if (!authStore.authenticated) return "login"
}
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -10,22 +16,22 @@ const router = createRouter({
path: "/", path: "/",
name: "home-redirect", name: "home-redirect",
redirect: to => { redirect: to => {
return "login" return "/login"
} }
}, },
{ {
path: "/login", path: "/login",
name: "login",
component: LoginView component: LoginView
}, },
{ {
path: "/lists", path: "/lists",
name: "lists",
component: ListsView, component: ListsView,
beforeEnter: (to, from) => { beforeEnter: checkAuth
const authStore = useAuthStore() },
if (!authStore.authenticated) return "login" {
} path: "/lists/:id",
component: SingleListView,
beforeEnter: checkAuth
} }
] ]
}) })

View File

@ -1,11 +1,12 @@
import {defineStore} from "pinia"; import {defineStore} from "pinia";
import {type Ref, ref} from "vue"; import {type Ref, ref} from "vue";
import type {User} from "@/api/auth"; import type {User} from "@/api/auth";
import {emptyUser} from "@/api/auth";
export const useAuthStore = defineStore("auth", () => { export const useAuthStore = defineStore("auth", () => {
const authenticated: Ref<boolean> = ref(false) const authenticated: Ref<boolean> = ref(false)
const user: Ref<User | null> = ref(null) const user: Ref<User> = ref(emptyUser())
const token: Ref<string | null> = ref(null) const token: Ref<string> = ref("")
function logIn(newToken: string, newUser: User) { function logIn(newToken: string, newUser: User) {
authenticated.value = true authenticated.value = true
@ -15,8 +16,8 @@ export const useAuthStore = defineStore("auth", () => {
function logOut() { function logOut() {
authenticated.value = false authenticated.value = false
user.value = null user.value = emptyUser()
token.value = null token.value = ""
} }
return {authenticated, user, token, logIn, logOut} return {authenticated, user, token, logIn, logOut}

8
litelist-app/src/util.ts Normal file
View File

@ -0,0 +1,8 @@
export function stringToColor(str: string, saturation: number = 100, lightness: number = 75): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
return `hsl(${(hash % 360)}, ${saturation}%, ${lightness}%)`;
}

View File

@ -2,31 +2,125 @@
import {useAuthStore} from "@/stores/auth"; import {useAuthStore} from "@/stores/auth";
import {onMounted, ref, type Ref} from "vue"; import {onMounted, ref, type Ref} from "vue";
import type {NoteList} from "@/api/lists"; import type {NoteList} from "@/api/lists";
import {getNoteLists} from "@/api/lists"; import {createNoteList, getNoteLists} from "@/api/lists";
import {useRouter} from "vue-router";
import {stringToColor} from "@/util";
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter()
const noteLists: Ref<NoteList[]> = ref([]) const noteLists: Ref<NoteList[]> = ref([])
const creatingNewList: Ref<boolean> = ref(false)
const newListModel = ref({
name: "",
description: ""
})
onMounted(async () => { onMounted(async () => {
noteLists.value = await getNoteLists(authStore.token) noteLists.value = await getNoteLists(authStore.token)
}) })
function toggleCreatingNewList() {
if (!creatingNewList.value) {
newListModel.value.name = ""
newListModel.value.description = ""
}
creatingNewList.value = !creatingNewList.value
}
async function goToList(id: number) {
await router.push("/lists/" + id)
}
async function createList() {
const noteList = await createNoteList(
authStore.token,
newListModel.value.name,
newListModel.value.description
)
await router.push("/lists/" + noteList.id)
}
</script> </script>
<template> <template>
<h1> <header>
Lists <h1>Lists</h1>
</h1> <div>
<p> <button @click="toggleCreatingNewList()">
Here are your lists! Create New List
</p> </button>
<div v-for="list in noteLists" :key="list.id"> </div>
</header>
<form v-if="creatingNewList" class="new-list-form" @submit.prevent="createList()">
<div class="form-row">
<label for="list-name">Name</label>
<input type="text" id="list-name" required minlength="3" v-model="newListModel.name"/>
</div>
<div class="form-row">
<label for="list-description">Description</label>
<input type="text" id="list-description" v-model="newListModel.description"/>
</div>
<div class="form-row">
<button type="submit">Create List</button>
</div>
</form>
<div
class="note-list-item"
v-for="list in noteLists"
:key="list.id"
@click="goToList(list.id)"
:style="{'background-color': stringToColor(list.name, 100, 90)}"
>
<h3 v-text="list.name"></h3> <h3 v-text="list.name"></h3>
<ul> <p v-text="list.description"></p>
<li v-for="note in list.notes" :key="note.id" v-text="note.content"></li>
</ul>
</div> </div>
</template> </template>
<style scoped> <style scoped>
h1 {
text-align: center;
}
header {
max-width: 50ch;
margin: 0 auto;
}
.note-list-item {
display: block;
max-width: 50ch;
margin: 1rem auto;
padding: 1rem;
border-radius: 1rem;
border: 2px solid black;
position: relative;
}
.note-list-item:hover {
cursor: pointer;
}
.note-list-item h3 {
margin: 0;
}
.note-list-item p {
margin: 0.5rem 0;
font-style: italic;
font-size: small;
}
.new-list-form {
max-width: 50ch;
margin: 0 auto;
}
.form-row {
margin: 1rem 0;
}
.form-row label {
display: block;
}
</style> </style>

View File

@ -29,20 +29,66 @@ async function doLogin() {
</script> </script>
<template> <template>
<form @submit.prevent="doLogin" @reset="resetLogin">
<h1>LiteList</h1> <h1>LiteList</h1>
<label> <form @submit.prevent="doLogin" @reset="resetLogin">
Username: <div class="form-row">
<input type="text" name="username" required v-model="loginModel.username"/> <label for="username-input">Username</label>
</label> <input
<label> id="username-input"
Password: type="text"
<input type="password" name="password" required v-model="loginModel.password"/> name="username"
</label> required
<button type="submit">Submit</button> v-model="loginModel.username"
</form> minlength="3"
/>
</div>
<div class="form-row">
<label for="password-input">Password</label>
<input
id="password-input"
type="password"
name="password"
required
v-model="loginModel.password"
minlength="8"
/>
</div>
<div class="form-row">
<button type="submit">Login</button>
</div>
</form>
</template> </template>
<style scoped> <style scoped>
h1 {
text-align: center;
}
form {
max-width: 50ch;
margin: 0 auto;
background-color: #efefef;
padding: 1rem;
border: 3px solid black;
border-radius: 1em;
}
.form-row {
margin: 1rem 0;
}
.form-row label {
display: block;
}
.form-row input {
width: 75%;
padding: 0.5rem;
font-size: large;
}
.form-row button {
font-size: medium;
}
</style> </style>

View File

@ -0,0 +1,164 @@
<script setup lang="ts">
import type {NoteList} from "@/api/lists";
import {onMounted, ref, type Ref} from "vue";
import {useAuthStore} from "@/stores/auth";
import {createNote, deleteNote, deleteNoteList, getNoteList} from "@/api/lists";
import {useRoute, useRouter} from "vue-router";
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const list: Ref<NoteList | null> = ref(null)
const creatingNote: Ref<boolean> = ref(false)
const newNoteText: Ref<string> = ref("")
onMounted(async () => {
const listId = parseInt(route.params.id)
// If no valid list id could be found, go back.
if (!listId) {
await router.push("/lists")
return
}
list.value = await getNoteList(authStore.token, listId)
// If no such list could be found, go back to the page showing all lists.
if (list.value === null) {
await router.push("/lists")
}
})
async function deleteNoteAndRefresh(id: number) {
if (!list.value) return
await deleteNote(authStore.token, list.value.id, id)
list.value = await getNoteList(authStore.token, list.value.id)
}
async function deleteList(id: number) {
const dialog: HTMLDialogElement = document.getElementById("list-delete-dialog")
dialog.showModal()
const confirmButton: HTMLButtonElement = document.getElementById("delete-confirm-button")
confirmButton.onclick = async () => {
dialog.close()
await deleteNoteList(authStore.token, id)
await router.push("/lists")
}
const cancelButton: HTMLButtonElement = document.getElementById("delete-cancel-button")
cancelButton.onclick = async () => {
dialog.close()
}
}
function toggleCreatingNewNote() {
if (!creatingNote.value) {
newNoteText.value = ""
}
creatingNote.value = !creatingNote.value
}
async function createNoteAndRefresh() {
if (!list.value) return
await createNote(authStore.token, list.value.id, newNoteText.value)
creatingNote.value = false
newNoteText.value = ""
list.value = await getNoteList(authStore.token, list.value.id)
}
</script>
<template>
<div v-if="list">
<header>
<h1 v-text="list.name"></h1>
<p><em v-text="list.description"></em></p>
<div class="buttons-list">
<button @click="toggleCreatingNewNote()">Add Note</button>
<button @click="deleteList(list.id)">
Delete this List
</button>
</div>
</header>
<form v-if="creatingNote" @submit.prevent="createNoteAndRefresh()" class="new-note-form">
<div class="form-row">
<label for="note-content">Text</label>
<input type="text" id="note-content" required minlength="1" v-model="newNoteText"/>
</div>
<div class="form-row">
<button type="submit">Add</button>
<button @click="toggleCreatingNewNote()">Cancel</button>
</div>
</form>
<div class="note-item" v-for="note in list.notes" :key="note.id">
<p class="note-item-text" v-text="note.content"></p>
<img
class="trash-button"
alt="Delete button"
src="@/assets/trash-emoji.svg"
@click="deleteNoteAndRefresh(note.id)"
/>
</div>
</div>
<dialog id="list-delete-dialog">
<form method="dialog">
<p>
Are you sure you want to delete this list? All notes in it will be deleted.
</p>
<div>
<button id="delete-cancel-button" value="cancel" formmethod="dialog">Cancel</button>
<button id="delete-confirm-button" value="default">Confirm</button>
</div>
</form>
</dialog>
</template>
<style scoped>
h1 {
text-align: center;
}
header {
max-width: 50ch;
margin: 0 auto;
}
.buttons-list button {
margin-right: 1rem;
font-size: medium;
}
.note-item {
max-width: 50ch;
margin: 1rem auto;
border-bottom: 1px solid black;
position: relative;
}
.note-item-text {
width: 90%;
}
.trash-button {
width: 32px;
position: absolute;
top: 0;
right: 0;
}
.trash-button:hover {
cursor: pointer
}
.new-note-form {
max-width: 50ch;
margin: 0 auto;
}
.form-row {
margin: 1rem 0;
}
.form-row label {
display: block;
}
</style>