@app.get("/login")
@app.get("/search")
@app.get("/new")
+@app.get("/notes")
@app.get("/note/{title}")
async def root(title: str = ""):
with open("flatnotes/dist/index.html", "r", encoding="utf-8") as f:
) {
EventBus.$emit(
"navigate",
- `/${constants.basePaths.login}?${constants.params.redirect}=${encodeURI(
+ `${constants.basePaths.login}?${constants.params.redirect}=${encodeURI(
window.location.pathname + window.location.search
)}`
);
}
get href() {
- return `/${constants.basePaths.note}/${this.title}`;
+ return `${constants.basePaths.note}/${this.title}`;
+ }
+
+ get lastModifiedAsDate() {
+ return new Date(this.lastModified * 1000);
+ }
+
+ get lastModifiedAsString() {
+ return this.lastModifiedAsDate.toLocaleString();
}
}
$form-control-border: #ced4da;
$drop-shadow: #0000000a;
$muted-text: #6c757d;
+$very-muted-text: #d8dbdd;
$text: #222222;
-$button-background: #00000010;
+$button-background: #00000008;
$input-highlight: #bbcdff;
$logo-key-colour: #f9a76b;
import Logo from "./Logo";
import Mousetrap from "mousetrap";
import NavBar from "./NavBar";
+import NoteList from "./NoteList";
import NoteViewerEditor from "./NoteViewerEditor";
-import RecentlyModified from "./RecentlyModified";
import SearchInput from "./SearchInput";
import SearchResults from "./SearchResults";
name: "App",
components: {
- RecentlyModified,
+ NoteList,
LoadingIndicator,
Login,
NavBar,
home: 1,
note: 2,
search: 3,
+ notes: 4,
},
currentView: 1,
methods: {
route: function() {
let path = window.location.pathname.split("/");
- let basePath = path[1];
+ let basePath = `/${path[1]}`;
this.$bvModal.hide("search-modal");
// Home Page
- if (basePath == "") {
+ if (basePath == constants.basePaths.home) {
this.updateDocumentTitle();
this.currentView = this.views.home;
this.$nextTick(function() {
this.currentView = this.views.note;
}
+ // Notes
+ else if (basePath == constants.basePaths.notes) {
+ this.updateDocumentTitle();
+ this.currentView = this.views.notes;
+ }
+
// Login
else if (basePath == constants.basePaths.login) {
this.updateDocumentTitle("Log In");
logout: function() {
sessionStorage.removeItem("token");
localStorage.removeItem("token");
- this.navigate(`/${constants.basePaths.login}`);
+ this.navigate(constants.basePaths.login);
},
newNote: function() {
- this.navigate(`/${constants.basePaths.new}`);
+ this.navigate(constants.basePaths.new);
},
noteDeletedToast: function() {
created: function() {
let parent = this;
+ this.constants = constants;
+
EventBus.$on("navigate", this.navigate);
EventBus.$on("unhandledServerError", this.unhandledServerErrorToast);
EventBus.$on("updateDocumentTitle", this.updateDocumentTitle);
v-if="currentView != views.login"
class="w-100 mb-5"
:show-logo="currentView != views.home"
- @navigate-home="navigate('/')"
+ @navigate-home="navigate(constants.basePaths.home)"
@new-note="newNote()"
+ @a-z="navigate(constants.basePaths.notes)"
@logout="logout()"
@search="openSearch()"
></NavBar>
:initial-value="searchTerm"
class="search-input mb-4"
></SearchInput>
- <RecentlyModified class="recently-modified"></RecentlyModified>
+ <NoteList
+ class="recently-modified"
+ mini-header="Recently Modified"
+ :num-recently-modified="5"
+ :show-loader="false"
+ centered
+ ></NoteList>
</div>
<!-- Search Results -->
<div
v-if="currentView == views.search"
- class="flex-grow-1 search-results-view"
+ class="flex-grow-1 search-results-view d-flex flex-column"
>
<SearchInput
:initial-value="searchTerm"
class="search-input mb-4"
></SearchInput>
- <SearchResults :search-term="searchTerm" class="h-100"></SearchResults>
+ <SearchResults
+ :search-term="searchTerm"
+ class="flex-grow-1"
+ ></SearchResults>
</div>
+ <!-- Notes -->
+ <NoteList
+ v-if="currentView == views.notes"
+ class="flex-grow-1"
+ grouped
+ show-last-modified
+ ></NoteList>
+
<!-- Note -->
<NoteViewerEditor
v-if="currentView == this.views.note"
</div>
</template>
+<style lang="scss" scoped>
+@import "../colours";
+
+.home-view {
+ max-width: 500px;
+}
+
+.search-results-view {
+ max-width: 700px;
+}
+
+.search-input {
+ box-shadow: 0 0 20px $drop-shadow;
+}
+
+.recently-modified {
+ // Prevent UI from moving during load
+ min-height: 180px;
+}
+</style>
+
<script>
export { default } from "./App.js";
</script>
<template>
- <div>
+ <div class="d-flex justify-content-center">
<div v-if="showLoader && !failed" class="loader"></div>
- <div v-else-if="failed" class="d-flex flex-column align-items-center">
+ <div
+ v-else-if="failed"
+ class="d-flex flex-column align-items-center failure-message"
+ >
<b-icon
class="failed-icon mb-3"
- :icon="failedBootstrapIcon || 'cloud-slash'"
+ :icon="failedBootstrapIcon || 'cone-striped'"
></b-icon>
<p>{{ failedMessage }}</p>
</div>
font-size: 60px;
}
+.failure-message {
+ max-width: 300px;
+ text-align: center;
+}
+
.loader,
.loader:before,
.loader:after {
@click.native="$emit('navigate-home')"
responsive
></Logo>
+
+ <!-- Buttons -->
<div>
<!-- New Note -->
<button type="button" class="bttn" @click="$emit('new-note')">
- <b-icon icon="plus-circle"></b-icon> New Note
+ <b-icon icon="plus-circle"></b-icon> New
</button>
<!-- Log Out -->
<b-icon icon="box-arrow-right"></b-icon> Log Out
</button>
+ <!-- A-Z -->
+ <button type="button" class="bttn" @click="$emit('a-z')">A-Z</button>
+
<!-- Search -->
<button
type="button"
--- /dev/null
+<template>
+ <div>
+ <!-- Loading -->
+ <div
+ v-if="notes == null || notes.length == 0"
+ class="h-100 d-flex flex-column justify-content-center"
+ >
+ <LoadingIndicator
+ :failed="loadingFailed"
+ :failedMessage="loadingFailedMessage"
+ :failedBootstrapIcon="loadingFailedIcon"
+ :show-loader="showLoader"
+ />
+ </div>
+
+ <!-- Notes Loaded -->
+ <div v-else>
+ <p
+ v-if="miniHeader"
+ class="mini-header mb-1"
+ :class="{ centered: centered }"
+ >
+ {{ miniHeader }}
+ </p>
+ <div
+ v-for="group in notesGrouped"
+ :key="group.name"
+ :class="{ centered: centered, 'mb-5': grouped }"
+ >
+ <p v-if="grouped" class="group-name">{{ group.name }}</p>
+ <a
+ v-for="note in group.notes"
+ :key="note.title"
+ class="d-flex justify-content-between align-items-center note-row"
+ :href="note.href"
+ @click.prevent="openNote(note.href, $event)"
+ >
+ <span>{{ note.title }}</span>
+ <span v-if="showLastModified" class="last-modified d-none d-md-block">
+ {{ note.lastModifiedAsString }}
+ </span>
+ </a>
+ </div>
+ </div>
+ </div>
+</template>
+
+<style lang="scss" >
+@import "../colours";
+
+.centered {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.mini-header {
+ text-transform: uppercase;
+ font-size: 12px;
+ font-weight: bold;
+ color: $very-muted-text;
+}
+
+.group-name {
+ padding-left: 8px;
+ font-weight: bold;
+ font-size: 32px;
+ color: $very-muted-text;
+ margin-bottom: 1px solid $very-muted-text;
+}
+
+.note-row {
+ padding: 4px 8px;
+ margin: 2px 0;
+ border-radius: 4px;
+ &:hover {
+ background-color: $button-background;
+ }
+}
+
+a {
+ &:hover {
+ filter: none;
+ cursor: pointer;
+ }
+}
+
+.last-modified {
+ color: $muted-text;
+ font-size: 12px;
+}
+</style>
+
+<script>
+import * as constants from "../constants";
+
+import EventBus from "../eventBus";
+import LoadingIndicator from "./LoadingIndicator.vue";
+import { Note } from "../classes";
+import api from "../api";
+
+const alphaGroups = ["#", ...constants.alphabet];
+
+export default {
+ components: {
+ LoadingIndicator,
+ },
+
+ props: {
+ numRecentlyModified: { type: Number },
+ grouped: { type: Boolean, default: false },
+ showLastModified: { type: Boolean, default: false },
+ centered: { type: Boolean, default: false },
+ miniHeader: { type: String },
+ showLoader: { type: Boolean, default: true },
+ },
+
+ data: function () {
+ return {
+ notes: null,
+ loadingFailed: false,
+ loadingFailedMessage: "Failed to load notes",
+ loadingFailedIcon: null,
+ };
+ },
+
+ computed: {
+ notesGrouped: function () {
+ if (!this.grouped) {
+ return [{ name: "all", notes: this.notes }];
+ }
+
+ let notesGroupedDict = {};
+ alphaGroups.forEach(function (group) {
+ notesGroupedDict[group] = [];
+ });
+
+ this.notes.forEach(function (note) {
+ let firstCharUpper = note.title[0].toUpperCase();
+ if (constants.alphabet.includes(firstCharUpper)) {
+ notesGroupedDict[firstCharUpper].push(note);
+ } else {
+ notesGroupedDict["#"].push(note);
+ }
+ });
+
+ // Convert dict to an array skipping empty groups
+ let notesGroupedArray = [];
+ Object.entries(notesGroupedDict).forEach(function (item) {
+ if (item[1].length) {
+ notesGroupedArray.push({
+ name: item[0],
+ notes: item[1].sort(function (noteA, noteB) {
+ return noteA.title.localeCompare(noteB.title);
+ }),
+ });
+ }
+ });
+
+ // Ensure the array is ordered correctly
+ notesGroupedArray.sort(function (groupA, groupB) {
+ return groupA.name.localeCompare(groupB.name);
+ });
+
+ return notesGroupedArray;
+ },
+ },
+
+ methods: {
+ getNotes: function () {
+ let parent = this;
+ api
+ .get("/api/notes", {
+ params: {
+ limit: this.numRecentlyModified,
+ sort: "lastModified",
+ order: "desc",
+ },
+ })
+ .then(function (response) {
+ parent.notes = [];
+ if (response.data.length) {
+ response.data.forEach(function (note) {
+ parent.notes.push(new Note(note.title, note.lastModified));
+ });
+ } else {
+ parent.loadingFailedMessage =
+ "Click the 'New' button at the top of the page to add your first note";
+ parent.loadingFailedIcon = "pencil";
+ parent.loadingFailed = true;
+ }
+ })
+ .catch(function (error) {
+ parent.loadingFailed = true;
+ if (!error.handled) {
+ EventBus.$emit("unhandledServerError");
+ }
+ });
+ },
+
+ notesByTitle: function () {
+ return null;
+ },
+
+ openNote: function (href, event) {
+ EventBus.$emit("navigate", href, event);
+ },
+ },
+
+ created: function () {
+ this.getNotes();
+ },
+};
+</script>
+++ /dev/null
-<template>
- <div>
- <!-- Loading -->
- <div v-if="notes == null" class="h-100 d-flex flex-column justify-content-center">
- <LoadingIndicator
- :showLoader="false"
- :failed="loadingFailed"
- failedMessage="Failed to load Recently Modified"
- />
- </div>
-
- <!-- Notes Loaded -->
- <div v-else-if="notes.length > 0">
- <h6 class="text-center text-muted text-bold">Recently Modified</h6>
- <p
- v-for="note in notes"
- class="text-center clickable-link mb-2"
- :key="note.title"
- >
- <a :href="note.href" @click.prevent="openNote(note.href, $event)">{{
- note.title
- }}</a>
- </p>
- </div>
- </div>
-</template>
-
-<script>
-import EventBus from "../eventBus";
-import LoadingIndicator from "./LoadingIndicator.vue";
-import { Note } from "../classes";
-import api from "../api";
-
-export default {
- components: {
- LoadingIndicator,
- },
-
- data: function () {
- return {
- notes: null,
- loadingFailed: false,
- };
- },
-
- methods: {
- getNotes: function (limit = null, sort = "title", order = "asc") {
- let parent = this;
- api
- .get("/api/notes", {
- params: { limit: limit, sort: sort, order: order },
- })
- .then(function (response) {
- parent.notes = [];
- response.data.forEach(function (note) {
- parent.notes.push(new Note(note.title, note.lastModified));
- });
- })
- .catch(function (error) {
- parent.loadingFailed = true;
- if (!error.handled) {
- EventBus.$emit("unhandledServerError");
- }
- });
- },
-
- openNote: function (href, event) {
- EventBus.$emit("navigate", href, event);
- },
- },
-
- created: function () {
- this.getNotes(5, "lastModified", "desc");
- },
-};
-</script>
\ No newline at end of file
if (this.searchTermInput) {
EventBus.$emit(
"navigate",
- `/${constants.basePaths.search}?${
+ `${constants.basePaths.search}?${
constants.params.searchTerm
}=${encodeURI(this.searchTermInput)}`
);
</div>
</template>
-<style lang="scss">
+<style lang="scss" scoped>
@import "../colours";
+a {
+ &:hover {
+ filter: opacity(70%);
+ }
+}
+
.result-contents {
color: $muted-text;
}
+</style>
+
+<style lang="scss">
+@import "../colours";
+
.match {
font-weight: bold;
color: $logo-key-colour;
// Base Paths
export const basePaths = {
- login: "login",
- note: "note",
- search: "search",
- new: "new",
+ home: "/",
+ login: "/login",
+ note: "/note",
+ search: "/search",
+ new: "/new",
+ notes: "/notes",
};
// Params
export const params = { searchTerm: "term", redirect: "redirect" };
+
+// Other
+export const alphabet = [
+ "A",
+ "B",
+ "C",
+ "D",
+ "E",
+ "F",
+ "G",
+ "H",
+ "I",
+ "J",
+ "K",
+ "L",
+ "M",
+ "N",
+ "O",
+ "P",
+ "Q",
+ "R",
+ "S",
+ "T",
+ "U",
+ "V",
+ "W",
+ "X",
+ "Y",
+ "Z",
+];
color: inherit;
&:hover {
text-decoration: none;
- filter: opacity(70%);
+ color: inherit;
}
}
border-color: $form-control-border;
}
-.home-view {
- max-width: 500px;
-}
-
-.search-results-view {
- max-width: 700px;
-}
-
-.search-input {
- box-shadow: 0 0 20px $drop-shadow;
-}
-
-.recently-modified {
- // Prevent UI from moving during load
- min-height: 190px;
-}
-
.bttn {
border: 0;
background-color: transparent;
-import "./main.scss"
+import "./global.scss"
import { BootstrapVue, IconsPlugin } from "bootstrap-vue";