Compare commits
5 Commits
dev
...
eba89011aa
| Author | SHA1 | Date | |
|---|---|---|---|
| eba89011aa | |||
| c76a137090 | |||
| 99e97ce750 | |||
| b005a39f7c | |||
| 95514c8eea |
@@ -9,11 +9,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.stanrunge.dev
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- uses: docker/build-push-action@v5
|
- uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: true
|
||||||
load: true
|
|
||||||
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }}
|
tags: git.stanrunge.dev/stan/personal-website:${{ github.sha }}
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ RUN bun run build
|
|||||||
|
|
||||||
FROM oven/bun
|
FROM oven/bun
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/build ./build
|
COPY --from=builder /app/build ./build
|
||||||
COPY package.json ./
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
CMD ["bun", "build/index.js"]
|
CMD ["bun", "build/index.js"]
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
CREATE TYPE "availability" AS ENUM('catchable', 'trade_only', 'transfer_only', 'event_only');--> statement-breakpoint
|
|
||||||
CREATE TABLE "animals" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
"species" text NOT NULL,
|
|
||||||
"breed" text,
|
|
||||||
"animal_name" text,
|
|
||||||
"description" text,
|
|
||||||
"ai_breed_suggestion" text,
|
|
||||||
"ai_breed_confidence" real,
|
|
||||||
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"accepted_at" timestamp with time zone,
|
|
||||||
"denied_at" timestamp with time zone
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "blog_articles" (
|
|
||||||
"id" serial PRIMARY KEY,
|
|
||||||
"title" varchar(256),
|
|
||||||
"slug" varchar(256),
|
|
||||||
"content" varchar(8192),
|
|
||||||
"created_at" timestamp DEFAULT now(),
|
|
||||||
"updated_at" timestamp DEFAULT now()
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "certificates" (
|
|
||||||
"id" serial PRIMARY KEY,
|
|
||||||
"name" varchar(256),
|
|
||||||
"progress" integer DEFAULT 0,
|
|
||||||
"created_at" timestamp DEFAULT now(),
|
|
||||||
"updated_at" timestamp DEFAULT now()
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "game" (
|
|
||||||
"id" serial PRIMARY KEY,
|
|
||||||
"name" varchar(256) NOT NULL,
|
|
||||||
"generation" integer DEFAULT 1 NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "photos" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
"sighting_id" uuid NOT NULL,
|
|
||||||
"r2_key" text NOT NULL,
|
|
||||||
"url" text NOT NULL,
|
|
||||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
|
||||||
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "pokemon" (
|
|
||||||
"id" serial PRIMARY KEY,
|
|
||||||
"number" integer NOT NULL,
|
|
||||||
"generation" integer DEFAULT 1 NOT NULL,
|
|
||||||
"name" varchar(256) NOT NULL,
|
|
||||||
"type1" varchar(256) NOT NULL,
|
|
||||||
"type2" varchar(256),
|
|
||||||
"hp" integer DEFAULT 0 NOT NULL,
|
|
||||||
"attack" integer DEFAULT 0 NOT NULL,
|
|
||||||
"defense" integer DEFAULT 0 NOT NULL,
|
|
||||||
"special_attack" integer DEFAULT 0 NOT NULL,
|
|
||||||
"special_defense" integer DEFAULT 0 NOT NULL,
|
|
||||||
"speed" integer DEFAULT 0 NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "pokemon_game_entry" (
|
|
||||||
"id" serial PRIMARY KEY,
|
|
||||||
"pokemon_id" integer NOT NULL,
|
|
||||||
"game_id" integer NOT NULL,
|
|
||||||
"availability" "availability" DEFAULT 'catchable'::"availability" NOT NULL,
|
|
||||||
"note" varchar(256),
|
|
||||||
CONSTRAINT "pokemon_game_entry_pokemon_id_game_id_unique" UNIQUE("pokemon_id","game_id")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "publications" (
|
|
||||||
"id" serial PRIMARY KEY,
|
|
||||||
"name" varchar(256),
|
|
||||||
"created_at" timestamp DEFAULT now(),
|
|
||||||
"updated_at" timestamp DEFAULT now()
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "sightings" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
"animal_id" uuid NOT NULL,
|
|
||||||
"reporter_name" text,
|
|
||||||
"seen_at" timestamp with time zone NOT NULL,
|
|
||||||
"lat" double precision NOT NULL,
|
|
||||||
"lng" double precision NOT NULL,
|
|
||||||
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"accepted_at" timestamp with time zone,
|
|
||||||
"denied_at" timestamp with time zone
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "tasks" (
|
|
||||||
"id" serial PRIMARY KEY,
|
|
||||||
"name" varchar(256),
|
|
||||||
"current_points" integer DEFAULT 0,
|
|
||||||
"total_points" integer DEFAULT 1,
|
|
||||||
"notes" varchar(8192),
|
|
||||||
"created_at" timestamp DEFAULT now(),
|
|
||||||
"updated_at" timestamp DEFAULT now()
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "tasks_to_topics" (
|
|
||||||
"task_id" integer NOT NULL,
|
|
||||||
"topic_id" integer NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "topics" (
|
|
||||||
"id" serial PRIMARY KEY,
|
|
||||||
"name" varchar(256),
|
|
||||||
"emoji" varchar(256),
|
|
||||||
"created_at" timestamp DEFAULT now(),
|
|
||||||
"updated_at" timestamp DEFAULT now()
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "universities" (
|
|
||||||
"id" serial PRIMARY KEY,
|
|
||||||
"name" varchar(256),
|
|
||||||
"progress" integer DEFAULT 0,
|
|
||||||
"created_at" timestamp DEFAULT now(),
|
|
||||||
"updated_at" timestamp DEFAULT now()
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "user_entry" (
|
|
||||||
"id" serial PRIMARY KEY,
|
|
||||||
"pokemon_game_entry_id" integer NOT NULL,
|
|
||||||
"user_id" text NOT NULL,
|
|
||||||
CONSTRAINT "user_entry_pokemon_game_entry_id_user_id_unique" UNIQUE("pokemon_game_entry_id","user_id")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "user_game" (
|
|
||||||
"id" serial PRIMARY KEY,
|
|
||||||
"user_id" text NOT NULL,
|
|
||||||
"game_id" integer NOT NULL,
|
|
||||||
CONSTRAINT "user_game_user_id_game_id_unique" UNIQUE("user_id","game_id")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "account" (
|
|
||||||
"id" text PRIMARY KEY,
|
|
||||||
"account_id" text NOT NULL,
|
|
||||||
"provider_id" text NOT NULL,
|
|
||||||
"user_id" text NOT NULL,
|
|
||||||
"access_token" text,
|
|
||||||
"refresh_token" text,
|
|
||||||
"id_token" text,
|
|
||||||
"access_token_expires_at" timestamp,
|
|
||||||
"refresh_token_expires_at" timestamp,
|
|
||||||
"scope" text,
|
|
||||||
"password" text,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "session" (
|
|
||||||
"id" text PRIMARY KEY,
|
|
||||||
"expires_at" timestamp NOT NULL,
|
|
||||||
"token" text NOT NULL UNIQUE,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp NOT NULL,
|
|
||||||
"ip_address" text,
|
|
||||||
"user_agent" text,
|
|
||||||
"user_id" text NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "user" (
|
|
||||||
"id" text PRIMARY KEY,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"email" text NOT NULL UNIQUE,
|
|
||||||
"email_verified" boolean DEFAULT false NOT NULL,
|
|
||||||
"image" text,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "verification" (
|
|
||||||
"id" text PRIMARY KEY,
|
|
||||||
"identifier" text NOT NULL,
|
|
||||||
"value" text NOT NULL,
|
|
||||||
"expires_at" timestamp NOT NULL,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX "pokemon_game_entry_game_id_index" ON "pokemon_game_entry" ("game_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "user_entry_user_id_index" ON "user_entry" ("user_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "user_game_user_id_index" ON "user_game" ("user_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "account_userId_idx" ON "account" ("user_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "session_userId_idx" ON "session" ("user_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "verification_identifier_idx" ON "verification" ("identifier");--> statement-breakpoint
|
|
||||||
ALTER TABLE "photos" ADD CONSTRAINT "photos_sighting_id_sightings_id_fkey" FOREIGN KEY ("sighting_id") REFERENCES "sightings"("id") ON DELETE CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "pokemon_game_entry" ADD CONSTRAINT "pokemon_game_entry_pokemon_id_pokemon_id_fkey" FOREIGN KEY ("pokemon_id") REFERENCES "pokemon"("id") ON DELETE CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "pokemon_game_entry" ADD CONSTRAINT "pokemon_game_entry_game_id_game_id_fkey" FOREIGN KEY ("game_id") REFERENCES "game"("id") ON DELETE CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "sightings" ADD CONSTRAINT "sightings_animal_id_animals_id_fkey" FOREIGN KEY ("animal_id") REFERENCES "animals"("id") ON DELETE CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "tasks_to_topics" ADD CONSTRAINT "tasks_to_topics_task_id_tasks_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id");--> statement-breakpoint
|
|
||||||
ALTER TABLE "tasks_to_topics" ADD CONSTRAINT "tasks_to_topics_topic_id_topics_id_fkey" FOREIGN KEY ("topic_id") REFERENCES "topics"("id");--> statement-breakpoint
|
|
||||||
ALTER TABLE "user_entry" ADD CONSTRAINT "user_entry_pokemon_game_entry_id_pokemon_game_entry_id_fkey" FOREIGN KEY ("pokemon_game_entry_id") REFERENCES "pokemon_game_entry"("id") ON DELETE CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "user_entry" ADD CONSTRAINT "user_entry_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "user_game" ADD CONSTRAINT "user_game_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "user_game" ADD CONSTRAINT "user_game_game_id_game_id_fkey" FOREIGN KEY ("game_id") REFERENCES "game"("id") ON DELETE CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
|
||||||
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;
|
|
||||||
File diff suppressed because it is too large
Load Diff
52
migrations/0000_famous_random.sql
Executable file
52
migrations/0000_famous_random.sql
Executable file
@@ -0,0 +1,52 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "books" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(256),
|
||||||
|
"progress" integer DEFAULT 0,
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "certificates" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(256),
|
||||||
|
"progress" integer DEFAULT 0,
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "courses" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(256),
|
||||||
|
"university_id" integer,
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "publications" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(256),
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "topics" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(256),
|
||||||
|
"emoji" varchar(256),
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "universities" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(256),
|
||||||
|
"progress" integer DEFAULT 0,
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "courses" ADD CONSTRAINT "courses_university_id_universities_id_fk" FOREIGN KEY ("university_id") REFERENCES "public"."universities"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
279
migrations/meta/0000_snapshot.json
Executable file
279
migrations/meta/0000_snapshot.json
Executable file
@@ -0,0 +1,279 @@
|
|||||||
|
{
|
||||||
|
"id": "95e1c262-0787-412f-bb2f-7db06e20f3a0",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.books": {
|
||||||
|
"name": "books",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(256)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"name": "progress",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.certificates": {
|
||||||
|
"name": "certificates",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(256)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"name": "progress",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.courses": {
|
||||||
|
"name": "courses",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(256)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"university_id": {
|
||||||
|
"name": "university_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"courses_university_id_universities_id_fk": {
|
||||||
|
"name": "courses_university_id_universities_id_fk",
|
||||||
|
"tableFrom": "courses",
|
||||||
|
"tableTo": "universities",
|
||||||
|
"columnsFrom": [
|
||||||
|
"university_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.publications": {
|
||||||
|
"name": "publications",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(256)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.topics": {
|
||||||
|
"name": "topics",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(256)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"emoji": {
|
||||||
|
"name": "emoji",
|
||||||
|
"type": "varchar(256)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.universities": {
|
||||||
|
"name": "universities",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(256)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"name": "progress",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
migrations/meta/_journal.json
Executable file
13
migrations/meta/_journal.json
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1725668865024,
|
||||||
|
"tag": "0000_famous_random",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
61
package-lock.json
generated
61
package-lock.json
generated
@@ -15,9 +15,6 @@
|
|||||||
"@sveltejs/adapter-node": "^5.5.7",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"ai": "^7.0.85",
|
"ai": "^7.0.85",
|
||||||
"drizzle-orm": "^1.0.0-rc.4",
|
"drizzle-orm": "^1.0.0-rc.4",
|
||||||
"highlight.js": "^11.12.0",
|
|
||||||
"marked": "^18.0.11",
|
|
||||||
"marked-highlight": "^2.2.4",
|
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"zod": "^4.5.4"
|
"zod": "^4.5.4"
|
||||||
@@ -29,7 +26,6 @@
|
|||||||
"@sveltejs/enhanced-img": "^0.11.0",
|
"@sveltejs/enhanced-img": "^0.11.0",
|
||||||
"@sveltejs/kit": "^2.70.3",
|
"@sveltejs/kit": "^2.70.3",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/eslint": "9.6.1",
|
"@types/eslint": "9.6.1",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
@@ -3241,33 +3237,6 @@
|
|||||||
"node": ">= 20"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/typography": {
|
|
||||||
"version": "0.5.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz",
|
|
||||||
"integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"postcss-selector-parser": "6.0.10"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"tailwindcss": ">=3.0.0 || >=4.0.0 || insiders"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
|
||||||
"version": "6.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
|
||||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cssesc": "^3.0.0",
|
|
||||||
"util-deprecate": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/vite": {
|
"node_modules/@tailwindcss/vite": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
|
||||||
@@ -5963,15 +5932,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/highlight.js": {
|
|
||||||
"version": "11.12.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",
|
|
||||||
"integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -6577,27 +6537,6 @@
|
|||||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/marked": {
|
|
||||||
"version": "18.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz",
|
|
||||||
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"marked": "bin/marked.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/marked-highlight": {
|
|
||||||
"version": "2.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.2.4.tgz",
|
|
||||||
"integrity": "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"marked": ">=4 <19"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
"@sveltejs/enhanced-img": "^0.11.0",
|
"@sveltejs/enhanced-img": "^0.11.0",
|
||||||
"@sveltejs/kit": "^2.70.3",
|
"@sveltejs/kit": "^2.70.3",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/eslint": "9.6.1",
|
"@types/eslint": "9.6.1",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
@@ -65,9 +64,6 @@
|
|||||||
"@sveltejs/adapter-node": "^5.5.7",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"ai": "^7.0.85",
|
"ai": "^7.0.85",
|
||||||
"drizzle-orm": "^1.0.0-rc.4",
|
"drizzle-orm": "^1.0.0-rc.4",
|
||||||
"highlight.js": "^11.12.0",
|
|
||||||
"marked": "^18.0.11",
|
|
||||||
"marked-highlight": "^2.2.4",
|
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"zod": "^4.5.4"
|
"zod": "^4.5.4"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@plugin '@tailwindcss/typography';
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-bg: #f6f4f0;
|
--color-bg: #f6f4f0;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { sveltekitCookies } from 'better-auth/svelte-kit';
|
|||||||
import { getRequestEvent } from '$app/server';
|
import { getRequestEvent } from '$app/server';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import * as schema from '$lib/server/db/schema';
|
import * as schema from '$lib/server/db/schema';
|
||||||
import { error, type RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
baseURL: env.ORIGIN ?? 'http://localhost:3000',
|
baseURL: env.ORIGIN ?? 'http://localhost:3000',
|
||||||
@@ -30,6 +30,9 @@ export function isAdmin(event: RequestEvent): boolean {
|
|||||||
|
|
||||||
export function requireAdmin(event: RequestEvent): void {
|
export function requireAdmin(event: RequestEvent): void {
|
||||||
if (!isAdmin(event)) {
|
if (!isAdmin(event)) {
|
||||||
error(401, { message: 'Unauthorized' });
|
throw new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,21 +69,14 @@ export const publications = pgTable('publications', {
|
|||||||
// Legacy ^
|
// Legacy ^
|
||||||
// New
|
// New
|
||||||
|
|
||||||
export const blogArticleStatus = pgEnum('blog_article_status', ['draft', 'published']);
|
export const blogArticles = pgTable('blog_articles', {
|
||||||
|
|
||||||
export const blogArticles = pgTable(
|
|
||||||
'blog_articles',
|
|
||||||
{
|
|
||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
title: varchar('title', { length: 256 }).notNull(),
|
title: varchar('title', { length: 256 }),
|
||||||
slug: varchar('slug', { length: 256 }).notNull(),
|
slug: varchar('slug', { length: 256 }),
|
||||||
content: text('content').default(''),
|
content: varchar('content', { length: 8192 }),
|
||||||
status: blogArticleStatus('status').notNull().default('draft'),
|
|
||||||
createdAt: timestamp('created_at').defaultNow(),
|
createdAt: timestamp('created_at').defaultNow(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow()
|
updatedAt: timestamp('updated_at').defaultNow()
|
||||||
},
|
});
|
||||||
(table) => [unique().on(table.slug)]
|
|
||||||
);
|
|
||||||
|
|
||||||
// CatchEmAll
|
// CatchEmAll
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Marked } from 'marked';
|
|
||||||
import { markedHighlight } from 'marked-highlight';
|
|
||||||
import hljs from 'highlight.js';
|
|
||||||
|
|
||||||
const marked = new Marked(
|
|
||||||
markedHighlight({
|
|
||||||
emptyLangClass: 'hljs',
|
|
||||||
langPrefix: 'hljs language-',
|
|
||||||
highlight(code, lang) {
|
|
||||||
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
|
|
||||||
return hljs.highlight(code, { language }).value;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export function renderMarkdown(content: string): string {
|
|
||||||
return marked.parse(content, { async: false });
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { db } from '$lib/server/db';
|
|
||||||
import { blogArticles } from '$lib/server/db/schema';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
|
|
||||||
export function slugify(title: string): string {
|
|
||||||
return (
|
|
||||||
title
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '') || 'article'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uniqueSlug(title: string): Promise<string> {
|
|
||||||
const base = slugify(title);
|
|
||||||
let slug = base;
|
|
||||||
let suffix = 2;
|
|
||||||
|
|
||||||
while (
|
|
||||||
(await db.select({ id: blogArticles.id }).from(blogArticles).where(eq(blogArticles.slug, slug)))
|
|
||||||
.length > 0
|
|
||||||
) {
|
|
||||||
slug = `${base}-${suffix}`;
|
|
||||||
suffix += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return slug;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const createArticleSchema = z.object({
|
|
||||||
title: z.string().min(1).max(256)
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateArticleSchema = z.object({
|
|
||||||
title: z.string().min(1).max(256),
|
|
||||||
content: z.string().max(50000)
|
|
||||||
});
|
|
||||||
@@ -48,7 +48,6 @@
|
|||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col min-h-screen">
|
|
||||||
<header class="flex justify-between items-center gap-4 py-4 px-4">
|
<header class="flex justify-between items-center gap-4 py-4 px-4">
|
||||||
<a href={resolve('/')} class="text-2xl font-bold">Stan Runge</a>
|
<a href={resolve('/')} class="text-2xl font-bold">Stan Runge</a>
|
||||||
<nav class="flex flex-wrap justify-center gap-4" aria-label="Social links">
|
<nav class="flex flex-wrap justify-center gap-4" aria-label="Social links">
|
||||||
@@ -63,16 +62,4 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex-1">
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="flex justify-center py-8 bg-gray-200">
|
|
||||||
<p>
|
|
||||||
Hosted on <a
|
|
||||||
class="underline text-blue-500"
|
|
||||||
href="https://git.stanrunge.dev/stan/personal-website">my git server :)</a
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
|
||||||
import type { RequestHandler } from './$types';
|
|
||||||
import { env } from '$env/dynamic/private';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const loginSchema = z.object({ password: z.string().min(1) });
|
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
|
||||||
const body = await request.json().catch(() => null);
|
|
||||||
const parsed = loginSchema.safeParse(body);
|
|
||||||
|
|
||||||
if (!parsed.success || parsed.data.password !== env.ADMIN_SECRET) {
|
|
||||||
throw error(401, { message: 'Invalid password' });
|
|
||||||
}
|
|
||||||
|
|
||||||
cookies.set('admin_token', env.ADMIN_SECRET, {
|
|
||||||
path: '/',
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'strict',
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
maxAge: 60 * 60 * 24 * 7
|
|
||||||
});
|
|
||||||
|
|
||||||
return json({ ok: true });
|
|
||||||
};
|
|
||||||
@@ -1,50 +1,29 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { blogArticles } from '$lib/server/db/schema';
|
import { blogArticles } from '$lib/server/db/schema';
|
||||||
import { isAdmin, requireAdmin } from '$lib/server/auth';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { uniqueSlug } from '$lib/server/slugify';
|
|
||||||
import { createArticleSchema } from '$lib/validation/blog';
|
|
||||||
import { desc, eq } from 'drizzle-orm';
|
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
|
||||||
import type { PageServerLoad } from './$types.js';
|
import type { PageServerLoad } from './$types.js';
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
export const load: PageServerLoad = async () => {
|
||||||
const admin = isAdmin(event);
|
const articles = await db.select().from(blogArticles);
|
||||||
|
|
||||||
const articles = admin
|
|
||||||
? await db.select().from(blogArticles).orderBy(desc(blogArticles.createdAt))
|
|
||||||
: await db
|
|
||||||
.select()
|
|
||||||
.from(blogArticles)
|
|
||||||
.where(eq(blogArticles.status, 'published'))
|
|
||||||
.orderBy(desc(blogArticles.createdAt));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blogArticles: articles,
|
blogArticles: articles
|
||||||
isAdmin: admin
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async (event) => {
|
default: async ({ request }): Promise<Response> => {
|
||||||
requireAdmin(event);
|
const formData = await request.formData();
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const blogArticle = await db
|
||||||
const parsed = createArticleSchema.safeParse({
|
.insert(blogArticles)
|
||||||
title: formData.get('title')?.toString()
|
.values({
|
||||||
});
|
title: formData.get('title')?.toString(),
|
||||||
|
slug: formData.get('slug')?.toString(),
|
||||||
|
content: formData.get('content')?.toString()
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
if (!parsed.success) {
|
return redirect(303, `/blog/${blogArticle[0].slug}`);
|
||||||
return fail(400, { message: 'Title is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const slug = await uniqueSlug(parsed.data.title);
|
|
||||||
|
|
||||||
await db.insert(blogArticles).values({
|
|
||||||
title: parsed.data.title,
|
|
||||||
slug,
|
|
||||||
status: 'draft'
|
|
||||||
});
|
|
||||||
|
|
||||||
return redirect(303, `/blog/${slug}/edit`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,131 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { invalidateAll } from '$app/navigation';
|
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let createNewArticlePopup = $state(false);
|
let createNewArticlePopup = $state(false);
|
||||||
let loginPopup = $state(false);
|
|
||||||
let password = $state('');
|
|
||||||
let loginError = $state('');
|
|
||||||
let loginLoading = $state(false);
|
|
||||||
|
|
||||||
async function login() {
|
|
||||||
loginLoading = true;
|
|
||||||
loginError = '';
|
|
||||||
const res = await fetch('/admin/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ password })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
loginPopup = false;
|
|
||||||
password = '';
|
|
||||||
await invalidateAll();
|
|
||||||
} else {
|
|
||||||
loginError = 'Wrong password';
|
|
||||||
}
|
|
||||||
loginLoading = false;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-center py-8 gap-2 flex align-center justify-center">
|
<div class="text-center py-8 gap-2 flex align-center justify-center">
|
||||||
<h1 class="text-2xl">Blog (Stan's yapping corner)</h1>
|
<h1 class="text-2xl">Blog (Stan's yapping corner)</h1>
|
||||||
{#if data.isAdmin}
|
|
||||||
<button
|
<button
|
||||||
onclick={() => (createNewArticlePopup = true)}
|
onclick={() => (createNewArticlePopup = true)}
|
||||||
class="bg-gray-800 rounded px-4 py-2 text-white font-bold cursor-pointer"
|
class="bg-gray-800 rounded px-4 py-2 text-white font-bold">Write new article</button
|
||||||
>Write new article</button
|
|
||||||
>
|
>
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
onclick={() => (loginPopup = true)}
|
|
||||||
class="text-xs text-gray-400 underline cursor-pointer">Admin login</button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each data.blogArticles as article (article.slug)}
|
{#each data.blogArticles as article (article.slug)}
|
||||||
<div class="flex gap-2 px-4">
|
<div class="flex">
|
||||||
<a href={resolve('/blog/[slug]', { slug: article.slug })}>{article.title}</a>
|
<a href={resolve('/blog/{slug}', { slug: article.slug })}>{article.title}</a>
|
||||||
{#if data.isAdmin && article.status === 'draft'}
|
|
||||||
<span class="text-xs uppercase text-orange-500">draft</span>
|
|
||||||
<a href={resolve('/blog/[slug]/edit', { slug: article.slug })} class="text-xs underline"
|
|
||||||
>edit</a
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<p>{article.createdAt?.toDateString()}</p>
|
<p>{article.createdAt?.toDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if createNewArticlePopup}
|
{#if createNewArticlePopup}
|
||||||
<div class="fixed inset-0 flex items-center justify-center bg-black/40">
|
<form method="POST" use:enhance>
|
||||||
<form
|
<div>
|
||||||
method="POST"
|
<h2>Create New Article</h2>
|
||||||
use:enhance={() => {
|
|
||||||
return async ({ result }) => {
|
|
||||||
if (result.type !== 'redirect') {
|
|
||||||
createNewArticlePopup = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="w-80 rounded bg-white p-6 shadow-xl"
|
|
||||||
>
|
|
||||||
<h2 class="mb-4 text-lg font-bold">Create New Article</h2>
|
|
||||||
<input
|
|
||||||
name="title"
|
|
||||||
placeholder="Article title"
|
|
||||||
required
|
|
||||||
class="mb-4 w-full rounded border border-gray-300 px-3 py-2 text-sm outline-none"
|
|
||||||
/>
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (createNewArticlePopup = false)}
|
|
||||||
class="rounded px-3 py-2 text-sm cursor-pointer">Cancel</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded bg-gray-800 px-3 py-2 text-sm font-bold text-white cursor-pointer"
|
|
||||||
>Create draft</button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loginPopup}
|
|
||||||
<div class="fixed inset-0 flex items-center justify-center bg-black/40">
|
|
||||||
<div class="w-80 rounded bg-white p-6 shadow-xl">
|
|
||||||
<h2 class="mb-4 text-lg font-bold">Admin login</h2>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
bind:value={password}
|
|
||||||
placeholder="Password"
|
|
||||||
onkeydown={(e) => e.key === 'Enter' && login()}
|
|
||||||
class="mb-3 w-full rounded border border-gray-300 px-3 py-2 text-sm outline-none"
|
|
||||||
/>
|
|
||||||
{#if loginError}
|
|
||||||
<p class="mb-3 text-xs text-red-500">{loginError}</p>
|
|
||||||
{/if}
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
loginPopup = false;
|
|
||||||
loginError = '';
|
|
||||||
}}
|
|
||||||
class="rounded px-3 py-2 text-sm cursor-pointer">Cancel</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onclick={login}
|
|
||||||
disabled={loginLoading}
|
|
||||||
class="rounded bg-gray-800 px-3 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{loginLoading ? 'Logging in…' : 'Log in'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { db } from '$lib/server/db';
|
|
||||||
import { blogArticles } from '$lib/server/db/schema';
|
|
||||||
import { isAdmin } from '$lib/server/auth';
|
|
||||||
import { renderMarkdown } from '$lib/server/markdown';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
|
|
||||||
import type { PageServerLoad } from './$types.js';
|
|
||||||
import { error, redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
|
||||||
const [article] = await db
|
|
||||||
.select()
|
|
||||||
.from(blogArticles)
|
|
||||||
.where(eq(blogArticles.slug, event.params.slug));
|
|
||||||
|
|
||||||
if (!article) {
|
|
||||||
return error(404, { message: 'Article not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const admin = isAdmin(event);
|
|
||||||
|
|
||||||
if (article.status !== 'published') {
|
|
||||||
if (!admin) {
|
|
||||||
return error(404, { message: 'Article not found' });
|
|
||||||
}
|
|
||||||
// Drafts are only ever viewed/edited through the editor.
|
|
||||||
return redirect(303, `/blog/${article.slug}/edit`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
blogArticle: article,
|
|
||||||
contentHtml: renderMarkdown(article.content ?? ''),
|
|
||||||
isAdmin: admin
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actions = {};
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
import 'highlight.js/styles/github-dark.css';
|
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head><title>{data.blogArticle.title} - Blog</title></svelte:head>
|
|
||||||
|
|
||||||
<article class="mx-auto max-w-2xl px-4 py-8">
|
|
||||||
<div class="mb-6 flex items-center justify-between">
|
|
||||||
<h1 class="text-2xl font-bold">{data.blogArticle.title}</h1>
|
|
||||||
{#if data.isAdmin}
|
|
||||||
<a
|
|
||||||
href={resolve('/blog/[slug]/edit', { slug: data.blogArticle.slug })}
|
|
||||||
class="text-sm underline">Edit</a
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="mb-6 text-sm text-gray-500">{data.blogArticle.createdAt?.toDateString()}</p>
|
|
||||||
<div class="prose prose-neutral max-w-none">
|
|
||||||
{@html data.contentHtml}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { db } from '$lib/server/db';
|
|
||||||
import { blogArticles } from '$lib/server/db/schema';
|
|
||||||
import { isAdmin, requireAdmin } from '$lib/server/auth';
|
|
||||||
import { updateArticleSchema } from '$lib/validation/blog';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
import type { PageServerLoad } from './$types.js';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
|
||||||
if (!isAdmin(event)) {
|
|
||||||
return redirect(303, `/blog/${event.params.slug}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [article] = await db
|
|
||||||
.select()
|
|
||||||
.from(blogArticles)
|
|
||||||
.where(eq(blogArticles.slug, event.params.slug));
|
|
||||||
|
|
||||||
if (!article) {
|
|
||||||
return error(404, { message: 'Article not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
blogArticle: article
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actions = {
|
|
||||||
save: async (event) => {
|
|
||||||
requireAdmin(event);
|
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
|
||||||
const parsed = updateArticleSchema.safeParse({
|
|
||||||
title: formData.get('title')?.toString(),
|
|
||||||
content: formData.get('content')?.toString()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!parsed.success) {
|
|
||||||
return fail(400, { message: 'Title and content are required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await db
|
|
||||||
.update(blogArticles)
|
|
||||||
.set({
|
|
||||||
title: parsed.data.title,
|
|
||||||
content: parsed.data.content,
|
|
||||||
updatedAt: new Date()
|
|
||||||
})
|
|
||||||
.where(eq(blogArticles.slug, event.params.slug))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
return error(404, { message: 'Article not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { saved: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
publish: async (event) => {
|
|
||||||
requireAdmin(event);
|
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
|
||||||
const parsed = updateArticleSchema.safeParse({
|
|
||||||
title: formData.get('title')?.toString(),
|
|
||||||
content: formData.get('content')?.toString()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!parsed.success) {
|
|
||||||
return fail(400, { message: 'Title and content are required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await db
|
|
||||||
.update(blogArticles)
|
|
||||||
.set({
|
|
||||||
title: parsed.data.title,
|
|
||||||
content: parsed.data.content,
|
|
||||||
status: 'published',
|
|
||||||
updatedAt: new Date()
|
|
||||||
})
|
|
||||||
.where(eq(blogArticles.slug, event.params.slug))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
return error(404, { message: 'Article not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect(303, `/blog/${updated.slug}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
|
|
||||||
let { data, form } = $props();
|
|
||||||
|
|
||||||
let title = $state(data.blogArticle.title);
|
|
||||||
let content = $state(data.blogArticle.content ?? '');
|
|
||||||
let saving = $state(false);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head><title>Editing {data.blogArticle.title}</title></svelte:head>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
|
||||||
<div class="mb-4 flex items-center justify-between">
|
|
||||||
<h1 class="text-xl font-bold">
|
|
||||||
Editing article
|
|
||||||
<span
|
|
||||||
class="ml-2 rounded px-2 py-0.5 text-xs uppercase {data.blogArticle.status === 'published'
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-orange-100 text-orange-700'}">{data.blogArticle.status}</span
|
|
||||||
>
|
|
||||||
</h1>
|
|
||||||
<a href={resolve('/blog/[slug]', { slug: data.blogArticle.slug })} class="text-sm underline"
|
|
||||||
>View</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if form?.message}
|
|
||||||
<p class="mb-3 text-sm text-red-500">{form.message}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/save"
|
|
||||||
use:enhance={() => {
|
|
||||||
saving = true;
|
|
||||||
return async ({ update }) => {
|
|
||||||
await update();
|
|
||||||
saving = false;
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="flex flex-col gap-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="title"
|
|
||||||
bind:value={title}
|
|
||||||
required
|
|
||||||
class="rounded border border-gray-300 px-3 py-2 text-lg font-bold outline-none"
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
name="content"
|
|
||||||
bind:value={content}
|
|
||||||
rows="20"
|
|
||||||
class="rounded border border-gray-300 px-3 py-2 font-body text-base leading-relaxed text-gray-900 outline-none"
|
|
||||||
></textarea>
|
|
||||||
<p class="-mt-2 text-xs text-gray-400">Supports Markdown formatting.</p>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={saving}
|
|
||||||
class="rounded bg-gray-800 px-4 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
|
|
||||||
>Save draft</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
formaction="?/publish"
|
|
||||||
disabled={saving}
|
|
||||||
class="rounded bg-green-700 px-4 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
|
|
||||||
>Publish</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
20
src/routes/blog/{slug}/+page.server.ts
Normal file
20
src/routes/blog/{slug}/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { blogArticles } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import type { PageServerLoad } from './$types.js';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const article = await db.select().from(blogArticles).where(eq(blogArticles.slug, params.slug));
|
||||||
|
|
||||||
|
if (article.length === 0) {
|
||||||
|
return fail(404, { message: 'Article not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blogArticle: article[0]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {};
|
||||||
1
src/routes/blog/{slug}/+page.svelte
Normal file
1
src/routes/blog/{slug}/+page.svelte
Normal file
@@ -0,0 +1 @@
|
|||||||
|
bu
|
||||||
@@ -55,16 +55,15 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const { Map, Marker, config } = await import('@maptiler/sdk');
|
const { Map, Marker, config } = await import('@maptiler/sdk');
|
||||||
const { env } = await import('$env/dynamic/public');
|
const { PUBLIC_MAPTILER_API_KEY } = await import('$env/static/public');
|
||||||
|
|
||||||
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
|
config.apiKey = PUBLIC_MAPTILER_API_KEY;
|
||||||
MapMarker = Marker;
|
MapMarker = Marker;
|
||||||
|
|
||||||
const initMap = (center: [number, number], zoom: number) => {
|
const initMap = (center: [number, number], zoom: number) => {
|
||||||
map = new Map({
|
map = new Map({
|
||||||
container: mapContainer,
|
container: mapContainer,
|
||||||
style:
|
style: 'https://api.maptiler.com/maps/satellite/style.json?key=' + PUBLIC_MAPTILER_API_KEY,
|
||||||
'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
|
|
||||||
center,
|
center,
|
||||||
zoom
|
zoom
|
||||||
});
|
});
|
||||||
@@ -218,7 +217,7 @@
|
|||||||
|
|
||||||
<div class="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 gap-2">
|
<div class="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 gap-2">
|
||||||
<a
|
<a
|
||||||
href={resolve('/projects/animaldex/register')}
|
href={resolve('/register')}
|
||||||
class="flex items-center gap-2 rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-orange-600"
|
class="flex items-center gap-2 rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-orange-600"
|
||||||
>
|
>
|
||||||
+ Log a sighting
|
+ Log a sighting
|
||||||
@@ -346,7 +345,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={resolve(`/projects/animaldex/register?animalId=${selected.animalId}`)}
|
href={resolve(`/register?animalId=${selected.animalId}`)}
|
||||||
class="mt-3 block w-full rounded-lg bg-orange-500 py-2 text-center text-sm font-semibold text-white hover:bg-orange-600"
|
class="mt-3 block w-full rounded-lg bg-orange-500 py-2 text-center text-sm font-semibold text-white hover:bg-orange-600"
|
||||||
>
|
>
|
||||||
I saw this animal too!
|
I saw this animal too!
|
||||||
|
|||||||
@@ -68,13 +68,12 @@
|
|||||||
async function initMap() {
|
async function initMap() {
|
||||||
if (!mapContainer || lat === null || lng === null) return;
|
if (!mapContainer || lat === null || lng === null) return;
|
||||||
const { Map, Marker, config } = await import('@maptiler/sdk');
|
const { Map, Marker, config } = await import('@maptiler/sdk');
|
||||||
const { env } = await import('$env/dynamic/public');
|
const { PUBLIC_MAPTILER_API_KEY } = await import('$env/static/public');
|
||||||
config.apiKey = env.PUBLIC_MAPTILER_API_KEY;
|
config.apiKey = PUBLIC_MAPTILER_API_KEY;
|
||||||
|
|
||||||
map = new Map({
|
map = new Map({
|
||||||
container: mapContainer,
|
container: mapContainer,
|
||||||
style:
|
style: 'https://api.maptiler.com/maps/satellite/style.json?key=' + PUBLIC_MAPTILER_API_KEY,
|
||||||
'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
|
|
||||||
center: [lng!, lat!],
|
center: [lng!, lat!],
|
||||||
zoom: 15
|
zoom: 15
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user