feat: Update and delete an image

This commit is contained in:
wilson 2026-03-17 21:32:47 +00:00
parent 61bf7f6162
commit 933278ec98
52 changed files with 8844 additions and 5755 deletions

View file

@ -1,20 +1,20 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': () => require('typescript')
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: true
}
root: true,
parser: '@typescript-eslint/parser',
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': () => require('typescript'),
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
},
env: {
browser: true,
es2017: true,
node: true,
},
};

View file

@ -36,6 +36,50 @@ export type DateTimeFilter<$PrismaModel = never> = {
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
@ -66,6 +110,54 @@ export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
@ -88,6 +180,45 @@ export type NestedDateTimeFilter<$PrismaModel = never> = {
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
@ -129,4 +260,63 @@ export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}

View file

@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = {
"clientVersion": "7.4.2",
"engineVersion": "94a226be1cf2967af2541cca5529f0f7ba866919",
"activeProvider": "sqlite",
"inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\nmodel PhotoPost {\n id Int @id @default(autoincrement())\n createdAt DateTime @default(now())\n}\n",
"inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\nmodel PhotoPost {\n id Int @id @default(autoincrement())\n createdAt DateTime @default(now())\n deletedAt DateTime?\n publishedAt DateTime?\n filePath String\n fileName String\n title String\n description String?\n}\n",
"runtimeDataModel": {
"models": {},
"enums": {},
@ -32,10 +32,10 @@ const config: runtime.GetPrismaClientConfig = {
}
}
config.runtimeDataModel = JSON.parse("{\"models\":{\"PhotoPost\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}")
config.runtimeDataModel = JSON.parse("{\"models\":{\"PhotoPost\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"publishedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"filePath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"fileName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}")
config.parameterizationSchema = {
strings: JSON.parse("[\"where\",\"PhotoPost.findUnique\",\"PhotoPost.findUniqueOrThrow\",\"orderBy\",\"cursor\",\"PhotoPost.findFirst\",\"PhotoPost.findFirstOrThrow\",\"PhotoPost.findMany\",\"data\",\"PhotoPost.createOne\",\"PhotoPost.createMany\",\"PhotoPost.createManyAndReturn\",\"PhotoPost.updateOne\",\"PhotoPost.updateMany\",\"PhotoPost.updateManyAndReturn\",\"create\",\"update\",\"PhotoPost.upsertOne\",\"PhotoPost.deleteOne\",\"PhotoPost.deleteMany\",\"having\",\"_count\",\"_avg\",\"_sum\",\"_min\",\"_max\",\"PhotoPost.groupBy\",\"PhotoPost.aggregate\",\"AND\",\"OR\",\"NOT\",\"id\",\"createdAt\",\"equals\",\"in\",\"notIn\",\"lt\",\"lte\",\"gt\",\"gte\",\"not\",\"set\",\"increment\",\"decrement\",\"multiply\",\"divide\"]"),
graph: "KwsQBRwAACIAMB0AAAQAEB4AACIAMB8CAAAAASBAACQAIQEAAAABACABAAAAAQAgBRwAACIAMB0AAAQAEB4AACIAMB8CACMAISBAACQAIQADAAAABAAgAwAABQAwBAAAAQAgAwAAAAQAIAMAAAUAMAQAAAEAIAMAAAAEACADAAAFADAEAAABACACHwIAAAABIEAAAAABAQgAAAkAIAIfAgAAAAEgQAAAAAEBCAAACwAwAQgAAAsAMAIfAgArACEgQAAqACECAAAAAQAgCAAADgAgAh8CACsAISBAACoAIQIAAAAEACAIAAAQACACAAAABAAgCAAAEAAgAwAAAAEAIA8AAAkAIBAAAA4AIAEAAAABACABAAAABAAgBRUAACUAIBYAACYAIBcAACkAIBgAACgAIBkAACcAIAUcAAAaADAdAAAXABAeAAAaADAfAgAbACEgQAAcACEDAAAABAAgAwAAFgAwFAAAFwAgAwAAAAQAIAMAAAUAMAQAAAEAIAUcAAAaADAdAAAXABAeAAAaADAfAgAbACEgQAAcACENFQAAHgAgFgAAIQAgFwAAHgAgGAAAHgAgGQAAHgAgIQIAAAABIgIAAAAEIwIAAAAEJAIAAAABJQIAAAABJgIAAAABJwIAAAABKAIAIAAhCxUAAB4AIBgAAB8AIBkAAB8AICFAAAAAASJAAAAABCNAAAAABCRAAAAAASVAAAAAASZAAAAAASdAAAAAAShAAB0AIQsVAAAeACAYAAAfACAZAAAfACAhQAAAAAEiQAAAAAQjQAAAAAQkQAAAAAElQAAAAAEmQAAAAAEnQAAAAAEoQAAdACEIIQIAAAABIgIAAAAEIwIAAAAEJAIAAAABJQIAAAABJgIAAAABJwIAAAABKAIAHgAhCCFAAAAAASJAAAAABCNAAAAABCRAAAAAASVAAAAAASZAAAAAASdAAAAAAShAAB8AIQ0VAAAeACAWAAAhACAXAAAeACAYAAAeACAZAAAeACAhAgAAAAEiAgAAAAQjAgAAAAQkAgAAAAElAgAAAAEmAgAAAAEnAgAAAAEoAgAgACEIIQgAAAABIggAAAAEIwgAAAAEJAgAAAABJQgAAAABJggAAAABJwgAAAABKAgAIQAhBRwAACIAMB0AAAQAEB4AACIAMB8CACMAISBAACQAIQghAgAAAAEiAgAAAAQjAgAAAAQkAgAAAAElAgAAAAEmAgAAAAEnAgAAAAEoAgAeACEIIUAAAAABIkAAAAAEI0AAAAAEJEAAAAABJUAAAAABJkAAAAABJ0AAAAABKEAAHwAhAAAAAAABKUAAAAABBSkCAAAAASoCAAAAASsCAAAAASwCAAAAAS0CAAAAAQAAAAAFFQAGFgAHFwAIGAAJGQAKAAAAAAAFFQAGFgAHFwAIGAAJGQAKAQIBAgMBBQYBBgcBBwgBCQoBCgwCCw0DDA8BDRECDhIEERMBEhQBExUCGhgFGxkL"
strings: JSON.parse("[\"where\",\"PhotoPost.findUnique\",\"PhotoPost.findUniqueOrThrow\",\"orderBy\",\"cursor\",\"PhotoPost.findFirst\",\"PhotoPost.findFirstOrThrow\",\"PhotoPost.findMany\",\"data\",\"PhotoPost.createOne\",\"PhotoPost.createMany\",\"PhotoPost.createManyAndReturn\",\"PhotoPost.updateOne\",\"PhotoPost.updateMany\",\"PhotoPost.updateManyAndReturn\",\"create\",\"update\",\"PhotoPost.upsertOne\",\"PhotoPost.deleteOne\",\"PhotoPost.deleteMany\",\"having\",\"_count\",\"_avg\",\"_sum\",\"_min\",\"_max\",\"PhotoPost.groupBy\",\"PhotoPost.aggregate\",\"AND\",\"OR\",\"NOT\",\"id\",\"createdAt\",\"deletedAt\",\"publishedAt\",\"filePath\",\"fileName\",\"title\",\"description\",\"equals\",\"in\",\"notIn\",\"lt\",\"lte\",\"gt\",\"gte\",\"contains\",\"startsWith\",\"endsWith\",\"not\",\"set\",\"increment\",\"decrement\",\"multiply\",\"divide\"]"),
graph: "PAsQCxwAACwAMB0AAAQAEB4AACwAMB8CAAAAASBAAC4AISFAAC8AISJAAC8AISMBADAAISQBADAAISUBADAAISYBADEAIQEAAAABACABAAAAAQAgCxwAACwAMB0AAAQAEB4AACwAMB8CAC0AISBAAC4AISFAAC8AISJAAC8AISMBADAAISQBADAAISUBADAAISYBADEAIQMhAAAyACAiAAAyACAmAAAyACADAAAABAAgAwAABQAwBAAAAQAgAwAAAAQAIAMAAAUAMAQAAAEAIAMAAAAEACADAAAFADAEAAABACAIHwIAAAABIEAAAAABIUAAAAABIkAAAAABIwEAAAABJAEAAAABJQEAAAABJgEAAAABAQgAAAkAIAgfAgAAAAEgQAAAAAEhQAAAAAEiQAAAAAEjAQAAAAEkAQAAAAElAQAAAAEmAQAAAAEBCAAACwAwAQgAAAsAMAgfAgA8ACEgQAA4ACEhQAA5ACEiQAA5ACEjAQA6ACEkAQA6ACElAQA6ACEmAQA7ACECAAAAAQAgCAAADgAgCB8CADwAISBAADgAISFAADkAISJAADkAISMBADoAISQBADoAISUBADoAISYBADsAIQIAAAAEACAIAAAQACACAAAABAAgCAAAEAAgAwAAAAEAIA8AAAkAIBAAAA4AIAEAAAABACABAAAABAAgCBUAADMAIBYAADQAIBcAADcAIBgAADYAIBkAADUAICEAADIAICIAADIAICYAADIAIAscAAAaADAdAAAXABAeAAAaADAfAgAbACEgQAAcACEhQAAdACEiQAAdACEjAQAeACEkAQAeACElAQAeACEmAQAfACEDAAAABAAgAwAAFgAwFAAAFwAgAwAAAAQAIAMAAAUAMAQAAAEAIAscAAAaADAdAAAXABAeAAAaADAfAgAbACEgQAAcACEhQAAdACEiQAAdACEjAQAeACEkAQAeACElAQAeACEmAQAfACENFQAAJAAgFgAAKwAgFwAAJAAgGAAAJAAgGQAAJAAgJwIAAAABKAIAAAAEKQIAAAAEKgIAAAABKwIAAAABLAIAAAABLQIAAAABMQIAKgAhCxUAACQAIBgAACkAIBkAACkAICdAAAAAAShAAAAABClAAAAABCpAAAAAAStAAAAAASxAAAAAAS1AAAAAATFAACgAIQsVAAAhACAYAAAnACAZAAAnACAnQAAAAAEoQAAAAAUpQAAAAAUqQAAAAAErQAAAAAEsQAAAAAEtQAAAAAExQAAmACEOFQAAJAAgGAAAJQAgGQAAJQAgJwEAAAABKAEAAAAEKQEAAAAEKgEAAAABKwEAAAABLAEAAAABLQEAAAABLgEAAAABLwEAAAABMAEAAAABMQEAIwAhDhUAACEAIBgAACIAIBkAACIAICcBAAAAASgBAAAABSkBAAAABSoBAAAAASsBAAAAASwBAAAAAS0BAAAAAS4BAAAAAS8BAAAAATABAAAAATEBACAAIQ4VAAAhACAYAAAiACAZAAAiACAnAQAAAAEoAQAAAAUpAQAAAAUqAQAAAAErAQAAAAEsAQAAAAEtAQAAAAEuAQAAAAEvAQAAAAEwAQAAAAExAQAgACEIJwIAAAABKAIAAAAFKQIAAAAFKgIAAAABKwIAAAABLAIAAAABLQIAAAABMQIAIQAhCycBAAAAASgBAAAABSkBAAAABSoBAAAAASsBAAAAASwBAAAAAS0BAAAAAS4BAAAAAS8BAAAAATABAAAAATEBACIAIQ4VAAAkACAYAAAlACAZAAAlACAnAQAAAAEoAQAAAAQpAQAAAAQqAQAAAAErAQAAAAEsAQAAAAEtAQAAAAEuAQAAAAEvAQAAAAEwAQAAAAExAQAjACEIJwIAAAABKAIAAAAEKQIAAAAEKgIAAAABKwIAAAABLAIAAAABLQIAAAABMQIAJAAhCycBAAAAASgBAAAABCkBAAAABCoBAAAAASsBAAAAASwBAAAAAS0BAAAAAS4BAAAAAS8BAAAAATABAAAAATEBACUAIQsVAAAhACAYAAAnACAZAAAnACAnQAAAAAEoQAAAAAUpQAAAAAUqQAAAAAErQAAAAAEsQAAAAAEtQAAAAAExQAAmACEIJ0AAAAABKEAAAAAFKUAAAAAFKkAAAAABK0AAAAABLEAAAAABLUAAAAABMUAAJwAhCxUAACQAIBgAACkAIBkAACkAICdAAAAAAShAAAAABClAAAAABCpAAAAAAStAAAAAASxAAAAAAS1AAAAAATFAACgAIQgnQAAAAAEoQAAAAAQpQAAAAAQqQAAAAAErQAAAAAEsQAAAAAEtQAAAAAExQAApACENFQAAJAAgFgAAKwAgFwAAJAAgGAAAJAAgGQAAJAAgJwIAAAABKAIAAAAEKQIAAAAEKgIAAAABKwIAAAABLAIAAAABLQIAAAABMQIAKgAhCCcIAAAAASgIAAAABCkIAAAABCoIAAAAASsIAAAAASwIAAAAAS0IAAAAATEIACsAIQscAAAsADAdAAAEABAeAAAsADAfAgAtACEgQAAuACEhQAAvACEiQAAvACEjAQAwACEkAQAwACElAQAwACEmAQAxACEIJwIAAAABKAIAAAAEKQIAAAAEKgIAAAABKwIAAAABLAIAAAABLQIAAAABMQIAJAAhCCdAAAAAAShAAAAABClAAAAABCpAAAAAAStAAAAAASxAAAAAAS1AAAAAATFAACkAIQgnQAAAAAEoQAAAAAUpQAAAAAUqQAAAAAErQAAAAAEsQAAAAAEtQAAAAAExQAAnACELJwEAAAABKAEAAAAEKQEAAAAEKgEAAAABKwEAAAABLAEAAAABLQEAAAABLgEAAAABLwEAAAABMAEAAAABMQEAJQAhCycBAAAAASgBAAAABSkBAAAABSoBAAAAASsBAAAAASwBAAAAAS0BAAAAAS4BAAAAAS8BAAAAATABAAAAATEBACIAIQAAAAAAAAEyQAAAAAEBMkAAAAABATIBAAAAAQEyAQAAAAEFMgIAAAABMwIAAAABNAIAAAABNQIAAAABNgIAAAABAAAAAAUVAAYWAAcXAAgYAAkZAAoAAAAAAAUVAAYWAAcXAAgYAAkZAAoBAgECAwEFBgEGBwEHCAEJCgEKDAILDQMMDwENEQIOEgQREwESFAETFQIaGAUbGQs"
}
async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Module> {

View file

@ -516,7 +516,13 @@ export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof
export const PhotoPostScalarFieldEnum = {
id: 'id',
createdAt: 'createdAt'
createdAt: 'createdAt',
deletedAt: 'deletedAt',
publishedAt: 'publishedAt',
filePath: 'filePath',
fileName: 'fileName',
title: 'title',
description: 'description'
} as const
export type PhotoPostScalarFieldEnum = (typeof PhotoPostScalarFieldEnum)[keyof typeof PhotoPostScalarFieldEnum]
@ -530,6 +536,14 @@ export const SortOrder = {
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
/**
* Field references
@ -550,6 +564,13 @@ export type DateTimeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel
/**
* Reference to a field of type 'String'
*/
export type StringFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'String'>
/**
* Reference to a field of type 'Float'
*/

View file

@ -69,7 +69,13 @@ export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof
export const PhotoPostScalarFieldEnum = {
id: 'id',
createdAt: 'createdAt'
createdAt: 'createdAt',
deletedAt: 'deletedAt',
publishedAt: 'publishedAt',
filePath: 'filePath',
fileName: 'fileName',
title: 'title',
description: 'description'
} as const
export type PhotoPostScalarFieldEnum = (typeof PhotoPostScalarFieldEnum)[keyof typeof PhotoPostScalarFieldEnum]
@ -82,3 +88,11 @@ export const SortOrder = {
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]

View file

@ -37,16 +37,34 @@ export type PhotoPostSumAggregateOutputType = {
export type PhotoPostMinAggregateOutputType = {
id: number | null
createdAt: Date | null
deletedAt: Date | null
publishedAt: Date | null
filePath: string | null
fileName: string | null
title: string | null
description: string | null
}
export type PhotoPostMaxAggregateOutputType = {
id: number | null
createdAt: Date | null
deletedAt: Date | null
publishedAt: Date | null
filePath: string | null
fileName: string | null
title: string | null
description: string | null
}
export type PhotoPostCountAggregateOutputType = {
id: number
createdAt: number
deletedAt: number
publishedAt: number
filePath: number
fileName: number
title: number
description: number
_all: number
}
@ -62,16 +80,34 @@ export type PhotoPostSumAggregateInputType = {
export type PhotoPostMinAggregateInputType = {
id?: true
createdAt?: true
deletedAt?: true
publishedAt?: true
filePath?: true
fileName?: true
title?: true
description?: true
}
export type PhotoPostMaxAggregateInputType = {
id?: true
createdAt?: true
deletedAt?: true
publishedAt?: true
filePath?: true
fileName?: true
title?: true
description?: true
}
export type PhotoPostCountAggregateInputType = {
id?: true
createdAt?: true
deletedAt?: true
publishedAt?: true
filePath?: true
fileName?: true
title?: true
description?: true
_all?: true
}
@ -164,6 +200,12 @@ export type PhotoPostGroupByArgs<ExtArgs extends runtime.Types.Extensions.Intern
export type PhotoPostGroupByOutputType = {
id: number
createdAt: Date
deletedAt: Date | null
publishedAt: Date | null
filePath: string
fileName: string
title: string
description: string | null
_count: PhotoPostCountAggregateOutputType | null
_avg: PhotoPostAvgAggregateOutputType | null
_sum: PhotoPostSumAggregateOutputType | null
@ -192,11 +234,23 @@ export type PhotoPostWhereInput = {
NOT?: Prisma.PhotoPostWhereInput | Prisma.PhotoPostWhereInput[]
id?: Prisma.IntFilter<"PhotoPost"> | number
createdAt?: Prisma.DateTimeFilter<"PhotoPost"> | Date | string
deletedAt?: Prisma.DateTimeNullableFilter<"PhotoPost"> | Date | string | null
publishedAt?: Prisma.DateTimeNullableFilter<"PhotoPost"> | Date | string | null
filePath?: Prisma.StringFilter<"PhotoPost"> | string
fileName?: Prisma.StringFilter<"PhotoPost"> | string
title?: Prisma.StringFilter<"PhotoPost"> | string
description?: Prisma.StringNullableFilter<"PhotoPost"> | string | null
}
export type PhotoPostOrderByWithRelationInput = {
id?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
deletedAt?: Prisma.SortOrderInput | Prisma.SortOrder
publishedAt?: Prisma.SortOrderInput | Prisma.SortOrder
filePath?: Prisma.SortOrder
fileName?: Prisma.SortOrder
title?: Prisma.SortOrder
description?: Prisma.SortOrderInput | Prisma.SortOrder
}
export type PhotoPostWhereUniqueInput = Prisma.AtLeast<{
@ -205,11 +259,23 @@ export type PhotoPostWhereUniqueInput = Prisma.AtLeast<{
OR?: Prisma.PhotoPostWhereInput[]
NOT?: Prisma.PhotoPostWhereInput | Prisma.PhotoPostWhereInput[]
createdAt?: Prisma.DateTimeFilter<"PhotoPost"> | Date | string
deletedAt?: Prisma.DateTimeNullableFilter<"PhotoPost"> | Date | string | null
publishedAt?: Prisma.DateTimeNullableFilter<"PhotoPost"> | Date | string | null
filePath?: Prisma.StringFilter<"PhotoPost"> | string
fileName?: Prisma.StringFilter<"PhotoPost"> | string
title?: Prisma.StringFilter<"PhotoPost"> | string
description?: Prisma.StringNullableFilter<"PhotoPost"> | string | null
}, "id">
export type PhotoPostOrderByWithAggregationInput = {
id?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
deletedAt?: Prisma.SortOrderInput | Prisma.SortOrder
publishedAt?: Prisma.SortOrderInput | Prisma.SortOrder
filePath?: Prisma.SortOrder
fileName?: Prisma.SortOrder
title?: Prisma.SortOrder
description?: Prisma.SortOrderInput | Prisma.SortOrder
_count?: Prisma.PhotoPostCountOrderByAggregateInput
_avg?: Prisma.PhotoPostAvgOrderByAggregateInput
_max?: Prisma.PhotoPostMaxOrderByAggregateInput
@ -223,43 +289,97 @@ export type PhotoPostScalarWhereWithAggregatesInput = {
NOT?: Prisma.PhotoPostScalarWhereWithAggregatesInput | Prisma.PhotoPostScalarWhereWithAggregatesInput[]
id?: Prisma.IntWithAggregatesFilter<"PhotoPost"> | number
createdAt?: Prisma.DateTimeWithAggregatesFilter<"PhotoPost"> | Date | string
deletedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"PhotoPost"> | Date | string | null
publishedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"PhotoPost"> | Date | string | null
filePath?: Prisma.StringWithAggregatesFilter<"PhotoPost"> | string
fileName?: Prisma.StringWithAggregatesFilter<"PhotoPost"> | string
title?: Prisma.StringWithAggregatesFilter<"PhotoPost"> | string
description?: Prisma.StringNullableWithAggregatesFilter<"PhotoPost"> | string | null
}
export type PhotoPostCreateInput = {
createdAt?: Date | string
deletedAt?: Date | string | null
publishedAt?: Date | string | null
filePath: string
fileName: string
title: string
description?: string | null
}
export type PhotoPostUncheckedCreateInput = {
id?: number
createdAt?: Date | string
deletedAt?: Date | string | null
publishedAt?: Date | string | null
filePath: string
fileName: string
title: string
description?: string | null
}
export type PhotoPostUpdateInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
deletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
publishedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
filePath?: Prisma.StringFieldUpdateOperationsInput | string
fileName?: Prisma.StringFieldUpdateOperationsInput | string
title?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type PhotoPostUncheckedUpdateInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
deletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
publishedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
filePath?: Prisma.StringFieldUpdateOperationsInput | string
fileName?: Prisma.StringFieldUpdateOperationsInput | string
title?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type PhotoPostCreateManyInput = {
id?: number
createdAt?: Date | string
deletedAt?: Date | string | null
publishedAt?: Date | string | null
filePath: string
fileName: string
title: string
description?: string | null
}
export type PhotoPostUpdateManyMutationInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
deletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
publishedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
filePath?: Prisma.StringFieldUpdateOperationsInput | string
fileName?: Prisma.StringFieldUpdateOperationsInput | string
title?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type PhotoPostUncheckedUpdateManyInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
deletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
publishedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
filePath?: Prisma.StringFieldUpdateOperationsInput | string
fileName?: Prisma.StringFieldUpdateOperationsInput | string
title?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type PhotoPostCountOrderByAggregateInput = {
id?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
deletedAt?: Prisma.SortOrder
publishedAt?: Prisma.SortOrder
filePath?: Prisma.SortOrder
fileName?: Prisma.SortOrder
title?: Prisma.SortOrder
description?: Prisma.SortOrder
}
export type PhotoPostAvgOrderByAggregateInput = {
@ -269,11 +389,23 @@ export type PhotoPostAvgOrderByAggregateInput = {
export type PhotoPostMaxOrderByAggregateInput = {
id?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
deletedAt?: Prisma.SortOrder
publishedAt?: Prisma.SortOrder
filePath?: Prisma.SortOrder
fileName?: Prisma.SortOrder
title?: Prisma.SortOrder
description?: Prisma.SortOrder
}
export type PhotoPostMinOrderByAggregateInput = {
id?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
deletedAt?: Prisma.SortOrder
publishedAt?: Prisma.SortOrder
filePath?: Prisma.SortOrder
fileName?: Prisma.SortOrder
title?: Prisma.SortOrder
description?: Prisma.SortOrder
}
export type PhotoPostSumOrderByAggregateInput = {
@ -284,6 +416,18 @@ export type DateTimeFieldUpdateOperationsInput = {
set?: Date | string
}
export type NullableDateTimeFieldUpdateOperationsInput = {
set?: Date | string | null
}
export type StringFieldUpdateOperationsInput = {
set?: string
}
export type NullableStringFieldUpdateOperationsInput = {
set?: string | null
}
export type IntFieldUpdateOperationsInput = {
set?: number
increment?: number
@ -297,24 +441,48 @@ export type IntFieldUpdateOperationsInput = {
export type PhotoPostSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
createdAt?: boolean
deletedAt?: boolean
publishedAt?: boolean
filePath?: boolean
fileName?: boolean
title?: boolean
description?: boolean
}, ExtArgs["result"]["photoPost"]>
export type PhotoPostSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
createdAt?: boolean
deletedAt?: boolean
publishedAt?: boolean
filePath?: boolean
fileName?: boolean
title?: boolean
description?: boolean
}, ExtArgs["result"]["photoPost"]>
export type PhotoPostSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
createdAt?: boolean
deletedAt?: boolean
publishedAt?: boolean
filePath?: boolean
fileName?: boolean
title?: boolean
description?: boolean
}, ExtArgs["result"]["photoPost"]>
export type PhotoPostSelectScalar = {
id?: boolean
createdAt?: boolean
deletedAt?: boolean
publishedAt?: boolean
filePath?: boolean
fileName?: boolean
title?: boolean
description?: boolean
}
export type PhotoPostOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "createdAt", ExtArgs["result"]["photoPost"]>
export type PhotoPostOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "createdAt" | "deletedAt" | "publishedAt" | "filePath" | "fileName" | "title" | "description", ExtArgs["result"]["photoPost"]>
export type $PhotoPostPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "PhotoPost"
@ -322,6 +490,12 @@ export type $PhotoPostPayload<ExtArgs extends runtime.Types.Extensions.InternalA
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: number
createdAt: Date
deletedAt: Date | null
publishedAt: Date | null
filePath: string
fileName: string
title: string
description: string | null
}, ExtArgs["result"]["photoPost"]>
composites: {}
}
@ -747,6 +921,12 @@ export interface Prisma__PhotoPostClient<T, Null = never, ExtArgs extends runtim
export interface PhotoPostFieldRefs {
readonly id: Prisma.FieldRef<"PhotoPost", 'Int'>
readonly createdAt: Prisma.FieldRef<"PhotoPost", 'DateTime'>
readonly deletedAt: Prisma.FieldRef<"PhotoPost", 'DateTime'>
readonly publishedAt: Prisma.FieldRef<"PhotoPost", 'DateTime'>
readonly filePath: Prisma.FieldRef<"PhotoPost", 'String'>
readonly fileName: Prisma.FieldRef<"PhotoPost", 'String'>
readonly title: Prisma.FieldRef<"PhotoPost", 'String'>
readonly description: Prisma.FieldRef<"PhotoPost", 'String'>
}
@ -941,7 +1121,7 @@ export type PhotoPostCreateArgs<ExtArgs extends runtime.Types.Extensions.Interna
/**
* The data needed to create a PhotoPost.
*/
data?: Prisma.XOR<Prisma.PhotoPostCreateInput, Prisma.PhotoPostUncheckedCreateInput>
data: Prisma.XOR<Prisma.PhotoPostCreateInput, Prisma.PhotoPostUncheckedCreateInput>
}
/**

View file

@ -1,72 +1,72 @@
{
"name": "thomaswilson-sveltekit",
"license": "UNLICENSED",
"version": "1.0.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"lint": "prettier --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
"format": "prettier --config ./prettierrc --write --plugin-search-dir=. .",
"test": "vitest"
},
"devDependencies": {
"@sveltejs/kit": "^2.51.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/better-sqlite3": "^7.6.13",
"@types/leaflet": "^1.9.15",
"@types/node": "^25.3.2",
"@types/sanitize-html": "^2.13.0",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.55.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-svelte": "^3.15.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"svelte": "^5.50.3",
"svelte-check": "^4.3.6",
"svelte-preprocess": "^6.0.0",
"tslib": "^2.8.1",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vitest": "^3.0.8"
},
"type": "module",
"dependencies": {
"@immich/sdk": "^2.5.6",
"@prisma/adapter-better-sqlite3": "^7.4.2",
"@prisma/client": "^7.4.2",
"@sveltejs/adapter-node": "^5.5.3",
"@types/js-yaml": "^4.0.9",
"canvas-dither": "^1.0.1",
"date-fns": "^4.1.0",
"dotenv": "^17.3.1",
"feed": "^4.2.2",
"js-yaml": "^4.1.0",
"just-shuffle": "^4.2.0",
"leaflet": "^1.9.4",
"node-fetch": "^3.3.2",
"prisma": "^7.4.2",
"rehype-stringify": "^10.0.1",
"remark": "^15.0.1",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"remark-stringify": "^11.0.0",
"sanitize-html": "^2.14.0",
"sass": "^1.85.1",
"strip-markdown": "^6.0.0",
"unified": "^11.0.5",
"zod": "^3.24.1"
},
"engines": {
"node": ">=22.0.0"
}
"name": "thomaswilson-sveltekit",
"license": "UNLICENSED",
"version": "1.0.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"lint": "prettier --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
"format": "prettier --config ./prettierrc --write --plugin-search-dir=. .",
"test": "vitest"
},
"devDependencies": {
"@sveltejs/kit": "^2.51.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/better-sqlite3": "^7.6.13",
"@types/leaflet": "^1.9.15",
"@types/node": "^25.3.2",
"@types/sanitize-html": "^2.13.0",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.55.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-svelte": "^3.15.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"svelte": "^5.50.3",
"svelte-check": "^4.3.6",
"svelte-preprocess": "^6.0.0",
"tslib": "^2.8.1",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vitest": "^3.0.8"
},
"type": "module",
"dependencies": {
"@immich/sdk": "^2.5.6",
"@prisma/adapter-better-sqlite3": "^7.4.2",
"@prisma/client": "^7.4.2",
"@sveltejs/adapter-node": "^5.5.3",
"@types/js-yaml": "^4.0.9",
"canvas-dither": "^1.0.1",
"date-fns": "^4.1.0",
"dotenv": "^17.3.1",
"feed": "^4.2.2",
"js-yaml": "^4.1.0",
"just-shuffle": "^4.2.0",
"leaflet": "^1.9.4",
"node-fetch": "^3.3.2",
"prisma": "^7.4.2",
"rehype-stringify": "^10.0.1",
"remark": "^15.0.1",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"remark-stringify": "^11.0.0",
"sanitize-html": "^2.14.0",
"sass": "^1.85.1",
"strip-markdown": "^6.0.0",
"unified": "^11.0.5",
"zod": "^3.24.1"
},
"engines": {
"node": ">=22.0.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: "./prisma/schema.prisma",
migrations: {
path: "./prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
schema: './prisma/schema.prisma',
migrations: {
path: './prisma/migrations',
},
datasource: {
url: process.env['DATABASE_URL'],
},
});

View file

@ -0,0 +1,26 @@
/*
Warnings:
- Added the required column `fileName` to the `PhotoPost` table without a default value. This is not possible if the table is not empty.
- Added the required column `filePath` to the `PhotoPost` table without a default value. This is not possible if the table is not empty.
- Added the required column `title` to the `PhotoPost` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_PhotoPost" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" DATETIME,
"publishedAt" DATETIME,
"filePath" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT
);
INSERT INTO "new_PhotoPost" ("createdAt", "id") SELECT "createdAt", "id" FROM "PhotoPost";
DROP TABLE "PhotoPost";
ALTER TABLE "new_PhotoPost" RENAME TO "PhotoPost";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -8,6 +8,12 @@ datasource db {
}
model PhotoPost {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
deletedAt DateTime?
publishedAt DateTime?
filePath String
fileName String
title String
description String?
}

View file

@ -44,13 +44,13 @@
method="POST"
action="/admin/photos"
enctype="multipart/form-data"
class="admin-form"
class="cms-form"
>
<canvas bind:this={canvasElement}></canvas>
<div class="field">
<label for="title">Title</label>
<input type="text" name="title" id="title" />
<input type="text" name="title" id="title" required />
</div>
<div class="field">
@ -59,6 +59,7 @@
type="file"
name="file"
accept="image/*"
required
/>
</div>

View file

@ -1,3 +1,3 @@
export interface Authenticator {
authenticate(password: string): boolean;
authenticate(password: string): boolean;
}

View file

@ -0,0 +1,52 @@
import { randomUUID } from "node:crypto";
import { unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
/**
*
*/
export class LocalFileRepository {
constructor(private readonly uploadDirectory: string) {}
private log(message: string): void {
console.log(`[LocalFileRepository] ${message}`);
}
async saveFile(file: File): Promise<{
fileName: string;
filePath: string;
}> {
this.log(
`Saving file: ${file.name} (size: ${file.size} bytes, type: ${file.type})`,
);
const filetype = file.type.split("/")[1];
const fileName = `${randomUUID()}.${filetype}`;
const filePath = join(this.uploadDirectory, fileName);
const fileContentBuffer = await file.arrayBuffer();
await writeFile(filePath, Buffer.from(fileContentBuffer));
this.log(`Successfully saved file to: ${filePath}`);
return {
fileName,
filePath,
};
}
async deleteFile(filePath: string): Promise<void> {
this.log(`Attempting to delete file at path: ${filePath}`);
try {
await unlink(filePath);
this.log(`Successfully deleted file at path: ${filePath}`);
} catch (error: unknown) {
// Treat missing files as already deleted so admin actions remain idempotent.
if (
!(error instanceof Error) ||
!("code" in error) ||
error.code !== "ENOENT"
) {
throw error;
}
}
}
}

View file

@ -1,149 +1,125 @@
import {
describe,
it,
beforeEach,
afterAll,
beforeAll,
expect,
afterEach,
} from "vitest";
import { BlogController } from "./BlogController.js";
import { MarkdownRepository } from "./markdown-repository.js";
import {
exampleMarkdown,
exampleMarkdownFrontmatter,
} from "./test-fixtures/example-markdown.js";
import { describe, it, beforeEach, afterAll, beforeAll, expect, afterEach } from 'vitest';
import { BlogController } from './BlogController.js';
import { MarkdownRepository } from './markdown-repository.js';
import { exampleMarkdown, exampleMarkdownFrontmatter } from './test-fixtures/example-markdown.js';
describe(`BlogController`, () => {
let controller: BlogController;
beforeEach(async () => {
controller = await BlogController.singleton();
});
describe(`Getting all posts which show up on the /blog page`, () => {
it(`should load blogs from the content folder`, async () => {
// GIVEN
const blogPosts = await controller.getAllBlogPosts();
// WHEN
const aKnownBlogPost = blogPosts.find(
(post) => post.title === "Vibe Check #10",
);
const aKnownBookReview = blogPosts.find((post) => post.title === "After");
const aMadeUpBlogPost = blogPosts.find(
(post) => post.title === "Some made up blog post",
);
// then
expect(aMadeUpBlogPost).toBeUndefined();
expect(aKnownBlogPost).not.toBeUndefined();
expect(aKnownBookReview).not.toBeUndefined();
});
});
describe(`getBlogPostBySlug`, () => {
it(`should return null when the post doesn't exist`, async () => {
// When
const shouldBeNull = await controller.getBlogPostBySlug(
"some-made-up-blog-post",
);
// Then
expect(shouldBeNull).toBeNull();
});
it(`should return the blog post when it exists`, async () => {
// When
const blogPost = await controller.getBlogPostBySlug(
"2023-02-03-vibe-check-10",
);
// Then
expect(blogPost).not.toBeNull();
expect(blogPost.title).toBe("Vibe Check #10");
});
});
describe(`Finding content by slug`, () => {
describe(`Finding a blog post`, () => {
// GIVEN
const slugForRealBlogPost = "2023-02-03-vibe-check-10";
const slugForFakeBlogPost = "some-made-up-blog-post";
it(`should return null if there's no blog post with the slug`, async () => {
// WHEN
const blogPost =
await controller.getAnyKindOfContentBySlug(slugForFakeBlogPost);
// THEN
expect(blogPost).toBeNull();
});
it(`should return the blog post if it exists`, async () => {
// WHEN
const blogPost =
await controller.getAnyKindOfContentBySlug(slugForRealBlogPost);
// THEN
expect(blogPost).not.toBeNull();
expect(blogPost.title).toBe("Vibe Check #10");
});
});
describe(`Finding a book review`, () => {
const realSlug = "after";
const fakeSlug = "some-made-up-book-review";
it(`should return null if there's no book review with the slug`, async () => {
// WHEN
const bookReview = await controller.getAnyKindOfContentBySlug(fakeSlug);
// THEN
expect(bookReview).toBeNull();
});
it(`should return the book review if it exists`, async () => {
// WHEN
const bookReview = await controller.getAnyKindOfContentBySlug(realSlug);
// THEN
expect(bookReview).not.toBeNull();
expect(bookReview.title).toBe("After");
});
});
});
describe(`Creating a new blog post as a file`, () => {
const thisDirectory = import.meta.url
.replace("file://", "")
.split("/")
.filter((part) => part !== "BlogController.test.ts")
.join("/");
const fileName = `${thisDirectory}/test-fixtures/test-blog-controller.md`;
let controller: BlogController;
beforeEach(async () => {
controller = await BlogController.singleton();
controller = await BlogController.singleton();
});
afterAll(async () => {
await controller.markdownRepository.deleteBlogPostMarkdownFile(fileName);
describe(`Getting all posts which show up on the /blog page`, () => {
it(`should load blogs from the content folder`, async () => {
// GIVEN
const blogPosts = await controller.getAllBlogPosts();
// WHEN
const aKnownBlogPost = blogPosts.find((post) => post.title === 'Vibe Check #10');
const aKnownBookReview = blogPosts.find((post) => post.title === 'After');
const aMadeUpBlogPost = blogPosts.find((post) => post.title === 'Some made up blog post');
// then
expect(aMadeUpBlogPost).toBeUndefined();
expect(aKnownBlogPost).not.toBeUndefined();
expect(aKnownBookReview).not.toBeUndefined();
});
});
it(`should create a new file in the content folder`, async () => {
// GIVEN
const markdownContent = exampleMarkdown;
describe(`getBlogPostBySlug`, () => {
it(`should return null when the post doesn't exist`, async () => {
// When
const shouldBeNull = await controller.getBlogPostBySlug('some-made-up-blog-post');
// WHEN
const blogPost = await controller.createBlogPost(
fileName,
markdownContent,
);
// Then
expect(shouldBeNull).toBeNull();
});
// THEN
expect(blogPost).not.toBeNull();
expect(blogPost.html).not.toBeNull();
it(`should return the blog post when it exists`, async () => {
// When
const blogPost = await controller.getBlogPostBySlug('2023-02-03-vibe-check-10');
// Then
expect(blogPost).not.toBeNull();
expect(blogPost.title).toBe('Vibe Check #10');
});
});
describe(`Finding content by slug`, () => {
describe(`Finding a blog post`, () => {
// GIVEN
const slugForRealBlogPost = '2023-02-03-vibe-check-10';
const slugForFakeBlogPost = 'some-made-up-blog-post';
it(`should return null if there's no blog post with the slug`, async () => {
// WHEN
const blogPost = await controller.getAnyKindOfContentBySlug(slugForFakeBlogPost);
// THEN
expect(blogPost).toBeNull();
});
it(`should return the blog post if it exists`, async () => {
// WHEN
const blogPost = await controller.getAnyKindOfContentBySlug(slugForRealBlogPost);
// THEN
expect(blogPost).not.toBeNull();
expect(blogPost.title).toBe('Vibe Check #10');
});
});
describe(`Finding a book review`, () => {
const realSlug = 'after';
const fakeSlug = 'some-made-up-book-review';
it(`should return null if there's no book review with the slug`, async () => {
// WHEN
const bookReview = await controller.getAnyKindOfContentBySlug(fakeSlug);
// THEN
expect(bookReview).toBeNull();
});
it(`should return the book review if it exists`, async () => {
// WHEN
const bookReview = await controller.getAnyKindOfContentBySlug(realSlug);
// THEN
expect(bookReview).not.toBeNull();
expect(bookReview.title).toBe('After');
});
});
});
describe(`Creating a new blog post as a file`, () => {
const thisDirectory = import.meta.url
.replace('file://', '')
.split('/')
.filter((part) => part !== 'BlogController.test.ts')
.join('/');
const fileName = `${thisDirectory}/test-fixtures/test-blog-controller.md`;
let controller: BlogController;
beforeEach(async () => {
controller = await BlogController.singleton();
});
afterAll(async () => {
await controller.markdownRepository.deleteBlogPostMarkdownFile(fileName);
});
it(`should create a new file in the content folder`, async () => {
// GIVEN
const markdownContent = exampleMarkdown;
// WHEN
const blogPost = await controller.createBlogPost(fileName, markdownContent);
// THEN
expect(blogPost).not.toBeNull();
expect(blogPost.html).not.toBeNull();
});
});
});
});

View file

@ -1,158 +1,139 @@
import type { BlogPost } from "./BlogPost.js";
import type { BookReview } from "./BookReview.js";
import { MarkdownRepository } from "./markdown-repository.js";
import type { BlogPost } from './BlogPost.js';
import type { BookReview } from './BookReview.js';
import { MarkdownRepository } from './markdown-repository.js';
export interface BlogItem {
title: string;
date: string;
content: string;
slug: string;
content_type: "blog" | "book_review" | "snout_street_studios";
tags?: string[];
title: string;
date: string;
content: string;
slug: string;
content_type: 'blog' | 'book_review' | 'snout_street_studios';
tags?: string[];
}
export interface BlogPostListItem extends BlogItem {
title: string;
author: string;
date: string;
book_review: boolean;
preview: string;
tags: string[];
title: string;
author: string;
date: string;
book_review: boolean;
preview: string;
tags: string[];
}
export interface BookReviewListItem extends BlogItem {
book_review: true;
title: string;
author: string;
image: string;
slug: string;
score: number;
finished: string;
book_review: true;
title: string;
author: string;
image: string;
slug: string;
score: number;
finished: string;
}
export class BlogController {
private _markdownRepository: MarkdownRepository;
private _markdownRepository: MarkdownRepository;
static async singleton(): Promise<BlogController> {
const markdownRepository = await MarkdownRepository.singleton();
return new BlogController(markdownRepository);
}
constructor(markdownRepository: MarkdownRepository) {
this._markdownRepository = markdownRepository;
}
get markdownRepository(): MarkdownRepository {
return this._markdownRepository;
}
async createBlogPost(
resolvedFileName: string,
markdownContent: string,
): Promise<BlogPost> {
const createdBlogPost =
await this._markdownRepository.createBlogPostMarkdownFile(
resolvedFileName,
markdownContent,
);
this._markdownRepository = await MarkdownRepository.singleton(true);
return createdBlogPost;
}
async getAllBlogPosts(
pageSize?: number,
): Promise<Array<BlogPostListItem | BookReviewListItem>> {
const blogPosts = this._markdownRepository.blogPosts;
const bookReviews = this._markdownRepository.bookReviews;
const blogPostListItems: BlogPostListItem[] = blogPosts.blogPosts.map(
(blogPost) => {
return this.blogPostToBlogPostListItem(blogPost);
},
);
const bookReviewListItems: BookReviewListItem[] =
bookReviews.bookReviews.map((bookReview) => {
return this.bookReviewToBookReviewListItem(bookReview);
});
const allBlogPosts = [...blogPostListItems, ...bookReviewListItems].sort(
(a, b) => (a.date > b.date ? -1 : 1),
);
if (pageSize === undefined) {
return allBlogPosts;
static async singleton(): Promise<BlogController> {
const markdownRepository = await MarkdownRepository.singleton();
return new BlogController(markdownRepository);
}
return allBlogPosts.slice(0, pageSize);
}
async getBlogPostBySlug(slug: string): Promise<BlogPostListItem | null> {
const blogPost = this._markdownRepository.getBlogPostBySlug(slug);
if (blogPost) {
return this.blogPostToBlogPostListItem(blogPost);
constructor(markdownRepository: MarkdownRepository) {
this._markdownRepository = markdownRepository;
}
return null;
}
async getBlogPostsByTags(tags: string[]): Promise<BlogPostListItem[]> {
const posts = await this.getAllBlogPosts();
const blogPosts = posts.filter(
(post) => post.content_type === "blog",
) as BlogPostListItem[];
return blogPosts
.filter((post: BlogPostListItem) => post["tags"]?.length > 0)
.filter((post: BlogPostListItem) =>
(post.tags as string[]).some((tag) => tags.includes(tag)),
);
}
async getAnyKindOfContentBySlug(
slug: string,
): Promise<BookReviewListItem | BlogPostListItem | null> {
const blogPost = this._markdownRepository.getBlogPostBySlug(slug);
if (blogPost) {
return this.blogPostToBlogPostListItem(blogPost);
get markdownRepository(): MarkdownRepository {
return this._markdownRepository;
}
const bookReview = this._markdownRepository.getBookReviewBySlug(slug);
if (bookReview) {
return this.bookReviewToBookReviewListItem(bookReview);
async createBlogPost(resolvedFileName: string, markdownContent: string): Promise<BlogPost> {
const createdBlogPost = await this._markdownRepository.createBlogPostMarkdownFile(
resolvedFileName,
markdownContent
);
this._markdownRepository = await MarkdownRepository.singleton(true);
return createdBlogPost;
}
return null;
}
async getAllBlogPosts(pageSize?: number): Promise<Array<BlogPostListItem | BookReviewListItem>> {
const blogPosts = this._markdownRepository.blogPosts;
private bookReviewToBookReviewListItem(
bookReview: BookReview,
): BookReviewListItem {
return {
book_review: true,
title: bookReview.title,
author: bookReview.author,
date: bookReview.date.toISOString(),
finished: bookReview.finished.toISOString(),
image: bookReview.image,
score: bookReview.score,
slug: bookReview.slug,
content: bookReview.html,
content_type: "book_review",
};
}
const bookReviews = this._markdownRepository.bookReviews;
private blogPostToBlogPostListItem(blogPost: BlogPost): BlogPostListItem {
return {
title: blogPost.title,
author: blogPost.author,
book_review: false,
content: blogPost.html,
date: blogPost.date.toISOString(),
preview: blogPost.excerpt,
slug: blogPost.slug,
content_type: "blog",
tags: blogPost.tags,
};
}
const blogPostListItems: BlogPostListItem[] = blogPosts.blogPosts.map((blogPost) => {
return this.blogPostToBlogPostListItem(blogPost);
});
const bookReviewListItems: BookReviewListItem[] = bookReviews.bookReviews.map((bookReview) => {
return this.bookReviewToBookReviewListItem(bookReview);
});
const allBlogPosts = [...blogPostListItems, ...bookReviewListItems].sort((a, b) => (a.date > b.date ? -1 : 1));
if (pageSize === undefined) {
return allBlogPosts;
}
return allBlogPosts.slice(0, pageSize);
}
async getBlogPostBySlug(slug: string): Promise<BlogPostListItem | null> {
const blogPost = this._markdownRepository.getBlogPostBySlug(slug);
if (blogPost) {
return this.blogPostToBlogPostListItem(blogPost);
}
return null;
}
async getBlogPostsByTags(tags: string[]): Promise<BlogPostListItem[]> {
const posts = await this.getAllBlogPosts();
const blogPosts = posts.filter((post) => post.content_type === 'blog') as BlogPostListItem[];
return blogPosts
.filter((post: BlogPostListItem) => post['tags']?.length > 0)
.filter((post: BlogPostListItem) => (post.tags as string[]).some((tag) => tags.includes(tag)));
}
async getAnyKindOfContentBySlug(slug: string): Promise<BookReviewListItem | BlogPostListItem | null> {
const blogPost = this._markdownRepository.getBlogPostBySlug(slug);
if (blogPost) {
return this.blogPostToBlogPostListItem(blogPost);
}
const bookReview = this._markdownRepository.getBookReviewBySlug(slug);
if (bookReview) {
return this.bookReviewToBookReviewListItem(bookReview);
}
return null;
}
private bookReviewToBookReviewListItem(bookReview: BookReview): BookReviewListItem {
return {
book_review: true,
title: bookReview.title,
author: bookReview.author,
date: bookReview.date.toISOString(),
finished: bookReview.finished.toISOString(),
image: bookReview.image,
score: bookReview.score,
slug: bookReview.slug,
content: bookReview.html,
content_type: 'book_review',
};
}
private blogPostToBlogPostListItem(blogPost: BlogPost): BlogPostListItem {
return {
title: blogPost.title,
author: blogPost.author,
book_review: false,
content: blogPost.html,
date: blogPost.date.toISOString(),
preview: blogPost.excerpt,
slug: blogPost.slug,
content_type: 'blog',
tags: blogPost.tags,
};
}
}

View file

@ -8,7 +8,10 @@ import type { BookReview } from './BookReview.js';
export class RssFeed {
private feed: Feed;
constructor(private readonly blogPosts: BlogPostSet, private readonly bookReviews: BookReviewSet) {
constructor(
private readonly blogPosts: BlogPostSet,
private readonly bookReviews: BookReviewSet
) {
this.feed = new Feed({
copyright: `All Rights Reserved Thomas Wilson 2023`,
id: 'https://www.thomaswilson.xyz',

View file

@ -1,45 +1,41 @@
import type { Cookies } from "@sveltejs/kit";
import type { Cookies } from '@sveltejs/kit';
export class CookieAuthentication {
private readonly cookieValue: string;
private readonly cookieValueArray: string[];
public static cookieName = "auth";
public static adminAuthRole = "admin";
private readonly cookieValue: string;
private readonly cookieValueArray: string[];
public static cookieName = 'auth';
public static adminAuthRole = 'admin';
constructor(private readonly cookies: Cookies) {
this.cookieValue = this.cookies.get(CookieAuthentication.cookieName) ?? "";
this.cookieValueArray = this.cookieValue.split(",");
}
public get isAuthdAsAdmin(): boolean {
let isAuthdAsAdmin = false;
if (this.cookieValueArray.includes(CookieAuthentication.adminAuthRole)) {
isAuthdAsAdmin = true;
constructor(private readonly cookies: Cookies) {
this.cookieValue = this.cookies.get(CookieAuthentication.cookieName) ?? '';
this.cookieValueArray = this.cookieValue.split(',');
}
return isAuthdAsAdmin;
}
public get isAuthdAsAdmin(): boolean {
let isAuthdAsAdmin = false;
public logout() {
if (!this.isAuthdAsAdmin) return;
if (this.cookieValueArray.includes(CookieAuthentication.adminAuthRole)) {
isAuthdAsAdmin = true;
}
this.cookies.delete(CookieAuthentication.cookieName, { path: "/" });
}
public setAdminAuthentication(isAuthd: boolean) {
let value = this.cookieValue;
if (isAuthd) {
value = Array.from(
new Set([...this.cookieValueArray, CookieAuthentication.adminAuthRole]),
).join(",");
} else {
value = this.cookieValueArray
.filter((i) => i !== CookieAuthentication.adminAuthRole)
.join(",");
return isAuthdAsAdmin;
}
this.cookies.set(CookieAuthentication.cookieName, value, { path: "/" });
}
public logout() {
if (!this.isAuthdAsAdmin) return;
this.cookies.delete(CookieAuthentication.cookieName, { path: '/' });
}
public setAdminAuthentication(isAuthd: boolean) {
let value = this.cookieValue;
if (isAuthd) {
value = Array.from(new Set([...this.cookieValueArray, CookieAuthentication.adminAuthRole])).join(',');
} else {
value = this.cookieValueArray.filter((i) => i !== CookieAuthentication.adminAuthRole).join(',');
}
this.cookies.set(CookieAuthentication.cookieName, value, { path: '/' });
}
}

View file

@ -1,103 +1,94 @@
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
import { MarkdownRepository } from "./markdown-repository.js";
import { resolve, dirname } from "path";
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { MarkdownRepository } from './markdown-repository.js';
import { resolve, dirname } from 'path';
import { aBlogPost } from "./test-builders/blog-post-builder.js";
import { aBlogPost } from './test-builders/blog-post-builder.js';
const blogPostImport = import.meta.glob(`./test-fixtures/blog-*.md`, {
as: "raw",
as: 'raw',
});
const bookReviewImport = import.meta.glob(`./test-fixtures/book-review-*.md`, {
as: "raw",
as: 'raw',
});
const snoutStreetPostImport = import.meta.glob(
`./test-fixtures/snout-street-studio-*.md`,
{ as: "raw" },
);
const snoutStreetPostImport = import.meta.glob(`./test-fixtures/snout-street-studio-*.md`, { as: 'raw' });
const expectedHtml = `<p>This is a blog post written in markdown.</p>
<p>This is a <a href="http://www.bbc.co.uk">link</a></p>`;
describe(`Blog MarkdownRepository`, () => {
let repository: MarkdownRepository;
beforeEach(async () => {
repository = await MarkdownRepository.fromViteGlobImport(
blogPostImport,
bookReviewImport,
);
});
it(`should load`, async () => {
// GIVEN
const expectedBlogPost = aBlogPost()
.withAuthor("Thomas Wilson")
.withDate(new Date("2023-02-01T08:00:00Z"))
.withSlug("2023-02-01-test")
.withTitle("Test Blog Post")
.withExcerpt("This is a blog post written in markdown. This is a link")
.withHtml(expectedHtml)
.withFileName("blog-2023-02-01-test.md")
.build();
// WHEN
const blogPost =
repository.blogPosts.getBlogPostWithTitle("Test Blog Post");
// THEN
expect(repository).toBeDefined();
expect(blogPost).toStrictEqual(expectedBlogPost);
});
it(`should automatically build all the blog posts and book reviews`, async () => {
// WHEN/THEN
expect(repository.blogPosts.blogPosts[0].html).not.toBeNull();
expect(repository.bookReviews.bookReviews[0].html).not.toBeNull();
});
describe(`Finding by Slug`, () => {
it(`should return null if there's neither a blog post nor a book review with the slug`, async () => {
// WHEN
const markdownFile = repository.getBlogPostBySlug("non-existent-slug");
// THEN
expect(markdownFile).toBeNull();
});
});
describe(`Deleting markdown files`, () => {
let repository: MarkdownRepository;
const currentDirectory = dirname(import.meta.url.replace("file://", ""));
beforeAll(async () => {
repository = await MarkdownRepository.fromViteGlobImport(
blogPostImport,
bookReviewImport,
snoutStreetPostImport,
);
const resolvedPath = resolve(
`${currentDirectory}/test-fixtures/test-file.md`,
);
await repository.createBlogPostMarkdownFile(resolvedPath, expectedHtml);
beforeEach(async () => {
repository = await MarkdownRepository.fromViteGlobImport(blogPostImport, bookReviewImport);
});
it(`should throw an error if it attempts to delete a blog post file which does not exist`, async () => {
// GIVEN
const theFileName = "non-existent-file.md";
it(`should load`, async () => {
// GIVEN
const expectedBlogPost = aBlogPost()
.withAuthor('Thomas Wilson')
.withDate(new Date('2023-02-01T08:00:00Z'))
.withSlug('2023-02-01-test')
.withTitle('Test Blog Post')
.withExcerpt('This is a blog post written in markdown. This is a link')
.withHtml(expectedHtml)
.withFileName('blog-2023-02-01-test.md')
.build();
// WHEN/THEN
expect(async () =>
repository.deleteBlogPostMarkdownFile(theFileName),
).rejects.toThrowError(`File 'non-existent-file.md' not found.`);
// WHEN
const blogPost = repository.blogPosts.getBlogPostWithTitle('Test Blog Post');
// THEN
expect(repository).toBeDefined();
expect(blogPost).toStrictEqual(expectedBlogPost);
});
it(`should successfully delete a file when it does exist`, async () => {
// GIVEN
const fileName = `${currentDirectory}/test-fixtures/test-file.md`;
// WHEN
await repository.deleteBlogPostMarkdownFile(fileName);
it(`should automatically build all the blog posts and book reviews`, async () => {
// WHEN/THEN
expect(repository.blogPosts.blogPosts[0].html).not.toBeNull();
expect(repository.bookReviews.bookReviews[0].html).not.toBeNull();
});
describe(`Finding by Slug`, () => {
it(`should return null if there's neither a blog post nor a book review with the slug`, async () => {
// WHEN
const markdownFile = repository.getBlogPostBySlug('non-existent-slug');
// THEN
expect(markdownFile).toBeNull();
});
});
describe(`Deleting markdown files`, () => {
let repository: MarkdownRepository;
const currentDirectory = dirname(import.meta.url.replace('file://', ''));
beforeAll(async () => {
repository = await MarkdownRepository.fromViteGlobImport(
blogPostImport,
bookReviewImport,
snoutStreetPostImport
);
const resolvedPath = resolve(`${currentDirectory}/test-fixtures/test-file.md`);
await repository.createBlogPostMarkdownFile(resolvedPath, expectedHtml);
});
it(`should throw an error if it attempts to delete a blog post file which does not exist`, async () => {
// GIVEN
const theFileName = 'non-existent-file.md';
// WHEN/THEN
expect(async () => repository.deleteBlogPostMarkdownFile(theFileName)).rejects.toThrowError(
`File 'non-existent-file.md' not found.`
);
});
it(`should successfully delete a file when it does exist`, async () => {
// GIVEN
const fileName = `${currentDirectory}/test-fixtures/test-file.md`;
// WHEN
await repository.deleteBlogPostMarkdownFile(fileName);
});
});
});
});

View file

@ -1,222 +1,186 @@
import { writeFile, unlink, existsSync } from "fs";
import { writeFile, unlink, existsSync } from 'fs';
import { BlogPost } from "./BlogPost.js";
import { MarkdownFile } from "./MarkdownFile.js";
import { BlogPostSet } from "./BlogPostSet.js";
import { BookReviewSet } from "./BookReviewSet.js";
import { BookReview } from "./BookReview.js";
import { BlogPost } from './BlogPost.js';
import { MarkdownFile } from './MarkdownFile.js';
import { BlogPostSet } from './BlogPostSet.js';
import { BookReviewSet } from './BookReviewSet.js';
import { BookReview } from './BookReview.js';
// We have to duplicate the `../..` here because import.meta must have a static string,
// and it (rightfully) cannot have dynamic locations
const blogPostMetaGlobImport = import.meta.glob(`../../content/blog/*.md`, {
as: "raw",
as: 'raw',
});
const bookReviewsMetaGlobImport = import.meta.glob(
`../../content/book-reviews/*.md`,
{ as: "raw" },
);
const bookReviewsMetaGlobImport = import.meta.glob(`../../content/book-reviews/*.md`, { as: 'raw' });
interface BlogPostFrontmatterValues {
title: string;
slug: string;
date: Date;
author: string;
tags?: string[];
title: string;
slug: string;
date: Date;
author: string;
tags?: string[];
}
interface BookReviewFrontmatterValues {
title: string;
author: string; // Author of the book, not the review
slug: string;
date: Date;
finished: Date;
score: number;
image: string;
title: string;
author: string; // Author of the book, not the review
slug: string;
date: Date;
finished: Date;
score: number;
image: string;
}
export class MarkdownRepository {
readonly blogPosts: BlogPostSet;
readonly bookReviews: BookReviewSet;
private static _singleton: MarkdownRepository;
readonly blogPosts: BlogPostSet;
readonly bookReviews: BookReviewSet;
private static _singleton: MarkdownRepository;
private constructor(blogPosts: BlogPost[], bookReviews: BookReview[]) {
this.blogPosts = new BlogPostSet(blogPosts);
this.bookReviews = new BookReviewSet(bookReviews);
}
public static async singleton(
forceRefresh = false,
): Promise<MarkdownRepository> {
if (forceRefresh || !this._singleton) {
console.log(
`[MarkdownRepository::singleton] Building MarkdownRepository singleton.`,
);
this._singleton = await MarkdownRepository.fromViteGlobImport(
blogPostMetaGlobImport,
bookReviewsMetaGlobImport,
);
private constructor(blogPosts: BlogPost[], bookReviews: BookReview[]) {
this.blogPosts = new BlogPostSet(blogPosts);
this.bookReviews = new BookReviewSet(bookReviews);
}
return this._singleton;
}
public static async fromViteGlobImport(
blogGlobImport: any,
bookReviewGlobImport: any,
): Promise<MarkdownRepository> {
let fileImports: MarkdownFile<BlogPostFrontmatterValues>[] = [];
let blogPosts: BlogPost[] = [];
let bookReviews: BookReview[] = [];
const blogPostFiles = Object.entries(blogGlobImport);
for (const blogPostFile of blogPostFiles) {
const [filename, module] = blogPostFile as [
string,
() => Promise<string>,
];
try {
const markdownFile =
await MarkdownFile.build<BlogPostFrontmatterValues>(
filename,
await module(),
);
const blogPost = new BlogPost({
excerpt: markdownFile.excerpt,
html: markdownFile.html,
title: markdownFile.frontmatter.title,
slug: markdownFile.frontmatter.slug,
author: markdownFile.frontmatter.author,
date: markdownFile.frontmatter.date,
fileName: filename,
tags: markdownFile.frontmatter.tags ?? [],
});
fileImports = [...fileImports, markdownFile];
blogPosts = [...blogPosts, blogPost];
} catch (e) {
console.error({
message: `[MarkdownRespository::fromViteGlobImport] Error loading file ${filename}`,
error: e,
});
}
}
for (const bookReviewFile of Object.entries(bookReviewGlobImport)) {
const [filename, module] = bookReviewFile as [
string,
() => Promise<string>,
];
try {
const markdownFile =
await MarkdownFile.build<BookReviewFrontmatterValues>(
filename,
await module(),
);
const bookReview = new BookReview({
author: markdownFile.frontmatter.author,
title: markdownFile.frontmatter.title,
slug: markdownFile.frontmatter.slug,
date: markdownFile.frontmatter.date,
draft: false,
finished: markdownFile.frontmatter.finished,
image: markdownFile.frontmatter.image,
score: markdownFile.frontmatter.score,
html: markdownFile.html,
});
bookReviews = [...bookReviews, bookReview];
} catch (e) {
console.error({
message: `[MarkdownRespository::fromViteGlobImport] Error loading file ${filename}`,
error: e,
});
}
}
console.log(
`[MarkdownRepository::fromViteGlobImport] Loaded ${fileImports.length} files.`,
);
const repository = new MarkdownRepository(blogPosts, bookReviews);
console.log(`[MarkdownRepository::fromViteGlobImport] Built all posts.`);
return repository;
}
getBlogPostBySlug(slug: string): BlogPost | null {
return (
this.blogPosts.blogPosts.find((blogPost) => blogPost.slug === slug) ??
null
);
}
getBookReviewBySlug(slug: string): BookReview | null {
return (
this.bookReviews.bookReviews.find(
(bookReview) => bookReview.slug === slug,
) ?? null
);
}
async createBlogPostMarkdownFile(
resolvedPath: string,
contents: string,
): Promise<BlogPost> {
return new Promise<void>((resolve, reject) => {
writeFile(resolvedPath, contents, (err) => {
if (err) {
console.error({
message: `createBlogPostMarkdownFile: Caught error while writing file ${resolvedPath}`,
err,
error: JSON.stringify(err),
});
reject(err);
public static async singleton(forceRefresh = false): Promise<MarkdownRepository> {
if (forceRefresh || !this._singleton) {
console.log(`[MarkdownRepository::singleton] Building MarkdownRepository singleton.`);
this._singleton = await MarkdownRepository.fromViteGlobImport(
blogPostMetaGlobImport,
bookReviewsMetaGlobImport
);
}
resolve();
});
}).then(async () => {
const markdownFile = await MarkdownFile.build<BlogPostFrontmatterValues>(
resolvedPath,
contents,
);
const blogPost = new BlogPost({
html: markdownFile.html,
excerpt: markdownFile.excerpt,
title: markdownFile.frontmatter?.title ?? undefined,
slug: markdownFile.frontmatter?.slug ?? undefined,
author: markdownFile.frontmatter?.author ?? undefined,
date: markdownFile.frontmatter?.date ?? undefined,
fileName: resolvedPath,
tags: [],
});
return blogPost;
});
}
async deleteBlogPostMarkdownFile(resolvedFilePath: string): Promise<void> {
const isPresent = existsSync(resolvedFilePath);
if (!isPresent) {
throw `Sausages File '${resolvedFilePath}' not found.`;
return this._singleton;
}
return new Promise((resolve, reject) => {
unlink(resolvedFilePath, (err) => {
if (err) {
console.error({
message: `deleteBlogPostMarkdownFile: Caught error while deleting file ${resolvedFilePath}`,
err,
error: JSON.stringify(err),
});
reject(err);
public static async fromViteGlobImport(
blogGlobImport: any,
bookReviewGlobImport: any
): Promise<MarkdownRepository> {
let fileImports: MarkdownFile<BlogPostFrontmatterValues>[] = [];
let blogPosts: BlogPost[] = [];
let bookReviews: BookReview[] = [];
const blogPostFiles = Object.entries(blogGlobImport);
for (const blogPostFile of blogPostFiles) {
const [filename, module] = blogPostFile as [string, () => Promise<string>];
try {
const markdownFile = await MarkdownFile.build<BlogPostFrontmatterValues>(filename, await module());
const blogPost = new BlogPost({
excerpt: markdownFile.excerpt,
html: markdownFile.html,
title: markdownFile.frontmatter.title,
slug: markdownFile.frontmatter.slug,
author: markdownFile.frontmatter.author,
date: markdownFile.frontmatter.date,
fileName: filename,
tags: markdownFile.frontmatter.tags ?? [],
});
fileImports = [...fileImports, markdownFile];
blogPosts = [...blogPosts, blogPost];
} catch (e) {
console.error({
message: `[MarkdownRespository::fromViteGlobImport] Error loading file ${filename}`,
error: e,
});
}
}
resolve();
});
});
}
for (const bookReviewFile of Object.entries(bookReviewGlobImport)) {
const [filename, module] = bookReviewFile as [string, () => Promise<string>];
try {
const markdownFile = await MarkdownFile.build<BookReviewFrontmatterValues>(filename, await module());
const bookReview = new BookReview({
author: markdownFile.frontmatter.author,
title: markdownFile.frontmatter.title,
slug: markdownFile.frontmatter.slug,
date: markdownFile.frontmatter.date,
draft: false,
finished: markdownFile.frontmatter.finished,
image: markdownFile.frontmatter.image,
score: markdownFile.frontmatter.score,
html: markdownFile.html,
});
bookReviews = [...bookReviews, bookReview];
} catch (e) {
console.error({
message: `[MarkdownRespository::fromViteGlobImport] Error loading file ${filename}`,
error: e,
});
}
}
console.log(`[MarkdownRepository::fromViteGlobImport] Loaded ${fileImports.length} files.`);
const repository = new MarkdownRepository(blogPosts, bookReviews);
console.log(`[MarkdownRepository::fromViteGlobImport] Built all posts.`);
return repository;
}
getBlogPostBySlug(slug: string): BlogPost | null {
return this.blogPosts.blogPosts.find((blogPost) => blogPost.slug === slug) ?? null;
}
getBookReviewBySlug(slug: string): BookReview | null {
return this.bookReviews.bookReviews.find((bookReview) => bookReview.slug === slug) ?? null;
}
async createBlogPostMarkdownFile(resolvedPath: string, contents: string): Promise<BlogPost> {
return new Promise<void>((resolve, reject) => {
writeFile(resolvedPath, contents, (err) => {
if (err) {
console.error({
message: `createBlogPostMarkdownFile: Caught error while writing file ${resolvedPath}`,
err,
error: JSON.stringify(err),
});
reject(err);
}
resolve();
});
}).then(async () => {
const markdownFile = await MarkdownFile.build<BlogPostFrontmatterValues>(resolvedPath, contents);
const blogPost = new BlogPost({
html: markdownFile.html,
excerpt: markdownFile.excerpt,
title: markdownFile.frontmatter?.title ?? undefined,
slug: markdownFile.frontmatter?.slug ?? undefined,
author: markdownFile.frontmatter?.author ?? undefined,
date: markdownFile.frontmatter?.date ?? undefined,
fileName: resolvedPath,
tags: [],
});
return blogPost;
});
}
async deleteBlogPostMarkdownFile(resolvedFilePath: string): Promise<void> {
const isPresent = existsSync(resolvedFilePath);
if (!isPresent) {
throw `Sausages File '${resolvedFilePath}' not found.`;
}
return new Promise((resolve, reject) => {
unlink(resolvedFilePath, (err) => {
if (err) {
console.error({
message: `deleteBlogPostMarkdownFile: Caught error while deleting file ${resolvedFilePath}`,
err,
error: JSON.stringify(err),
});
reject(err);
}
resolve();
});
});
}
}

View file

@ -1,26 +1,25 @@
import { SimplePasswordAuthenticator } from './simple-password-authenticator';
import { it, expect} from 'vitest'
import { it, expect } from 'vitest';
it('should do nothing when things are valid', () => {
// GIVEN
const authenticator = new SimplePasswordAuthenticator('expected-password');
// GIVEN
const authenticator = new SimplePasswordAuthenticator('expected-password');
// WHEN
const result = authenticator.authenticate('expected-password');
// WHEN
const result = authenticator.authenticate('expected-password');
//
expect(result).toBeTruthy();
})
//
expect(result).toBeTruthy();
});
it('should not authenticate when the password is invalid', () => {
// GIVEN
const authenticator = new SimplePasswordAuthenticator('expected-password');
// GIVEN
const authenticator = new SimplePasswordAuthenticator('expected-password');
// WHEN
const result = authenticator.authenticate('invalid-password');
// WHEN
const result = authenticator.authenticate('invalid-password');
// THEN
expect(result).toBeFalsy();
})
// THEN
expect(result).toBeFalsy();
});

View file

@ -1,13 +1,13 @@
import type { Authenticator } from './Authenticator';
export class SimplePasswordAuthenticator implements Authenticator{
constructor(private readonly password: string) {
if (this.password === undefined) {
throw new Error('Password must be defined');
}
}
export class SimplePasswordAuthenticator implements Authenticator {
constructor(private readonly password: string) {
if (this.password === undefined) {
throw new Error('Password must be defined');
}
}
authenticate(password: string): boolean {
return this.password === password;
}
authenticate(password: string): boolean {
return this.password === password;
}
}

View file

@ -1,14 +1,14 @@
export interface SunriseOrSunsetPhotoSet {
total: number
total_pages: number
search_term: string
results: SunriseOrSunsetPhoto[]
total: number;
total_pages: number;
search_term: string;
results: SunriseOrSunsetPhoto[];
}
export interface SunriseOrSunsetPhoto {
id: string
description: string
username: string
username_url: string
small_url: string
id: string;
description: string;
username: string;
username_url: string;
small_url: string;
}

View file

@ -1,20 +1,20 @@
import { notStrictEqual } from "node:assert";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../../generated/prisma/client.js";
import { env } from "$env/dynamic/private";
import { notStrictEqual } from 'node:assert';
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
import { PrismaClient } from '../../generated/prisma/client.js';
import { env } from '$env/dynamic/private';
export class PrismaClientFactory {
private constructor(private readonly databaseUrl: string) {}
public static fromEnv(): PrismaClientFactory {
const value = env.PRIVATE_DATABASE_URL ?? "";
notStrictEqual(value, "", `"env.PRIVATE_DATABASE_URL" must be defined`);
private constructor(private readonly databaseUrl: string) {}
public static fromEnv(): PrismaClientFactory {
const value = env.PRIVATE_DATABASE_URL ?? '';
notStrictEqual(value, '', `"env.PRIVATE_DATABASE_URL" must be defined`);
return new PrismaClientFactory(value);
}
return new PrismaClientFactory(value);
}
createClient(): PrismaClient {
const adapter = new PrismaBetterSqlite3({ url: this.databaseUrl });
const prisma = new PrismaClient({ adapter });
return prisma;
}
createClient(): PrismaClient {
const adapter = new PrismaBetterSqlite3({ url: this.databaseUrl });
const prisma = new PrismaClient({ adapter });
return prisma;
}
}

View file

@ -1,16 +1,16 @@
import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types.js";
import { CookieAuthentication } from "$lib/blog/auth/CookieAuthentication.js";
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types.js';
import { CookieAuthentication } from '$lib/blog/auth/CookieAuthentication.js';
export const load: LayoutServerLoad = ({ cookies, route }) => {
const auth = new CookieAuthentication(cookies)
const isAuthd = auth.isAuthdAsAdmin
const auth = new CookieAuthentication(cookies);
const isAuthd = auth.isAuthdAsAdmin;
if (route.id === '/admin/login' && isAuthd) {
return redirect(307, '/admin')
} else if (!isAuthd && route.id !== '/admin/login') {
return redirect(307, '/admin/login')
}
if (route.id === '/admin/login' && isAuthd) {
return redirect(307, '/admin');
} else if (!isAuthd && route.id !== '/admin/login') {
return redirect(307, '/admin/login');
}
return {}
}
return {};
};

View file

@ -1,25 +1,24 @@
import { PRIVATE_ADMIN_AUTH_TOKEN } from "$env/static/private";
import { redirect } from "@sveltejs/kit";
import type { Actions} from "./$types.js";
import { CookieAuthentication } from "$lib/blog/auth/CookieAuthentication.js";
import { PRIVATE_ADMIN_AUTH_TOKEN } from '$env/static/private';
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types.js';
import { CookieAuthentication } from '$lib/blog/auth/CookieAuthentication.js';
export const actions = {
default: async ({cookies, request}) => {
const formData = await request.formData()
const token = formData.get('token')
default: async ({ cookies, request }) => {
const formData = await request.formData();
const token = formData.get('token');
const isAuthd = PRIVATE_ADMIN_AUTH_TOKEN === token;
const auth = new CookieAuthentication(cookies)
const isAuthd = PRIVATE_ADMIN_AUTH_TOKEN === token;
const auth = new CookieAuthentication(cookies);
auth.setAdminAuthentication(isAuthd)
auth.setAdminAuthentication(isAuthd);
if (isAuthd) {
return redirect(307, '/admin')
}
if (isAuthd) {
return redirect(307, '/admin');
}
return {
isAuthd
}
}
} satisfies Actions
return {
isAuthd,
};
},
} satisfies Actions;

View file

@ -1,7 +1,7 @@
import { CookieAuthentication } from "$lib/blog/auth/CookieAuthentication.js";
import { redirect, type ServerLoad } from "@sveltejs/kit";
import { CookieAuthentication } from '$lib/blog/auth/CookieAuthentication.js';
import { redirect, type ServerLoad } from '@sveltejs/kit';
export const GET: ServerLoad = ({ cookies }) => {
new CookieAuthentication(cookies).logout();
redirect(307, "/");
new CookieAuthentication(cookies).logout();
redirect(307, '/');
};

View file

@ -1,47 +1,41 @@
import { writeFile } from "node:fs/promises";
import { Buffer } from "node:buffer";
import { join } from "node:path";
import { PRIVATE_PHOTO_UPLOAD_DIR } from "$env/static/private";
import type { Actions, ServerLoad } from "@sveltejs/kit";
import { randomUUID } from "crypto";
import { PRIVATE_PHOTO_UPLOAD_DIR } from '$env/static/private';
import type { Actions, ServerLoad } from '@sveltejs/kit';
import { LocalFileRepository } from '$lib/LocalFileRepository.js';
export const load: ServerLoad = async ({ locals }) => {
const photos = await locals.prisma.photoPost.findMany({
select: {
id: true,
fileName: true,
title: true,
description: true,
createdAt: true,
},
});
return { photos };
const photos = await locals.prisma.photoPost.findMany({
select: {
id: true,
filePath: true,
fileName: true,
title: true,
description: true,
createdAt: true,
},
});
return { photos };
};
export const actions = {
default: async ({ request, locals }) => {
const formData = await request.formData();
const file = formData.get("file") as File;
const title = formData.get("title") as string;
const description = formData.get("description") as string;
default: async ({ request, locals }) => {
const formData = await request.formData();
const file = formData.get('file') as File;
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const filetype = file.type.split("/")[1];
const fileName = `${randomUUID()}.${filetype}`;
const fileLocation = join(PRIVATE_PHOTO_UPLOAD_DIR, fileName);
const fileRepo = new LocalFileRepository(PRIVATE_PHOTO_UPLOAD_DIR);
const { fileName, filePath } = await fileRepo.saveFile(file);
const fileContentBuffer = await file.arrayBuffer();
await writeFile(fileLocation, Buffer.from(fileContentBuffer));
await locals.prisma.photoPost.create({
data: {
fileName,
filePath,
title,
description,
createdAt: new Date(),
},
});
await locals.prisma.photoPost.create({
data: {
fileName,
title,
description,
createdAt: new Date(),
},
});
return { success: true };
},
return { success: true };
},
} satisfies Actions;

View file

@ -0,0 +1,51 @@
<script lang="ts">
import { page } from "$app/state";
import type { PhotoPost } from "../../../../generated/prisma/client.js";
interface Props {
photo: PhotoPost
}
const { photo }: Props = $props()
</script>
<li class="item">
<a href={`/admin/photos/${photo.id}`} class="no-icon">
<img width="250" src={`/image/${photo.fileName}`} alt={photo.title} />
</a>
<input type="text" name="id" value={`${page.url.protocol}//${page.url.host}/image/${photo.fileName}`} />
<div class="text">
<a href={`/admin/photos/${photo.id}`}>{photo.title}</a>
{#if photo.description}
<p>{photo.description}</p>
{/if}
</div>
</li>
<style>
.item {
--photo-width: 250px;
display: flex;
flex-direction: column;
justify-content: center;
img {
width: var(--photo--width);
}
.text {
width: var(--photo-width);
}
}
.item img {
width: 250px;
height: auto;
height: fit-content;
}
</style>

View file

@ -1,15 +1,13 @@
<script lang="ts">
import type { PhotoPost } from "../../../../generated/prisma/client.js";
import FeedItem from "./FeedItem.svelte";
const { photos }: { photos: PhotoPost[] } = $props();
</script>
<ul class="photo-feed">
{#each photos as photo, id}
<li class="item">
<img width="250" src={`/image/${photo.fileName}`} alt={photo.title} />
<p>{photo.title}</p>
</li>
<FeedItem {photo} />
{/each}
</ul>
@ -21,15 +19,4 @@
list-style: none;
}
.item {
display: flex;
flex-direction: column;
justify-content: center;
}
.item img {
width: 250px;
height: auto;
height: fit-content;
}
</style>

View file

@ -0,0 +1,9 @@
<script lang="ts">
import { page } from "$app/state";
</script>
<section>
<h1>Photo error!</h1>
<p>{page.error.message}</p>
<a href="/admin/photos">Back to photos</a>
</section>

View file

@ -0,0 +1,77 @@
import { error, redirect, type Actions } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types.js";
import { LocalFileRepository } from "$lib/LocalFileRepository.js";
import { PRIVATE_PHOTO_UPLOAD_DIR } from "$env/static/private";
import type { Prisma } from "../../../../../generated/prisma/client.js";
export const load: PageServerLoad = async ({ params, locals }) => {
const { id } = params;
const photo = await locals.prisma.photoPost.findFirst({
where: {
id: Number(id),
},
});
if (!photo) {
return error(404, { message: `Photo with ID ${id} not found` });
}
return { photo };
};
export const actions = {
update: async ({ request, locals, params }) => {
const formData = await request.formData();
const { id } = params;
const fileRepo = new LocalFileRepository(PRIVATE_PHOTO_UPLOAD_DIR);
const title = formData.get("title") as string;
const description = formData.get("description") as string;
const file = formData.get("file") as File | null;
const options: Prisma.PhotoPostUpdateInput = {
title,
description,
};
if (file && file.size > 0) {
const saveResult = await fileRepo.saveFile(file);
options.fileName = saveResult.fileName;
options.filePath = saveResult.filePath;
}
await locals.prisma.photoPost.update({
where: { id: Number(id) },
data: options,
});
return { success: true };
},
delete: async ({ locals, params }) => {
const { id } = params;
const photoId = Number(id);
const fileRepo = new LocalFileRepository(PRIVATE_PHOTO_UPLOAD_DIR);
const photo = await locals.prisma.photoPost.findUnique({
where: { id: photoId },
select: {
id: true,
filePath: true,
},
});
if (!photo) {
return error(404, { message: `Photo with ID ${id} not found` });
}
await locals.prisma.photoPost.delete({
where: { id: photoId },
});
await fileRepo.deleteFile(photo.filePath);
throw redirect(303, "/admin/photos");
},
} satisfies Actions;

View file

@ -0,0 +1,19 @@
<script lang="ts">
import type { PageProps } from "./$types.js";
import EditPhotoForm from "./EditPhotoForm.svelte";
const { data, form }: PageProps = $props()
const success = form?.success ?? false;
</script>
<div>
<a class="breadcrumb" href="/admin/photos">Photos</a>
<h1>{data.photo.title}</h1>
<EditPhotoForm photo={data.photo} />
{#if success}
<p>Photo updated successfully!</p>
{/if}
</div>

View file

@ -0,0 +1,131 @@
<script lang="ts">
import type { PhotoPost } from "../../../../../generated/prisma/client.js";
interface Props {
photo: PhotoPost;
}
const { photo }: Props = $props();
let imgElement: HTMLImageElement;
let deleteDialogElement: HTMLDialogElement;
let title = $state(photo.title);
let description = $state(photo.description);
let isDeleteDialogOpen = $state(false);
const handleImageChange = (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
imgElement.src = e.target.result as string;
}
};
reader.readAsDataURL(file);
}
};
const openDeleteDialog = () => {
isDeleteDialogOpen = true;
deleteDialogElement.showModal();
};
const closeDeleteDialog = () => {
isDeleteDialogOpen = false;
if (deleteDialogElement.open) {
deleteDialogElement.close();
}
};
const handleDeleteDialogClose = () => {
isDeleteDialogOpen = false;
};
</script>
<form
class="cms-form"
method="POST"
enctype="multipart/form-data"
action="?/update"
>
<div class="image-container">
<img
src={`/image/${photo.fileName}`}
alt={photo.title}
class="preview"
bind:this={imgElement}
/>
</div>
<div class="field">
<label for="title">Title</label>
<input type="text" id="title" name="title" bind:value={title} />
</div>
<div class="field">
<label for="description">Description</label>
<textarea
id="description"
name="description"
rows="4"
bind:value={description}
></textarea>
</div>
<div class="field">
<label for="file">File</label>
<input type="file" id="file" name="file" onchange={handleImageChange} />
</div>
<div class="actions">
<input type="submit" value="Update Photo" class="thomaswilson-button" />
<button
type="button"
class="thomaswilson-button danger"
onclick={openDeleteDialog}
>
Delete photo
</button>
</div>
</form>
<dialog
class="confirm-dialog"
bind:this={deleteDialogElement}
onclose={handleDeleteDialogClose}
>
<p>Delete this photo permanently?</p>
<div class="dialog-actions">
<button
type="button"
class="thomaswilson-button"
onclick={closeDeleteDialog}
>
No
</button>
<form method="POST" action="?/delete">
<button type="submit" class="thomaswilson-button delete-button"
>Yes, delete</button
>
</form>
</div>
</dialog>
<style>
.image-container {
display: flex;
justify-content: center;
}
.actions {
display: flex;
gap: 0.5rem;
}
img.preview {
height: 250px;
}
</style>

View file

@ -0,0 +1,7 @@
import type { PageServerLoad } from '../upload/[id]/$types.js';
export const load: PageServerLoad = async ({ params }) => {
const { id } = params;
return { id };
};

View file

@ -1,15 +1,15 @@
import { SunriseSunsetController } from './SunriseSunsetController.js'
import { SunriseSunsetController } from './SunriseSunsetController.js';
const controller = new SunriseSunsetController()
const controller = new SunriseSunsetController();
export const GET = async () => {
const now = new Date()
const body = controller.getSunriseSunsetPhotoForDate(now)
const now = new Date();
const body = controller.getSunriseSunsetPhotoForDate(now);
const response = {
status: 200,
body,
}
};
return new Response(JSON.stringify(response.body))
}
return new Response(JSON.stringify(response.body));
};

View file

@ -1,22 +1,19 @@
import { it, describe, expect, beforeAll } from 'vitest'
import {
type ISunriseSunsetController,
SunriseSunsetController,
} from './SunriseSunsetController'
import { it, describe, expect, beforeAll } from 'vitest';
import { type ISunriseSunsetController, SunriseSunsetController } from './SunriseSunsetController';
describe('SunriseSunsetController', () => {
let controller: ISunriseSunsetController
let controller: ISunriseSunsetController;
beforeAll(() => {
controller = new SunriseSunsetController()
})
controller = new SunriseSunsetController();
});
it(`Should return a known photo for a known date`, () => {
// GIVEN
const aKnownDate = new Date('2023-01-24T14:00Z')
const aKnownDate = new Date('2023-01-24T14:00Z');
// WHEN
const photo = controller.getSunriseSunsetPhotoForDate(aKnownDate)
const photo = controller.getSunriseSunsetPhotoForDate(aKnownDate);
// THEN
expect(photo).toStrictEqual({
@ -30,17 +27,17 @@ describe('SunriseSunsetController', () => {
'https://images.unsplash.com/photo-1475656106224-d72c2ab53e8d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=Mnw0MDEyNTV8MHwxfHNlYXJjaHw5M3x8c3VucmlzZXxlbnwwfHx8fDE2NzQ1MDI4MzQ&ixlib=rb-4.0.3&q=80&w=400',
sunrise_or_sunset: 'sunrise',
},
})
})
});
});
it(`should return null when there is no photo for the day`, () => {
// GIVEN
const aDateWithoutPhoto = new Date('2020-01-01T00:00Z')
const aDateWithoutPhoto = new Date('2020-01-01T00:00Z');
// WHEN
const photo = controller.getSunriseSunsetPhotoForDate(aDateWithoutPhoto)
const photo = controller.getSunriseSunsetPhotoForDate(aDateWithoutPhoto);
// THEN
expect(photo).toBeNull()
})
})
expect(photo).toBeNull();
});
});

View file

@ -1,28 +1,28 @@
import data from './data.json'
import { format as formatDate } from 'date-fns'
import data from './data.json';
import { format as formatDate } from 'date-fns';
type Daytime = 'sunrise' | 'sunset'
type Daytime = 'sunrise' | 'sunset';
interface DailyPhoto {
date: string // e.g. "2023-01-24"
date: string; // e.g. "2023-01-24"
photo: {
id: string
description: string
username: string
username_url: string
small_url: string
sunrise_or_sunset: Daytime
}
id: string;
description: string;
username: string;
username_url: string;
small_url: string;
sunrise_or_sunset: Daytime;
};
}
export interface ISunriseSunsetController {
getSunriseSunsetPhotoForDate(date: Date): DailyPhoto | null
getSunriseSunsetPhotoForDate(date: Date): DailyPhoto | null;
}
export class SunriseSunsetController implements ISunriseSunsetController {
private data: DailyPhoto[] = data.photos as any
private data: DailyPhoto[] = data.photos as any;
getSunriseSunsetPhotoForDate(date) {
const formattedDate = formatDate(date, 'yyyy-MM-dd')
const formattedDate = formatDate(date, 'yyyy-MM-dd');
return this.data.find((photo) => photo.date === formattedDate) ?? null
return this.data.find((photo) => photo.date === formattedDate) ?? null;
}
}

View file

@ -2,19 +2,19 @@ import { json } from '@sveltejs/kit';
import wainwrights from '../../../content/wainwrights/wainwrights.json';
export const GET = async ({ url }) => {
try {
return json({
wainwrights
});
} catch (error) {
console.error({ error: JSON.stringify(error) });
return json(
{
error: 'Could not fetch wainwrights' + error
},
{
status: 500
}
);
}
try {
return json({
wainwrights,
});
} catch (error) {
console.error({ error: JSON.stringify(error) });
return json(
{
error: 'Could not fetch wainwrights' + error,
},
{
status: 500,
}
);
}
};

View file

@ -1,54 +1,54 @@
import {
BlogController,
type BlogItem,
type BlogPostListItem,
type BookReviewListItem,
} from "$lib/blog/BlogController.js";
import type { BookReview } from "$lib/blog/BookReview.js";
import type { Load } from "@sveltejs/kit";
import { differenceInCalendarDays, getYear } from "date-fns";
BlogController,
type BlogItem,
type BlogPostListItem,
type BookReviewListItem,
} from '$lib/blog/BlogController.js';
import type { BookReview } from '$lib/blog/BookReview.js';
import type { Load } from '@sveltejs/kit';
import { differenceInCalendarDays, getYear } from 'date-fns';
export const prerender = true;
type PostsGroupedByMonth = Array<{
yearDate: string;
posts: (BlogPostListItem | BookReviewListItem)[];
yearDate: string;
posts: (BlogPostListItem | BookReviewListItem)[];
}>;
export const load: Load = async ({}) => {
const controller = await BlogController.singleton();
const posts = await controller.getAllBlogPosts();
const controller = await BlogController.singleton();
const posts = await controller.getAllBlogPosts();
const currentYear = getYear(new Date());
const currentYear = getYear(new Date());
const numberOfPosts = posts.length;
const firstPost = posts[numberOfPosts - 1];
const numberOfBlogPostsThisYear: number = posts.filter(
(post) => getYear(new Date(post.date)) === currentYear,
).length;
const numberOfPosts = posts.length;
const firstPost = posts[numberOfPosts - 1];
const numberOfBlogPostsThisYear: number = posts.filter(
(post) => getYear(new Date(post.date)) === currentYear
).length;
const postsGroupedByMonth = posts.reduce((grouped, post) => {
const yearDate = Intl.DateTimeFormat("en-gb", {
year: "numeric",
month: "long",
}).format(new Date(post.date));
const postsGroupedByMonth = posts.reduce((grouped, post) => {
const yearDate = Intl.DateTimeFormat('en-gb', {
year: 'numeric',
month: 'long',
}).format(new Date(post.date));
const index = grouped.findIndex((entry) => entry.yearDate === yearDate);
const index = grouped.findIndex((entry) => entry.yearDate === yearDate);
if (index === -1) {
grouped.push({ yearDate, posts: [post] });
} else {
grouped[index].posts.push(post);
}
if (index === -1) {
grouped.push({ yearDate, posts: [post] });
} else {
grouped[index].posts.push(post);
}
return grouped;
}, [] as PostsGroupedByMonth);
return grouped;
}, [] as PostsGroupedByMonth);
return {
posts,
postsGroupedByMonth,
firstPost,
numberOfPosts,
numberOfBlogPostsThisYear,
};
return {
posts,
postsGroupedByMonth,
firstPost,
numberOfPosts,
numberOfBlogPostsThisYear,
};
};

View file

@ -1,25 +1,26 @@
import { access, readFile } from "node:fs/promises";
import { constants } from "node:fs";
import { PRIVATE_PHOTO_UPLOAD_DIR } from "$env/static/private";
import * as path from "node:path";
import { error, type ServerLoad } from "@sveltejs/kit";
import { access, readFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import { PRIVATE_PHOTO_UPLOAD_DIR } from '$env/static/private';
import * as path from 'node:path';
import { error, type ServerLoad } from '@sveltejs/kit';
export const GET: ServerLoad = async ({ params, locals }) => {
const { filename } = params;
const { filename } = params;
const proposedFilePath = path.join(PRIVATE_PHOTO_UPLOAD_DIR, filename);
const proposedFilePath = path.join(PRIVATE_PHOTO_UPLOAD_DIR, filename);
const fileExists = await access(proposedFilePath, constants.F_OK)
.then(() => true)
.catch(() => false);
const fileExists = await access(proposedFilePath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (!fileExists) {
return error(404, "File not found");
}
if (!fileExists) {
console.warn(`File with name ${filename} not found.`);
return error(404, 'File not found');
}
const file = await readFile(proposedFilePath);
const fileExt = path.extname(filename);
const contentType = `image/${fileExt.replace(".", "")}`;
const file = await readFile(proposedFilePath);
const fileExt = path.extname(filename);
const contentType = `image/${fileExt.replace('.', '')}`;
return new Response(file, { headers: { "Content-Type": contentType } });
return new Response(file, { headers: { 'Content-Type': contentType } });
};

View file

@ -1,7 +1,5 @@
import type { PageServerLoad } from "./$types.js";
import type { PageServerLoad } from './$types.js';
export const load: PageServerLoad = async({}) => {
return {
}
}
export const load: PageServerLoad = async ({}) => {
return {};
};

View file

@ -1,7 +1,5 @@
import type { PageServerLoad } from "./$types.js";
import type { PageServerLoad } from './$types.js';
export const load: PageServerLoad = async({}) => {
return {
}
}
export const load: PageServerLoad = async ({}) => {
return {};
};

View file

@ -2,14 +2,14 @@ import type { LoadEvent } from '@sveltejs/kit';
import type { Wainwright } from './Wainwright.js';
export async function load({ fetch }: LoadEvent): Promise<{ wainwrights: Wainwright[] }> {
const { wainwrights } = await fetch(`/api/wainwrights.json`)
.then((res) => res.json())
.catch((error) => {
console.error(error);
return { wainwrights: [] };
});
const { wainwrights } = await fetch(`/api/wainwrights.json`)
.then((res) => res.json())
.catch((error) => {
console.error(error);
return { wainwrights: [] };
});
return {
wainwrights
};
return {
wainwrights,
};
}

View file

@ -1,13 +1,13 @@
export interface Wainwright {
number: number;
name: string;
classification: string;
isWainwright: boolean;
heightMetres: number;
heightFeet: number;
dropMetres: number;
colMetres: number;
osGridRef: string;
latitude: number;
longitude: number;
number: number;
name: string;
classification: string;
isWainwright: boolean;
heightMetres: number;
heightFeet: number;
dropMetres: number;
colMetres: number;
osGridRef: string;
latitude: number;
longitude: number;
}

View file

@ -1,3 +1,105 @@
/* PrismJS 1.29.0
https://prismjs.com/download.html#themes=prism-solarizedlight&languages=markup+css+clike+javascript */
code[class*=language-],pre[class*=language-]{color:#657b83;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{background:#073642}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{background:#073642}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;border-radius:.3em}:not(pre)>code[class*=language-],pre[class*=language-]{background-color:#fdf6e3}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#93a1a1}.token.punctuation{color:#586e75}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#268bd2}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string,.token.url{color:#2aa198}.token.entity{color:#657b83;background:#eee8d5}.token.atrule,.token.attr-value,.token.keyword{color:#859900}.token.class-name,.token.function{color:#b58900}.token.important,.token.regex,.token.variable{color:#cb4b16}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}
code[class*='language-'],
pre[class*='language-'] {
color: #657b83;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
code[class*='language-'] ::-moz-selection,
code[class*='language-']::-moz-selection,
pre[class*='language-'] ::-moz-selection,
pre[class*='language-']::-moz-selection {
background: #073642;
}
code[class*='language-'] ::selection,
code[class*='language-']::selection,
pre[class*='language-'] ::selection,
pre[class*='language-']::selection {
background: #073642;
}
pre[class*='language-'] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border-radius: 0.3em;
}
:not(pre) > code[class*='language-'],
pre[class*='language-'] {
background-color: #fdf6e3;
}
:not(pre) > code[class*='language-'] {
padding: 0.1em;
border-radius: 0.3em;
}
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog {
color: #93a1a1;
}
.token.punctuation {
color: #586e75;
}
.token.namespace {
opacity: 0.7;
}
.token.boolean,
.token.constant,
.token.deleted,
.token.number,
.token.property,
.token.symbol,
.token.tag {
color: #268bd2;
}
.token.attr-name,
.token.builtin,
.token.char,
.token.inserted,
.token.selector,
.token.string,
.token.url {
color: #2aa198;
}
.token.entity {
color: #657b83;
background: #eee8d5;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #859900;
}
.token.class-name,
.token.function {
color: #b58900;
}
.token.important,
.token.regex,
.token.variable {
color: #cb4b16;
}
.token.bold,
.token.important {
font-weight: 700;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}

View file

@ -1,123 +1,126 @@
@font-face {
font-family: "FivoSansModern-Regular";
src: url("/FivoSansModern-Regular.otf");
font-display: swap;
font-family: 'FivoSansModern-Regular';
src: url('/FivoSansModern-Regular.otf');
font-display: swap;
}
:root {
--brand-orange: #ff8c0d;
--brand-purple: #464d77;
--brand-green: #36827f;
--brand-blue: #00a0e9;
--white: #fff;
--gray-100: #f8f9fa;
--gray-200: #e9ecef;
--gray-300: #dee2e6;
--gray-400: #ced4da;
--gray-500: #adb5bd;
--gray-600: #6c757d;
--gray-700: #495057;
--gray-800: #343a40;
--gray-900: #212529;
--gray-950: #1a1e23;
--gray-1000: #0a0c0e;
--font-family-mono: monospace;
--font-family-title: "FivoSansModern-Regular", sans-serif;
--font-family-sans:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--font-family-serif: Georgia, Cambria, "Times New Roman", Times, serif;
--brand-orange: #ff8c0d;
--brand-purple: #464d77;
--brand-green: #36827f;
--brand-blue: #00a0e9;
--white: #fff;
--gray-100: #f8f9fa;
--gray-200: #e9ecef;
--gray-300: #dee2e6;
--gray-400: #ced4da;
--gray-500: #adb5bd;
--gray-600: #6c757d;
--gray-700: #495057;
--gray-800: #343a40;
--gray-900: #212529;
--gray-950: #1a1e23;
--gray-1000: #0a0c0e;
--line-height: 120%;
--line-height-sm: 120%;
--line-height-md: 140%;
--line-height-lg: 145%;
--colour-danger: red;
--font-family-mono: monospace;
--font-family-title: 'FivoSansModern-Regular', sans-serif;
--font-family-sans:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-family-serif: Georgia, Cambria, 'Times New Roman', Times, serif;
--font-size-xs: 11px;
--font-size-sm: 13px;
--font-size-base: 1rem;
--font-size-lg: 1.25rem;
--font-size-xl: 1.5rem;
--font-size-2xl: 2rem;
--spacing-base: 1rem;
--spacing-sm: 4px;
--spacing-md: 8px;
--spacing-lg: 12px;
--spacing-xl: 16px;
--navbar-height: 75px;
--line-height: 120%;
--line-height-sm: 120%;
--line-height-md: 140%;
--line-height-lg: 145%;
--font-size-sm: 0.875rem;
--font-size: 1.12rem;
--font-size-md: 1.25rem;
--font-size-lg: 1.5rem;
--border-radius-sm: 4px;
--border-radius-md: 8px;
--border-radius-lg: 12px;
--btn-border: 0;
--btn-padding: var(--spacing-sm);
--btn-border-radius: 0.25rem;
--btn-font-size: 1.08rem;
--btn-text-decoration: none;
--font-size-xs: 11px;
--font-size-sm: 13px;
--font-size-base: 1rem;
--font-size-lg: 1.25rem;
--font-size-xl: 1.5rem;
--font-size-2xl: 2rem;
--spacing-base: 1rem;
--spacing-sm: 4px;
--spacing-md: 8px;
--spacing-lg: 12px;
--spacing-xl: 16px;
--navbar-height: 75px;
--font-size-sm: 0.875rem;
--font-size: 1.12rem;
--font-size-md: 1.25rem;
--font-size-lg: 1.5rem;
--btn-border: 0;
--btn-padding: var(--spacing-sm);
--btn-border-radius: 0.25rem;
--btn-font-size: 1.08rem;
--btn-text-decoration: none;
}
html {
font-size: 16px;
font-family: var(--font-family-sans);
line-height: var(--line-height-md);
color: var(--colour-scheme-text);
background-color: var(--colour-scheme-background, black);
transition: 0.3s ease;
transition-property: background-color, color;
font-size: 16px;
font-family: var(--font-family-sans);
line-height: var(--line-height-md);
color: var(--colour-scheme-text);
background-color: var(--colour-scheme-background, black);
transition: 0.3s ease;
transition-property: background-color, color;
}
body {
font-family: var(--font-family-sans);
line-height: var(--line-height-md);
min-height: 100vh;
background-color: var(--colour-scheme-background, black);
color: var(--colour-scheme-text);
transition: 0.3s ease;
transition-property: background-color, color;
font-family: var(--font-family-sans);
line-height: var(--line-height-md);
min-height: 100vh;
background-color: var(--colour-scheme-background, black);
color: var(--colour-scheme-text);
transition: 0.3s ease;
transition-property: background-color, color;
}
.thomaswilson-container {
--container-padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(
100vh - var(--navbar-height) - calc(2 * var(--container-padding))
);
padding: var(--container-padding);
--container-padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - var(--navbar-height) - calc(2 * var(--container-padding)));
padding: var(--container-padding);
}
.thomaswilson-container .section {
padding: 24px;
width: 100%;
max-width: 750px;
font-size: 1.19rem;
line-height: var(--line-height-md);
padding-bottom: var(--spacing-base);
padding-bottom: 2rem;
padding: 24px;
width: 100%;
max-width: 750px;
font-size: 1.19rem;
line-height: var(--line-height-md);
padding-bottom: var(--spacing-base);
padding-bottom: 2rem;
}
.thomaswilson-strapline .title {
font-family: var(--font-family-title);
font-size: var(--font-size-base);
font-weight: 700;
margin: 0;
font-family: var(--font-family-title);
font-size: var(--font-size-base);
font-weight: 700;
margin: 0;
}
.thomaswilson-strapline p {
font-size: var(--font-size-md);
line-height: var(--line-height-md);
letter-spacing: -0.25px;
font-weight: 200;
font-size: var(--font-size-md);
line-height: var(--line-height-md);
letter-spacing: -0.25px;
font-weight: 200;
}
@container (width < 500px) {
.thomaswilson-strapline p {
}
.thomaswilson-strapline p {
}
}
h1,
@ -126,137 +129,245 @@ h3,
h4,
h5,
h6 {
font-family: var(--font-family-title);
font-weight: 600;
margin: 0;
color: var(--colour-scheme-text);
padding-top: 12px;
padding-bottom: 8px;
line-height: var(--line-height);
letter-spacing: 1.5px;
font-family: var(--font-family-title);
font-weight: 600;
margin: 0;
color: var(--colour-scheme-text);
padding-top: 12px;
padding-bottom: 8px;
line-height: var(--line-height);
letter-spacing: 1.5px;
}
h2 {
font-size: 2.25rem;
padding-top: 0.7rem;
padding-bottom: 0.5rem;
font-size: 2.25rem;
padding-top: 0.7rem;
padding-bottom: 0.5rem;
}
p,
li,
a,
blockquote {
font-size: var(--font-size);
line-height: var(--line-height-lg);
font-family: var(--font-family-mono);
margin: 0;
color: var(--colour-scheme-text);
padding: 4px 0;
letter-spacing: 0px;
font-size: var(--font-size);
line-height: var(--line-height-lg);
font-family: var(--font-family-mono);
margin: 0;
color: var(--colour-scheme-text);
padding: 4px 0;
letter-spacing: 0px;
}
blockquote {
border-style: solid;
padding: 0.25rem 0 0.5rem 1rem;
border-width: 0px;
border-left: 4px solid var(--brand-orange);
opacity: 0.85;
max-width: 60ch;
border-style: solid;
padding: 0.25rem 0 0.5rem 1rem;
border-width: 0px;
border-left: 4px solid var(--brand-orange);
opacity: 0.85;
max-width: 60ch;
}
ul,
ol {
padding-left: var(--spacing-base);
padding-left: var(--spacing-base);
}
.thomaswilson-button {
border: var(--btn-border);
padding: var(--btn-padding);
border-radius: var(--btn-border-radius);
font-size: var(--btn-font-size);
text-decoration: var(--btn-text-decoration);
border: var(--btn-border);
padding: var(--btn-padding);
border-radius: var(--btn-border-radius);
font-size: var(--btn-font-size);
text-decoration: var(--btn-text-decoration);
}
.thomaswilson-button.danger {
color: var(--colour-danger);
border: 1px solid var(--colour-danger);
}
.thomaswilson-button:hover {
text-decoration: none;
cursor: pointer;
text-decoration: none;
cursor: pointer;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
img {
max-width: 100%;
height: auto;
max-width: 100%;
height: auto;
}
a::after {
content: url("/assets/icons/link.svg");
margin-left: 3px;
content: url('/assets/icons/link.svg');
margin-left: 3px;
}
sup a::after {
content: none;
margin-left: 1px;
content: none;
margin-left: 1px;
}
a.no-icon::after {
content: "";
display: none;
content: '';
display: none;
}
a.breadcrumb {
color: var(--gray-600);
font-size: var(--font-size-sm);
}
a.breadcrumb::after {
content: '';
display: none;
}
/* An Alert-like component*/
.alert {
--colour-scheme-border: var(--gray-800);
--font-size: var(--font-size-base);
--colour-scheme-text: var(--gray-800);
--colour-scheme-bg: var(--gray-100);
padding: 8px;
border: 1px solid var(--colour-scheme-border);
border-radius: 4px;
font-size: var(--font-size);
letter-spacing: 0px;
color: var(--colour-scheme-text);
background-color: var(--colour-scheme-bg);
--colour-scheme-border: var(--gray-800);
--font-size: var(--font-size-base);
--colour-scheme-text: var(--gray-800);
--colour-scheme-bg: var(--gray-100);
padding: 8px;
border: 1px solid var(--colour-scheme-border);
border-radius: 4px;
font-size: var(--font-size);
letter-spacing: 0px;
color: var(--colour-scheme-text);
background-color: var(--colour-scheme-bg);
}
.alert.error {
--colour-scheme-border: var(--red-800);
--colour-scheme-text: var(--red-800);
--colour-scheme-bg: var(--red-100);
--colour-scheme-border: var(--red-800);
--colour-scheme-text: var(--red-800);
--colour-scheme-bg: var(--red-100);
}
.admin-form {
display: flex;
flex-direction: column;
gap: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-form .field {
display: flex;
flex-direction: column;
gap: 4px;
display: flex;
flex-direction: column;
gap: 4px;
}
.admin-form .field label {
font-size: var(--font-size);
letter-spacing: 0px;
color: var(--colour-scheme-text);
font-size: var(--font-size);
letter-spacing: 0px;
color: var(--colour-scheme-text);
}
.admin-form .field input {
padding: 8px;
border: 1px solid var(--colour-scheme-border);
border-radius: 4px;
font-size: var(--font-size);
letter-spacing: 0px;
color: var(--colour-scheme-text);
background-color: var(--colour-scheme-bg);
padding: 8px;
border: 1px solid var(--colour-scheme-border);
border-radius: 4px;
font-size: var(--font-size);
letter-spacing: 0px;
color: var(--colour-scheme-text);
background-color: var(--colour-scheme-bg);
}
dialog.confirm-dialog {
border: 1px solid var(--colour-scheme-border);
border-radius: 4px;
padding: 16px;
background-color: var(--colour-scheme-background);
background-color: var(--colour-scheme-background);
color: var(--colour-scheme-text);
}
.confirm-dialog .delete-button {
border: 1px solid var(--colour-danger);
background-color: var(--colour-scheme-background);
color: var(--colour-danger);
}
.delete-button:hover {
background-color: #ffe3e3;
}
.delete-dialog::backdrop {
background: rgb(0 0 0 / 0.3);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.75rem;
}
/**
* SECTION: CMS
*/
.cms-form {
--form-max-width: 760px;
--form-border: var(--colour-scheme-border, var(--gray-300));
--button-bg: var(--gray-900);
--button-text: var(--white);
width: min(100%, var(--form-max-width));
padding: var(--spacing-base);
border: 1px solid var(--form-border);
border-radius: var(--border-radius-md);
display: flex;
flex-direction: column;
gap: var(--spacing-base);
}>
.cms-form .field {
gap: 0.4rem;
}
.cms-form :is(input[type="text"], textarea, input[type="file"]) {
width: 100%;
box-sizing: border-box;
padding: var(--spacing-md) var(--spacing-base);
border: 1px solid var(--form-border);
border-radius: var(--border-radius-sm);
background: var(--colour-scheme-bg);
color: var(--colour-scheme-text);
}
.cms-form textarea {
min-height: 7rem;
resize: vertical;
}
.actions {
display: flex;
justify-content: flex-end;
}
.submit-button:hover {
opacity: 0.92;
}
@media (max-width: 640px) {
.cms-form {
padding: 0.85rem;
border-radius: 8px;
}
.actions {
justify-content: stretch;
}
.submit-button {
width: 100%;
}
}

118
static/normalize.css vendored
View file

@ -9,8 +9,8 @@
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
@ -21,7 +21,7 @@ html {
*/
body {
margin: 0;
margin: 0;
}
/**
@ -29,7 +29,7 @@ body {
*/
main {
display: block;
display: block;
}
/**
@ -38,8 +38,8 @@ main {
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
@ -51,9 +51,9 @@ h1 {
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
@ -62,8 +62,8 @@ hr {
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
@ -74,7 +74,7 @@ pre {
*/
a {
background-color: transparent;
background-color: transparent;
}
/**
@ -83,9 +83,9 @@ a {
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
@ -94,7 +94,7 @@ abbr[title] {
b,
strong {
font-weight: bolder;
font-weight: bolder;
}
/**
@ -105,8 +105,8 @@ strong {
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
@ -114,7 +114,7 @@ samp {
*/
small {
font-size: 80%;
font-size: 80%;
}
/**
@ -124,18 +124,18 @@ small {
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
bottom: -0.25em;
}
sup {
top: -0.5em;
top: -0.5em;
}
/* Embedded content
@ -146,7 +146,7 @@ sup {
*/
img {
border-style: none;
border-style: none;
}
/* Forms
@ -162,10 +162,10 @@ input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
@ -175,8 +175,8 @@ textarea {
button,
input {
/* 1 */
overflow: visible;
/* 1 */
overflow: visible;
}
/**
@ -186,8 +186,8 @@ input {
button,
select {
/* 1 */
text-transform: none;
/* 1 */
text-transform: none;
}
/**
@ -198,7 +198,7 @@ button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
-webkit-appearance: button;
}
/**
@ -209,8 +209,8 @@ button::-moz-focus-inner,
[type='button']::-moz-focus-inner,
[type='reset']::-moz-focus-inner,
[type='submit']::-moz-focus-inner {
border-style: none;
padding: 0;
border-style: none;
padding: 0;
}
/**
@ -221,7 +221,7 @@ button:-moz-focusring,
[type='button']:-moz-focusring,
[type='reset']:-moz-focusring,
[type='submit']:-moz-focusring {
outline: 1px dotted ButtonText;
outline: 1px dotted ButtonText;
}
/**
@ -229,7 +229,7 @@ button:-moz-focusring,
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
padding: 0.35em 0.75em 0.625em;
}
/**
@ -240,12 +240,12 @@ fieldset {
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
@ -253,7 +253,7 @@ legend {
*/
progress {
vertical-align: baseline;
vertical-align: baseline;
}
/**
@ -261,7 +261,7 @@ progress {
*/
textarea {
overflow: auto;
overflow: auto;
}
/**
@ -271,8 +271,8 @@ textarea {
[type='checkbox'],
[type='radio'] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
@ -281,7 +281,7 @@ textarea {
[type='number']::-webkit-inner-spin-button,
[type='number']::-webkit-outer-spin-button {
height: auto;
height: auto;
}
/**
@ -290,8 +290,8 @@ textarea {
*/
[type='search'] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
@ -299,7 +299,7 @@ textarea {
*/
[type='search']::-webkit-search-decoration {
-webkit-appearance: none;
-webkit-appearance: none;
}
/**
@ -308,8 +308,8 @@ textarea {
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
@ -320,7 +320,7 @@ textarea {
*/
details {
display: block;
display: block;
}
/*
@ -328,7 +328,7 @@ details {
*/
summary {
display: list-item;
display: list-item;
}
/* Misc
@ -339,7 +339,7 @@ summary {
*/
template {
display: none;
display: none;
}
/**
@ -347,5 +347,5 @@ template {
*/
[hidden] {
display: none;
display: none;
}

File diff suppressed because one or more lines are too long

View file

@ -1,25 +1,25 @@
import adapter from "@sveltejs/adapter-node";
import preprocess from "svelte-preprocess";
import adapter from '@sveltejs/adapter-node';
import preprocess from 'svelte-preprocess';
/** @type {import('@sveltejs/kit').Config} */
const config = {
extensions: [".svelte", ".md"],
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: [preprocess()],
extensions: ['.svelte', '.md'],
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: [preprocess()],
kit: {
adapter: adapter({ split: false }),
alias: {
$lib: "/src/lib",
$srcPrisma: "/src/prisma",
$generatedPrisma: "/generated/prisma/*",
kit: {
adapter: adapter({ split: false }),
alias: {
$lib: '/src/lib',
$srcPrisma: '/src/prisma',
$generatedPrisma: '/generated/prisma/*',
},
env: {
publicPrefix: 'PUBLIC_',
privatePrefix: 'PRIVATE_',
},
},
env: {
publicPrefix: "PUBLIC_",
privatePrefix: "PRIVATE_",
},
},
};
export default config;

View file

@ -1,14 +1,8 @@
import { sveltekit } from "@sveltejs/kit/vite";
import { sveltekit } from '@sveltejs/kit/vite';
/** @type {import('vite').UserConfig} */
const config = {
plugins: [sveltekit()],
resolve: {
alias: {
$lib: "/src/lib",
$srcPrisma: "/src/prisma",
},
},
plugins: [sveltekit()],
};
export default config;

View file

@ -4,6 +4,7 @@ export default {
alias: {
$lib: '/src/lib',
$srcPrisma: '/src/prisma',
$generatedPrisma: '/generated/prisma',
},
},
test: {