feat: add dark mode UI and SQLite as alternate database backend
Dark mode: CSS custom properties + .dark class toggled via Redux/localStorage, with a theme toggle in the Header and Settings page. Foundation didn't exist yet despite prior assumption, so it was built from scratch. SQLite: new DB_ENGINE=mongo|sqlite env var selects the metadata backend at runtime (Mongo stays default, no behavior change unless opted in). File/thumbnail bytes continue to use the existing fs/S3 storage path. Implemented via a shared DB interface (dbTypes.ts) with parallel mongoDB/ and sqliteDB/ implementations, selected through dbFactory.ts, with JWT/encryption logic extracted into userCrypto.ts so both engines share it.
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
import db from "../connections/sqlite";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { IFolderDB } from "../dbTypes";
|
||||
import { FolderListQueryType } from "../../types/folder-types";
|
||||
|
||||
const encodeParentList = (parentList: (string | any)[]): string =>
|
||||
parentList.map((id) => id.toString()).join(",");
|
||||
|
||||
const decodeParentList = (parentList: string): string[] =>
|
||||
parentList ? parentList.split(",").filter((p) => p !== "") : [];
|
||||
|
||||
const rowToFolder = (row: any) => {
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
_id: row.id,
|
||||
name: row.name,
|
||||
parent: row.parent,
|
||||
owner: row.owner,
|
||||
parentList: decodeParentList(row.parentList),
|
||||
personalFolder: row.personalFolder ? true : undefined,
|
||||
trashed: row.trashed ? true : null,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
};
|
||||
};
|
||||
|
||||
// Wraps the stored comma-joined parentList with delimiters at query time so
|
||||
// membership checks can't match a partial id (mirrors Mongo's array $in/$all/$nin
|
||||
// semantics, which match whole elements, not substrings).
|
||||
const containsClause = () => "(',' || parentList || ',') LIKE ?";
|
||||
const containsParam = (id: string) => `%,${id},%`;
|
||||
|
||||
const sortColumn = (sortBy: string): { column: string; direction: string } => {
|
||||
if (sortBy === "alp_asc") return { column: "name COLLATE NOCASE", direction: "ASC" };
|
||||
if (sortBy === "alp_desc") return { column: "name COLLATE NOCASE", direction: "DESC" };
|
||||
if (sortBy === "date_asc") return { column: "createdAt", direction: "ASC" };
|
||||
return { column: "createdAt", direction: "DESC" };
|
||||
};
|
||||
|
||||
class FolderDB implements IFolderDB {
|
||||
constructor() {}
|
||||
|
||||
// READ
|
||||
|
||||
getFolderSearchList = async (
|
||||
userID: string,
|
||||
searchQuery: RegExp | string,
|
||||
trashMode: boolean
|
||||
) => {
|
||||
const search =
|
||||
searchQuery instanceof RegExp ? searchQuery.source : searchQuery;
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM folders WHERE owner = ? AND name LIKE ? COLLATE NOCASE AND trashed = ?
|
||||
LIMIT 10`
|
||||
)
|
||||
.all(userID, `%${search}%`, trashMode ? 1 : 0);
|
||||
|
||||
return rows.map(rowToFolder);
|
||||
};
|
||||
|
||||
getFolderInfo = async (folderID: string, userID: string) => {
|
||||
const row = db
|
||||
.prepare("SELECT * FROM folders WHERE id = ? AND owner = ?")
|
||||
.get(folderID, userID);
|
||||
return rowToFolder(row);
|
||||
};
|
||||
|
||||
getFolderList = async (queryData: FolderListQueryType, sortBy: string) => {
|
||||
const { userID, search, parent, trashMode } = queryData;
|
||||
|
||||
const conditions = ["owner = ?"];
|
||||
const params: any[] = [userID];
|
||||
|
||||
if (search && search !== "") {
|
||||
conditions.push("name LIKE ? COLLATE NOCASE");
|
||||
params.push(`%${search}%`);
|
||||
} else {
|
||||
conditions.push("parent = ?");
|
||||
params.push(parent);
|
||||
}
|
||||
|
||||
conditions.push("trashed = ?");
|
||||
params.push(trashMode ? 1 : 0);
|
||||
|
||||
const { column, direction } = sortColumn(sortBy);
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM folders WHERE ${conditions.join(
|
||||
" AND "
|
||||
)} ORDER BY ${column} ${direction}`
|
||||
)
|
||||
.all(...params);
|
||||
|
||||
return rows.map(rowToFolder);
|
||||
};
|
||||
|
||||
getMoveFolderList = async (
|
||||
userID: string,
|
||||
parent = "/",
|
||||
search?: string,
|
||||
folderIDs?: string[]
|
||||
) => {
|
||||
const conditions = ["owner = ?", "trashed = 0"];
|
||||
const params: any[] = [userID];
|
||||
|
||||
if (!search || search === "") {
|
||||
conditions.push("parent = ?");
|
||||
params.push(parent);
|
||||
} else {
|
||||
conditions.push("name LIKE ? COLLATE NOCASE");
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM folders WHERE ${conditions.join(
|
||||
" AND "
|
||||
)} ORDER BY createdAt DESC`
|
||||
)
|
||||
.all(...params) as any[];
|
||||
|
||||
const filtered =
|
||||
folderIDs && folderIDs.length > 0
|
||||
? rows.filter((row) => {
|
||||
if (folderIDs.includes(row.id)) return false;
|
||||
const list = decodeParentList(row.parentList);
|
||||
return !folderIDs.some((id) => list.includes(id));
|
||||
})
|
||||
: rows;
|
||||
|
||||
return filtered.map(rowToFolder);
|
||||
};
|
||||
|
||||
getFolderListByIncludedParent = async (userID: string, parent: string) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM folders WHERE owner = ? AND ${containsClause()} AND trashed = 0`
|
||||
)
|
||||
.all(userID, containsParam(parent));
|
||||
return rows.map(rowToFolder);
|
||||
};
|
||||
|
||||
findAllFoldersByParent = async (parentID: string, userID: string) => {
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM folders WHERE owner = ? AND ${containsClause()}`)
|
||||
.all(userID, containsParam(parentID));
|
||||
return rows.map(rowToFolder);
|
||||
};
|
||||
|
||||
// UPDATE
|
||||
|
||||
moveFolder = async (
|
||||
folderID: string,
|
||||
userID: string,
|
||||
parent: string,
|
||||
parentList: string[]
|
||||
) => {
|
||||
const existing: any = db
|
||||
.prepare("SELECT * FROM folders WHERE id = ? AND owner = ?")
|
||||
.get(folderID, userID);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
db.prepare(
|
||||
"UPDATE folders SET parent = ?, parentList = ?, updatedAt = ? WHERE id = ? AND owner = ?"
|
||||
).run(
|
||||
parent,
|
||||
encodeParentList(parentList),
|
||||
new Date().toISOString(),
|
||||
folderID,
|
||||
userID
|
||||
);
|
||||
|
||||
return this.getFolderInfo(folderID, userID);
|
||||
};
|
||||
|
||||
renameFolder = async (folderID: string, userID: string, title: string) => {
|
||||
const existing: any = db
|
||||
.prepare("SELECT * FROM folders WHERE id = ? AND owner = ?")
|
||||
.get(folderID, userID);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
db.prepare(
|
||||
"UPDATE folders SET name = ?, updatedAt = ? WHERE id = ? AND owner = ?"
|
||||
).run(title, new Date().toISOString(), folderID, userID);
|
||||
|
||||
return this.getFolderInfo(folderID, userID);
|
||||
};
|
||||
|
||||
trashFoldersByParent = async (parentList: string[], userID: string) => {
|
||||
const clauses = parentList.map(() => containsClause()).join(" AND ");
|
||||
const params = parentList.map((id) => containsParam(id));
|
||||
|
||||
const result = db
|
||||
.prepare(
|
||||
`UPDATE folders SET trashed = 1, updatedAt = ? WHERE owner = ? AND ${clauses}`
|
||||
)
|
||||
.run(new Date().toISOString(), userID, ...params);
|
||||
return result;
|
||||
};
|
||||
|
||||
restoreFolder = async (folderID: string, userID: string) => {
|
||||
const existing: any = db
|
||||
.prepare("SELECT * FROM folders WHERE id = ? AND owner = ?")
|
||||
.get(folderID, userID);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
db.prepare(
|
||||
"UPDATE folders SET trashed = 0, updatedAt = ? WHERE id = ? AND owner = ?"
|
||||
).run(new Date().toISOString(), folderID, userID);
|
||||
|
||||
return this.getFolderInfo(folderID, userID);
|
||||
};
|
||||
|
||||
restoreFoldersByParent = async (parentList: string[], userID: string) => {
|
||||
const clauses = parentList.map(() => containsClause()).join(" AND ");
|
||||
const params = parentList.map((id) => containsParam(id));
|
||||
|
||||
const result = db
|
||||
.prepare(
|
||||
`UPDATE folders SET trashed = 0, updatedAt = ? WHERE owner = ? AND ${clauses}`
|
||||
)
|
||||
.run(new Date().toISOString(), userID, ...params);
|
||||
return result;
|
||||
};
|
||||
|
||||
trashFolder = async (folderID: string, userID: string) => {
|
||||
const existing: any = db
|
||||
.prepare("SELECT * FROM folders WHERE id = ? AND owner = ?")
|
||||
.get(folderID, userID);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
db.prepare(
|
||||
"UPDATE folders SET trashed = 1, updatedAt = ? WHERE id = ? AND owner = ?"
|
||||
).run(new Date().toISOString(), folderID, userID);
|
||||
|
||||
return this.getFolderInfo(folderID, userID);
|
||||
};
|
||||
|
||||
// CREATE
|
||||
|
||||
createFolder = async (folderData: {
|
||||
name: string;
|
||||
parent: string;
|
||||
parentList: string[];
|
||||
owner: string;
|
||||
}) => {
|
||||
const id = new ObjectId().toString();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO folders (id, name, parent, owner, parentList, personalFolder, trashed, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
folderData.name,
|
||||
folderData.parent,
|
||||
folderData.owner,
|
||||
encodeParentList(folderData.parentList),
|
||||
0,
|
||||
0,
|
||||
now,
|
||||
now
|
||||
);
|
||||
|
||||
const row = db.prepare("SELECT * FROM folders WHERE id = ?").get(id);
|
||||
return rowToFolder(row);
|
||||
};
|
||||
|
||||
// DELETE
|
||||
|
||||
deleteFolder = async (folderID: string, userID: string) => {
|
||||
const result = db
|
||||
.prepare("DELETE FROM folders WHERE id = ? AND owner = ?")
|
||||
.run(folderID, userID);
|
||||
return result;
|
||||
};
|
||||
|
||||
deleteFoldersByParentList = async (
|
||||
parentList: string[],
|
||||
userID: string
|
||||
) => {
|
||||
const clauses = parentList.map(() => containsClause()).join(" AND ");
|
||||
const params = parentList.map((id) => containsParam(id.toString()));
|
||||
|
||||
const result = db
|
||||
.prepare(`DELETE FROM folders WHERE owner = ? AND ${clauses}`)
|
||||
.run(userID, ...params);
|
||||
return result;
|
||||
};
|
||||
|
||||
deleteFoldersByOwner = async (userID: string) => {
|
||||
const result = db
|
||||
.prepare("DELETE FROM folders WHERE owner = ?")
|
||||
.run(userID);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export default FolderDB;
|
||||
Reference in New Issue
Block a user