Compare commits
86 Commits
2c4730487d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ef6bf9862e | |||
| d75c74ac3c | |||
| fa14156d82 | |||
| 29297d3773 | |||
| 28bb8f526b | |||
| 288271fb04 | |||
| fb64c84a17 | |||
| 81e205dd4e | |||
| ded1c8313d | |||
| 4426b5d28a | |||
| 5ad0428420 | |||
| 7760570365 | |||
| 5fde54a2a7 | |||
| 2a3c82f777 | |||
| 835163f5bb | |||
| 5020393b22 | |||
| 94393851c8 | |||
| 997b2f1781 | |||
| bd121b7d85 | |||
| 6d2dccd47f | |||
| 5fdde9d177 | |||
| b1cc691422 | |||
| 8b08950719 | |||
| fd83ac911a | |||
| eeccf812cf | |||
| 9485d9841c | |||
| 31308ef126 | |||
| 57a0427e77 | |||
| 3bd2506c2f | |||
| e5a21cb0af | |||
| 4e95abf09f | |||
| 66afda5101 | |||
| a041a8caf5 | |||
| a91b298ee5 | |||
| d3e36e486f | |||
| 249da5ad2e | |||
| ce08329b2d | |||
| f35f4565b6 | |||
| 5cd989b098 | |||
| edab42fd26 | |||
| a78473b5ff | |||
| 2ddcd548a2 | |||
| 0b2797440e | |||
| c268cd5301 | |||
| 5978963939 | |||
| 1035a98eca | |||
| df224d97bb | |||
| 08f5d620be | |||
| b4601a5caf | |||
| a7775783af | |||
| e6bfc1e413 | |||
| 7c9aef1aee | |||
| de2c8cdc77 | |||
| b5816e6c28 | |||
| 70de84f3ab | |||
| 6402c378dd | |||
| 085dae6765 | |||
| bfc6d76dfe | |||
| 4ee7445b68 | |||
| 7ecc46b5a6 | |||
| 8010ddf00e | |||
| 91400f9910 | |||
| 2b4a55636e | |||
| 6cd18992f6 | |||
| 26d3734e7e | |||
| 33efee0707 | |||
| 73593f99a6 | |||
| 74d051cbfe | |||
| bf963ee3fd | |||
| 1ba853771e | |||
| 949d9596a6 | |||
| 040a8963b0 | |||
| d78a70c473 | |||
| b70e47b0d6 | |||
| 60d2474f51 | |||
| ef7ef4dd6c | |||
| 02c2ff2684 | |||
| b245ee984a | |||
| cf25a96719 | |||
| 4d059d45ef | |||
| a6a5055f3a | |||
| 6c2e0886af | |||
| 18ac6220f1 | |||
| 114f6cde7a | |||
| e45dfb9832 | |||
| b45909f86e |
@@ -7,3 +7,10 @@ ORIGIN=""
|
|||||||
# For production use 32 characters and generated with high entropy
|
# For production use 32 characters and generated with high entropy
|
||||||
# https://www.better-auth.com/docs/installation
|
# https://www.better-auth.com/docs/installation
|
||||||
BETTER_AUTH_SECRET=""
|
BETTER_AUTH_SECRET=""
|
||||||
|
|
||||||
|
# SvelteKit request body limit (default is 512K)
|
||||||
|
# Increase this for image uploads (e.g. 5M, 10M)
|
||||||
|
BODY_SIZE_LIMIT="10M"
|
||||||
|
|
||||||
|
# Uploaded files directory (relative to project root)
|
||||||
|
UPLOADS_DIR="uploads"
|
||||||
|
|||||||
70
.gitea/workflows/build-image.yaml
Normal file
70
.gitea/workflows/build-image.yaml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: Build Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_REGISTRY: ${{ gitea.server_url }}
|
||||||
|
GITEA_USERNAME: ${{ gitea.actor }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Determine image tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||||
|
TAG=${GITHUB_REF#refs/tags/}
|
||||||
|
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||||
|
TAG=main
|
||||||
|
else
|
||||||
|
echo "Not on main branch or a tag, skipping build"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Remove protocol from registry URL
|
||||||
|
REGISTRY="${{ env.GITEA_REGISTRY }}"
|
||||||
|
REGISTRY="${REGISTRY#https://}"
|
||||||
|
REGISTRY="${REGISTRY#http://}"
|
||||||
|
echo "registry=$REGISTRY" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Lowercase repository name
|
||||||
|
REPO="${{ gitea.repository }}"
|
||||||
|
REPO="${REPO,,}"
|
||||||
|
echo "repository=$REPO" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ steps.tag.outputs.registry }}
|
||||||
|
username: ${{ vars.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ steps.tag.outputs.registry }}/${{ steps.tag.outputs.repository }}:${{ steps.tag.outputs.tag }}
|
||||||
|
${{ steps.tag.outputs.registry }}/${{ steps.tag.outputs.repository }}:latest
|
||||||
|
|
||||||
|
- name: Call deployment webhook
|
||||||
|
if: ${{ secrets.DEPLOYMENT_WEBHOOK != '' }}
|
||||||
|
run: |
|
||||||
|
curl -k -X POST ${{ secrets.DEPLOYMENT_WEBHOOK }} \
|
||||||
|
-H 'Content-Type: application/json'
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,3 +27,6 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
# Script outputs
|
# Script outputs
|
||||||
/scraped-data
|
/scraped-data
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
/uploads
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
CREATE TABLE `arc` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`startChapter` integer NOT NULL,
|
|
||||||
`endChapter` integer,
|
|
||||||
`url` text
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `character` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`gender` text,
|
|
||||||
`age` integer,
|
|
||||||
`affiliations` text,
|
|
||||||
`devilFruitId` text,
|
|
||||||
`hakiObservation` integer DEFAULT false,
|
|
||||||
`hakiArmament` integer DEFAULT false,
|
|
||||||
`hakiConqueror` integer DEFAULT false,
|
|
||||||
`bounty` integer DEFAULT 0,
|
|
||||||
`height` real,
|
|
||||||
`origin` text,
|
|
||||||
`firstAppearance` integer NOT NULL,
|
|
||||||
`pictureUrl` text,
|
|
||||||
`epithets` text,
|
|
||||||
`status` text,
|
|
||||||
`arcId` text,
|
|
||||||
`url` text,
|
|
||||||
`isInDailyMode` integer DEFAULT true,
|
|
||||||
FOREIGN KEY (`devilFruitId`) REFERENCES `devilFruit`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`arcId`) REFERENCES `arc`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `characterHistory` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`characterId` text,
|
|
||||||
`date` text,
|
|
||||||
`won` integer DEFAULT 0 NOT NULL,
|
|
||||||
`createdAt` integer NOT NULL,
|
|
||||||
`updatedAt` integer NOT NULL,
|
|
||||||
FOREIGN KEY (`characterId`) REFERENCES `character`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `characterOverride` (
|
|
||||||
`characterId` text PRIMARY KEY NOT NULL,
|
|
||||||
`name` text,
|
|
||||||
`gender` text,
|
|
||||||
`age` integer,
|
|
||||||
`affiliations` text,
|
|
||||||
`devilFruitId` text,
|
|
||||||
`hakiObservation` integer,
|
|
||||||
`hakiArmament` integer,
|
|
||||||
`hakiConqueror` integer,
|
|
||||||
`bounty` integer,
|
|
||||||
`height` real,
|
|
||||||
`origin` text,
|
|
||||||
`firstAppearance` integer NOT NULL,
|
|
||||||
`pictureUrl` text,
|
|
||||||
`epithets` text,
|
|
||||||
`status` text,
|
|
||||||
`arcId` text,
|
|
||||||
`url` text,
|
|
||||||
`notes` text,
|
|
||||||
FOREIGN KEY (`characterId`) REFERENCES `character`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`devilFruitId`) REFERENCES `devilFruit`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`arcId`) REFERENCES `arc`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `characterScrapeValidation` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`gender` text,
|
|
||||||
`age` integer,
|
|
||||||
`affiliations` text,
|
|
||||||
`devilFruitId` text,
|
|
||||||
`hakiObservation` integer DEFAULT false,
|
|
||||||
`hakiArmament` integer DEFAULT false,
|
|
||||||
`hakiConqueror` integer DEFAULT false,
|
|
||||||
`bounty` integer,
|
|
||||||
`height` real,
|
|
||||||
`origin` text,
|
|
||||||
`firstAppearance` integer NOT NULL,
|
|
||||||
`pictureUrl` text,
|
|
||||||
`epithets` text,
|
|
||||||
`status` text,
|
|
||||||
`arcId` text,
|
|
||||||
`url` text,
|
|
||||||
FOREIGN KEY (`devilFruitId`) REFERENCES `devilFruit`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`arcId`) REFERENCES `arc`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `config` (
|
|
||||||
`key` text PRIMARY KEY NOT NULL,
|
|
||||||
`value` text
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `devilFruit` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`type` text,
|
|
||||||
`url` text
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `devilFruit_name_unique` ON `devilFruit` (`name`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `account` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`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` integer,
|
|
||||||
`refresh_token_expires_at` integer,
|
|
||||||
`scope` text,
|
|
||||||
`password` text,
|
|
||||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
|
||||||
`updated_at` integer NOT NULL,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `session` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`expires_at` integer NOT NULL,
|
|
||||||
`token` text NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
|
||||||
`updated_at` integer NOT NULL,
|
|
||||||
`ip_address` text,
|
|
||||||
`user_agent` text,
|
|
||||||
`user_id` text NOT NULL,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `user` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`email` text NOT NULL,
|
|
||||||
`email_verified` integer DEFAULT false NOT NULL,
|
|
||||||
`image` text,
|
|
||||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
|
||||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `verification` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`identifier` text NOT NULL,
|
|
||||||
`value` text NOT NULL,
|
|
||||||
`expires_at` integer NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
|
||||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);
|
|
||||||
199
drizzle/0000_huge_doctor_octopus.sql
Normal file
199
drizzle/0000_huge_doctor_octopus.sql
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
CREATE TABLE `arc` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`fr_name` text,
|
||||||
|
`start_chapter` integer NOT NULL,
|
||||||
|
`end_chapter` integer,
|
||||||
|
`url` text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `character` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`fr_name` text,
|
||||||
|
`gender` text,
|
||||||
|
`age` integer,
|
||||||
|
`affiliations` text,
|
||||||
|
`fr_affiliations` text,
|
||||||
|
`devil_fruit_id` text,
|
||||||
|
`haki_observation` integer DEFAULT false,
|
||||||
|
`haki_armament` integer DEFAULT false,
|
||||||
|
`haki_conqueror` integer DEFAULT false,
|
||||||
|
`bounty` integer DEFAULT 0,
|
||||||
|
`height` real,
|
||||||
|
`origin` text,
|
||||||
|
`fr_origin` text,
|
||||||
|
`first_appearance` integer NOT NULL,
|
||||||
|
`picture_url` text,
|
||||||
|
`epithets` text,
|
||||||
|
`fr_epithets` text,
|
||||||
|
`status` text,
|
||||||
|
`arc_id` text,
|
||||||
|
`url` text,
|
||||||
|
`fr_url` text,
|
||||||
|
`is_in_daily_mode` integer DEFAULT false,
|
||||||
|
FOREIGN KEY (`devil_fruit_id`) REFERENCES `devil_fruit`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`arc_id`) REFERENCES `arc`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `character_history` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`character_id` text,
|
||||||
|
`date` integer NOT NULL,
|
||||||
|
`won` integer DEFAULT 0 NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`character_id`) REFERENCES `character`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `character_history_date_unique` ON `character_history` (`date`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `character_override` (
|
||||||
|
`character_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text,
|
||||||
|
`gender` text,
|
||||||
|
`age` integer,
|
||||||
|
`affiliations` text,
|
||||||
|
`fr_affiliations` text,
|
||||||
|
`devil_fruit_id` text,
|
||||||
|
`haki_observation` integer,
|
||||||
|
`haki_armament` integer,
|
||||||
|
`haki_conqueror` integer,
|
||||||
|
`bounty` integer,
|
||||||
|
`height` real,
|
||||||
|
`origin` text,
|
||||||
|
`fr_origin` text,
|
||||||
|
`first_appearance` integer,
|
||||||
|
`picture_url` text,
|
||||||
|
`epithets` text,
|
||||||
|
`fr_epithets` text,
|
||||||
|
`status` text,
|
||||||
|
`arc_id` text,
|
||||||
|
`url` text,
|
||||||
|
`fr_url` text,
|
||||||
|
`notes` text,
|
||||||
|
FOREIGN KEY (`character_id`) REFERENCES `character`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`devil_fruit_id`) REFERENCES `devil_fruit`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`arc_id`) REFERENCES `arc`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `character_scrape_validation` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`fr_name` text,
|
||||||
|
`gender` text,
|
||||||
|
`age` integer,
|
||||||
|
`affiliations` text,
|
||||||
|
`fr_affiliations` text,
|
||||||
|
`devil_fruit_id` text,
|
||||||
|
`haki_observation` integer DEFAULT false,
|
||||||
|
`haki_armament` integer DEFAULT false,
|
||||||
|
`haki_conqueror` integer DEFAULT false,
|
||||||
|
`bounty` integer,
|
||||||
|
`height` real,
|
||||||
|
`origin` text,
|
||||||
|
`fr_origin` text,
|
||||||
|
`first_appearance` integer NOT NULL,
|
||||||
|
`picture_url` text,
|
||||||
|
`epithets` text,
|
||||||
|
`fr_epithets` text,
|
||||||
|
`status` text,
|
||||||
|
`arc_id` text,
|
||||||
|
`url` text,
|
||||||
|
`fr_url` text,
|
||||||
|
FOREIGN KEY (`devil_fruit_id`) REFERENCES `devil_fruit`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`arc_id`) REFERENCES `arc`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `config` (
|
||||||
|
`key` text PRIMARY KEY NOT NULL,
|
||||||
|
`value` text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `devil_fruit` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`type` text,
|
||||||
|
`url` text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `devil_fruit_name_unique` ON `devil_fruit` (`name`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `friendship` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`requester_id` text NOT NULL,
|
||||||
|
`addressee_id` text NOT NULL,
|
||||||
|
`status` text DEFAULT 'pending' NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`requester_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`addressee_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `friendship_requester_id_addressee_id_unique` ON `friendship` (`requester_id`,`addressee_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `user_character_history` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text,
|
||||||
|
`character_history_id` text,
|
||||||
|
`try_count` integer NOT NULL,
|
||||||
|
`tried_character_ids` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`character_history_id`) REFERENCES `character_history`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_character_history_user_id_character_history_id_unique` ON `user_character_history` (`user_id`,`character_history_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `account` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`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` integer,
|
||||||
|
`refresh_token_expires_at` integer,
|
||||||
|
`scope` text,
|
||||||
|
`password` text,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `session` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`token` text NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
`ip_address` text,
|
||||||
|
`user_agent` text,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `user` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`username` text NOT NULL,
|
||||||
|
`email` text NOT NULL,
|
||||||
|
`email_verified` integer DEFAULT false NOT NULL,
|
||||||
|
`image` text,
|
||||||
|
`is_admin` integer DEFAULT false NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `verification` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`identifier` text NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);
|
||||||
1
drizzle/0001_fuzzy_talisman.sql
Normal file
1
drizzle/0001_fuzzy_talisman.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `character_scrape_validation` ADD `is_deleted` integer DEFAULT false;
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
|
||||||
CREATE TABLE `__new_characterOverride` (
|
|
||||||
`characterId` text PRIMARY KEY NOT NULL,
|
|
||||||
`name` text,
|
|
||||||
`gender` text,
|
|
||||||
`age` integer,
|
|
||||||
`affiliations` text,
|
|
||||||
`devilFruitId` text,
|
|
||||||
`hakiObservation` integer,
|
|
||||||
`hakiArmament` integer,
|
|
||||||
`hakiConqueror` integer,
|
|
||||||
`bounty` integer,
|
|
||||||
`height` real,
|
|
||||||
`origin` text,
|
|
||||||
`firstAppearance` integer,
|
|
||||||
`pictureUrl` text,
|
|
||||||
`epithets` text,
|
|
||||||
`status` text,
|
|
||||||
`arcId` text,
|
|
||||||
`url` text,
|
|
||||||
`notes` text,
|
|
||||||
FOREIGN KEY (`characterId`) REFERENCES `character`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`devilFruitId`) REFERENCES `devilFruit`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`arcId`) REFERENCES `arc`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
INSERT INTO `__new_characterOverride`("characterId", "name", "gender", "age", "affiliations", "devilFruitId", "hakiObservation", "hakiArmament", "hakiConqueror", "bounty", "height", "origin", "firstAppearance", "pictureUrl", "epithets", "status", "arcId", "url", "notes") SELECT "characterId", "name", "gender", "age", "affiliations", "devilFruitId", "hakiObservation", "hakiArmament", "hakiConqueror", "bounty", "height", "origin", "firstAppearance", "pictureUrl", "epithets", "status", "arcId", "url", "notes" FROM `characterOverride`;--> statement-breakpoint
|
|
||||||
DROP TABLE `characterOverride`;--> statement-breakpoint
|
|
||||||
ALTER TABLE `__new_characterOverride` RENAME TO `characterOverride`;--> statement-breakpoint
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
1
drizzle/0002_old_earthquake.sql
Normal file
1
drizzle/0002_old_earthquake.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE `character_override`;
|
||||||
8
drizzle/0003_mixed_ben_grimm.sql
Normal file
8
drizzle/0003_mixed_ben_grimm.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE `character` ADD `affiliation` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `character` ADD `fr_affiliation` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `character` DROP COLUMN `affiliations`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `character` DROP COLUMN `fr_affiliations`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `character_scrape_validation` ADD `affiliation` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `character_scrape_validation` ADD `fr_affiliation` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `character_scrape_validation` DROP COLUMN `affiliations`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `character_scrape_validation` DROP COLUMN `fr_affiliations`;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "d1237d76-8f1c-4721-b8dd-d31082ed7b9a",
|
"id": "4b4f14a1-b37b-44f4-aed3-7289bd8cb6a0",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"arc": {
|
"arc": {
|
||||||
@@ -21,15 +21,22 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"startChapter": {
|
"fr_name": {
|
||||||
"name": "startChapter",
|
"name": "fr_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"start_chapter": {
|
||||||
|
"name": "start_chapter",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"endChapter": {
|
"end_chapter": {
|
||||||
"name": "endChapter",
|
"name": "end_chapter",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -66,6 +73,13 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"fr_name": {
|
||||||
|
"name": "fr_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"gender": {
|
"gender": {
|
||||||
"name": "gender",
|
"name": "gender",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -87,31 +101,38 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"devilFruitId": {
|
"fr_affiliations": {
|
||||||
"name": "devilFruitId",
|
"name": "fr_affiliations",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"hakiObservation": {
|
"devil_fruit_id": {
|
||||||
"name": "hakiObservation",
|
"name": "devil_fruit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"haki_observation": {
|
||||||
|
"name": "haki_observation",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
"hakiArmament": {
|
"haki_armament": {
|
||||||
"name": "hakiArmament",
|
"name": "haki_armament",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
"hakiConqueror": {
|
"haki_conqueror": {
|
||||||
"name": "hakiConqueror",
|
"name": "haki_conqueror",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -140,15 +161,22 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"firstAppearance": {
|
"fr_origin": {
|
||||||
"name": "firstAppearance",
|
"name": "fr_origin",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"first_appearance": {
|
||||||
|
"name": "first_appearance",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"pictureUrl": {
|
"picture_url": {
|
||||||
"name": "pictureUrl",
|
"name": "picture_url",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -161,6 +189,13 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"fr_epithets": {
|
||||||
|
"name": "fr_epithets",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -168,8 +203,8 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"arcId": {
|
"arc_id": {
|
||||||
"name": "arcId",
|
"name": "arc_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -182,23 +217,30 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"isInDailyMode": {
|
"fr_url": {
|
||||||
"name": "isInDailyMode",
|
"name": "fr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_in_daily_mode": {
|
||||||
|
"name": "is_in_daily_mode",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": true
|
"default": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"character_devilFruitId_devilFruit_id_fk": {
|
"character_devil_fruit_id_devil_fruit_id_fk": {
|
||||||
"name": "character_devilFruitId_devilFruit_id_fk",
|
"name": "character_devil_fruit_id_devil_fruit_id_fk",
|
||||||
"tableFrom": "character",
|
"tableFrom": "character",
|
||||||
"tableTo": "devilFruit",
|
"tableTo": "devil_fruit",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"devilFruitId"
|
"devil_fruit_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
@@ -206,17 +248,17 @@
|
|||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
"character_arcId_arc_id_fk": {
|
"character_arc_id_arc_id_fk": {
|
||||||
"name": "character_arcId_arc_id_fk",
|
"name": "character_arc_id_arc_id_fk",
|
||||||
"tableFrom": "character",
|
"tableFrom": "character",
|
||||||
"tableTo": "arc",
|
"tableTo": "arc",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"arcId"
|
"arc_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -224,8 +266,8 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"characterHistory": {
|
"character_history": {
|
||||||
"name": "characterHistory",
|
"name": "character_history",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -234,8 +276,8 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"characterId": {
|
"character_id": {
|
||||||
"name": "characterId",
|
"name": "character_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -243,9 +285,9 @@
|
|||||||
},
|
},
|
||||||
"date": {
|
"date": {
|
||||||
"name": "date",
|
"name": "date",
|
||||||
"type": "text",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"won": {
|
"won": {
|
||||||
@@ -256,34 +298,42 @@
|
|||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": 0
|
"default": 0
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"created_at": {
|
||||||
"name": "createdAt",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updated_at": {
|
||||||
"name": "updatedAt",
|
"name": "updated_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"character_history_date_unique": {
|
||||||
|
"name": "character_history_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"characterHistory_characterId_character_id_fk": {
|
"character_history_character_id_character_id_fk": {
|
||||||
"name": "characterHistory_characterId_character_id_fk",
|
"name": "character_history_character_id_character_id_fk",
|
||||||
"tableFrom": "characterHistory",
|
"tableFrom": "character_history",
|
||||||
"tableTo": "character",
|
"tableTo": "character",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"characterId"
|
"character_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -291,11 +341,11 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"characterOverride": {
|
"character_override": {
|
||||||
"name": "characterOverride",
|
"name": "character_override",
|
||||||
"columns": {
|
"columns": {
|
||||||
"characterId": {
|
"character_id": {
|
||||||
"name": "characterId",
|
"name": "character_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": true,
|
"primaryKey": true,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
@@ -329,29 +379,36 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"devilFruitId": {
|
"fr_affiliations": {
|
||||||
"name": "devilFruitId",
|
"name": "fr_affiliations",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"hakiObservation": {
|
"devil_fruit_id": {
|
||||||
"name": "hakiObservation",
|
"name": "devil_fruit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"haki_observation": {
|
||||||
|
"name": "haki_observation",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"hakiArmament": {
|
"haki_armament": {
|
||||||
"name": "hakiArmament",
|
"name": "haki_armament",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"hakiConqueror": {
|
"haki_conqueror": {
|
||||||
"name": "hakiConqueror",
|
"name": "haki_conqueror",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -378,15 +435,22 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"firstAppearance": {
|
"fr_origin": {
|
||||||
"name": "firstAppearance",
|
"name": "fr_origin",
|
||||||
"type": "integer",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"pictureUrl": {
|
"first_appearance": {
|
||||||
"name": "pictureUrl",
|
"name": "first_appearance",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"picture_url": {
|
||||||
|
"name": "picture_url",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -399,6 +463,13 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"fr_epithets": {
|
||||||
|
"name": "fr_epithets",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -406,8 +477,8 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"arcId": {
|
"arc_id": {
|
||||||
"name": "arcId",
|
"name": "arc_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -420,6 +491,13 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"fr_url": {
|
||||||
|
"name": "fr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"name": "notes",
|
"name": "notes",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -430,43 +508,43 @@
|
|||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"characterOverride_characterId_character_id_fk": {
|
"character_override_character_id_character_id_fk": {
|
||||||
"name": "characterOverride_characterId_character_id_fk",
|
"name": "character_override_character_id_character_id_fk",
|
||||||
"tableFrom": "characterOverride",
|
"tableFrom": "character_override",
|
||||||
"tableTo": "character",
|
"tableTo": "character",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"characterId"
|
"character_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
"characterOverride_devilFruitId_devilFruit_id_fk": {
|
"character_override_devil_fruit_id_devil_fruit_id_fk": {
|
||||||
"name": "characterOverride_devilFruitId_devilFruit_id_fk",
|
"name": "character_override_devil_fruit_id_devil_fruit_id_fk",
|
||||||
"tableFrom": "characterOverride",
|
"tableFrom": "character_override",
|
||||||
"tableTo": "devilFruit",
|
"tableTo": "devil_fruit",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"devilFruitId"
|
"devil_fruit_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
"characterOverride_arcId_arc_id_fk": {
|
"character_override_arc_id_arc_id_fk": {
|
||||||
"name": "characterOverride_arcId_arc_id_fk",
|
"name": "character_override_arc_id_arc_id_fk",
|
||||||
"tableFrom": "characterOverride",
|
"tableFrom": "character_override",
|
||||||
"tableTo": "arc",
|
"tableTo": "arc",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"arcId"
|
"arc_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -474,8 +552,8 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"characterScrapeValidation": {
|
"character_scrape_validation": {
|
||||||
"name": "characterScrapeValidation",
|
"name": "character_scrape_validation",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -491,6 +569,13 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"fr_name": {
|
||||||
|
"name": "fr_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"gender": {
|
"gender": {
|
||||||
"name": "gender",
|
"name": "gender",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -512,31 +597,38 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"devilFruitId": {
|
"fr_affiliations": {
|
||||||
"name": "devilFruitId",
|
"name": "fr_affiliations",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"hakiObservation": {
|
"devil_fruit_id": {
|
||||||
"name": "hakiObservation",
|
"name": "devil_fruit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"haki_observation": {
|
||||||
|
"name": "haki_observation",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
"hakiArmament": {
|
"haki_armament": {
|
||||||
"name": "hakiArmament",
|
"name": "haki_armament",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
"hakiConqueror": {
|
"haki_conqueror": {
|
||||||
"name": "hakiConqueror",
|
"name": "haki_conqueror",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -564,15 +656,22 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"firstAppearance": {
|
"fr_origin": {
|
||||||
"name": "firstAppearance",
|
"name": "fr_origin",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"first_appearance": {
|
||||||
|
"name": "first_appearance",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"pictureUrl": {
|
"picture_url": {
|
||||||
"name": "pictureUrl",
|
"name": "picture_url",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -585,6 +684,13 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"fr_epithets": {
|
||||||
|
"name": "fr_epithets",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -592,8 +698,8 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"arcId": {
|
"arc_id": {
|
||||||
"name": "arcId",
|
"name": "arc_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -605,34 +711,41 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fr_url": {
|
||||||
|
"name": "fr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"characterScrapeValidation_devilFruitId_devilFruit_id_fk": {
|
"character_scrape_validation_devil_fruit_id_devil_fruit_id_fk": {
|
||||||
"name": "characterScrapeValidation_devilFruitId_devilFruit_id_fk",
|
"name": "character_scrape_validation_devil_fruit_id_devil_fruit_id_fk",
|
||||||
"tableFrom": "characterScrapeValidation",
|
"tableFrom": "character_scrape_validation",
|
||||||
"tableTo": "devilFruit",
|
"tableTo": "devil_fruit",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"devilFruitId"
|
"devil_fruit_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
"characterScrapeValidation_arcId_arc_id_fk": {
|
"character_scrape_validation_arc_id_arc_id_fk": {
|
||||||
"name": "characterScrapeValidation_arcId_arc_id_fk",
|
"name": "character_scrape_validation_arc_id_arc_id_fk",
|
||||||
"tableFrom": "characterScrapeValidation",
|
"tableFrom": "character_scrape_validation",
|
||||||
"tableTo": "arc",
|
"tableTo": "arc",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"arcId"
|
"arc_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -664,8 +777,8 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"devilFruit": {
|
"devil_fruit": {
|
||||||
"name": "devilFruit",
|
"name": "devil_fruit",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -697,8 +810,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
"devilFruit_name_unique": {
|
"devil_fruit_name_unique": {
|
||||||
"name": "devilFruit_name_unique",
|
"name": "devil_fruit_name_unique",
|
||||||
"columns": [
|
"columns": [
|
||||||
"name"
|
"name"
|
||||||
],
|
],
|
||||||
@@ -710,6 +823,183 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
|
"friendship": {
|
||||||
|
"name": "friendship",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"requester_id": {
|
||||||
|
"name": "requester_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"addressee_id": {
|
||||||
|
"name": "addressee_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'pending'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"friendship_requester_id_addressee_id_unique": {
|
||||||
|
"name": "friendship_requester_id_addressee_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"requester_id",
|
||||||
|
"addressee_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"friendship_requester_id_user_id_fk": {
|
||||||
|
"name": "friendship_requester_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"requester_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"friendship_addressee_id_user_id_fk": {
|
||||||
|
"name": "friendship_addressee_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"addressee_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_character_history": {
|
||||||
|
"name": "user_character_history",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"character_history_id": {
|
||||||
|
"name": "character_history_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"try_count": {
|
||||||
|
"name": "try_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tried_character_ids": {
|
||||||
|
"name": "tried_character_ids",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_character_history_user_id_character_history_id_unique": {
|
||||||
|
"name": "user_character_history_user_id_character_history_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"user_id",
|
||||||
|
"character_history_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_character_history_user_id_user_id_fk": {
|
||||||
|
"name": "user_character_history_user_id_user_id_fk",
|
||||||
|
"tableFrom": "user_character_history",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"user_character_history_character_history_id_character_history_id_fk": {
|
||||||
|
"name": "user_character_history_character_history_id_character_history_id_fk",
|
||||||
|
"tableFrom": "user_character_history",
|
||||||
|
"tableTo": "character_history",
|
||||||
|
"columnsFrom": [
|
||||||
|
"character_history_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"name": "account",
|
"name": "account",
|
||||||
"columns": {
|
"columns": {
|
||||||
@@ -947,6 +1237,13 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"name": "email",
|
"name": "email",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -969,6 +1266,14 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"is_admin": {
|
||||||
|
"name": "is_admin",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -987,6 +1292,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
"user_email_unique": {
|
"user_email_unique": {
|
||||||
"name": "user_email_unique",
|
"name": "user_email_unique",
|
||||||
"columns": [
|
"columns": [
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "23b693a1-eebd-499e-9755-27a732e1afc1",
|
"id": "9a965dd1-d97c-4142-a795-0558214180a4",
|
||||||
"prevId": "d1237d76-8f1c-4721-b8dd-d31082ed7b9a",
|
"prevId": "4b4f14a1-b37b-44f4-aed3-7289bd8cb6a0",
|
||||||
"tables": {
|
"tables": {
|
||||||
"arc": {
|
"arc": {
|
||||||
"name": "arc",
|
"name": "arc",
|
||||||
@@ -21,15 +21,22 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"startChapter": {
|
"fr_name": {
|
||||||
"name": "startChapter",
|
"name": "fr_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"start_chapter": {
|
||||||
|
"name": "start_chapter",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"endChapter": {
|
"end_chapter": {
|
||||||
"name": "endChapter",
|
"name": "end_chapter",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -66,6 +73,13 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"fr_name": {
|
||||||
|
"name": "fr_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"gender": {
|
"gender": {
|
||||||
"name": "gender",
|
"name": "gender",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -87,31 +101,38 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"devilFruitId": {
|
"fr_affiliations": {
|
||||||
"name": "devilFruitId",
|
"name": "fr_affiliations",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"hakiObservation": {
|
"devil_fruit_id": {
|
||||||
"name": "hakiObservation",
|
"name": "devil_fruit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"haki_observation": {
|
||||||
|
"name": "haki_observation",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
"hakiArmament": {
|
"haki_armament": {
|
||||||
"name": "hakiArmament",
|
"name": "haki_armament",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
"hakiConqueror": {
|
"haki_conqueror": {
|
||||||
"name": "hakiConqueror",
|
"name": "haki_conqueror",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -140,15 +161,22 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"firstAppearance": {
|
"fr_origin": {
|
||||||
"name": "firstAppearance",
|
"name": "fr_origin",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"first_appearance": {
|
||||||
|
"name": "first_appearance",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"pictureUrl": {
|
"picture_url": {
|
||||||
"name": "pictureUrl",
|
"name": "picture_url",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -161,6 +189,13 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"fr_epithets": {
|
||||||
|
"name": "fr_epithets",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -168,8 +203,8 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"arcId": {
|
"arc_id": {
|
||||||
"name": "arcId",
|
"name": "arc_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -182,23 +217,30 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"isInDailyMode": {
|
"fr_url": {
|
||||||
"name": "isInDailyMode",
|
"name": "fr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_in_daily_mode": {
|
||||||
|
"name": "is_in_daily_mode",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": true
|
"default": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"character_devilFruitId_devilFruit_id_fk": {
|
"character_devil_fruit_id_devil_fruit_id_fk": {
|
||||||
"name": "character_devilFruitId_devilFruit_id_fk",
|
"name": "character_devil_fruit_id_devil_fruit_id_fk",
|
||||||
"tableFrom": "character",
|
"tableFrom": "character",
|
||||||
"tableTo": "devilFruit",
|
"tableTo": "devil_fruit",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"devilFruitId"
|
"devil_fruit_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
@@ -206,17 +248,17 @@
|
|||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
"character_arcId_arc_id_fk": {
|
"character_arc_id_arc_id_fk": {
|
||||||
"name": "character_arcId_arc_id_fk",
|
"name": "character_arc_id_arc_id_fk",
|
||||||
"tableFrom": "character",
|
"tableFrom": "character",
|
||||||
"tableTo": "arc",
|
"tableTo": "arc",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"arcId"
|
"arc_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -224,8 +266,8 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"characterHistory": {
|
"character_history": {
|
||||||
"name": "characterHistory",
|
"name": "character_history",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -234,8 +276,8 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"characterId": {
|
"character_id": {
|
||||||
"name": "characterId",
|
"name": "character_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -243,9 +285,9 @@
|
|||||||
},
|
},
|
||||||
"date": {
|
"date": {
|
||||||
"name": "date",
|
"name": "date",
|
||||||
"type": "text",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"won": {
|
"won": {
|
||||||
@@ -256,34 +298,42 @@
|
|||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": 0
|
"default": 0
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"created_at": {
|
||||||
"name": "createdAt",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updated_at": {
|
||||||
"name": "updatedAt",
|
"name": "updated_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"character_history_date_unique": {
|
||||||
|
"name": "character_history_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"characterHistory_characterId_character_id_fk": {
|
"character_history_character_id_character_id_fk": {
|
||||||
"name": "characterHistory_characterId_character_id_fk",
|
"name": "character_history_character_id_character_id_fk",
|
||||||
"tableFrom": "characterHistory",
|
"tableFrom": "character_history",
|
||||||
"tableTo": "character",
|
"tableTo": "character",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"characterId"
|
"character_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -291,11 +341,11 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"characterOverride": {
|
"character_override": {
|
||||||
"name": "characterOverride",
|
"name": "character_override",
|
||||||
"columns": {
|
"columns": {
|
||||||
"characterId": {
|
"character_id": {
|
||||||
"name": "characterId",
|
"name": "character_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": true,
|
"primaryKey": true,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
@@ -329,29 +379,36 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"devilFruitId": {
|
"fr_affiliations": {
|
||||||
"name": "devilFruitId",
|
"name": "fr_affiliations",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"hakiObservation": {
|
"devil_fruit_id": {
|
||||||
"name": "hakiObservation",
|
"name": "devil_fruit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"haki_observation": {
|
||||||
|
"name": "haki_observation",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"hakiArmament": {
|
"haki_armament": {
|
||||||
"name": "hakiArmament",
|
"name": "haki_armament",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"hakiConqueror": {
|
"haki_conqueror": {
|
||||||
"name": "hakiConqueror",
|
"name": "haki_conqueror",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -378,15 +435,22 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"firstAppearance": {
|
"fr_origin": {
|
||||||
"name": "firstAppearance",
|
"name": "fr_origin",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"first_appearance": {
|
||||||
|
"name": "first_appearance",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"pictureUrl": {
|
"picture_url": {
|
||||||
"name": "pictureUrl",
|
"name": "picture_url",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -399,6 +463,13 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"fr_epithets": {
|
||||||
|
"name": "fr_epithets",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -406,8 +477,8 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"arcId": {
|
"arc_id": {
|
||||||
"name": "arcId",
|
"name": "arc_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -420,6 +491,13 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"fr_url": {
|
||||||
|
"name": "fr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"name": "notes",
|
"name": "notes",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -430,43 +508,43 @@
|
|||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"characterOverride_characterId_character_id_fk": {
|
"character_override_character_id_character_id_fk": {
|
||||||
"name": "characterOverride_characterId_character_id_fk",
|
"name": "character_override_character_id_character_id_fk",
|
||||||
"tableFrom": "characterOverride",
|
"tableFrom": "character_override",
|
||||||
"tableTo": "character",
|
"tableTo": "character",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"characterId"
|
"character_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
"characterOverride_devilFruitId_devilFruit_id_fk": {
|
"character_override_devil_fruit_id_devil_fruit_id_fk": {
|
||||||
"name": "characterOverride_devilFruitId_devilFruit_id_fk",
|
"name": "character_override_devil_fruit_id_devil_fruit_id_fk",
|
||||||
"tableFrom": "characterOverride",
|
"tableFrom": "character_override",
|
||||||
"tableTo": "devilFruit",
|
"tableTo": "devil_fruit",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"devilFruitId"
|
"devil_fruit_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
"characterOverride_arcId_arc_id_fk": {
|
"character_override_arc_id_arc_id_fk": {
|
||||||
"name": "characterOverride_arcId_arc_id_fk",
|
"name": "character_override_arc_id_arc_id_fk",
|
||||||
"tableFrom": "characterOverride",
|
"tableFrom": "character_override",
|
||||||
"tableTo": "arc",
|
"tableTo": "arc",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"arcId"
|
"arc_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -474,8 +552,8 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"characterScrapeValidation": {
|
"character_scrape_validation": {
|
||||||
"name": "characterScrapeValidation",
|
"name": "character_scrape_validation",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -491,6 +569,13 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"fr_name": {
|
||||||
|
"name": "fr_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"gender": {
|
"gender": {
|
||||||
"name": "gender",
|
"name": "gender",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -512,31 +597,38 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"devilFruitId": {
|
"fr_affiliations": {
|
||||||
"name": "devilFruitId",
|
"name": "fr_affiliations",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"hakiObservation": {
|
"devil_fruit_id": {
|
||||||
"name": "hakiObservation",
|
"name": "devil_fruit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"haki_observation": {
|
||||||
|
"name": "haki_observation",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
"hakiArmament": {
|
"haki_armament": {
|
||||||
"name": "hakiArmament",
|
"name": "haki_armament",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
"hakiConqueror": {
|
"haki_conqueror": {
|
||||||
"name": "hakiConqueror",
|
"name": "haki_conqueror",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -564,15 +656,22 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"firstAppearance": {
|
"fr_origin": {
|
||||||
"name": "firstAppearance",
|
"name": "fr_origin",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"first_appearance": {
|
||||||
|
"name": "first_appearance",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"pictureUrl": {
|
"picture_url": {
|
||||||
"name": "pictureUrl",
|
"name": "picture_url",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -585,6 +684,13 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"fr_epithets": {
|
||||||
|
"name": "fr_epithets",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -592,8 +698,8 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"arcId": {
|
"arc_id": {
|
||||||
"name": "arcId",
|
"name": "arc_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -605,34 +711,49 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fr_url": {
|
||||||
|
"name": "fr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_deleted": {
|
||||||
|
"name": "is_deleted",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"characterScrapeValidation_devilFruitId_devilFruit_id_fk": {
|
"character_scrape_validation_devil_fruit_id_devil_fruit_id_fk": {
|
||||||
"name": "characterScrapeValidation_devilFruitId_devilFruit_id_fk",
|
"name": "character_scrape_validation_devil_fruit_id_devil_fruit_id_fk",
|
||||||
"tableFrom": "characterScrapeValidation",
|
"tableFrom": "character_scrape_validation",
|
||||||
"tableTo": "devilFruit",
|
"tableTo": "devil_fruit",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"devilFruitId"
|
"devil_fruit_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
"characterScrapeValidation_arcId_arc_id_fk": {
|
"character_scrape_validation_arc_id_arc_id_fk": {
|
||||||
"name": "characterScrapeValidation_arcId_arc_id_fk",
|
"name": "character_scrape_validation_arc_id_arc_id_fk",
|
||||||
"tableFrom": "characterScrapeValidation",
|
"tableFrom": "character_scrape_validation",
|
||||||
"tableTo": "arc",
|
"tableTo": "arc",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"arcId"
|
"arc_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -664,8 +785,8 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"devilFruit": {
|
"devil_fruit": {
|
||||||
"name": "devilFruit",
|
"name": "devil_fruit",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -697,8 +818,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
"devilFruit_name_unique": {
|
"devil_fruit_name_unique": {
|
||||||
"name": "devilFruit_name_unique",
|
"name": "devil_fruit_name_unique",
|
||||||
"columns": [
|
"columns": [
|
||||||
"name"
|
"name"
|
||||||
],
|
],
|
||||||
@@ -710,6 +831,183 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
|
"friendship": {
|
||||||
|
"name": "friendship",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"requester_id": {
|
||||||
|
"name": "requester_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"addressee_id": {
|
||||||
|
"name": "addressee_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'pending'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"friendship_requester_id_addressee_id_unique": {
|
||||||
|
"name": "friendship_requester_id_addressee_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"requester_id",
|
||||||
|
"addressee_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"friendship_requester_id_user_id_fk": {
|
||||||
|
"name": "friendship_requester_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"requester_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"friendship_addressee_id_user_id_fk": {
|
||||||
|
"name": "friendship_addressee_id_user_id_fk",
|
||||||
|
"tableFrom": "friendship",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"addressee_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_character_history": {
|
||||||
|
"name": "user_character_history",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"character_history_id": {
|
||||||
|
"name": "character_history_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"try_count": {
|
||||||
|
"name": "try_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tried_character_ids": {
|
||||||
|
"name": "tried_character_ids",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_character_history_user_id_character_history_id_unique": {
|
||||||
|
"name": "user_character_history_user_id_character_history_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"user_id",
|
||||||
|
"character_history_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_character_history_user_id_user_id_fk": {
|
||||||
|
"name": "user_character_history_user_id_user_id_fk",
|
||||||
|
"tableFrom": "user_character_history",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"user_character_history_character_history_id_character_history_id_fk": {
|
||||||
|
"name": "user_character_history_character_history_id_character_history_id_fk",
|
||||||
|
"tableFrom": "user_character_history",
|
||||||
|
"tableTo": "character_history",
|
||||||
|
"columnsFrom": [
|
||||||
|
"character_history_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"name": "account",
|
"name": "account",
|
||||||
"columns": {
|
"columns": {
|
||||||
@@ -947,6 +1245,13 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"name": "email",
|
"name": "email",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -969,6 +1274,14 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"is_admin": {
|
||||||
|
"name": "is_admin",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -987,6 +1300,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
"user_email_unique": {
|
"user_email_unique": {
|
||||||
"name": "user_email_unique",
|
"name": "user_email_unique",
|
||||||
"columns": [
|
"columns": [
|
||||||
|
|||||||
1185
drizzle/meta/0002_snapshot.json
Normal file
1185
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1185
drizzle/meta/0003_snapshot.json
Normal file
1185
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,15 +5,29 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1772325597983,
|
"when": 1773602933375,
|
||||||
"tag": "0000_graceful_master_mold",
|
"tag": "0000_huge_doctor_octopus",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1772383366179,
|
"when": 1773697753818,
|
||||||
"tag": "0001_nostalgic_hercules",
|
"tag": "0001_fuzzy_talisman",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775950314114,
|
||||||
|
"tag": "0002_old_earthquake",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1776195681488,
|
||||||
|
"tag": "0003_mixed_ben_grimm",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -6,7 +6,6 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "onepiecedle",
|
"name": "onepiecedle",
|
||||||
"version": "0.0.1",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@libsql/client": "^0.17.0",
|
"@libsql/client": "^0.17.0",
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
@@ -23,7 +22,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/node": "^24",
|
"@types/node": "^24.11.0",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"csv-writer": "^1.6.0",
|
"csv-writer": "^1.6.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "onepiecedle",
|
"name": "onepiecedle",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -18,6 +17,7 @@
|
|||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:import": "npx tsx scripts/import-json.ts",
|
"db:import": "npx tsx scripts/import-json.ts",
|
||||||
"db:set-daily-mode": "npx tsx scripts/set-daily-mode.ts",
|
"db:set-daily-mode": "npx tsx scripts/set-daily-mode.ts",
|
||||||
|
"user:promote-admin": "npx tsx scripts/promote-admin.ts",
|
||||||
"auth:schema": "npx @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes",
|
"auth:schema": "npx @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes",
|
||||||
"scrape": "npx tsx scripts/scrape-onepiece.ts"
|
"scrape": "npx tsx scripts/scrape-onepiece.ts"
|
||||||
},
|
},
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/node": "^24",
|
"@types/node": "^24.11.0",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"csv-writer": "^1.6.0",
|
"csv-writer": "^1.6.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
@@ -47,11 +47,11 @@
|
|||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tsx": "^4.21.0",
|
"@libsql/client": "^0.17.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
|
||||||
"drizzle-kit": "^0.31.8",
|
|
||||||
"better-auth": "^1.4.18",
|
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@libsql/client": "^0.17.0"
|
"better-auth": "^1.4.18",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"tsx": "^4.21.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,65 @@
|
|||||||
[
|
[
|
||||||
"aladdin_aladdin",
|
"absalom_absalom",
|
||||||
|
"king_king",
|
||||||
"alvida_alvida",
|
"alvida_alvida",
|
||||||
"aramaki_aramaki",
|
"aramaki_aramaki",
|
||||||
"arlong_arlong",
|
"arlong_arlong",
|
||||||
"ashura_doji_ashura_doji",
|
"ashura_doji_ashura_doji",
|
||||||
|
"vegapunk/atlas_atlas",
|
||||||
|
"avalo_pizarro_avalo_pizarro",
|
||||||
"baby_5_baby_5",
|
"baby_5_baby_5",
|
||||||
"baggy_baggy",
|
"buggy_buggy",
|
||||||
"bartholomew_kuma_bartholomew_kuma",
|
"bartholomew_kuma_bartholomew_kuma",
|
||||||
"bartolomeo_bartolomeo",
|
"bartolomeo_bartolomeo",
|
||||||
"basil_hawkins_basil_hawkins",
|
"basil_hawkins_basil_hawkins",
|
||||||
"batman_batman",
|
"bell-mère_bell-mère",
|
||||||
"bellamy_bellamy",
|
"bellamy_bellamy",
|
||||||
"belo_betty_belo_betty",
|
"belo_betty_belo_betty",
|
||||||
"ben_beckman_ben_beckman",
|
"benn_beckman_ben_beckman",
|
||||||
"bentham_bentham",
|
"bentham_bentham",
|
||||||
"bepo_bepo",
|
"bepo_bepo",
|
||||||
"black_maria_black_maria",
|
"black_maria_black_maria",
|
||||||
|
"blueno_blueno",
|
||||||
"boa_hancock_boa_hancock",
|
"boa_hancock_boa_hancock",
|
||||||
"boa_marigold_boa_marigold",
|
"boa_marigold_boa_marigold",
|
||||||
"boa_sandersonia_boa_sandersonia",
|
"boa_sandersonia_boa_sandersonia",
|
||||||
"borsalino_borsalino",
|
"borsalino_borsalino",
|
||||||
"brogy_brogy",
|
"brogy_brogy",
|
||||||
"brook_brook",
|
"brook_brook",
|
||||||
|
"buckingham_stussy_buckingham_stussy",
|
||||||
|
"buffalo_buffalo",
|
||||||
"camie_camie",
|
"camie_camie",
|
||||||
"capone_bege_capone_bege",
|
"capone_bege_capone_bege",
|
||||||
|
"carmel_carmel",
|
||||||
"caribou_caribou",
|
"caribou_caribou",
|
||||||
"carrot_carrot",
|
"carrot_carrot",
|
||||||
"catarina_devon_catarina_devon",
|
"catarina_devon_catarina_devon",
|
||||||
"cavendish_cavendish",
|
"cavendish_cavendish",
|
||||||
"cesar_clown_cesar_clown",
|
"caesar_clown_caesar_clown",
|
||||||
|
"charlotte_brûlée_charlotte_brûlée",
|
||||||
|
"charlotte_cracker_charlotte_cracker",
|
||||||
|
"charlotte_katakuri_charlotte_katakuri",
|
||||||
|
"charlotte_linlin_charlotte_linlin",
|
||||||
|
"charlotte_mont-d'or_charlotte_mont-d'or",
|
||||||
|
"charlotte_oven_charlotte_oven",
|
||||||
|
"charlotte_perospero_charlotte_perospero",
|
||||||
|
"charlotte_pudding_charlotte_pudding",
|
||||||
|
"charlotte_smoothie_charlotte_smoothie",
|
||||||
"chinjao_chinjao",
|
"chinjao_chinjao",
|
||||||
"coby_coby",
|
"clou_d_clover_clou_d_clover",
|
||||||
"corazon_corazon",
|
|
||||||
"crocodile_crocodile",
|
"crocodile_crocodile",
|
||||||
"crocus_crocus",
|
"crocus_crocus",
|
||||||
"curly_dadan_curly_dadan",
|
"curly_dadan_curly_dadan",
|
||||||
"dalton_dalton",
|
"dalton_dalton",
|
||||||
"daz_bones_daz_bones",
|
"daz_bonez_daz_bonez",
|
||||||
"denjiro_denjiro",
|
"denjiro_denjiro",
|
||||||
"diamante_diamante",
|
"diamante_diamante",
|
||||||
"doc_q_doc_q",
|
"doc_q_doc_q",
|
||||||
"don_quichotte_doflamingo_don_quichotte_doflamingo",
|
"donquixote_doflamingo_donquixote_doflamingo",
|
||||||
"don_quichotte_rossinante_don_quichotte_rossinante",
|
"donquixote_rosinante_donquixote_rosinante",
|
||||||
"dorry_dorry",
|
"dorry_dorry",
|
||||||
"dracule_mihawk_dracule_mihawk",
|
"dracule_mihawk_dracule_mihawk",
|
||||||
"duval_duval",
|
"vegapunk/edison_edison",
|
||||||
"edward_newgate_edward_newgate",
|
"edward_newgate_edward_newgate",
|
||||||
"edward_weevil_edward_weevil",
|
"edward_weevil_edward_weevil",
|
||||||
"emporio_ivankov_emporio_ivankov",
|
"emporio_ivankov_emporio_ivankov",
|
||||||
@@ -53,30 +68,47 @@
|
|||||||
"fisher_tiger_fisher_tiger",
|
"fisher_tiger_fisher_tiger",
|
||||||
"foxy_foxy",
|
"foxy_foxy",
|
||||||
"franky_franky",
|
"franky_franky",
|
||||||
"fujitora_fujitora",
|
"fukaboshi_fukaboshi",
|
||||||
"gan_forr_gan_forr",
|
"fukurou_fukurou",
|
||||||
|
"galdino_galdino",
|
||||||
|
"gan_fall_gan_fall",
|
||||||
"gecko_moria_gecko_moria",
|
"gecko_moria_gecko_moria",
|
||||||
|
"gem_gem",
|
||||||
|
"genzo_genzo",
|
||||||
"gin_gin",
|
"gin_gin",
|
||||||
|
"ginny_ginny",
|
||||||
"gol_d_roger_gol_d_roger",
|
"gol_d_roger_gol_d_roger",
|
||||||
"haguar_d_sauro_haguar_d_sauro",
|
"guernika_guernika",
|
||||||
|
"hack_hack",
|
||||||
|
"jaguar_d_saul_jaguar_d_saul",
|
||||||
"hajrudin_hajrudin",
|
"hajrudin_hajrudin",
|
||||||
"hannyabal_hannyabal",
|
"hannyabal_hannyabal",
|
||||||
"hatchan_hatchan",
|
"harald_harald",
|
||||||
|
"haredas_haredas",
|
||||||
|
"heracles_heracles",
|
||||||
|
"helmeppo_helmeppo",
|
||||||
|
"hibari_hibari",
|
||||||
|
"hiriluk_hiriluk",
|
||||||
"hina_hina",
|
"hina_hina",
|
||||||
"hody_jones_hody_jones",
|
"hody_jones_hody_jones",
|
||||||
"hyogoro_hyogoro",
|
"hogback_hogback",
|
||||||
|
"hyougoro_hyougoro",
|
||||||
"iceburg_iceburg",
|
"iceburg_iceburg",
|
||||||
|
"igaram_igaram",
|
||||||
"imu_imu",
|
"imu_imu",
|
||||||
"inazuma_inazuma",
|
"inazuma_inazuma",
|
||||||
"inuarashi_inuarashi",
|
"inuarashi_inuarashi",
|
||||||
"issho_issho",
|
"issho_issho",
|
||||||
"izo_izo",
|
"izou_izou",
|
||||||
"jabra_jabra",
|
"jabra_jabra",
|
||||||
"jack_jack",
|
"jack_jack",
|
||||||
|
"jango_jango",
|
||||||
"jesus_burgess_jesus_burgess",
|
"jesus_burgess_jesus_burgess",
|
||||||
"jewelry_bonney_jewelry_bonney",
|
"jewelry_bonney_jewelry_bonney",
|
||||||
"jinbei_jinbei",
|
"jinbe_jinbe",
|
||||||
|
"giolla_giolla",
|
||||||
"joy_boy_joy_boy",
|
"joy_boy_joy_boy",
|
||||||
|
"jozu_jozu",
|
||||||
"kaidou_kaidou",
|
"kaidou_kaidou",
|
||||||
"kaku_kaku",
|
"kaku_kaku",
|
||||||
"kalgara_kalgara",
|
"kalgara_kalgara",
|
||||||
@@ -85,60 +117,139 @@
|
|||||||
"karoo_karoo",
|
"karoo_karoo",
|
||||||
"kawamatsu_kawamatsu",
|
"kawamatsu_kawamatsu",
|
||||||
"kaya_kaya",
|
"kaya_kaya",
|
||||||
|
"kelly_funk_kelly_funk",
|
||||||
|
"kikunojo_kikunojo",
|
||||||
"killer_killer",
|
"killer_killer",
|
||||||
"kinemon_kinemon",
|
"kin'emon_kin'emon",
|
||||||
"koala_koala",
|
"koala_koala",
|
||||||
"koby_koby",
|
"koby_koby",
|
||||||
"kong_kong",
|
"kokoro_kokoro",
|
||||||
"kozuki_hiyori_kozuki_hiyori",
|
"kouzuki_hiyori_kouzuki_hiyori",
|
||||||
"kozuki_momonosuke_kozuki_momonosuke",
|
"kouzuki_momonosuke_kouzuki_momonosuke",
|
||||||
"kozuki_oden_kozuki_oden",
|
"kouzuki_oden_kouzuki_oden",
|
||||||
|
"kouzuki_sukiyaki_kouzuki_sukiyaki",
|
||||||
|
"kouzuki_toki_kouzuki_toki",
|
||||||
"krieg_krieg",
|
"krieg_krieg",
|
||||||
|
"kumadori_kumadori",
|
||||||
"kureha_kureha",
|
"kureha_kureha",
|
||||||
"kuro_kuro",
|
"kuro_kuro",
|
||||||
|
"kurozumi_kanjuro_kurozumi_kanjuro",
|
||||||
"kurozumi_orochi_kurozumi_orochi",
|
"kurozumi_orochi_kurozumi_orochi",
|
||||||
|
"kurozumi_tama_kurozumi_tama",
|
||||||
"kuzan_kuzan",
|
"kuzan_kuzan",
|
||||||
"kyros_kyros",
|
"kyros_kyros",
|
||||||
"laboon_laboon",
|
"laboon_laboon",
|
||||||
"laffitte_laffitte",
|
"laffitte_laffitte",
|
||||||
"lao_g_lao_g",
|
"lao_g_lao_g",
|
||||||
"leo_leo",
|
"leo_leo",
|
||||||
|
"vegapunk/lilith_lilith",
|
||||||
"lindbergh_lindbergh",
|
"lindbergh_lindbergh",
|
||||||
"loki_loki",
|
"loki_loki",
|
||||||
"lucky_roux_lucky_roux",
|
"lucky_roux_lucky_roux",
|
||||||
"magellan_magellan",
|
"magellan_magellan",
|
||||||
"makino_makino",
|
"makino_makino",
|
||||||
|
"mansherry_mansherry",
|
||||||
"marco_marco",
|
"marco_marco",
|
||||||
"marshall_d_teach_marshall_d_teach",
|
"marshall_d_teach_marshall_d_teach",
|
||||||
|
"merry_merry",
|
||||||
|
"momoo_momoo",
|
||||||
|
"mocha_mocha",
|
||||||
|
"monet_monet",
|
||||||
"monkey_d_dragon_monkey_d_dragon",
|
"monkey_d_dragon_monkey_d_dragon",
|
||||||
"monkey_d_garp_monkey_d_garp",
|
"monkey_d_garp_monkey_d_garp",
|
||||||
"monkey_d_luffy_monkey_d_luffy",
|
"monkey_d_luffy_monkey_d_luffy",
|
||||||
"montblanc_norland_montblanc_norland",
|
"mont_blanc_cricket_mont_blanc_cricket",
|
||||||
|
"mont_blanc_noland_mont_blanc_noland",
|
||||||
"morgans_morgans",
|
"morgans_morgans",
|
||||||
|
"morgan_morgan",
|
||||||
"morley_morley",
|
"morley_morley",
|
||||||
"mr_3_mr_3",
|
|
||||||
"nami_nami",
|
"nami_nami",
|
||||||
"nefertari_cobra_nefertari_cobra",
|
"nefertari_cobra_nefertari_cobra",
|
||||||
"nefertari_vivi_nefertari_vivi",
|
"nefertari_vivi_nefertari_vivi",
|
||||||
"nekomamushi_nekomamushi",
|
"nekomamushi_nekomamushi",
|
||||||
"neptune_neptune",
|
"neptune_neptune",
|
||||||
|
"nico_olvia_nico_olvia",
|
||||||
"nico_robin_nico_robin",
|
"nico_robin_nico_robin",
|
||||||
"oars_oars",
|
"nojiko_nojiko",
|
||||||
|
"hatchan_hatchan",
|
||||||
"otohime_otohime",
|
"otohime_otohime",
|
||||||
|
"oars_oars",
|
||||||
"page_one_page_one",
|
"page_one_page_one",
|
||||||
"pandaman_pandaman",
|
"pandaman_pandaman",
|
||||||
|
"paulie_paulie",
|
||||||
|
"pedro_pedro",
|
||||||
"pekoms_pekoms",
|
"pekoms_pekoms",
|
||||||
"pell_pell",
|
"pell_pell",
|
||||||
"perona_perona",
|
"perona_perona",
|
||||||
"pica_pica",
|
"pica_pica",
|
||||||
"portgas_d_ace_portgas_d_ace",
|
"portgas_d_ace_portgas_d_ace",
|
||||||
|
"vegapunk/pythagoras_pythagoras",
|
||||||
"queen_queen",
|
"queen_queen",
|
||||||
"raizo_raizo",
|
"raizo_raizo",
|
||||||
"rebecca_rebecca",
|
"rebecca_rebecca",
|
||||||
|
"riku_doldo_iii_riku_doldo_iii",
|
||||||
"rob_lucci_rob_lucci",
|
"rob_lucci_rob_lucci",
|
||||||
"rocks_d_xebec_rocks_d_xebec",
|
"rocks_d_xebec_rocks_d_xebec",
|
||||||
"roronoa_zoro_roronoa_zoro",
|
"roronoa_zoro_roronoa_zoro",
|
||||||
|
"s-bear_s-bear",
|
||||||
|
"s-hawk_s-hawk",
|
||||||
|
"s-snake_s-snake",
|
||||||
"sabo_sabo",
|
"sabo_sabo",
|
||||||
|
"sadi_sadi",
|
||||||
|
"donquixote_mjosgard_donquixote_mjosgard",
|
||||||
|
"rimoshifu_killingham_rimoshifu_killingham",
|
||||||
|
"manmayer_gunko_manmayer_gunko",
|
||||||
|
"shepherd_sommers_shepherd_sommers",
|
||||||
|
"sakazuki_sakazuki",
|
||||||
|
"sanjuan_wolf_sanjuan_wolf",
|
||||||
|
"sasaki_sasaki",
|
||||||
|
"scratchmen_apoo_scratchmen_apoo",
|
||||||
|
"sengoku_sengoku",
|
||||||
|
"senor_pink_senor_pink",
|
||||||
|
"sentomaru_sentomaru",
|
||||||
|
"vegapunk/shaka_shaka",
|
||||||
|
"shakuyaku_shakuyaku",
|
||||||
|
"shanks_shanks",
|
||||||
|
"shiryu_shiryu",
|
||||||
|
"shimotsuki_kuina_shimotsuki_kuina",
|
||||||
|
"shimotsuki_yasuie_shimotsuki_yasuie",
|
||||||
|
"shinobu_shinobu",
|
||||||
|
"shirahoshi_shirahoshi",
|
||||||
|
"silvers_rayleigh_silvers_rayleigh",
|
||||||
|
"smoker_smoker",
|
||||||
|
"spandam_spandam",
|
||||||
|
"speed_speed",
|
||||||
|
"stussy_stussy",
|
||||||
|
"sugar_sugar",
|
||||||
|
"tamago_tamago",
|
||||||
|
"tashigi_tashigi",
|
||||||
|
"toko_toko",
|
||||||
|
"tom_tom",
|
||||||
|
"tony_tony_chopper_tony_tony_chopper",
|
||||||
|
"trafalgar_d_water_law_trafalgar_d_water_law",
|
||||||
|
"trebol_trebol",
|
||||||
|
"tsuru_tsuru",
|
||||||
|
"ulti_ulti",
|
||||||
|
"urouge_urouge",
|
||||||
|
"usopp_usopp",
|
||||||
|
"uta_uta",
|
||||||
|
"van_augur_van_augur",
|
||||||
|
"vander_decken_ix_vander_decken_ix",
|
||||||
"vegapunk_vegapunk",
|
"vegapunk_vegapunk",
|
||||||
"yamato_yamato"
|
"vergo_vergo",
|
||||||
|
"vinsmoke_ichiji_vinsmoke_ichiji",
|
||||||
|
"vinsmoke_judge_vinsmoke_judge",
|
||||||
|
"vinsmoke_niji_vinsmoke_niji",
|
||||||
|
"vinsmoke_reiju_vinsmoke_reiju",
|
||||||
|
"sanji_sanji",
|
||||||
|
"vinsmoke_yonji_vinsmoke_yonji",
|
||||||
|
"viola_viola",
|
||||||
|
"wadatsumi_wadatsumi",
|
||||||
|
"wapol_wapol",
|
||||||
|
"wyper_wyper",
|
||||||
|
"x_drake_x_drake",
|
||||||
|
"yamato_yamato",
|
||||||
|
"yasopp_yasopp",
|
||||||
|
"vegapunk/york_york",
|
||||||
|
"zeff_zeff"
|
||||||
]
|
]
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { createClient } from '@libsql/client';
|
import { createClient } from '@libsql/client';
|
||||||
import { drizzle } from 'drizzle-orm/libsql';
|
import { drizzle } from 'drizzle-orm/libsql';
|
||||||
import { sql, eq } from 'drizzle-orm';
|
import { sql, eq, inArray } from 'drizzle-orm';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { arc, character, devilFruit, characterScrapeValidation, type DevilFruitType } from '../src/lib/server/db/schema';
|
import { arc, character, devilFruit, characterScrapeValidation, type DevilFruitType } from '../src/lib/server/db/schema';
|
||||||
|
|
||||||
|
type Status = 'Alive' | 'Dead' | 'Unknown';
|
||||||
|
|
||||||
type ArcRecord = {
|
type ArcRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
frName?: string | null;
|
||||||
startChapter: number;
|
startChapter: number;
|
||||||
endChapter?: number | null;
|
endChapter?: number | null;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
@@ -22,9 +25,11 @@ type DevilFruitRecord = {
|
|||||||
type CharacterRecord = {
|
type CharacterRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
frName?: string | null;
|
||||||
gender?: string | null;
|
gender?: string | null;
|
||||||
age?: number | null;
|
age?: number | null;
|
||||||
affiliations?: string[] | string | null;
|
affiliation?: string | null;
|
||||||
|
frAffiliation?: string | null;
|
||||||
devilFruitId?: string | null;
|
devilFruitId?: string | null;
|
||||||
hakiObservation?: boolean;
|
hakiObservation?: boolean;
|
||||||
hakiArmament?: boolean;
|
hakiArmament?: boolean;
|
||||||
@@ -32,12 +37,15 @@ type CharacterRecord = {
|
|||||||
bounty?: number | null;
|
bounty?: number | null;
|
||||||
height?: number | null;
|
height?: number | null;
|
||||||
origin?: string | null;
|
origin?: string | null;
|
||||||
|
frOrigin?: string | null;
|
||||||
firstAppearance?: number;
|
firstAppearance?: number;
|
||||||
pictureUrl?: string | null;
|
pictureUrl?: string | null;
|
||||||
epithets?: string[] | string | null;
|
epithets?: string[] | string | null;
|
||||||
status?: string | null;
|
frEpithets?: string[] | string | null;
|
||||||
|
status?: Status | null;
|
||||||
arcId?: string | null;
|
arcId?: string | null;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
|
frUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DATABASE_URL = process.env.DATABASE_URL || 'file:local.db';
|
const DATABASE_URL = process.env.DATABASE_URL || 'file:local.db';
|
||||||
@@ -86,7 +94,7 @@ function toJsonArray(value: string[] | string | null | undefined): string[] | nu
|
|||||||
|
|
||||||
function toDevilFruitType(value: DevilFruitType | string | null | undefined): DevilFruitType | null {
|
function toDevilFruitType(value: DevilFruitType | string | null | undefined): DevilFruitType | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
if (value === 'Paramecia' || value === 'Zoan' || value === 'Logia' || value === 'Unknown') {
|
if (value === 'Paramecia' || value === 'Zoan' || value === 'Logia' || value === 'Smile' || value === 'Unknown') {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
@@ -112,62 +120,31 @@ function transformCharacterData(item: CharacterRecord) {
|
|||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
|
frName: toNullable(item.frName),
|
||||||
gender: toNullable(item.gender),
|
gender: toNullable(item.gender),
|
||||||
age: toNullable(item.age),
|
age: toNullable(item.age),
|
||||||
affiliations: toJsonArray(item.affiliations),
|
affiliation: toNullable(item.affiliation),
|
||||||
|
frAffiliation: toNullable(item.frAffiliation),
|
||||||
devilFruitId: toNullable(item.devilFruitId),
|
devilFruitId: toNullable(item.devilFruitId),
|
||||||
hakiObservation: !!item.hakiObservation,
|
hakiObservation: !!item.hakiObservation,
|
||||||
hakiArmament: !!item.hakiArmament,
|
hakiArmament: !!item.hakiArmament,
|
||||||
hakiConqueror: !!item.hakiConqueror,
|
hakiConqueror: !!item.hakiConqueror,
|
||||||
bounty: item.bounty ?? 0,
|
bounty: item.bounty ?? 0,
|
||||||
height: toNumber(item.height as any),
|
height: toNumber(item.height as string | number | null),
|
||||||
origin: toNullable(item.origin),
|
origin: toNullable(item.origin),
|
||||||
|
frOrigin: toNullable(item.frOrigin),
|
||||||
firstAppearance: item.firstAppearance ?? 0,
|
firstAppearance: item.firstAppearance ?? 0,
|
||||||
pictureUrl: toNullable(item.pictureUrl),
|
pictureUrl: toNullable(item.pictureUrl),
|
||||||
epithets: toJsonArray(item.epithets),
|
epithets: toJsonArray(item.epithets),
|
||||||
|
frEpithets: toJsonArray(item.frEpithets),
|
||||||
status: toNullable(item.status),
|
status: toNullable(item.status),
|
||||||
arcId: toNullable(item.arcId),
|
arcId: toNullable(item.arcId),
|
||||||
url: toNullable(item.url)
|
url: toNullable(item.url),
|
||||||
|
frUrl: toNullable(item.frUrl),
|
||||||
|
isDeleted: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasChanged(jsonData: any, dbData: any): boolean {
|
|
||||||
if (!dbData) return true;
|
|
||||||
|
|
||||||
// Print any differences for debugging
|
|
||||||
for (const key in jsonData) {
|
|
||||||
const jsonValue = jsonData[key];
|
|
||||||
const dbValue = dbData[key];
|
|
||||||
const jsonString = typeof jsonValue === 'object' ? JSON.stringify(jsonValue) : String(jsonValue);
|
|
||||||
const dbString = typeof dbValue === 'object' ? JSON.stringify(dbValue) : String(dbValue);
|
|
||||||
if (jsonString !== dbString) {
|
|
||||||
console.log(`\nField "${key}" changed for character ID ${jsonData.id}:`);
|
|
||||||
console.log(` JSON: ${jsonString}`);
|
|
||||||
console.log(` DB: ${dbString}`);
|
|
||||||
} }
|
|
||||||
|
|
||||||
// Compare each field
|
|
||||||
return (
|
|
||||||
jsonData.name != dbData.name ||
|
|
||||||
jsonData.gender != dbData.gender ||
|
|
||||||
jsonData.age != dbData.age ||
|
|
||||||
JSON.stringify(jsonData.affiliations) != JSON.stringify(dbData.affiliations) ||
|
|
||||||
jsonData.devilFruitId != dbData.devilFruitId ||
|
|
||||||
jsonData.hakiObservation != dbData.hakiObservation ||
|
|
||||||
jsonData.hakiArmament != dbData.hakiArmament ||
|
|
||||||
jsonData.hakiConqueror != dbData.hakiConqueror ||
|
|
||||||
jsonData.bounty != dbData.bounty ||
|
|
||||||
jsonData.height != dbData.height ||
|
|
||||||
jsonData.origin != dbData.origin ||
|
|
||||||
jsonData.firstAppearance != dbData.firstAppearance ||
|
|
||||||
jsonData.pictureUrl != dbData.pictureUrl ||
|
|
||||||
JSON.stringify(jsonData.epithets) != JSON.stringify(dbData.epithets) ||
|
|
||||||
jsonData.status != dbData.status ||
|
|
||||||
jsonData.arcId != dbData.arcId ||
|
|
||||||
jsonData.url != dbData.url
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isCharacterTableEmpty(): Promise<boolean> {
|
async function isCharacterTableEmpty(): Promise<boolean> {
|
||||||
const result = await db.select({ count: sql<number>`COUNT(*)` }).from(character);
|
const result = await db.select({ count: sql<number>`COUNT(*)` }).from(character);
|
||||||
return result[0]?.count === 0;
|
return result[0]?.count === 0;
|
||||||
@@ -195,6 +172,7 @@ async function importFromJson(): Promise<void> {
|
|||||||
.values({
|
.values({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
|
frName: toNullable(item.frName),
|
||||||
startChapter: item.startChapter,
|
startChapter: item.startChapter,
|
||||||
endChapter: toNullable(item.endChapter),
|
endChapter: toNullable(item.endChapter),
|
||||||
url: toNullable(item.url)
|
url: toNullable(item.url)
|
||||||
@@ -203,6 +181,7 @@ async function importFromJson(): Promise<void> {
|
|||||||
target: arc.id,
|
target: arc.id,
|
||||||
set: {
|
set: {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
|
frName: toNullable(item.frName),
|
||||||
startChapter: item.startChapter,
|
startChapter: item.startChapter,
|
||||||
endChapter: toNullable(item.endChapter),
|
endChapter: toNullable(item.endChapter),
|
||||||
url: toNullable(item.url)
|
url: toNullable(item.url)
|
||||||
@@ -327,8 +306,9 @@ async function importFromJson(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Check for changes and update scrapeValidation table
|
// Update scrapeValidation table
|
||||||
console.log('Characters table not empty, checking for changes...\n');
|
console.log('Characters table not empty, updating scrapeValidation table for changes...\n');
|
||||||
|
const scrapedCharacterIds: string[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < characters.length; i++) {
|
for (let i = 0; i < characters.length; i++) {
|
||||||
const item = characters[i];
|
const item = characters[i];
|
||||||
@@ -340,13 +320,10 @@ async function importFromJson(): Promise<void> {
|
|||||||
.where(eq(character.id, item.id));
|
.where(eq(character.id, item.id));
|
||||||
|
|
||||||
lastSql = selectQuery.toSQL();
|
lastSql = selectQuery.toSQL();
|
||||||
const [dbCharacter] = await selectQuery;
|
|
||||||
|
|
||||||
|
scrapedCharacterIds.push(item.id);
|
||||||
const jsonData = transformCharacterData(item);
|
const jsonData = transformCharacterData(item);
|
||||||
const changed = hasChanged(jsonData, dbCharacter);
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
// Update scrapeValidation table with changes
|
|
||||||
const upsertQuery = db
|
const upsertQuery = db
|
||||||
.insert(characterScrapeValidation)
|
.insert(characterScrapeValidation)
|
||||||
.values(jsonData)
|
.values(jsonData)
|
||||||
@@ -357,16 +334,6 @@ async function importFromJson(): Promise<void> {
|
|||||||
|
|
||||||
lastSql = upsertQuery.toSQL();
|
lastSql = upsertQuery.toSQL();
|
||||||
await upsertQuery;
|
await upsertQuery;
|
||||||
} else {
|
|
||||||
// No changes, delete from scrapeValidation if it exists
|
|
||||||
const deleteQuery = db
|
|
||||||
.delete(characterScrapeValidation)
|
|
||||||
.where(eq(characterScrapeValidation.id, item.id));
|
|
||||||
|
|
||||||
lastSql = deleteQuery.toSQL();
|
|
||||||
await deleteQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
successCount++;
|
successCount++;
|
||||||
process.stdout.write(`\rProcessed: ${successCount}/${characters.length}`);
|
process.stdout.write(`\rProcessed: ${successCount}/${characters.length}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -377,6 +344,57 @@ async function importFromJson(): Promise<void> {
|
|||||||
logSqlOnError(lastSql);
|
logSqlOnError(lastSql);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch all characters from the character table and mark those absent from the
|
||||||
|
// scrape as deleted in scrape validation.
|
||||||
|
const allCharacters = await db.select({ id: character.id }).from(character);
|
||||||
|
const scrapedSet = new Set(scrapedCharacterIds);
|
||||||
|
const idsToMarkDeleted = allCharacters
|
||||||
|
.map((c) => c.id)
|
||||||
|
.filter((id) => !scrapedSet.has(id));
|
||||||
|
|
||||||
|
if (idsToMarkDeleted.length > 0) {
|
||||||
|
console.log(`\n⚠️ Marking ${idsToMarkDeleted.length} character(s) as deleted in scrape validation...`);
|
||||||
|
const deletedCharacterRows = await db
|
||||||
|
.select()
|
||||||
|
.from(character)
|
||||||
|
.where(inArray(character.id, idsToMarkDeleted));
|
||||||
|
|
||||||
|
for (const row of deletedCharacterRows) {
|
||||||
|
await db
|
||||||
|
.insert(characterScrapeValidation)
|
||||||
|
.values({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
frName: row.frName,
|
||||||
|
gender: row.gender,
|
||||||
|
age: row.age,
|
||||||
|
affiliation: row.affiliation,
|
||||||
|
frAffiliation: row.frAffiliation,
|
||||||
|
devilFruitId: row.devilFruitId,
|
||||||
|
hakiObservation: row.hakiObservation,
|
||||||
|
hakiArmament: row.hakiArmament,
|
||||||
|
hakiConqueror: row.hakiConqueror,
|
||||||
|
bounty: row.bounty,
|
||||||
|
height: row.height,
|
||||||
|
origin: row.origin,
|
||||||
|
frOrigin: row.frOrigin,
|
||||||
|
firstAppearance: row.firstAppearance,
|
||||||
|
pictureUrl: row.pictureUrl,
|
||||||
|
epithets: row.epithets,
|
||||||
|
frEpithets: row.frEpithets,
|
||||||
|
status: row.status,
|
||||||
|
arcId: row.arcId,
|
||||||
|
url: row.url,
|
||||||
|
frUrl: row.frUrl,
|
||||||
|
isDeleted: true
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: characterScrapeValidation.id,
|
||||||
|
set: { isDeleted: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n\n✓ Characters imported!`);
|
console.log(`\n\n✓ Characters imported!`);
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ const columns = [
|
|||||||
'origin',
|
'origin',
|
||||||
'devilFruitType',
|
'devilFruitType',
|
||||||
'arc',
|
'arc',
|
||||||
'status'
|
'status',
|
||||||
|
'age'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
async function initColumnConfig(): Promise<void> {
|
async function initColumnConfig(): Promise<void> {
|
||||||
|
|||||||
55
scripts/promote-admin.ts
Normal file
55
scripts/promote-admin.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { createClient } from '@libsql/client';
|
||||||
|
import { drizzle } from 'drizzle-orm/libsql';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { user } from '../src/lib/server/db/auth.schema';
|
||||||
|
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL || 'file:local.db';
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promoteAdmin(): Promise<void> {
|
||||||
|
const email = process.argv[2]?.trim();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
console.error('❌ Missing email argument');
|
||||||
|
console.error('Usage: npm run user:promote-admin -- <email>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createClient({ url: DATABASE_URL });
|
||||||
|
const db = drizzle(client);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingUsers = await db.select().from(user).where(eq(user.email, email)).limit(1);
|
||||||
|
const targetUser = existingUsers[0];
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
console.error(`❌ User not found for email: ${email}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUser.isAdmin) {
|
||||||
|
console.log(`ℹ️ User is already admin: ${targetUser.email}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({ isAdmin: true })
|
||||||
|
.where(eq(user.id, targetUser.id));
|
||||||
|
|
||||||
|
console.log(`✅ Admin granted to: ${targetUser.email}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Failed to promote admin: ${getErrorMessage(error)}`);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
promoteAdmin().catch((error) => {
|
||||||
|
console.error(getErrorMessage(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import { createClient } from '@libsql/client';
|
import { createClient } from '@libsql/client';
|
||||||
import { drizzle } from 'drizzle-orm/libsql';
|
import { drizzle } from 'drizzle-orm/libsql';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { character, characterHistory } from '../src/lib/server/db/schema';
|
import { character, characterHistory } from '../src/lib/server/db/schema';
|
||||||
|
|
||||||
@@ -24,13 +24,14 @@ function getErrorMessage(error: unknown): string {
|
|||||||
|
|
||||||
async function setDailyCharacters(): Promise<void> {
|
async function setDailyCharacters(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const dailyCharacterIds = readJsonFile('./scripts/daily-characters.json');
|
const dailyCharacterIdsRaw = readJsonFile('./scripts/daily-characters.json');
|
||||||
|
|
||||||
if (!dailyCharacterIds || dailyCharacterIds.length === 0) {
|
if (!dailyCharacterIdsRaw || dailyCharacterIdsRaw.length === 0) {
|
||||||
console.error('❌ No daily characters found in daily-characters.json');
|
throw new Error('No daily characters found in daily-characters.json');
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dailyCharacterIds = dailyCharacterIdsRaw;
|
||||||
|
|
||||||
console.log(`\n=== Setting Daily Mode Characters ===\n`);
|
console.log(`\n=== Setting Daily Mode Characters ===\n`);
|
||||||
console.log(`Found ${dailyCharacterIds.length} characters to set as daily\n`);
|
console.log(`Found ${dailyCharacterIds.length} characters to set as daily\n`);
|
||||||
|
|
||||||
@@ -45,16 +46,36 @@ async function setDailyCharacters(): Promise<void> {
|
|||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
|
||||||
|
const existingCharacters = await db
|
||||||
|
.select({ id: character.id })
|
||||||
|
.from(character)
|
||||||
|
.where(inArray(character.id, dailyCharacterIds));
|
||||||
|
|
||||||
|
const existingIdSet = new Set(existingCharacters.map((c) => c.id));
|
||||||
|
const missingIds = dailyCharacterIds.filter((id) => !existingIdSet.has(id));
|
||||||
|
|
||||||
|
if (missingIds.length > 0) {
|
||||||
|
errorCount += missingIds.length;
|
||||||
|
console.error(`✗ ${missingIds.length} character ID(s) were not found in database:`);
|
||||||
|
for (const missingId of missingIds) {
|
||||||
|
console.error(` - ${missingId}`);
|
||||||
|
}
|
||||||
|
console.error('');
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < dailyCharacterIds.length; i++) {
|
for (let i = 0; i < dailyCharacterIds.length; i++) {
|
||||||
const charId = dailyCharacterIds[i];
|
const charId = dailyCharacterIds[i];
|
||||||
|
if (!existingIdSet.has(charId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await db
|
await db
|
||||||
.update(character)
|
.update(character)
|
||||||
.set({ isInDailyMode: true })
|
.set({ isInDailyMode: true })
|
||||||
.where(eq(character.id, charId));
|
.where(eq(character.id, charId));
|
||||||
|
|
||||||
successCount++;
|
successCount++;
|
||||||
process.stdout.write(`\rUpdated: ${successCount}/${dailyCharacterIds.length}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
console.error(`\n✗ Error updating character ${i + 1}:`);
|
console.error(`\n✗ Error updating character ${i + 1}:`);
|
||||||
|
|||||||
9
src/app.d.ts
vendored
9
src/app.d.ts
vendored
@@ -1,18 +1,11 @@
|
|||||||
import type { User, Session } from 'better-auth/minimal';
|
import type { User, Session } from 'better-auth/minimal';
|
||||||
|
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
|
||||||
// for information about these interfaces
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
user?: User;
|
user?: User & { isAdmin?: boolean };
|
||||||
session?: Session;
|
session?: Session;
|
||||||
}
|
}
|
||||||
|
|
||||||
// interface Error {}
|
|
||||||
// interface PageData {}
|
|
||||||
// interface PageState {}
|
|
||||||
// interface Platform {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
import { building } from '$app/environment';
|
import { building } from '$app/environment';
|
||||||
import { auth } from '$lib/server/auth';
|
import { auth } from '$lib/server/auth';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { user as userTable } from '$lib/server/db/auth.schema';
|
||||||
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
||||||
|
|
||||||
const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
||||||
@@ -9,6 +12,12 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
|||||||
if (session) {
|
if (session) {
|
||||||
event.locals.session = session.session;
|
event.locals.session = session.session;
|
||||||
event.locals.user = session.user;
|
event.locals.user = session.user;
|
||||||
|
|
||||||
|
// Fetch the isAdmin field from the database
|
||||||
|
const dbUser = await db.select({ isAdmin: userTable.isAdmin }).from(userTable).where(eq(userTable.id, session.user.id)).limit(1);
|
||||||
|
if (dbUser.length > 0) {
|
||||||
|
(event.locals.user as any).isAdmin = dbUser[0].isAdmin;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return svelteKitHandler({ event, resolve, auth, building });
|
return svelteKitHandler({ event, resolve, auth, building });
|
||||||
|
|||||||
BIN
src/lib/assets/favicon.png
Normal file
BIN
src/lib/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
222
src/lib/components/CharacterSearchInput.svelte
Normal file
222
src/lib/components/CharacterSearchInput.svelte
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CharacterWithRelations } from '$lib/server/daily-character';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { language, t } from '$lib/i18n';
|
||||||
|
|
||||||
|
let {
|
||||||
|
characters,
|
||||||
|
selectedCharacters,
|
||||||
|
onSelect
|
||||||
|
}: {
|
||||||
|
characters: CharacterWithRelations[];
|
||||||
|
selectedCharacters: CharacterWithRelations[];
|
||||||
|
onSelect: (character: CharacterWithRelations) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const state = $state({
|
||||||
|
searchInput: '',
|
||||||
|
highlightedIndex: 0,
|
||||||
|
dropdownContainer: null as HTMLDivElement | null,
|
||||||
|
searchContainer: null as HTMLDivElement | null
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFrench = $derived($language === 'fr');
|
||||||
|
|
||||||
|
function parseEpithets(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (value.length > 0) {
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(character: CharacterWithRelations): string {
|
||||||
|
if (isFrench && typeof character.frName === 'string' && character.frName.length > 0) {
|
||||||
|
return character.frName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayEpithets(character: CharacterWithRelations): string[] {
|
||||||
|
const frenchEpithets = parseEpithets(character.frEpithets);
|
||||||
|
if (isFrench && frenchEpithets.length > 0) {
|
||||||
|
return frenchEpithets;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseEpithets(character.epithets);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchText(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Add click outside listener
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredCharacters = $derived.by(() => {
|
||||||
|
const searchTerm = normalizeSearchText(state.searchInput);
|
||||||
|
|
||||||
|
return characters.filter((char) => {
|
||||||
|
const displayName = getDisplayName(char);
|
||||||
|
const displayEpithets = getDisplayEpithets(char);
|
||||||
|
const nameMatches = normalizeSearchText(displayName).includes(searchTerm);
|
||||||
|
const epithetsMatches = displayEpithets.some((epithet) =>
|
||||||
|
normalizeSearchText(epithet).includes(searchTerm)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (nameMatches || epithetsMatches) &&
|
||||||
|
!selectedCharacters.some((selected) => selected.id === char.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset highlighted index when filtered list changes.
|
||||||
|
$effect(() => {
|
||||||
|
const nextFilteredCharacters = filteredCharacters;
|
||||||
|
if (!nextFilteredCharacters) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.highlightedIndex = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll highlighted item into view.
|
||||||
|
$effect(() => {
|
||||||
|
const nextFilteredCharacters = filteredCharacters;
|
||||||
|
|
||||||
|
if (!state.dropdownContainer || state.highlightedIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.highlightedIndex >= nextFilteredCharacters.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightedButton = state.dropdownContainer.querySelector(
|
||||||
|
`button:nth-child(${state.highlightedIndex + 1})`
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
highlightedButton?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectCharacter(character: CharacterWithRelations) {
|
||||||
|
onSelect(character);
|
||||||
|
state.searchInput = '';
|
||||||
|
state.highlightedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (filteredCharacters.length === 0) return;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
state.highlightedIndex = Math.min(
|
||||||
|
state.highlightedIndex + 1,
|
||||||
|
filteredCharacters.length - 1
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
state.highlightedIndex = Math.max(state.highlightedIndex - 1, 0);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
event.preventDefault();
|
||||||
|
if (filteredCharacters[state.highlightedIndex]) {
|
||||||
|
selectCharacter(filteredCharacters[state.highlightedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitGuess() {
|
||||||
|
if (filteredCharacters.length === 0) return;
|
||||||
|
const characterToSelect = filteredCharacters[state.highlightedIndex] ?? filteredCharacters[0];
|
||||||
|
if (characterToSelect) {
|
||||||
|
selectCharacter(characterToSelect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (state.searchContainer && !state.searchContainer.contains(event.target as Node)) {
|
||||||
|
state.searchInput = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur z-10">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">{$t.game.components.searchInput.title}</h2>
|
||||||
|
<div class="mt-4 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<div bind:this={state.searchContainer} class="relative w-full">
|
||||||
|
<input
|
||||||
|
bind:value={state.searchInput}
|
||||||
|
class="w-full rounded-full border border-amber-200/30 bg-slate-900/60 px-5 py-3 text-sm text-slate-100 placeholder:text-slate-400 focus:border-amber-200/70 focus:outline-none"
|
||||||
|
placeholder={$t.game.components.searchInput.placeholder}
|
||||||
|
type="text"
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
/>
|
||||||
|
{#if state.searchInput.length > 0 && filteredCharacters.length > 0}
|
||||||
|
<div bind:this={state.dropdownContainer} class="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-amber-200/30 bg-slate-900/90 backdrop-blur z-10">
|
||||||
|
{#each filteredCharacters as character, index (character.id)}
|
||||||
|
<button
|
||||||
|
class="w-full px-5 py-4 text-left text-base text-slate-100 transition border-b border-slate-800/50 last:border-b-0 flex items-center gap-4 {index === state.highlightedIndex ? 'bg-slate-700' : 'hover:bg-slate-800/70'}"
|
||||||
|
type="button"
|
||||||
|
onmouseenter={() => (state.highlightedIndex = index)}
|
||||||
|
onclick={() => selectCharacter(character)}
|
||||||
|
>
|
||||||
|
{#if character.pictureUrl}
|
||||||
|
<img
|
||||||
|
src={character.pictureUrl}
|
||||||
|
alt={getDisplayName(character)}
|
||||||
|
loading="lazy"
|
||||||
|
class="w-12 h-12 rounded-full object-cover border border-amber-200/30"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="w-12 h-12 rounded-full bg-slate-800 border border-amber-200/30 flex items-center justify-center">
|
||||||
|
<span class="text-xs text-slate-400">?</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="font-semibold text-amber-100">{getDisplayName(character)}</span>
|
||||||
|
{#if getDisplayEpithets(character).length > 0}
|
||||||
|
<span class="ml-2 text-xs text-slate-400">
|
||||||
|
• {getDisplayEpithets(character).join(', ')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={submitGuess}
|
||||||
|
disabled={state.searchInput.length === 0 || filteredCharacters.length === 0}
|
||||||
|
class="rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition hover:bg-amber-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{$t.game.components.searchInput.submit}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
73
src/lib/components/FriendsTodaySection.svelte
Normal file
73
src/lib/components/FriendsTodaySection.svelte
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
type TriedCharacter = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
pictureUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FriendTodayResult = {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
image: string | null;
|
||||||
|
tryCount: number;
|
||||||
|
triedCharacters: TriedCharacter[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export let friendsTodayResults: FriendTodayResult[] = [];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if friendsTodayResults.length > 0}
|
||||||
|
<section class="mt-6 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100 text-center">{$t.game.daily.friendsToday}</p>
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
{#each friendsTodayResults as friendResult (friendResult.userId)}
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-950/50 px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if friendResult.image}
|
||||||
|
<img
|
||||||
|
src={friendResult.image}
|
||||||
|
alt={friendResult.name}
|
||||||
|
class="h-8 w-8 rounded-full border border-white/20 object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-amber-300/20 text-xs font-semibold text-amber-100">
|
||||||
|
{friendResult.name?.charAt(0).toUpperCase() || 'U'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<p class="text-sm font-semibold text-slate-100">{friendResult.name}</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-amber-300">
|
||||||
|
{friendResult.tryCount} {friendResult.tryCount > 1 ? $t.game.daily.friendTryPlural : $t.game.daily.friendTrySingular}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 border-t border-white/10 pt-2">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
{$t.game.daily.friendsTriedCharacters}
|
||||||
|
</p>
|
||||||
|
{#if friendResult.triedCharacters && friendResult.triedCharacters.length > 0}
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
{#each friendResult.triedCharacters as triedCharacter (triedCharacter.id)}
|
||||||
|
<span class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-xs text-slate-200">
|
||||||
|
{#if triedCharacter.pictureUrl}
|
||||||
|
<img
|
||||||
|
src={triedCharacter.pictureUrl}
|
||||||
|
alt={triedCharacter.name}
|
||||||
|
class="h-4 w-4 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{triedCharacter.name}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{$t.game.daily.friendsNoTriedCharacters}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
498
src/lib/components/GuessHistoryTable.svelte
Normal file
498
src/lib/components/GuessHistoryTable.svelte
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatBounty } from '$lib';
|
||||||
|
import type { CharacterWithRelations } from '$lib/server/daily-character';
|
||||||
|
import { language, t } from '$lib/i18n';
|
||||||
|
|
||||||
|
export let selectedCharacters: CharacterWithRelations[];
|
||||||
|
export let dailyCharacter: CharacterWithRelations;
|
||||||
|
export let columnVisibility: {
|
||||||
|
status?: boolean;
|
||||||
|
gender?: boolean;
|
||||||
|
affiliation?: boolean;
|
||||||
|
devilFruitType?: boolean;
|
||||||
|
haki?: boolean;
|
||||||
|
bounty?: boolean;
|
||||||
|
height?: boolean;
|
||||||
|
age?: boolean;
|
||||||
|
origin?: boolean;
|
||||||
|
arc?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
$: isFrench = $language === 'fr';
|
||||||
|
|
||||||
|
function getDisplayName(character: CharacterWithRelations): string {
|
||||||
|
if (isFrench && typeof character.frName === 'string' && character.frName.length > 0) {
|
||||||
|
return character.frName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWikiUrl(character: CharacterWithRelations): string {
|
||||||
|
if (isFrench && typeof character.frUrl === 'string' && character.frUrl.length > 0) {
|
||||||
|
return character.frUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character.url || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWikiBaseUrl(): string {
|
||||||
|
return isFrench ? 'https://onepiece.fandom.com/fr/wiki/' : 'https://onepiece.fandom.com/wiki/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayOrigin(character: CharacterWithRelations): string | null {
|
||||||
|
if (isFrench && typeof character.frOrigin === 'string' && character.frOrigin.length > 0) {
|
||||||
|
return character.frOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMatchingOrigin(characterEntry: CharacterWithRelations, dailyEntry: CharacterWithRelations): boolean {
|
||||||
|
return getDisplayOrigin(characterEntry) === getDisplayOrigin(dailyEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayArcName(character: CharacterWithRelations): string | null {
|
||||||
|
if (isFrench && typeof character.frArcName === 'string' && character.frArcName.length > 0) {
|
||||||
|
return character.frArcName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character.arcName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDislayAffiliation(character: CharacterWithRelations): string | null {
|
||||||
|
if (isFrench && typeof character.frAffiliation === 'string' && character.frAffiliation.length > 0) {
|
||||||
|
return character.frAffiliation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character.affiliation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMatchingArc(characterEntry: CharacterWithRelations, dailyEntry: CharacterWithRelations): boolean {
|
||||||
|
return getDisplayArcName(characterEntry) === getDisplayArcName(dailyEntry);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="mt-8 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<p class="text-xs font-semibold tracking-[0.28em] text-amber-100 uppercase">{$t.game.components.guessHistory.title}</p>
|
||||||
|
</div>
|
||||||
|
{#if selectedCharacters.length === 0}
|
||||||
|
<p class="text-center text-sm text-slate-200">{$t.game.components.guessHistory.empty}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="-mx-6 overflow-x-auto px-6 pb-2 sm:mx-0 sm:px-0">
|
||||||
|
<div class="mx-auto w-max min-w-max">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-2 flex gap-1 sm:gap-2">
|
||||||
|
<div
|
||||||
|
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.character}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if columnVisibility.status !== false}
|
||||||
|
<div
|
||||||
|
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if columnVisibility.gender !== false}
|
||||||
|
<div
|
||||||
|
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.gender}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if columnVisibility.affiliation !== false}
|
||||||
|
<div
|
||||||
|
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.affiliations}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if columnVisibility.devilFruitType !== false}
|
||||||
|
<div
|
||||||
|
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.fruit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if columnVisibility.haki !== false}
|
||||||
|
<div
|
||||||
|
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.haki}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if columnVisibility.bounty !== false}
|
||||||
|
<div
|
||||||
|
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.bounty}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if columnVisibility.height !== false}
|
||||||
|
<div
|
||||||
|
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.height}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if columnVisibility.age !== false}
|
||||||
|
<div
|
||||||
|
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.age}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if columnVisibility.origin !== false}
|
||||||
|
<div
|
||||||
|
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.origin}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if columnVisibility.arc !== false}
|
||||||
|
<div
|
||||||
|
class="flex w-16 shrink-0 items-center justify-center rounded-lg border border-amber-200/30 bg-amber-900/30 p-1 text-center sm:w-20 sm:p-2 md:w-24"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-[9px] font-semibold tracking-wider text-amber-100 uppercase sm:text-xs"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.arc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rows -->
|
||||||
|
{#each selectedCharacters as character (character.id)}
|
||||||
|
<div class="mb-2 flex gap-1 sm:gap-2">
|
||||||
|
<!-- Personnage -->
|
||||||
|
<div
|
||||||
|
class="h-16 w-16 shrink-0 overflow-hidden rounded-lg border border-white/10 bg-slate-950/60 sm:h-20 sm:w-20 md:h-24 md:w-24"
|
||||||
|
>
|
||||||
|
{#if character.pictureUrl}
|
||||||
|
<a
|
||||||
|
href={getWikiBaseUrl() + getWikiUrl(character)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="block h-full w-full"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={character.pictureUrl}
|
||||||
|
alt={getDisplayName(character)}
|
||||||
|
class="h-full w-full cursor-pointer object-cover transition-opacity hover:opacity-80"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex h-full w-full items-center justify-center bg-slate-800 p-1 sm:p-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="line-clamp-3 text-center text-xs font-semibold sm:text-sm md:text-xl"
|
||||||
|
>{getDisplayName(character)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vivant / Mort -->
|
||||||
|
{#if columnVisibility.status !== false}
|
||||||
|
<div
|
||||||
|
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {character.status ===
|
||||||
|
dailyCharacter.status
|
||||||
|
? 'bg-emerald-600/90'
|
||||||
|
: 'bg-red-900/60'} flex items-center justify-center p-1 sm:p-2"
|
||||||
|
>
|
||||||
|
<p class="text-center text-[10px] font-bold text-white sm:text-xs md:text-sm">
|
||||||
|
{character.status === 'Alive'
|
||||||
|
? $t.game.components.guessHistory.alive
|
||||||
|
: character.status === 'Dead'
|
||||||
|
? $t.game.components.guessHistory.dead
|
||||||
|
: character.status === 'Unknown'
|
||||||
|
? $t.game.components.guessHistory.unknown
|
||||||
|
: character.status === null
|
||||||
|
? '-'
|
||||||
|
: character.status || $t.game.components.guessHistory.unknown}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Genre -->
|
||||||
|
{#if columnVisibility.gender !== false}
|
||||||
|
<div
|
||||||
|
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {character.gender ===
|
||||||
|
dailyCharacter.gender
|
||||||
|
? 'bg-emerald-600/90'
|
||||||
|
: 'bg-red-900/60'} flex items-center justify-center p-1 sm:p-2"
|
||||||
|
>
|
||||||
|
<p class="text-center text-xs font-bold text-white sm:text-sm md:text-base">
|
||||||
|
{character.gender === 'Male'
|
||||||
|
? $t.game.components.guessHistory.male
|
||||||
|
: character.gender === 'Female'
|
||||||
|
? $t.game.components.guessHistory.female
|
||||||
|
: character.gender || $t.game.components.guessHistory.unknown}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Affiliations -->
|
||||||
|
{#if columnVisibility.affiliation !== false}
|
||||||
|
<div
|
||||||
|
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {getDislayAffiliation(character) === getDislayAffiliation(dailyCharacter)
|
||||||
|
? 'bg-emerald-600/90'
|
||||||
|
: 'bg-red-900/60'} flex items-center justify-center p-1 sm:p-2"
|
||||||
|
>
|
||||||
|
<p class="text-center text-[10px] font-bold text-white sm:text-xs md:text-sm">
|
||||||
|
{getDislayAffiliation(character) || $t.game.components.guessHistory.unknown}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Fruit -->
|
||||||
|
{#if columnVisibility.devilFruitType !== false}
|
||||||
|
<div
|
||||||
|
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {character.devilFruitType ===
|
||||||
|
dailyCharacter.devilFruitType
|
||||||
|
? 'bg-emerald-600/90'
|
||||||
|
: 'bg-red-900/60'} flex items-center justify-center p-1 sm:p-2"
|
||||||
|
>
|
||||||
|
{#if character.devilFruitType}
|
||||||
|
<p class="text-center text-[10px] font-bold text-white sm:text-xs md:text-sm">
|
||||||
|
{character.devilFruitType}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-center text-2xl font-bold text-white sm:text-3xl md:text-5xl">
|
||||||
|
✕
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Haki -->
|
||||||
|
{#if columnVisibility.haki !== false}
|
||||||
|
<div
|
||||||
|
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {(() => {
|
||||||
|
if (
|
||||||
|
character.hakiObservation === dailyCharacter.hakiObservation &&
|
||||||
|
character.hakiArmament === dailyCharacter.hakiArmament &&
|
||||||
|
character.hakiConqueror === dailyCharacter.hakiConqueror
|
||||||
|
) {
|
||||||
|
return 'bg-emerald-600/90';
|
||||||
|
} else if (
|
||||||
|
(character.hakiObservation && dailyCharacter.hakiObservation) ||
|
||||||
|
(character.hakiArmament && dailyCharacter.hakiArmament) ||
|
||||||
|
(character.hakiConqueror && dailyCharacter.hakiConqueror)
|
||||||
|
) {
|
||||||
|
return 'bg-yellow-600/80';
|
||||||
|
} else {
|
||||||
|
return 'bg-red-900/60';
|
||||||
|
}
|
||||||
|
})()} flex items-center justify-center p-1 sm:p-2"
|
||||||
|
>
|
||||||
|
<p class="text-center text-sm font-bold text-white sm:text-lg md:text-2xl">
|
||||||
|
{#if character.hakiObservation}<span title={$t.game.components.guessHistory.obsHakiTitle}>👁️</span
|
||||||
|
>{/if}
|
||||||
|
{#if character.hakiArmament}<span title={$t.game.components.guessHistory.armHakiTitle}>🦾</span>{/if}
|
||||||
|
{#if character.hakiConqueror}<span title={$t.game.components.guessHistory.kingHakiTitle}>👑</span>{/if}
|
||||||
|
{#if !character.hakiObservation && !character.hakiArmament && !character.hakiConqueror}
|
||||||
|
<span class="text-2xl sm:text-3xl md:text-5xl">✕</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Prime -->
|
||||||
|
{#if columnVisibility.bounty !== false}
|
||||||
|
<div
|
||||||
|
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {character.bounty ===
|
||||||
|
dailyCharacter.bounty
|
||||||
|
? 'bg-emerald-600/90'
|
||||||
|
: 'bg-red-900/60'} relative flex items-center justify-center overflow-hidden p-1 sm:p-2"
|
||||||
|
>
|
||||||
|
{#if character.bounty != null && dailyCharacter.bounty != null && character.bounty !== dailyCharacter.bounty}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute h-full w-full opacity-30"
|
||||||
|
style="
|
||||||
|
background-color: rgb(203, 213, 225);
|
||||||
|
clip-path: {character.bounty > dailyCharacter.bounty
|
||||||
|
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
||||||
|
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#if character.bounty != null}
|
||||||
|
<p
|
||||||
|
class="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
|
||||||
|
>
|
||||||
|
{formatBounty(character.bounty)} ฿
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p
|
||||||
|
class="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.unknown}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Taille -->
|
||||||
|
{#if columnVisibility.height !== false}
|
||||||
|
<div
|
||||||
|
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {character.height ===
|
||||||
|
dailyCharacter.height
|
||||||
|
? 'bg-emerald-600/90'
|
||||||
|
: 'bg-red-900/60'} relative flex items-center justify-center overflow-hidden p-1 sm:p-2"
|
||||||
|
>
|
||||||
|
{#if character.height && dailyCharacter.height && character.height !== dailyCharacter.height}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute h-full w-full opacity-30"
|
||||||
|
style="
|
||||||
|
background-color: rgb(203, 213, 225);
|
||||||
|
clip-path: {character.height > dailyCharacter.height
|
||||||
|
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
||||||
|
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#if character.height}
|
||||||
|
<p
|
||||||
|
class="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
|
||||||
|
>
|
||||||
|
{character.height} m
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p
|
||||||
|
class="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.unknown}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Age -->
|
||||||
|
{#if columnVisibility.age !== false}
|
||||||
|
<div
|
||||||
|
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {character.age ===
|
||||||
|
dailyCharacter.age
|
||||||
|
? 'bg-emerald-600/90'
|
||||||
|
: 'bg-red-900/60'} relative flex items-center justify-center overflow-hidden p-1 sm:p-2"
|
||||||
|
>
|
||||||
|
{#if character.age != null && dailyCharacter.age != null && character.age !== dailyCharacter.age}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute h-full w-full opacity-30"
|
||||||
|
style="
|
||||||
|
background-color: rgb(203, 213, 225);
|
||||||
|
clip-path: {character.age > dailyCharacter.age
|
||||||
|
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
||||||
|
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#if character.age != null}
|
||||||
|
<p
|
||||||
|
class="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
|
||||||
|
>
|
||||||
|
{character.age}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p
|
||||||
|
class="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
|
||||||
|
>
|
||||||
|
{$t.game.components.guessHistory.unknown}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Origine -->
|
||||||
|
{#if columnVisibility.origin !== false}
|
||||||
|
<div
|
||||||
|
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {hasMatchingOrigin(character, dailyCharacter)
|
||||||
|
? 'bg-emerald-600/90'
|
||||||
|
: 'bg-red-900/60'} flex items-center justify-center p-1 sm:p-2"
|
||||||
|
>
|
||||||
|
<p class="text-center text-[10px] font-bold text-white sm:text-xs md:text-sm">
|
||||||
|
{getDisplayOrigin(character) || $t.game.components.guessHistory.unknown}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Arc -->
|
||||||
|
{#if columnVisibility.arc !== false}
|
||||||
|
<div
|
||||||
|
class="h-16 w-16 shrink-0 rounded-lg border border-white/10 sm:h-20 sm:w-20 md:h-24 md:w-24 {hasMatchingArc(character, dailyCharacter)
|
||||||
|
? 'bg-emerald-600/90'
|
||||||
|
: 'bg-red-900/60'} relative flex items-center justify-center overflow-hidden p-1 sm:p-2"
|
||||||
|
>
|
||||||
|
{#if !hasMatchingArc(character, dailyCharacter) && character.firstAppearance && dailyCharacter.firstAppearance && character.firstAppearance !== dailyCharacter.firstAppearance}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute h-full w-full opacity-30"
|
||||||
|
style="
|
||||||
|
background-color: rgb(203, 213, 225);
|
||||||
|
clip-path: {character.firstAppearance > dailyCharacter.firstAppearance
|
||||||
|
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
||||||
|
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
<p
|
||||||
|
class="relative z-10 text-center text-[10px] font-bold text-white sm:text-xs md:text-sm"
|
||||||
|
>
|
||||||
|
{getDisplayArcName(character) || $t.game.components.guessHistory.unknown}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
97
src/lib/components/HintsPanel.svelte
Normal file
97
src/lib/components/HintsPanel.svelte
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CharacterWithRelations } from "$lib/server/daily-character";
|
||||||
|
import { language, t } from '$lib/i18n';
|
||||||
|
|
||||||
|
export let dailyCharacter: CharacterWithRelations;
|
||||||
|
export let selectedCharacters: CharacterWithRelations[];
|
||||||
|
export let showOriginUnlock: boolean = false;
|
||||||
|
export let showFruitUnlock: boolean = false;
|
||||||
|
export let showAffiliationUnlock: boolean = false;
|
||||||
|
|
||||||
|
let showHintOrigin = false;
|
||||||
|
let showHintFruit = false;
|
||||||
|
let showHintAffiliation = false;
|
||||||
|
|
||||||
|
// Hint availability - indices are available after a certain number of guesses
|
||||||
|
$: isOriginAvailable = selectedCharacters.length >= 5;
|
||||||
|
$: isFruitAvailable = selectedCharacters.length >= 10;
|
||||||
|
$: isAffiliationAvailable = selectedCharacters.length >= 15;
|
||||||
|
$: isFrench = $language === 'fr';
|
||||||
|
|
||||||
|
function getDisplayOrigin(character: CharacterWithRelations): string | null {
|
||||||
|
if (isFrench && typeof character.frOrigin === 'string' && character.frOrigin.length > 0) {
|
||||||
|
return character.frOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character.origin;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<style>
|
||||||
|
@keyframes hint-unlock {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px rgba(251, 146, 60, 0.8);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hint-unlocking {
|
||||||
|
animation: hint-unlock 0.6s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isOriginAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showOriginUnlock ? 'hint-unlocking' : ''}"
|
||||||
|
disabled={!isOriginAvailable}
|
||||||
|
onclick={() => showHintOrigin = !showHintOrigin}
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium text-amber-100">{$t.game.components.hints.origin}</p>
|
||||||
|
{#if showHintOrigin}
|
||||||
|
<p class="mt-2 text-xs text-white font-semibold">{getDisplayOrigin(dailyCharacter) || $t.game.components.hints.unknown}</p>
|
||||||
|
{:else if Math.max(0, 5 - selectedCharacters.length) > 0}
|
||||||
|
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 5 - selectedCharacters.length)} {$t.game.components.hints.beforeUnlock}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-2 text-xs text-slate-400">{$t.game.components.hints.available}</p>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isFruitAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showFruitUnlock ? 'hint-unlocking' : ''}"
|
||||||
|
disabled={!isFruitAvailable}
|
||||||
|
onclick={() => showHintFruit = !showHintFruit}
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium text-amber-100">{$t.game.components.hints.devilFruit}</p>
|
||||||
|
{#if showHintFruit}
|
||||||
|
<p class="mt-2 text-xs text-white font-semibold">{dailyCharacter.devilFruitName || $t.game.components.hints.none}</p>
|
||||||
|
{:else if Math.max(0, 10 - selectedCharacters.length) > 0}
|
||||||
|
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 10 - selectedCharacters.length)} {$t.game.components.hints.beforeUnlock}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-2 text-xs text-slate-400">{$t.game.components.hints.available}</p>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isAffiliationAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showAffiliationUnlock ? 'hint-unlocking' : ''}"
|
||||||
|
disabled={!isAffiliationAvailable}
|
||||||
|
onclick={() => showHintAffiliation = !showHintAffiliation}
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium text-amber-100">{$t.game.components.hints.affiliation}</p>
|
||||||
|
{#if showHintAffiliation}
|
||||||
|
<p class="mt-2 text-xs text-white font-semibold">{isFrench && dailyCharacter.frAffiliation ? dailyCharacter.frAffiliation : dailyCharacter.affiliation || $t.game.components.hints.unknown}</p>
|
||||||
|
{:else if Math.max(0, 15 - selectedCharacters.length) > 0}
|
||||||
|
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 15 - selectedCharacters.length)} {$t.game.components.hints.beforeUnlock}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-2 text-xs text-slate-400">{$t.game.components.hints.available}</p>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
104
src/lib/components/LanguageSwitcher.svelte
Normal file
104
src/lib/components/LanguageSwitcher.svelte
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { availableLanguages, language, setLanguage } from '$lib/i18n';
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
let rootElement: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
const languageLabels: Record<string, string> = {
|
||||||
|
en: 'English',
|
||||||
|
fr: 'Francais'
|
||||||
|
};
|
||||||
|
|
||||||
|
const languageFlags: Record<string, string> = {
|
||||||
|
en: 'GB',
|
||||||
|
fr: 'FR'
|
||||||
|
};
|
||||||
|
|
||||||
|
function getLanguageLabel(lang: string): string {
|
||||||
|
return languageLabels[lang] || lang.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFlagCode(lang: string): string {
|
||||||
|
return languageFlags[lang] || 'UN';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFlagEmoji(code: string): string {
|
||||||
|
const normalized = code.toUpperCase();
|
||||||
|
if (normalized.length !== 2) {
|
||||||
|
return 'UN';
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = normalized.codePointAt(0);
|
||||||
|
const second = normalized.codePointAt(1);
|
||||||
|
if (!first || !second) {
|
||||||
|
return 'UN';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.fromCodePoint(127397 + first, 127397 + second);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
isOpen = !isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLanguage(lang: string) {
|
||||||
|
setLanguage(lang);
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const onDocumentClick = (event: MouseEvent) => {
|
||||||
|
if (!rootElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rootElement.contains(event.target as Node)) {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', onDocumentClick);
|
||||||
|
return () => document.removeEventListener('click', onDocumentClick);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={rootElement} class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggleMenu}
|
||||||
|
class="flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-2 text-sm font-semibold text-slate-100 transition hover:border-amber-300/50 hover:bg-white/10"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label="Change language"
|
||||||
|
>
|
||||||
|
<span class="text-base" aria-hidden="true">{toFlagEmoji(getFlagCode($language))}</span>
|
||||||
|
<span class="uppercase text-xs tracking-wider">{$language}</span>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5 transition-transform {isOpen ? 'rotate-180' : ''}"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="absolute right-0 top-full z-20 mt-2 w-44 rounded-xl border border-white/10 bg-slate-900/95 p-1 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||||
|
{#each availableLanguages as lang (lang)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectLanguage(lang)}
|
||||||
|
class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition {lang === $language ? 'bg-amber-300 text-slate-900' : 'text-slate-100 hover:bg-white/5'}"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="text-base" aria-hidden="true">{toFlagEmoji(getFlagCode(lang))}</span>
|
||||||
|
<span>{getLanguageLabel(lang)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs uppercase tracking-wide opacity-70">{lang}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
112
src/lib/components/ProfileButton.svelte
Normal file
112
src/lib/components/ProfileButton.svelte
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { User } from 'better-auth/types';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: (User & { isAdmin?: boolean }) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { user }: Props = $props();
|
||||||
|
|
||||||
|
let isMenuOpen = $state(false);
|
||||||
|
let menuElement: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
isMenuOpen = !isMenuOpen;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
isMenuOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
const response = await fetch('/login?/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (menuElement && !menuElement.contains(event.target as Node)) {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={menuElement} class="relative">
|
||||||
|
{#if user}
|
||||||
|
<button
|
||||||
|
onclick={toggleMenu}
|
||||||
|
class="flex items-center gap-3 rounded-full border border-white/10 bg-white/5 px-2 py-2 pr-4 transition hover:border-amber-300/50 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{#if user.image}
|
||||||
|
<img
|
||||||
|
src={user.image}
|
||||||
|
alt={user.name || 'Profil'}
|
||||||
|
class="h-8 w-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-amber-300/20 text-xs font-semibold text-amber-100">
|
||||||
|
{user.name?.charAt(0).toUpperCase() || 'U'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span class="max-w-37.5 truncate text-sm font-semibold text-slate-100">
|
||||||
|
{user.name || 'Utilisateur'}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 transition {isMenuOpen ? 'rotate-180' : ''}"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isMenuOpen}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full mt-2 w-48 rounded-xl border border-white/10 bg-slate-900/95 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={resolve("/profile")}
|
||||||
|
onclick={closeMenu}
|
||||||
|
class="block border-b border-white/5 px-4 py-3 text-sm font-semibold text-slate-100 transition hover:bg-white/5 hover:text-amber-100 first:rounded-t-xl"
|
||||||
|
>
|
||||||
|
Voir mon profil
|
||||||
|
</a>
|
||||||
|
{#if (user).isAdmin}
|
||||||
|
<a
|
||||||
|
href={resolve("/admin")}
|
||||||
|
onclick={closeMenu}
|
||||||
|
class="block border-b border-white/5 px-4 py-3 text-sm font-semibold text-amber-300 transition hover:bg-white/5 hover:text-amber-200"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={handleLogout}
|
||||||
|
class="w-full border-t border-white/5 px-4 py-3 text-sm font-semibold text-red-300 transition hover:bg-red-900/20 last:rounded-b-xl"
|
||||||
|
>
|
||||||
|
Se déconnecter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href={resolve("/login")}
|
||||||
|
class="rounded-full bg-amber-300 px-5 py-2.5 text-sm font-semibold text-slate-900 transition hover:bg-amber-200"
|
||||||
|
>
|
||||||
|
Se connecter
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
115
src/lib/components/WinPanel.svelte
Normal file
115
src/lib/components/WinPanel.svelte
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CharacterWithRelations } from "$lib/server/daily-character";
|
||||||
|
import { language, t } from '$lib/i18n';
|
||||||
|
|
||||||
|
export let selectedCharacter: CharacterWithRelations;
|
||||||
|
export let selectedCharacters: CharacterWithRelations[];
|
||||||
|
export let isGeckoMoriaWin: boolean = false;
|
||||||
|
|
||||||
|
$: isFrench = $language === 'fr';
|
||||||
|
|
||||||
|
function getDisplayName(character: CharacterWithRelations): string {
|
||||||
|
if (isFrench && typeof character.frName === 'string' && character.frName.length > 0) {
|
||||||
|
return character.frName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWikiUrl(character: CharacterWithRelations): string {
|
||||||
|
if (isFrench && typeof character.frUrl === 'string' && character.frUrl.length > 0) {
|
||||||
|
return character.frUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character.url || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWikiBaseUrl(): string {
|
||||||
|
return isFrench ? 'https://onepiece.fandom.com/fr/wiki/' : 'https://onepiece.fandom.com/wiki/';
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickMessage = (messages: readonly string[]) => messages[Math.floor(Math.random() * messages.length)];
|
||||||
|
|
||||||
|
const getAttemptMessage = (attempts: number): string => {
|
||||||
|
if (attempts <= 0) return '';
|
||||||
|
const oneTryMessages = $t.game.components.winPanel.oneTryMessages;
|
||||||
|
const twoTryMessages = $t.game.components.winPanel.twoTryMessages;
|
||||||
|
const tenPlusMessages = $t.game.components.winPanel.tenPlusMessages;
|
||||||
|
const fivePlusMessages = $t.game.components.winPanel.fivePlusMessages;
|
||||||
|
const defaultMessages = $t.game.components.winPanel.defaultMessages;
|
||||||
|
if (attempts === 1) {
|
||||||
|
return pickMessage(oneTryMessages);
|
||||||
|
}
|
||||||
|
if (attempts === 2) {
|
||||||
|
return pickMessage(twoTryMessages);
|
||||||
|
}
|
||||||
|
if (attempts >= 10) {
|
||||||
|
return pickMessage(tenPlusMessages).replace('${attempts}', String(attempts));
|
||||||
|
}
|
||||||
|
if (attempts >= 5) {
|
||||||
|
return pickMessage(fivePlusMessages).replace('${attempts}', String(attempts));
|
||||||
|
}
|
||||||
|
|
||||||
|
return pickMessage(defaultMessages);
|
||||||
|
};
|
||||||
|
|
||||||
|
$: attempts = selectedCharacters.length;
|
||||||
|
$: attemptMessage = getAttemptMessage(attempts);
|
||||||
|
$: attemptWord = selectedCharacters.length > 1
|
||||||
|
? $t.game.components.winPanel.attemptPlural
|
||||||
|
: $t.game.components.winPanel.attemptSingular;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isGeckoMoriaWin}
|
||||||
|
<div class="rounded-3xl border border-slate-700/80 bg-slate-950/80 p-4 shadow-[0_24px_60px_rgba(0,0,0,0.8)] backdrop-blur gecko-moria-effect">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl mb-2">🌑</div>
|
||||||
|
<h2 class="text-xl font-bold text-slate-300 mb-1">{$t.game.components.winPanel.moriaTitle}</h2>
|
||||||
|
<p class="text-sm text-slate-400">{$t.game.components.winPanel.moriaPrefix} {selectedCharacters.length} {attemptWord} !</p>
|
||||||
|
<p class="text-xs text-slate-300 mt-1">{attemptMessage}</p>
|
||||||
|
<div class="mt-3">
|
||||||
|
{#if selectedCharacter.pictureUrl}
|
||||||
|
<a
|
||||||
|
href={getWikiBaseUrl() + getWikiUrl(selectedCharacter)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-block"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={selectedCharacter.pictureUrl}
|
||||||
|
alt={getDisplayName(selectedCharacter)}
|
||||||
|
class="w-20 h-20 mx-auto rounded-full border-2 border-slate-600 shadow-lg object-cover hover:border-slate-500 transition-colors cursor-pointer opacity-80"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-2 text-lg font-bold text-slate-200">{getDisplayName(selectedCharacter)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-3xl border border-emerald-500/50 bg-emerald-500/10 p-4 shadow-[0_24px_60px_rgba(16,185,129,0.3)] backdrop-blur">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl mb-2">🎉</div>
|
||||||
|
<h2 class="text-xl font-bold text-emerald-400 mb-1">{$t.game.components.winPanel.winTitle}</h2>
|
||||||
|
<p class="text-sm text-emerald-300">{$t.game.components.winPanel.winPrefix} {selectedCharacters.length} {attemptWord} !</p>
|
||||||
|
<p class="text-xs text-emerald-200 mt-1">{attemptMessage}</p>
|
||||||
|
<div class="mt-3">
|
||||||
|
{#if selectedCharacter.pictureUrl}
|
||||||
|
<a
|
||||||
|
href={getWikiBaseUrl() + getWikiUrl(selectedCharacter)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-block"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={selectedCharacter.pictureUrl}
|
||||||
|
alt={getDisplayName(selectedCharacter)}
|
||||||
|
class="w-20 h-20 mx-auto rounded-full border-2 border-emerald-400 shadow-lg object-cover hover:border-emerald-300 transition-colors cursor-pointer"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-2 text-lg font-bold text-white">{getDisplayName(selectedCharacter)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
112
src/lib/components/YesterdayCharacter.svelte
Normal file
112
src/lib/components/YesterdayCharacter.svelte
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CharacterWithRelations } from "$lib/server/daily-character";
|
||||||
|
import { language, t } from '$lib/i18n';
|
||||||
|
|
||||||
|
export let yesterdayCharacter: CharacterWithRelations | null;
|
||||||
|
|
||||||
|
$: isFrench = $language === 'fr';
|
||||||
|
|
||||||
|
function parseEpithets(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (value.length > 0) {
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(character: CharacterWithRelations): string {
|
||||||
|
if (isFrench && typeof character.frName === 'string' && character.frName.length > 0) {
|
||||||
|
return character.frName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayEpithets(character: CharacterWithRelations): string[] {
|
||||||
|
const frenchEpithets = parseEpithets(character.frEpithets);
|
||||||
|
if (isFrench && frenchEpithets.length > 0) {
|
||||||
|
return frenchEpithets;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseEpithets(character.epithets);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWikiUrl(character: CharacterWithRelations): string {
|
||||||
|
if (isFrench && typeof character.frUrl === 'string' && character.frUrl.length > 0) {
|
||||||
|
return character.frUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character.url || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="mt-8 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||||
|
{#if yesterdayCharacter}
|
||||||
|
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
|
||||||
|
{#if yesterdayCharacter.pictureUrl}
|
||||||
|
<img
|
||||||
|
src={yesterdayCharacter.pictureUrl}
|
||||||
|
alt={getDisplayName(yesterdayCharacter)}
|
||||||
|
class="h-20 w-20 rounded-full border border-amber-200/40 object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
|
||||||
|
{$t.game.components.yesterdayCharacter.photo}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">{$t.game.components.yesterdayCharacter.title}</p>
|
||||||
|
<p class="mt-2 text-lg font-semibold text-white">{getDisplayName(yesterdayCharacter)}</p>
|
||||||
|
{#if getDisplayEpithets(yesterdayCharacter).length > 0}
|
||||||
|
<p class="mt-1 text-sm text-slate-400">
|
||||||
|
{getDisplayEpithets(yesterdayCharacter).join(', ')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isFrench}
|
||||||
|
<a
|
||||||
|
href="https://onepiece.fandom.com/fr/wiki/{getWikiUrl(yesterdayCharacter)}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50 sm:w-auto"
|
||||||
|
>
|
||||||
|
{$t.game.components.yesterdayCharacter.openPage}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href="https://onepiece.fandom.com/wiki/{getWikiUrl(yesterdayCharacter)}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50 sm:w-auto"
|
||||||
|
>
|
||||||
|
{$t.game.components.yesterdayCharacter.openPage}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
|
||||||
|
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
|
||||||
|
{$t.game.components.yesterdayCharacter.photo}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">{$t.game.components.yesterdayCharacter.title}</p>
|
||||||
|
<p class="mt-2 text-lg font-semibold text-white">{$t.game.components.yesterdayCharacter.none}</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-200">{$t.game.components.yesterdayCharacter.noneAvailable}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
233
src/lib/i18n/en.json
Normal file
233
src/lib/i18n/en.json
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"language": "Language",
|
||||||
|
"selectLanguage": "Select Language",
|
||||||
|
"english": "English",
|
||||||
|
"french": "Français",
|
||||||
|
"german": "Deutsch",
|
||||||
|
"spanish": "Español"
|
||||||
|
},
|
||||||
|
"game": {
|
||||||
|
"home": {
|
||||||
|
"heroDescription": "Guess the character from pirate crews, marines, or the wider world. Every hint brings you closer to the treasure.",
|
||||||
|
"dailyTitle": "Daily Character",
|
||||||
|
"dailySubtitle": "A new mystery every 24 hours",
|
||||||
|
"dailyDescription": "Compare your guesses, unlock hints, and keep your streak alive.",
|
||||||
|
"dailyCta": "Start",
|
||||||
|
"infiniteTitle": "Infinite Mode",
|
||||||
|
"infiniteSubtitle": "Endless challenges",
|
||||||
|
"infiniteDescription": "Chain characters and chase your score. No limits, only fun.",
|
||||||
|
"infiniteCta": "Play",
|
||||||
|
"photoFallback": "Photo",
|
||||||
|
"yesterdayCharacter": "Yesterday's character",
|
||||||
|
"openPage": "Open page",
|
||||||
|
"noCharacter": "No character",
|
||||||
|
"noYesterdayCharacter": "No character from yesterday available"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"titleSignUp": "Sign Up",
|
||||||
|
"titleSignIn": "Sign In",
|
||||||
|
"headerSignUp": "Create your account",
|
||||||
|
"headerSignIn": "Welcome, pirate",
|
||||||
|
"nameLabel": "Name",
|
||||||
|
"namePlaceholder": "Your name",
|
||||||
|
"usernameLabel": "Username",
|
||||||
|
"usernamePlaceholder": "e.g. luffy_gear5",
|
||||||
|
"identifierLabelSignUp": "Email",
|
||||||
|
"identifierLabelSignIn": "Email or username",
|
||||||
|
"identifierPlaceholderSignUp": "yourmail@email.com",
|
||||||
|
"identifierPlaceholderSignIn": "yourmail@email.com or luffy_gear5",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"confirmPasswordLabel": "Confirm password",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"submitSignUp": "Create an account",
|
||||||
|
"submitSignIn": "Log in",
|
||||||
|
"togglePromptSignUp": "Already have an account?",
|
||||||
|
"togglePromptSignIn": "Don't have an account?",
|
||||||
|
"toggleActionSignUp": "Log in",
|
||||||
|
"toggleActionSignIn": "Sign up",
|
||||||
|
"backHome": "Back to home"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"pageTitle": "My Profile",
|
||||||
|
"headerTitle": "My Profile",
|
||||||
|
"headerSubtitle": "Edit your profile information",
|
||||||
|
"tabProfile": "Profile",
|
||||||
|
"tabPassword": "Password",
|
||||||
|
"tabDaily": "Daily History",
|
||||||
|
"tabSessions": "Sessions",
|
||||||
|
"tabFriends": "Friends",
|
||||||
|
"avatarFallbackAlt": "Profile",
|
||||||
|
"email": "Email",
|
||||||
|
"displayName": "Display name",
|
||||||
|
"displayNamePlaceholder": "Your name",
|
||||||
|
"profileUpdateSuccess": "Profile updated successfully!",
|
||||||
|
"updating": "Updating...",
|
||||||
|
"saveChanges": "Save changes",
|
||||||
|
"friendsTitle": "Friends System",
|
||||||
|
"addFriendByUsername": "Add a friend by username",
|
||||||
|
"friendUsernamePlaceholder": "e.g. luffy_gear5",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"send": "Send",
|
||||||
|
"incomingRequests": "Incoming requests",
|
||||||
|
"noIncomingRequests": "No incoming requests.",
|
||||||
|
"accept": "Accept",
|
||||||
|
"decline": "Decline",
|
||||||
|
"outgoingRequests": "Outgoing requests",
|
||||||
|
"noOutgoingRequests": "No outgoing requests.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"myFriends": "My friends",
|
||||||
|
"noFriends": "You don't have any friends yet.",
|
||||||
|
"remove": "Remove",
|
||||||
|
"changePasswordTitle": "Change password",
|
||||||
|
"currentPassword": "Current password",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"confirmPassword": "Confirm password",
|
||||||
|
"passwordChangeSuccess": "Password changed successfully!",
|
||||||
|
"changing": "Changing...",
|
||||||
|
"changePassword": "Change password",
|
||||||
|
"dailyHistoryTitle": "Daily history",
|
||||||
|
"noDailyHistory": "No history available",
|
||||||
|
"triedCharactersTitle": "Tried characters",
|
||||||
|
"noTriedCharacters": "No characters recorded",
|
||||||
|
"noImage": "N/A",
|
||||||
|
"trySingular": "try",
|
||||||
|
"tryPlural": "tries",
|
||||||
|
"activeSessionsTitle": "Active sessions",
|
||||||
|
"noActiveSessions": "No active session",
|
||||||
|
"unknownDevice": "Unknown device",
|
||||||
|
"unknown": "Unknown",
|
||||||
|
"ip": "IP",
|
||||||
|
"created": "Created",
|
||||||
|
"terminate": "Terminate",
|
||||||
|
"backHome": "Back to home"
|
||||||
|
},
|
||||||
|
"daily": {
|
||||||
|
"metaTitle": "OnePieceDle - Daily Mode",
|
||||||
|
"title": "Daily Character",
|
||||||
|
"winsPeopleSingular": "person",
|
||||||
|
"winsPeoplePlural": "people",
|
||||||
|
"winsVerbSingular": "has",
|
||||||
|
"winsVerbPlural": "have",
|
||||||
|
"winsSuffix": "found it today 🎉",
|
||||||
|
"reset": "Play again",
|
||||||
|
"description": "Guess the character. Each hint unlocks after a certain number of guesses. Good luck!",
|
||||||
|
"friendsToday": "Your friends today",
|
||||||
|
"friendsTriedCharacters": "Tried characters",
|
||||||
|
"friendsNoTriedCharacters": "No characters recorded",
|
||||||
|
"friendTrySingular": "try",
|
||||||
|
"friendTryPlural": "tries"
|
||||||
|
},
|
||||||
|
"infinite": {
|
||||||
|
"metaTitle": "OnePieceDle - Infinite Mode",
|
||||||
|
"title": "Infinite Mode",
|
||||||
|
"score": "Score",
|
||||||
|
"resetScore": "Reset",
|
||||||
|
"description": "Guess characters endlessly. Each hint unlocks after a certain number of guesses. Good luck!",
|
||||||
|
"nextCharacter": "Play again",
|
||||||
|
"revealAnswer": "Reveal answer",
|
||||||
|
"loadingCharacter": "Loading character...",
|
||||||
|
"filtersTitle": "Character filters",
|
||||||
|
"clearFilters": "Reset",
|
||||||
|
"filterGender": "Gender",
|
||||||
|
"filterStatus": "Status",
|
||||||
|
"filterAbilities": "Abilities",
|
||||||
|
"filterInformation": "Information",
|
||||||
|
"filterArcs": "Arcs",
|
||||||
|
"male": "Male",
|
||||||
|
"female": "Female",
|
||||||
|
"alive": "Alive",
|
||||||
|
"dead": "Dead",
|
||||||
|
"unknown": "Unknown",
|
||||||
|
"hasHaki": "Has Haki",
|
||||||
|
"fruitAll": "Fruit (All)",
|
||||||
|
"withFruit": "With Fruit",
|
||||||
|
"withoutFruit": "Without Fruit",
|
||||||
|
"heightDefined": "Height defined",
|
||||||
|
"ageDefined": "Age defined",
|
||||||
|
"originDefined": "Origin defined",
|
||||||
|
"availableCharactersSingular": "character available",
|
||||||
|
"availableCharactersPlural": "characters available",
|
||||||
|
"columnsTitle": "Columns"
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"searchInput": {
|
||||||
|
"title": "Enter a guess",
|
||||||
|
"placeholder": "Character name",
|
||||||
|
"submit": "Submit"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"origin": "Origin",
|
||||||
|
"devilFruit": "Devil fruit",
|
||||||
|
"affiliation": "Affiliation",
|
||||||
|
"unknown": "Unknown",
|
||||||
|
"none": "None",
|
||||||
|
"beforeUnlock": "guesses before unlock",
|
||||||
|
"available": "Hint available!"
|
||||||
|
},
|
||||||
|
"guessHistory": {
|
||||||
|
"title": "History",
|
||||||
|
"empty": "No guesses yet.",
|
||||||
|
"character": "Character",
|
||||||
|
"status": "Status",
|
||||||
|
"gender": "Gender",
|
||||||
|
"affiliations": "Affiliations",
|
||||||
|
"fruit": "Fruit",
|
||||||
|
"haki": "Haki",
|
||||||
|
"bounty": "Bounty",
|
||||||
|
"height": "Height",
|
||||||
|
"age": "Age",
|
||||||
|
"origin": "Origin",
|
||||||
|
"arc": "Arc",
|
||||||
|
"alive": "Alive",
|
||||||
|
"dead": "Dead",
|
||||||
|
"unknown": "Unknown",
|
||||||
|
"male": "Male",
|
||||||
|
"female": "Female",
|
||||||
|
"obsHakiTitle": "Observation Haki",
|
||||||
|
"armHakiTitle": "Armament Haki",
|
||||||
|
"kingHakiTitle": "Conqueror's Haki"
|
||||||
|
},
|
||||||
|
"winPanel": {
|
||||||
|
"attemptSingular": "attempt",
|
||||||
|
"attemptPlural": "attempts",
|
||||||
|
"moriaTitle": "Moria controls you...",
|
||||||
|
"moriaPrefix": "You succumbed to the shadows in",
|
||||||
|
"winTitle": "Congratulations!",
|
||||||
|
"winPrefix": "You found the character in",
|
||||||
|
"oneTryMessages": [
|
||||||
|
"Cheater 👀",
|
||||||
|
"1 guess? Admit it, you already knew 😏",
|
||||||
|
"First try... suspicious 🤨"
|
||||||
|
],
|
||||||
|
"twoTryMessages": [
|
||||||
|
"Well played! ⚡",
|
||||||
|
"Two guesses, clean! 👏",
|
||||||
|
"You warmed up fast, nice 🔥"
|
||||||
|
],
|
||||||
|
"tenPlusMessages": [
|
||||||
|
"${attempts} guesses... even a transponder snail would be faster 📞",
|
||||||
|
"${attempts} attempts? The Grand Line is shorter than that 😵",
|
||||||
|
"${attempts} guesses: legendary performance... in the wrong direction 🫠"
|
||||||
|
],
|
||||||
|
"fivePlusMessages": [
|
||||||
|
"${attempts} guesses? Let's say it was for suspense 😅",
|
||||||
|
"That is a lot of guesses... but you made it 😬",
|
||||||
|
"You never give up, even after several guesses 😂"
|
||||||
|
],
|
||||||
|
"defaultMessages": [
|
||||||
|
"Not bad at all!",
|
||||||
|
"Nice try, good pace 👍",
|
||||||
|
"Things are going well, keep it up ✨"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"yesterdayCharacter": {
|
||||||
|
"photo": "Photo",
|
||||||
|
"title": "Yesterday's character",
|
||||||
|
"openPage": "Open page",
|
||||||
|
"none": "No character",
|
||||||
|
"noneAvailable": "No character from yesterday available"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
233
src/lib/i18n/fr.json
Normal file
233
src/lib/i18n/fr.json
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"language": "Langue",
|
||||||
|
"selectLanguage": "Sélectionnez la Langue",
|
||||||
|
"english": "English",
|
||||||
|
"french": "Français",
|
||||||
|
"german": "Deutsch",
|
||||||
|
"spanish": "Español"
|
||||||
|
},
|
||||||
|
"game": {
|
||||||
|
"home": {
|
||||||
|
"heroDescription": "Devine le personnage de l'equipage, des marines ou du vaste monde. Chaque indice te rapproche du tresor.",
|
||||||
|
"dailyTitle": "Personnage du jour",
|
||||||
|
"dailySubtitle": "Nouveau mystere toutes les 24 heures",
|
||||||
|
"dailyDescription": "Compare tes essais, debloque des indices et garde ta serie.",
|
||||||
|
"dailyCta": "Commencer",
|
||||||
|
"infiniteTitle": "Mode Infini",
|
||||||
|
"infiniteSubtitle": "Des defis sans fin",
|
||||||
|
"infiniteDescription": "Enchaine les personnages et croise ton score. Pas de limite, que du plaisir.",
|
||||||
|
"infiniteCta": "Jouer",
|
||||||
|
"photoFallback": "Photo",
|
||||||
|
"yesterdayCharacter": "Personnage d'hier",
|
||||||
|
"openPage": "Voir la page",
|
||||||
|
"noCharacter": "Aucun personnage",
|
||||||
|
"noYesterdayCharacter": "Aucun personnage d'hier disponible"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"titleSignUp": "Inscription",
|
||||||
|
"titleSignIn": "Connexion",
|
||||||
|
"headerSignUp": "Creer votre compte",
|
||||||
|
"headerSignIn": "Bienvenue, pirate",
|
||||||
|
"nameLabel": "Nom",
|
||||||
|
"namePlaceholder": "Votre nom",
|
||||||
|
"usernameLabel": "Nom d'utilisateur",
|
||||||
|
"usernamePlaceholder": "ex: luffy_gear5",
|
||||||
|
"identifierLabelSignUp": "E-mail",
|
||||||
|
"identifierLabelSignIn": "E-mail ou nom d'utilisateur",
|
||||||
|
"identifierPlaceholderSignUp": "votremail@email.com",
|
||||||
|
"identifierPlaceholderSignIn": "votremail@email.com ou luffy_gear5",
|
||||||
|
"passwordLabel": "Mot de passe",
|
||||||
|
"confirmPasswordLabel": "Confirmer le mot de passe",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"submitSignUp": "Creer un compte",
|
||||||
|
"submitSignIn": "Se connecter",
|
||||||
|
"togglePromptSignUp": "Vous avez deja un compte ?",
|
||||||
|
"togglePromptSignIn": "Vous n'avez pas de compte ?",
|
||||||
|
"toggleActionSignUp": "Se connecter",
|
||||||
|
"toggleActionSignIn": "S'inscrire",
|
||||||
|
"backHome": "Retour a l'accueil"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"pageTitle": "Mon Profil",
|
||||||
|
"headerTitle": "Mon Profil",
|
||||||
|
"headerSubtitle": "Modifie les informations de ton profil",
|
||||||
|
"tabProfile": "Profil",
|
||||||
|
"tabPassword": "Mot de passe",
|
||||||
|
"tabDaily": "Historique Daily",
|
||||||
|
"tabSessions": "Sessions",
|
||||||
|
"tabFriends": "Amis",
|
||||||
|
"avatarFallbackAlt": "Profil",
|
||||||
|
"email": "Email",
|
||||||
|
"displayName": "Nom d'affichage",
|
||||||
|
"displayNamePlaceholder": "Ton nom",
|
||||||
|
"profileUpdateSuccess": "Profil mis a jour avec succes !",
|
||||||
|
"updating": "Mise a jour...",
|
||||||
|
"saveChanges": "Enregistrer les modifications",
|
||||||
|
"friendsTitle": "Systeme d'amis",
|
||||||
|
"addFriendByUsername": "Ajouter un ami par nom d'utilisateur",
|
||||||
|
"friendUsernamePlaceholder": "ex: luffy_gear5",
|
||||||
|
"sending": "Envoi...",
|
||||||
|
"send": "Envoyer",
|
||||||
|
"incomingRequests": "Demandes recues",
|
||||||
|
"noIncomingRequests": "Aucune demande recue.",
|
||||||
|
"accept": "Accepter",
|
||||||
|
"decline": "Refuser",
|
||||||
|
"outgoingRequests": "Demandes envoyees",
|
||||||
|
"noOutgoingRequests": "Aucune demande envoyee.",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"myFriends": "Mes amis",
|
||||||
|
"noFriends": "Tu n'as pas encore d'amis.",
|
||||||
|
"remove": "Supprimer",
|
||||||
|
"changePasswordTitle": "Changer le mot de passe",
|
||||||
|
"currentPassword": "Mot de passe actuel",
|
||||||
|
"newPassword": "Nouveau mot de passe",
|
||||||
|
"confirmPassword": "Confirmer le mot de passe",
|
||||||
|
"passwordChangeSuccess": "Mot de passe change avec succes !",
|
||||||
|
"changing": "Changement en cours...",
|
||||||
|
"changePassword": "Changer le mot de passe",
|
||||||
|
"dailyHistoryTitle": "Historique des Daily",
|
||||||
|
"noDailyHistory": "Aucun historique disponible",
|
||||||
|
"triedCharactersTitle": "Personnages essayes",
|
||||||
|
"noTriedCharacters": "Aucun personnage enregistre",
|
||||||
|
"noImage": "N/A",
|
||||||
|
"trySingular": "tentative",
|
||||||
|
"tryPlural": "tentatives",
|
||||||
|
"activeSessionsTitle": "Sessions actives",
|
||||||
|
"noActiveSessions": "Aucune session active",
|
||||||
|
"unknownDevice": "Appareil inconnu",
|
||||||
|
"unknown": "Inconnue",
|
||||||
|
"ip": "IP",
|
||||||
|
"created": "Creee",
|
||||||
|
"terminate": "Terminer",
|
||||||
|
"backHome": "Retour a l'accueil"
|
||||||
|
},
|
||||||
|
"daily": {
|
||||||
|
"metaTitle": "OnePieceDle - Mode du jour",
|
||||||
|
"title": "Personnage du jour",
|
||||||
|
"winsPeopleSingular": "personne",
|
||||||
|
"winsPeoplePlural": "personnes",
|
||||||
|
"winsVerbSingular": "a",
|
||||||
|
"winsVerbPlural": "ont",
|
||||||
|
"winsSuffix": "trouve aujourd'hui 🎉",
|
||||||
|
"reset": "Recommencer",
|
||||||
|
"description": "Devine le personnage. Chaque indice se debloque apres un certain nombre de tentatives. Bonne chance !",
|
||||||
|
"friendsToday": "Tes amis aujourd'hui",
|
||||||
|
"friendsTriedCharacters": "Personnages essayes",
|
||||||
|
"friendsNoTriedCharacters": "Aucun personnage enregistre",
|
||||||
|
"friendTrySingular": "coup",
|
||||||
|
"friendTryPlural": "coups"
|
||||||
|
},
|
||||||
|
"infinite": {
|
||||||
|
"metaTitle": "OnePieceDle - Mode Infini",
|
||||||
|
"title": "Mode Infini",
|
||||||
|
"score": "Score",
|
||||||
|
"resetScore": "Reinitialiser",
|
||||||
|
"description": "Devine des personnages a l'infini ! Chaque indice se debloque apres un certain nombre de tentatives. Bonne chance !",
|
||||||
|
"nextCharacter": "Recommencer",
|
||||||
|
"revealAnswer": "Reveler la reponse",
|
||||||
|
"loadingCharacter": "Chargement du personnage...",
|
||||||
|
"filtersTitle": "Filtres de personnages",
|
||||||
|
"clearFilters": "Reinitialiser",
|
||||||
|
"filterGender": "Genre",
|
||||||
|
"filterStatus": "Statut",
|
||||||
|
"filterAbilities": "Capacites",
|
||||||
|
"filterInformation": "Informations",
|
||||||
|
"filterArcs": "Arcs",
|
||||||
|
"male": "Homme",
|
||||||
|
"female": "Femme",
|
||||||
|
"alive": "Vivant",
|
||||||
|
"dead": "Mort",
|
||||||
|
"unknown": "Inconnu",
|
||||||
|
"hasHaki": "A du Haki",
|
||||||
|
"fruitAll": "Fruit (Tous)",
|
||||||
|
"withFruit": "Avec Fruit",
|
||||||
|
"withoutFruit": "Sans Fruit",
|
||||||
|
"heightDefined": "Taille definie",
|
||||||
|
"ageDefined": "Age defini",
|
||||||
|
"originDefined": "Origine definie",
|
||||||
|
"availableCharactersSingular": "personnage disponible",
|
||||||
|
"availableCharactersPlural": "personnages disponibles",
|
||||||
|
"columnsTitle": "Colonnes"
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"searchInput": {
|
||||||
|
"title": "Entrer une supposition",
|
||||||
|
"placeholder": "Nom du personnage",
|
||||||
|
"submit": "Valider"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"origin": "Origine",
|
||||||
|
"devilFruit": "Fruit du demon",
|
||||||
|
"affiliation": "Affiliation",
|
||||||
|
"unknown": "Inconnue",
|
||||||
|
"none": "Aucun",
|
||||||
|
"beforeUnlock": "essais avant deblocage",
|
||||||
|
"available": "Indice disponible !"
|
||||||
|
},
|
||||||
|
"guessHistory": {
|
||||||
|
"title": "Historique",
|
||||||
|
"empty": "Aucune tentative pour le moment.",
|
||||||
|
"character": "Personnage",
|
||||||
|
"status": "Statut",
|
||||||
|
"gender": "Genre",
|
||||||
|
"affiliations": "Affiliations",
|
||||||
|
"fruit": "Fruit",
|
||||||
|
"haki": "Haki",
|
||||||
|
"bounty": "Prime",
|
||||||
|
"height": "Taille",
|
||||||
|
"age": "Age",
|
||||||
|
"origin": "Origine",
|
||||||
|
"arc": "Arc",
|
||||||
|
"alive": "Vivant",
|
||||||
|
"dead": "Mort",
|
||||||
|
"unknown": "Inconnu",
|
||||||
|
"male": "Homme",
|
||||||
|
"female": "Femme",
|
||||||
|
"obsHakiTitle": "Haki de l'Observation",
|
||||||
|
"armHakiTitle": "Haki de l'Armement",
|
||||||
|
"kingHakiTitle": "Haki des Rois"
|
||||||
|
},
|
||||||
|
"winPanel": {
|
||||||
|
"attemptSingular": "tentative",
|
||||||
|
"attemptPlural": "tentatives",
|
||||||
|
"moriaTitle": "Moria vous controle...",
|
||||||
|
"moriaPrefix": "Vous avez succombe a l'ombre en",
|
||||||
|
"winTitle": "Felicitations !",
|
||||||
|
"winPrefix": "Vous avez trouve le personnage en",
|
||||||
|
"oneTryMessages": [
|
||||||
|
"Tricheur 👀",
|
||||||
|
"1 essai ? Avoue, tu avais la reponse 😏",
|
||||||
|
"Premier coup direct... suspect 🤨"
|
||||||
|
],
|
||||||
|
"twoTryMessages": [
|
||||||
|
"Bien joue ! ⚡",
|
||||||
|
"Deux essais, propre ! 👏",
|
||||||
|
"Tu chauffes vite, bien joue 🔥"
|
||||||
|
],
|
||||||
|
"tenPlusMessages": [
|
||||||
|
"${attempts} essais... meme un escargophone aurait trouve plus vite 📞",
|
||||||
|
"${attempts} tentatives ? Le Grand Line est moins long que ca 😵",
|
||||||
|
"${attempts} essais : performance legendaire... dans le mauvais sens 🫠"
|
||||||
|
],
|
||||||
|
"fivePlusMessages": [
|
||||||
|
"${attempts} essais ? On va dire que c'etait pour le suspense 😅",
|
||||||
|
"Ca en fait des essais... mais au moins tu y es arrive 😬",
|
||||||
|
"Tu ne laches rien, meme apres plusieurs essais 😂"
|
||||||
|
],
|
||||||
|
"defaultMessages": [
|
||||||
|
"Pas mal du tout !",
|
||||||
|
"Bien tente, bon rythme 👍",
|
||||||
|
"Ca se passe bien, continue comme ca ✨"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"yesterdayCharacter": {
|
||||||
|
"photo": "Photo",
|
||||||
|
"title": "Personnage d'hier",
|
||||||
|
"openPage": "Voir la page",
|
||||||
|
"none": "Aucun personnage",
|
||||||
|
"noneAvailable": "Aucun personnage d'hier disponible"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/lib/i18n/index.ts
Normal file
51
src/lib/i18n/index.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import type { Writable, Readable } from 'svelte/store';
|
||||||
|
|
||||||
|
import en from './en.json';
|
||||||
|
import fr from './fr.json';
|
||||||
|
|
||||||
|
type Messages = typeof en;
|
||||||
|
|
||||||
|
const translations: Record<string, Messages> = { en, fr };
|
||||||
|
|
||||||
|
// Get initial language
|
||||||
|
function getInitialLanguage(): string {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = localStorage.getItem('language');
|
||||||
|
if (stored && stored in translations) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
const browserLang = navigator.language.split('-')[0];
|
||||||
|
if (browserLang in translations) {
|
||||||
|
return browserLang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create writable store for the current language
|
||||||
|
export const language: Writable<string> = writable(getInitialLanguage());
|
||||||
|
|
||||||
|
// Create derived store for the current messages
|
||||||
|
export const t: Readable<Messages> = derived(language, ($language) => {
|
||||||
|
return translations[$language] || translations['en'];
|
||||||
|
});
|
||||||
|
|
||||||
|
export function setLanguage(lang: string) {
|
||||||
|
if (lang in translations) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('language', lang);
|
||||||
|
}
|
||||||
|
language.set(lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLanguage(): string {
|
||||||
|
let currentLang = 'en';
|
||||||
|
language.subscribe((lang) => {
|
||||||
|
currentLang = lang;
|
||||||
|
})();
|
||||||
|
return currentLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const availableLanguages = Object.keys(translations);
|
||||||
@@ -1 +1,3 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
|
|
||||||
|
export { formatBounty } from './utils';
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ export const auth = betterAuth({
|
|||||||
baseURL: env.ORIGIN,
|
baseURL: env.ORIGIN,
|
||||||
secret: env.BETTER_AUTH_SECRET || 'secret',
|
secret: env.BETTER_AUTH_SECRET || 'secret',
|
||||||
database: drizzleAdapter(db, { provider: 'sqlite' }),
|
database: drizzleAdapter(db, { provider: 'sqlite' }),
|
||||||
|
user: {
|
||||||
|
additionalFields: {
|
||||||
|
username: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
unique: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
emailAndPassword: { enabled: true },
|
emailAndPassword: { enabled: true },
|
||||||
plugins: [sveltekitCookies(getRequestEvent)] // make sure this is the last plugin in the array
|
plugins: [sveltekitCookies(getRequestEvent)] // make sure this is the last plugin in the array
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { arc, character, characterHistory, characterOverride, devilFruit } from '$lib/server/db/schema';
|
import { arc, character, characterHistory, devilFruit, type Character } from '$lib/server/db/schema';
|
||||||
import { desc, eq, inArray } from 'drizzle-orm';
|
import { desc, eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Generate or get random seed for daily character selection
|
||||||
|
const RANDOM_SEED = Math.random();
|
||||||
|
|
||||||
const characterWithRelationsSelect = {
|
const characterWithRelationsSelect = {
|
||||||
id: character.id,
|
id: character.id,
|
||||||
name: character.name,
|
name: character.name,
|
||||||
|
frName: character.frName,
|
||||||
gender: character.gender,
|
gender: character.gender,
|
||||||
age: character.age,
|
age: character.age,
|
||||||
affiliations: character.affiliations,
|
affiliation: character.affiliation,
|
||||||
|
frAffiliation: character.frAffiliation,
|
||||||
devilFruitId: character.devilFruitId,
|
devilFruitId: character.devilFruitId,
|
||||||
devilFruitName: devilFruit.name,
|
devilFruitName: devilFruit.name,
|
||||||
devilFruitType: devilFruit.type,
|
devilFruitType: devilFruit.type,
|
||||||
@@ -17,23 +22,26 @@ const characterWithRelationsSelect = {
|
|||||||
bounty: character.bounty,
|
bounty: character.bounty,
|
||||||
height: character.height,
|
height: character.height,
|
||||||
origin: character.origin,
|
origin: character.origin,
|
||||||
|
frOrigin: character.frOrigin,
|
||||||
firstAppearance: character.firstAppearance,
|
firstAppearance: character.firstAppearance,
|
||||||
pictureUrl: character.pictureUrl,
|
pictureUrl: character.pictureUrl,
|
||||||
epithets: character.epithets,
|
epithets: character.epithets,
|
||||||
|
frEpithets: character.frEpithets,
|
||||||
status: character.status,
|
status: character.status,
|
||||||
url: character.url,
|
url: character.url,
|
||||||
|
frUrl: character.frUrl,
|
||||||
arcId: character.arcId,
|
arcId: character.arcId,
|
||||||
arcName: arc.name
|
arcName: arc.name,
|
||||||
|
frArcName: arc.frName,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CharacterWithRelations = typeof character.$inferSelect & {
|
export type CharacterWithRelations = Character & {
|
||||||
devilFruitName: string | null;
|
devilFruitName: string | null;
|
||||||
devilFruitType: string | null;
|
devilFruitType: string | null;
|
||||||
arcName: string | null;
|
arcName: string | null;
|
||||||
|
frArcName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CharacterOverrideRow = typeof characterOverride.$inferSelect;
|
|
||||||
|
|
||||||
type RelationMaps = {
|
type RelationMaps = {
|
||||||
arcNameById: Map<string, string | null>;
|
arcNameById: Map<string, string | null>;
|
||||||
devilFruitById: Map<string, { name: string | null; type: string | null }>;
|
devilFruitById: Map<string, { name: string | null; type: string | null }>;
|
||||||
@@ -43,129 +51,41 @@ function isNotNullish<T>(value: T | null | undefined): value is T {
|
|||||||
return value !== null && value !== undefined;
|
return value !== null && value !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeCharacterWithOverride(
|
export function getDateKey(date: Date): number {
|
||||||
baseCharacter: CharacterWithRelations,
|
return normalizeDay(date).getTime();
|
||||||
overrideRow?: CharacterOverrideRow,
|
|
||||||
relationMaps?: RelationMaps
|
|
||||||
): CharacterWithRelations {
|
|
||||||
if (!overrideRow) {
|
|
||||||
return baseCharacter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedCharacter = { ...baseCharacter } as CharacterWithRelations;
|
export function normalizeDay(date: Date = new Date()): Date {
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(overrideRow)) {
|
|
||||||
if (key === 'characterId' || key === 'notes') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNotNullish(value)) {
|
|
||||||
(mergedCharacter as Record<string, unknown>)[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relationMaps) {
|
|
||||||
if (mergedCharacter.arcId) {
|
|
||||||
mergedCharacter.arcName = relationMaps.arcNameById.get(mergedCharacter.arcId) ?? null;
|
|
||||||
} else {
|
|
||||||
mergedCharacter.arcName = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mergedCharacter.devilFruitId) {
|
|
||||||
const devilFruitData = relationMaps.devilFruitById.get(mergedCharacter.devilFruitId);
|
|
||||||
mergedCharacter.devilFruitName = devilFruitData?.name ?? null;
|
|
||||||
mergedCharacter.devilFruitType = devilFruitData?.type ?? null;
|
|
||||||
} else {
|
|
||||||
mergedCharacter.devilFruitName = null;
|
|
||||||
mergedCharacter.devilFruitType = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mergedCharacter;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyCharacterOverrides(
|
|
||||||
characters: CharacterWithRelations[]
|
|
||||||
): Promise<CharacterWithRelations[]> {
|
|
||||||
if (characters.length === 0) {
|
|
||||||
return characters;
|
|
||||||
}
|
|
||||||
|
|
||||||
const characterIds = characters.map((currentCharacter) => currentCharacter.id);
|
|
||||||
const overrideRows = await db
|
|
||||||
.select()
|
|
||||||
.from(characterOverride)
|
|
||||||
.where(inArray(characterOverride.characterId, characterIds));
|
|
||||||
|
|
||||||
if (overrideRows.length === 0) {
|
|
||||||
return characters;
|
|
||||||
}
|
|
||||||
|
|
||||||
const overrideByCharacterId = new Map<string, CharacterOverrideRow>(
|
|
||||||
overrideRows.map((overrideRow) => [overrideRow.characterId, overrideRow])
|
|
||||||
);
|
|
||||||
|
|
||||||
const shouldRefreshRelations = overrideRows.some(
|
|
||||||
(overrideRow) => isNotNullish(overrideRow.arcId) || isNotNullish(overrideRow.devilFruitId)
|
|
||||||
);
|
|
||||||
|
|
||||||
let relationMaps: RelationMaps | undefined;
|
|
||||||
|
|
||||||
if (shouldRefreshRelations) {
|
|
||||||
const [allArcs, allDevilFruits] = await Promise.all([
|
|
||||||
db.select({ id: arc.id, name: arc.name }).from(arc),
|
|
||||||
db
|
|
||||||
.select({ id: devilFruit.id, name: devilFruit.name, type: devilFruit.type })
|
|
||||||
.from(devilFruit)
|
|
||||||
]);
|
|
||||||
|
|
||||||
relationMaps = {
|
|
||||||
arcNameById: new Map(allArcs.map((currentArc) => [currentArc.id, currentArc.name])),
|
|
||||||
devilFruitById: new Map(
|
|
||||||
allDevilFruits.map((currentDevilFruit) => [
|
|
||||||
currentDevilFruit.id,
|
|
||||||
{ name: currentDevilFruit.name, type: currentDevilFruit.type }
|
|
||||||
])
|
|
||||||
)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return characters.map((currentCharacter) =>
|
|
||||||
mergeCharacterWithOverride(
|
|
||||||
currentCharacter,
|
|
||||||
overrideByCharacterId.get(currentCharacter.id),
|
|
||||||
relationMaps
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDateKey(date: Date): string {
|
|
||||||
return date.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDay(date: Date = new Date()): Date {
|
|
||||||
const normalized = new Date(date);
|
const normalized = new Date(date);
|
||||||
normalized.setHours(1, 0, 0, 0);
|
normalized.setHours(1, 0, 0, 0);
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickDailyCharacter(characters: CharacterWithRelations[], date: Date): CharacterWithRelations {
|
function pickDailyCharacter(characters: CharacterWithRelations[], date: Date): CharacterWithRelations {
|
||||||
const dateStr = getDateKey(date);
|
const timestamp = getDateKey(date);
|
||||||
const seed = dateStr.split('-').reduce((acc, value) => acc + parseInt(value), 0);
|
const daysSinceEpoch = Math.floor(timestamp / 1000 / 60 / 60 / 24);
|
||||||
const index = seed % characters.length;
|
// Combine timestamp with random seed to avoid predictable results
|
||||||
return characters[index];
|
const combinedSeed = (daysSinceEpoch + Math.floor(RANDOM_SEED * 1000000)) % characters.length;
|
||||||
|
return characters[combinedSeed];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDailyModeCharacters(): Promise<CharacterWithRelations[]> {
|
export async function getDailyModeCharacters(): Promise<CharacterWithRelations[]> {
|
||||||
const characters = (await db
|
return (await db
|
||||||
.select(characterWithRelationsSelect)
|
.select(characterWithRelationsSelect)
|
||||||
.from(character)
|
.from(character)
|
||||||
.leftJoin(arc, eq(character.arcId, arc.id))
|
.leftJoin(arc, eq(character.arcId, arc.id))
|
||||||
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
|
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
|
||||||
.where(eq(character.isInDailyMode, true))
|
.where(eq(character.isInDailyMode, true))
|
||||||
.all()) as CharacterWithRelations[];
|
.all()) as CharacterWithRelations[];
|
||||||
|
}
|
||||||
|
|
||||||
return applyCharacterOverrides(characters);
|
export async function getAllCharacters(): Promise<CharacterWithRelations[]> {
|
||||||
|
return (await db
|
||||||
|
.select(characterWithRelationsSelect)
|
||||||
|
.from(character)
|
||||||
|
.leftJoin(arc, eq(character.arcId, arc.id))
|
||||||
|
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
|
||||||
|
.all()) as CharacterWithRelations[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCharacterById(characterId: string): Promise<CharacterWithRelations | null> {
|
export async function getCharacterById(characterId: string): Promise<CharacterWithRelations | null> {
|
||||||
@@ -181,15 +101,16 @@ export async function getCharacterById(characterId: string): Promise<CharacterWi
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [overriddenCharacter] = await applyCharacterOverrides([found as CharacterWithRelations]);
|
return found as CharacterWithRelations
|
||||||
return overriddenCharacter ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrCreateTodayCharacter(
|
export async function getOrCreateTodayCharacter(
|
||||||
characters: CharacterWithRelations[],
|
characters?: CharacterWithRelations[],
|
||||||
date: Date = new Date()
|
date: Date = new Date()
|
||||||
): Promise<CharacterWithRelations | null> {
|
): Promise<CharacterWithRelations | null> {
|
||||||
if (characters.length === 0) {
|
const dailyCharacters = characters ?? (await getDailyModeCharacters());
|
||||||
|
|
||||||
|
if (dailyCharacters.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +125,7 @@ export async function getOrCreateTodayCharacter(
|
|||||||
|
|
||||||
if (existingEntry?.characterId) {
|
if (existingEntry?.characterId) {
|
||||||
return (
|
return (
|
||||||
characters.find((currentCharacter) => currentCharacter.id === existingEntry.characterId) ??
|
dailyCharacters.find((currentCharacter) => currentCharacter.id === existingEntry.characterId) ??
|
||||||
(await getCharacterById(existingEntry.characterId))
|
(await getCharacterById(existingEntry.characterId))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -216,10 +137,10 @@ export async function getOrCreateTodayCharacter(
|
|||||||
.limit(100);
|
.limit(100);
|
||||||
|
|
||||||
const excludedIds = new Set(recentHistory.map((entry) => entry.characterId));
|
const excludedIds = new Set(recentHistory.map((entry) => entry.characterId));
|
||||||
const availableCharacters = characters.filter((currentCharacter) => !excludedIds.has(currentCharacter.id));
|
const availableCharacters = dailyCharacters.filter((currentCharacter) => !excludedIds.has(currentCharacter.id));
|
||||||
|
|
||||||
const dailyCharacter = pickDailyCharacter(
|
const dailyCharacter = pickDailyCharacter(
|
||||||
availableCharacters.length > 0 ? availableCharacters : characters,
|
availableCharacters.length > 0 ? availableCharacters : dailyCharacters,
|
||||||
today
|
today
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -241,9 +162,9 @@ export async function getYesterdayCharacter(
|
|||||||
date: Date = new Date(),
|
date: Date = new Date(),
|
||||||
characters?: CharacterWithRelations[]
|
characters?: CharacterWithRelations[]
|
||||||
): Promise<CharacterWithRelations | null> {
|
): Promise<CharacterWithRelations | null> {
|
||||||
const baseDate = normalizeDay(date);
|
const yesterday = new Date(date);
|
||||||
baseDate.setDate(baseDate.getDate() - 1);
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
const yesterdayDate = getDateKey(baseDate);
|
const yesterdayDate = getDateKey(yesterday);
|
||||||
|
|
||||||
const [yesterdayEntry] = await db
|
const [yesterdayEntry] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -264,3 +185,23 @@ export async function getYesterdayCharacter(
|
|||||||
|
|
||||||
return getCharacterById(yesterdayEntry.characterId);
|
return getCharacterById(yesterdayEntry.characterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTodayCharacterWinsCount(
|
||||||
|
characterId: string,
|
||||||
|
date: Date = new Date()
|
||||||
|
): Promise<number> {
|
||||||
|
const today = normalizeDay(date);
|
||||||
|
const todayDate = getDateKey(today);
|
||||||
|
|
||||||
|
const [result] = await db
|
||||||
|
.select({ won: characterHistory.won })
|
||||||
|
.from(characterHistory)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(characterHistory.characterId, characterId),
|
||||||
|
eq(characterHistory.date, todayDate)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return result?.won ?? 0;
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
|||||||
export const user = sqliteTable("user", {
|
export const user = sqliteTable("user", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
|
username: text("username").notNull().unique(),
|
||||||
email: text("email").notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
emailVerified: integer("email_verified", { mode: "boolean" })
|
emailVerified: integer("email_verified", { mode: "boolean" })
|
||||||
.default(false)
|
.default(false)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
|
isAdmin: integer("is_admin", { mode: "boolean" }).default(false).notNull(),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { integer, sqliteTable, text, real } from 'drizzle-orm/sqlite-core';
|
import { integer, sqliteTable, text, real, unique } from 'drizzle-orm/sqlite-core';
|
||||||
|
import { user } from './auth.schema';
|
||||||
|
import type { InferSelectModel } from 'drizzle-orm';
|
||||||
|
|
||||||
// Define devil fruit types
|
// Define devil fruit types
|
||||||
export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Unknown';
|
export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Smile' | 'Unknown';
|
||||||
|
|
||||||
|
export type Status = 'Alive' | 'Dead' | 'Unknown';
|
||||||
|
export type FriendshipStatus = 'pending' | 'accepted' | 'declined';
|
||||||
|
|
||||||
// Define the site config table schema
|
// Define the site config table schema
|
||||||
export const config = sqliteTable('config', {
|
export const config = sqliteTable('config', {
|
||||||
@@ -13,98 +18,132 @@ export const config = sqliteTable('config', {
|
|||||||
export const arc = sqliteTable('arc', {
|
export const arc = sqliteTable('arc', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
startChapter: integer('startChapter').notNull(),
|
frName: text('fr_name'),
|
||||||
endChapter: integer('endChapter'),
|
startChapter: integer('start_chapter').notNull(),
|
||||||
|
endChapter: integer('end_chapter'),
|
||||||
url: text('url')
|
url: text('url')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type Arc = InferSelectModel<typeof arc>;
|
||||||
|
|
||||||
// Define the devil fruit table schema
|
// Define the devil fruit table schema
|
||||||
export const devilFruit = sqliteTable('devilFruit', {
|
export const devilFruit = sqliteTable('devil_fruit', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
name: text('name').notNull().unique(),
|
name: text('name').notNull().unique(),
|
||||||
type: text('type').$type<DevilFruitType>(),
|
type: text('type').$type<DevilFruitType>(),
|
||||||
url: text('url')
|
url: text('url')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type DevilFruit = InferSelectModel<typeof devilFruit>;
|
||||||
|
|
||||||
// Define the character table schema
|
// Define the character table schema
|
||||||
export const character = sqliteTable('character', {
|
export const character = sqliteTable('character', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
|
frName: text('fr_name'),
|
||||||
gender: text('gender'),
|
gender: text('gender'),
|
||||||
age: integer('age'),
|
age: integer('age'),
|
||||||
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
|
affiliation: text('affiliation'),
|
||||||
devilFruitId: text('devilFruitId').references(() => devilFruit.id),
|
frAffiliation: text('fr_affiliation'),
|
||||||
hakiObservation: integer('hakiObservation', { mode: 'boolean' }).default(false),
|
devilFruitId: text('devil_fruit_id').references(() => devilFruit.id),
|
||||||
hakiArmament: integer('hakiArmament', { mode: 'boolean' }).default(false),
|
hakiObservation: integer('haki_observation', { mode: 'boolean' }).default(false),
|
||||||
hakiConqueror: integer('hakiConqueror', { mode: 'boolean' }).default(false),
|
hakiArmament: integer('haki_armament', { mode: 'boolean' }).default(false),
|
||||||
|
hakiConqueror: integer('haki_conqueror', { mode: 'boolean' }).default(false),
|
||||||
bounty: integer('bounty').default(0),
|
bounty: integer('bounty').default(0),
|
||||||
height: real('height'),
|
height: real('height'),
|
||||||
origin: text('origin'),
|
origin: text('origin'),
|
||||||
firstAppearance: integer('firstAppearance').notNull(),
|
frOrigin: text('fr_origin'),
|
||||||
pictureUrl: text('pictureUrl'),
|
firstAppearance: integer('first_appearance').notNull(),
|
||||||
|
pictureUrl: text('picture_url'),
|
||||||
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
|
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
|
||||||
status: text('status'),
|
frEpithets: text('fr_epithets', { mode: 'json' }).$type<string[]>(),
|
||||||
arcId: text('arcId').references(() => arc.id),
|
status: text('status').$type<Status | null>(),
|
||||||
|
arcId: text('arc_id').references(() => arc.id, { onDelete: 'set null' }),
|
||||||
url: text('url'),
|
url: text('url'),
|
||||||
isInDailyMode: integer('isInDailyMode', { mode: 'boolean' }).default(true)
|
frUrl: text('fr_url'),
|
||||||
|
isInDailyMode: integer('is_in_daily_mode', { mode: 'boolean' }).default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define the character override table schema
|
export type Character = InferSelectModel<typeof character>;
|
||||||
export const characterOverride = sqliteTable('characterOverride', {
|
|
||||||
characterId: text('characterId').primaryKey().references(() => character.id),
|
|
||||||
name: text('name'),
|
|
||||||
gender: text('gender'),
|
|
||||||
age: integer('age'),
|
|
||||||
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
|
|
||||||
devilFruitId: text('devilFruitId').references(() => devilFruit.id),
|
|
||||||
hakiObservation: integer('hakiObservation', { mode: 'boolean' }),
|
|
||||||
hakiArmament: integer('hakiArmament', { mode: 'boolean' }),
|
|
||||||
hakiConqueror: integer('hakiConqueror', { mode: 'boolean' }),
|
|
||||||
bounty: integer('bounty'),
|
|
||||||
height: real('height'),
|
|
||||||
origin: text('origin'),
|
|
||||||
firstAppearance: integer('firstAppearance'),
|
|
||||||
pictureUrl: text('pictureUrl'),
|
|
||||||
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
|
|
||||||
status: text('status'),
|
|
||||||
arcId: text('arcId').references(() => arc.id),
|
|
||||||
url: text('url'),
|
|
||||||
notes: text('notes')
|
|
||||||
});
|
|
||||||
|
|
||||||
// Define the character scrape validation table schema
|
// Define the character scrape validation table schema
|
||||||
export const characterScrapeValidation = sqliteTable('characterScrapeValidation', {
|
export const characterScrapeValidation = sqliteTable('character_scrape_validation', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
|
frName: text('fr_name'),
|
||||||
gender: text('gender'),
|
gender: text('gender'),
|
||||||
age: integer('age'),
|
age: integer('age'),
|
||||||
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
|
affiliation: text('affiliation'),
|
||||||
devilFruitId: text('devilFruitId').references(() => devilFruit.id),
|
frAffiliation: text('fr_affiliation'),
|
||||||
hakiObservation: integer('hakiObservation', { mode: 'boolean' }).default(false),
|
devilFruitId: text('devil_fruit_id').references(() => devilFruit.id, { onDelete: 'set null' }),
|
||||||
hakiArmament: integer('hakiArmament', { mode: 'boolean' }).default(false),
|
hakiObservation: integer('haki_observation', { mode: 'boolean' }).default(false),
|
||||||
hakiConqueror: integer('hakiConqueror', { mode: 'boolean' }).default(false),
|
hakiArmament: integer('haki_armament', { mode: 'boolean' }).default(false),
|
||||||
|
hakiConqueror: integer('haki_conqueror', { mode: 'boolean' }).default(false),
|
||||||
bounty: integer('bounty'),
|
bounty: integer('bounty'),
|
||||||
height: real('height'),
|
height: real('height'),
|
||||||
origin: text('origin'),
|
origin: text('origin'),
|
||||||
firstAppearance: integer('firstAppearance').notNull(),
|
frOrigin: text('fr_origin'),
|
||||||
pictureUrl: text('pictureUrl'),
|
firstAppearance: integer('first_appearance').notNull(),
|
||||||
|
pictureUrl: text('picture_url'),
|
||||||
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
|
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
|
||||||
status: text('status'),
|
frEpithets: text('fr_epithets', { mode: 'json' }).$type<string[]>(),
|
||||||
arcId: text('arcId').references(() => arc.id),
|
status: text('status').$type<Status | null>(),
|
||||||
url: text('url')
|
arcId: text('arc_id').references(() => arc.id, { onDelete: 'set null' }),
|
||||||
|
url: text('url'),
|
||||||
|
frUrl: text('fr_url'),
|
||||||
|
isDeleted: integer('is_deleted', { mode: 'boolean' }).default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define the caracter history table schema
|
export type CharacterScrapeValidation = InferSelectModel<typeof characterScrapeValidation>;
|
||||||
export const characterHistory = sqliteTable('characterHistory', {
|
|
||||||
|
// Define the character history table schema
|
||||||
|
export const characterHistory = sqliteTable('character_history', {
|
||||||
id: text('id')
|
id: text('id')
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => crypto.randomUUID()),
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
characterId: text('characterId').references(() => character.id),
|
characterId: text('character_id').references(() => character.id, { onDelete: 'cascade' }),
|
||||||
date: text('date'),
|
date: integer('date').notNull().unique(),
|
||||||
won: integer('won').notNull().default(0),
|
won: integer('won').notNull().default(0),
|
||||||
createdAt: integer('createdAt').notNull().$default(() => Date.now()),
|
createdAt: integer('created_at').notNull().$default(() => Date.now()),
|
||||||
updatedAt: integer('updatedAt').notNull().$default(() => Date.now()),
|
updatedAt: integer('updated_at').notNull().$default(() => Date.now()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type CharacterHistory = InferSelectModel<typeof characterHistory>;
|
||||||
|
|
||||||
|
// Define the user character history table schema
|
||||||
|
export const userCharacterHistory = sqliteTable('user_character_history', {
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
characterHistoryId: text('character_history_id').references(() => characterHistory.id, { onDelete: 'cascade' }),
|
||||||
|
tryCount: integer('try_count').notNull(),
|
||||||
|
triedCharacterIds: text('tried_character_ids', { mode: 'json' }).$type<string[]>(),
|
||||||
|
createdAt: integer('created_at').notNull().$default(() => Date.now())
|
||||||
|
}, (table) => [
|
||||||
|
unique().on(table.userId, table.characterHistoryId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type UserCharacterHistory = InferSelectModel<typeof userCharacterHistory>;
|
||||||
|
|
||||||
|
// Define the friendship table schema (friend requests + accepted friends)
|
||||||
|
export const friendship = sqliteTable('friendship', {
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
requesterId: text('requester_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
addresseeId: text('addressee_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
status: text('status').$type<FriendshipStatus>().notNull().default('pending'),
|
||||||
|
createdAt: integer('created_at').notNull().$default(() => Date.now()),
|
||||||
|
updatedAt: integer('updated_at').notNull().$default(() => Date.now()),
|
||||||
|
}, (table) => [
|
||||||
|
unique().on(table.requesterId, table.addresseeId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type Friendship = InferSelectModel<typeof friendship>;
|
||||||
|
|
||||||
export * from './auth.schema';
|
export * from './auth.schema';
|
||||||
|
|||||||
13
src/lib/utils.ts
Normal file
13
src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function formatBounty(bounty: number): string {
|
||||||
|
if (bounty >= 1_000_000_000) {
|
||||||
|
const billions = bounty / 1_000_000_000;
|
||||||
|
return `${billions}B`;
|
||||||
|
} else if (bounty >= 1_000_000) {
|
||||||
|
const millions = bounty / 1_000_000;
|
||||||
|
return `${millions}M`;
|
||||||
|
} else if (bounty >= 1_000) {
|
||||||
|
const thousands = bounty / 1_000;
|
||||||
|
return `${thousands}K`;
|
||||||
|
}
|
||||||
|
return bounty.toString();
|
||||||
|
}
|
||||||
12
src/routes/(admin)/admin/+layout.server.ts
Normal file
12
src/routes/(admin)/admin/+layout.server.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!locals.user.isAdmin) {
|
||||||
|
redirect(302, '/');
|
||||||
|
}
|
||||||
|
};
|
||||||
69
src/routes/(admin)/admin/+layout.svelte
Normal file
69
src/routes/(admin)/admin/+layout.svelte
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import ProfileButton from '$lib/components/ProfileButton.svelte';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
let { children, data } = $props();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/admin', label: 'Dashboard', icon: '📊' },
|
||||||
|
{ href: '/admin/characters', label: 'Characters', icon: '🗣️' },
|
||||||
|
{ href: '/admin/character-changes', label: 'Changes', icon: '🔄' },
|
||||||
|
{ href: '/admin/devil-fruits', label: 'Devil Fruits', icon: '🍎' },
|
||||||
|
{ href: '/admin/arcs', label: 'Arcs', icon: '📚' },
|
||||||
|
{ href: '/admin/users', label: 'Users', icon: '👥' },
|
||||||
|
{ href: '/admin/config', label: 'Settings', icon: '⚙️' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (href: string, currentPath: string) => {
|
||||||
|
if (href === '/admin') {
|
||||||
|
return currentPath === '/admin';
|
||||||
|
}
|
||||||
|
return currentPath.startsWith(href);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen bg-slate-900">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="flex flex-col w-64 border-r border-white/5 bg-slate-950">
|
||||||
|
<div class="p-6">
|
||||||
|
<h2 class="text-lg font-black uppercase tracking-[0.15em] text-amber-50">Admin</h2>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 space-y-2 px-3">
|
||||||
|
{#each navItems as item (item.label)}
|
||||||
|
<a
|
||||||
|
href={resolve(item.href)}
|
||||||
|
class={`flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium transition-colors ${
|
||||||
|
isActive(item.href, $page.url.pathname)
|
||||||
|
? 'bg-amber-600 text-white'
|
||||||
|
: 'text-gray-300 hover:bg-slate-800 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
<div class="border-t border-white/5 p-3">
|
||||||
|
<a
|
||||||
|
href={resolve('/')}
|
||||||
|
class="flex items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium text-gray-300 transition-colors hover:bg-slate-800 hover:text-white"
|
||||||
|
title="Return to site"
|
||||||
|
>
|
||||||
|
<span>←</span>
|
||||||
|
<span>Retour au site</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1">
|
||||||
|
<div class="flex items-center justify-between border-b border-white/5 bg-slate-950 px-8 py-4">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Admin Dashboard</h1>
|
||||||
|
<ProfileButton user={data.user} />
|
||||||
|
</div>
|
||||||
|
<div class="p-8">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
36
src/routes/(admin)/admin/+page.server.ts
Normal file
36
src/routes/(admin)/admin/+page.server.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { character, devilFruit, arc, user } from '$lib/server/db/schema';
|
||||||
|
import { getOrCreateTodayCharacter, getTodayCharacterWinsCount } from '$lib/server/daily-character';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { count, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const [totalCharacters, totalDevilFruits, totalArcs, totalUsers, adminUsers, charactersInDaily] = await Promise.all([
|
||||||
|
db.select({ count: count() }).from(character),
|
||||||
|
db.select({ count: count() }).from(devilFruit),
|
||||||
|
db.select({ count: count() }).from(arc),
|
||||||
|
db.select({ count: count() }).from(user),
|
||||||
|
db.select({ count: count() }).from(user).where(eq(user.isAdmin, true)),
|
||||||
|
db.select({ count: count() }).from(character).where(eq(character.isInDailyMode, true))
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get today's daily character and count wins
|
||||||
|
const todayCharacter = await getOrCreateTodayCharacter();
|
||||||
|
|
||||||
|
let dailyCharacterWins = 0;
|
||||||
|
if (todayCharacter) {
|
||||||
|
dailyCharacterWins = await getTodayCharacterWinsCount(todayCharacter.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats: {
|
||||||
|
totalCharacters: totalCharacters[0].count,
|
||||||
|
charactersInDaily: charactersInDaily[0].count,
|
||||||
|
totalDevilFruits: totalDevilFruits[0].count,
|
||||||
|
totalArcs: totalArcs[0].count,
|
||||||
|
totalUsers: totalUsers[0].count,
|
||||||
|
adminUsers: adminUsers[0].count,
|
||||||
|
dailyCharacterWins
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
121
src/routes/(admin)/admin/+page.svelte
Normal file
121
src/routes/(admin)/admin/+page.svelte
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
const statCards = $derived.by(() => [
|
||||||
|
{
|
||||||
|
label: 'Total Characters',
|
||||||
|
value: data.stats.totalCharacters,
|
||||||
|
icon: '🗣️',
|
||||||
|
bgColor: 'bg-blue-500/10 border-blue-500/20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'In Daily Mode',
|
||||||
|
value: data.stats.charactersInDaily,
|
||||||
|
icon: '📅',
|
||||||
|
bgColor: 'bg-green-500/10 border-green-500/20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Devil Fruits',
|
||||||
|
value: data.stats.totalDevilFruits,
|
||||||
|
icon: '🍎',
|
||||||
|
bgColor: 'bg-red-500/10 border-red-500/20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Arcs',
|
||||||
|
value: data.stats.totalArcs,
|
||||||
|
icon: '📚',
|
||||||
|
bgColor: 'bg-purple-500/10 border-purple-500/20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Users',
|
||||||
|
value: data.stats.totalUsers,
|
||||||
|
icon: '👥',
|
||||||
|
bgColor: 'bg-yellow-500/10 border-yellow-500/20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Admin Users',
|
||||||
|
value: data.stats.adminUsers,
|
||||||
|
icon: '🔑',
|
||||||
|
bgColor: 'bg-orange-500/10 border-orange-500/20'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Admin Dashboard - OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Welcome Section -->
|
||||||
|
<div class="rounded-lg border border-white/10 bg-gradient-to-r from-amber-600/20 to-amber-700/10 p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-white">Welcome Back!</h2>
|
||||||
|
<p class="mt-2 text-gray-400">
|
||||||
|
{#if data.stats.dailyCharacterWins > 0}
|
||||||
|
<strong class="text-amber-400">{data.stats.dailyCharacterWins}</strong>
|
||||||
|
{data.stats.dailyCharacterWins === 1 ? 'person has' : 'people have'} found today's daily character!
|
||||||
|
{:else}
|
||||||
|
No one has found today's daily character yet.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each statCards as card}
|
||||||
|
<div
|
||||||
|
class={`rounded-lg border p-6 transition-all hover:shadow-lg hover:shadow-white/5 ${card.bgColor}`}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-400">{card.label}</p>
|
||||||
|
<p class="mt-2 text-3xl font-bold text-white">{card.value}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-4xl">{card.icon}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-800/50 p-6">
|
||||||
|
<h3 class="mb-4 text-lg font-bold text-white">Quick Actions</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<a
|
||||||
|
href="/admin/characters"
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
↳ Manage Characters
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/devil-fruits"
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
↳ Manage Devil Fruits
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/arcs"
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
↳ Manage Arcs
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/users"
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
↳ Manage Users
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/config"
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
↳ App Settings
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
69
src/routes/(admin)/admin/arcs/+page.server.ts
Normal file
69
src/routes/(admin)/admin/arcs/+page.server.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { arc } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const arcs = await db.select().from(arc).orderBy(arc.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
arcs
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
update: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'Arc ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: Record<string, any> = {};
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
if (key !== 'id') {
|
||||||
|
if (key === 'startChapter' || key === 'endChapter') {
|
||||||
|
updates[key] = value ? parseInt(value as string) : null;
|
||||||
|
} else {
|
||||||
|
updates[key] = value || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.update(arc).set(updates).where(eq(arc.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Arc update error:', error);
|
||||||
|
return fail(500, { error: 'Failed to update arc' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'Arc ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(arc).where(eq(arc.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Arc delete error:', error);
|
||||||
|
return fail(500, { error: 'Failed to delete arc' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
246
src/routes/(admin)/admin/arcs/+page.svelte
Normal file
246
src/routes/(admin)/admin/arcs/+page.svelte
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let isEditModalOpen = $state(false);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let saveMessage = $state<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
let selectedArcId = $state<string | null>(null);
|
||||||
|
|
||||||
|
let editForm = $state<any>({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
startChapter: 1,
|
||||||
|
endChapter: null,
|
||||||
|
url: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredArcs = $derived.by(() => {
|
||||||
|
return data.arcs.filter((arc) => {
|
||||||
|
return arc.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const openEditModal = (arc: any) => {
|
||||||
|
selectedArcId = arc.id;
|
||||||
|
editForm = { ...arc };
|
||||||
|
isEditModalOpen = true;
|
||||||
|
saveMessage = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isEditModalOpen = false;
|
||||||
|
selectedArcId = null;
|
||||||
|
editForm = {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
startChapter: 1,
|
||||||
|
endChapter: null,
|
||||||
|
url: ''
|
||||||
|
};
|
||||||
|
saveMessage = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteArc = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this arc?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', id);
|
||||||
|
|
||||||
|
const response = await fetch('?/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete arc');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error deleting arc: ' + error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Arcs - Admin - OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-3xl font-bold text-white">Arc Management</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
+ Add Arc
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-col gap-4 rounded-lg border border-white/10 bg-slate-800/50 p-4 md:flex-row md:items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search arcs..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arcs Table -->
|
||||||
|
<div class="rounded-lg border border-white/10">
|
||||||
|
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||||
|
<tr class="border-b border-white/10">
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Name</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Start Chapter</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">End Chapter</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredArcs as arc}
|
||||||
|
<tr class="border-b border-white/5 hover:bg-slate-800/50">
|
||||||
|
<td class="px-6 py-4 text-sm text-white">{arc.name}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-400">{arc.startChapter}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-400">{arc.endChapter || '-'}</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => openEditModal(arc)}
|
||||||
|
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
title="Edit arc"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDeleteArc(arc.id)}
|
||||||
|
class="text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
title="Delete arc"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div> </div>
|
||||||
|
{#if filteredArcs.length === 0}
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||||
|
<p class="text-gray-400">No arcs found</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
{#if isEditModalOpen}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div class="w-full max-w-md rounded-lg border border-white/10 bg-slate-900 p-6">
|
||||||
|
<h3 class="text-lg font-bold text-white">Edit Arc</h3>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/update"
|
||||||
|
class="mt-6 space-y-4"
|
||||||
|
use:enhance={() => {
|
||||||
|
isSaving = true;
|
||||||
|
saveMessage = null;
|
||||||
|
return async ({ result }) => {
|
||||||
|
isSaving = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
saveMessage = { type: 'success', message: 'Arc updated successfully' };
|
||||||
|
setTimeout(() => {
|
||||||
|
closeModal();
|
||||||
|
window.location.reload();
|
||||||
|
}, 500);
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
saveMessage = { type: 'error', message: (result.data as any)?.error || 'Failed to update arc' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={editForm.id} />
|
||||||
|
<div>
|
||||||
|
<label for="arc-name" class="block text-sm font-medium text-gray-300">Name</label>
|
||||||
|
<input
|
||||||
|
id="arc-name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
bind:value={editForm.name}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="arc-start" class="block text-sm font-medium text-gray-300">Start Chapter</label>
|
||||||
|
<input
|
||||||
|
id="arc-start"
|
||||||
|
type="number"
|
||||||
|
name="startChapter"
|
||||||
|
bind:value={editForm.startChapter}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="arc-end" class="block text-sm font-medium text-gray-300">End Chapter</label>
|
||||||
|
<input
|
||||||
|
id="arc-end"
|
||||||
|
type="number"
|
||||||
|
name="endChapter"
|
||||||
|
bind:value={editForm.endChapter}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="arc-url" class="block text-sm font-medium text-gray-300">URL</label>
|
||||||
|
<input
|
||||||
|
id="arc-url"
|
||||||
|
type="text"
|
||||||
|
name="url"
|
||||||
|
bind:value={editForm.url}
|
||||||
|
placeholder="https://..."
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if saveMessage}
|
||||||
|
<div class={`rounded-lg p-3 text-sm ${
|
||||||
|
saveMessage.type === 'success'
|
||||||
|
? 'bg-green-500/10 text-green-400'
|
||||||
|
: 'bg-red-500/10 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{saveMessage.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeModal}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg border border-gray-500 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
272
src/routes/(admin)/admin/character-changes/+page.server.ts
Normal file
272
src/routes/(admin)/admin/character-changes/+page.server.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { character, characterScrapeValidation } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { exec } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
let isScrapeImportRunning = false;
|
||||||
|
const EXEC_OPTIONS = {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
maxBuffer: 50 * 1024 * 1024
|
||||||
|
};
|
||||||
|
|
||||||
|
async function applyCharacterChangeFromScrapeValidation(characterId: string): Promise<boolean> {
|
||||||
|
const [scraped] = await db
|
||||||
|
.select()
|
||||||
|
.from(characterScrapeValidation)
|
||||||
|
.where(eq(characterScrapeValidation.id, characterId));
|
||||||
|
|
||||||
|
if (!scraped) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scraped.isDeleted) {
|
||||||
|
await db.delete(character).where(eq(character.id, characterId));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(character)
|
||||||
|
.values({
|
||||||
|
id: scraped.id,
|
||||||
|
name: scraped.name,
|
||||||
|
frName: scraped.frName,
|
||||||
|
gender: scraped.gender,
|
||||||
|
age: scraped.age,
|
||||||
|
affiliation: scraped.affiliation,
|
||||||
|
frAffiliation: scraped.frAffiliation,
|
||||||
|
devilFruitId: scraped.devilFruitId,
|
||||||
|
hakiObservation: scraped.hakiObservation,
|
||||||
|
hakiArmament: scraped.hakiArmament,
|
||||||
|
hakiConqueror: scraped.hakiConqueror,
|
||||||
|
bounty: scraped.bounty,
|
||||||
|
height: scraped.height,
|
||||||
|
origin: scraped.origin,
|
||||||
|
frOrigin: scraped.frOrigin,
|
||||||
|
firstAppearance: scraped.firstAppearance,
|
||||||
|
pictureUrl: scraped.pictureUrl,
|
||||||
|
epithets: scraped.epithets,
|
||||||
|
frEpithets: scraped.frEpithets,
|
||||||
|
status: scraped.status,
|
||||||
|
arcId: scraped.arcId,
|
||||||
|
url: scraped.url,
|
||||||
|
frUrl: scraped.frUrl,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: character.id,
|
||||||
|
set: {
|
||||||
|
name: scraped.name,
|
||||||
|
frName: scraped.frName,
|
||||||
|
gender: scraped.gender,
|
||||||
|
age: scraped.age,
|
||||||
|
affiliation: scraped.affiliation,
|
||||||
|
frAffiliation: scraped.frAffiliation,
|
||||||
|
devilFruitId: scraped.devilFruitId,
|
||||||
|
hakiObservation: scraped.hakiObservation,
|
||||||
|
hakiArmament: scraped.hakiArmament,
|
||||||
|
hakiConqueror: scraped.hakiConqueror,
|
||||||
|
bounty: scraped.bounty,
|
||||||
|
height: scraped.height,
|
||||||
|
origin: scraped.origin,
|
||||||
|
frOrigin: scraped.frOrigin,
|
||||||
|
firstAppearance: scraped.firstAppearance,
|
||||||
|
pictureUrl: scraped.pictureUrl,
|
||||||
|
epithets: scraped.epithets,
|
||||||
|
frEpithets: scraped.frEpithets,
|
||||||
|
status: scraped.status,
|
||||||
|
arcId: scraped.arcId,
|
||||||
|
url: scraped.url,
|
||||||
|
frUrl: scraped.frUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function load() {
|
||||||
|
// Get all characters from both tables
|
||||||
|
const currentCharacters = await db.select().from(character);
|
||||||
|
const scrapedCharacters = await db.select().from(characterScrapeValidation);
|
||||||
|
|
||||||
|
// Create a map for quick lookup
|
||||||
|
const currentCharMap = new Map(currentCharacters.map(c => [c.id, c]));
|
||||||
|
|
||||||
|
// Compare and categorize changes
|
||||||
|
const changes: {
|
||||||
|
type: 'new' | 'modified' | 'deleted';
|
||||||
|
id: string;
|
||||||
|
scraped: (typeof scrapedCharacters)[0];
|
||||||
|
current?: (typeof currentCharacters)[0];
|
||||||
|
differences?: Record<string, { current: any; scraped: any }>;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const scraped of scrapedCharacters) {
|
||||||
|
const current = currentCharMap.get(scraped.id);
|
||||||
|
|
||||||
|
if (scraped.isDeleted) {
|
||||||
|
if (current) {
|
||||||
|
changes.push({
|
||||||
|
type: 'deleted',
|
||||||
|
id: scraped.id,
|
||||||
|
scraped,
|
||||||
|
current
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
// New character
|
||||||
|
changes.push({
|
||||||
|
type: 'new',
|
||||||
|
id: scraped.id,
|
||||||
|
scraped
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Check if different
|
||||||
|
const differences: Record<string, { current: any; scraped: any }> = {};
|
||||||
|
const fieldsToCompare = [
|
||||||
|
'name',
|
||||||
|
'frName',
|
||||||
|
'gender',
|
||||||
|
'age',
|
||||||
|
'affiliation',
|
||||||
|
'frAffiliation',
|
||||||
|
'devilFruitId',
|
||||||
|
'hakiObservation',
|
||||||
|
'hakiArmament',
|
||||||
|
'hakiConqueror',
|
||||||
|
'bounty',
|
||||||
|
'height',
|
||||||
|
'origin',
|
||||||
|
'frOrigin',
|
||||||
|
'firstAppearance',
|
||||||
|
'pictureUrl',
|
||||||
|
'epithets',
|
||||||
|
'frEpithets',
|
||||||
|
'status',
|
||||||
|
'arcId',
|
||||||
|
'url',
|
||||||
|
'frUrl'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fieldsToCompare) {
|
||||||
|
const currentValue = current[field as keyof typeof current];
|
||||||
|
const scrapedValue = scraped[field as keyof typeof scraped];
|
||||||
|
|
||||||
|
// Deep comparison for JSON fields
|
||||||
|
if (JSON.stringify(currentValue) !== JSON.stringify(scrapedValue)) {
|
||||||
|
differences[field] = {
|
||||||
|
current: currentValue,
|
||||||
|
scraped: scrapedValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(differences).length > 0) {
|
||||||
|
changes.push({
|
||||||
|
type: 'modified',
|
||||||
|
id: scraped.id,
|
||||||
|
scraped,
|
||||||
|
current,
|
||||||
|
differences
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeOrder: Record<'new' | 'modified' | 'deleted', number> = {
|
||||||
|
new: 0,
|
||||||
|
modified: 1,
|
||||||
|
deleted: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
changes: changes.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return typeOrder[a.type] - typeOrder[b.type];
|
||||||
|
}
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
runScrapeImport: async () => {
|
||||||
|
if (isScrapeImportRunning) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'A scrape is already running. Please wait for it to finish.',
|
||||||
|
logs: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isScrapeImportRunning = true;
|
||||||
|
try {
|
||||||
|
const scrapeResult = await execAsync('npm run scrape', EXEC_OPTIONS);
|
||||||
|
const importResult = await execAsync('npm run db:import', EXEC_OPTIONS);
|
||||||
|
|
||||||
|
const logs = [
|
||||||
|
'=== npm run scrape ===',
|
||||||
|
scrapeResult.stdout || '',
|
||||||
|
scrapeResult.stderr ? `\n[stderr]\n${scrapeResult.stderr}` : '',
|
||||||
|
'\n=== npm run db:import ===',
|
||||||
|
importResult.stdout || '',
|
||||||
|
importResult.stderr ? `\n[stderr]\n${importResult.stderr}` : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Scrape and import completed successfully',
|
||||||
|
logs
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to run scripts';
|
||||||
|
const stdout = typeof error === 'object' && error && 'stdout' in error ? String((error as any).stdout || '') : '';
|
||||||
|
const stderr = typeof error === 'object' && error && 'stderr' in error ? String((error as any).stderr || '') : '';
|
||||||
|
const logs = [stdout, stderr ? `\n[stderr]\n${stderr}` : ''].filter(Boolean).join('\n');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message,
|
||||||
|
logs
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
isScrapeImportRunning = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
acceptOne: async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const characterId = formData.get('characterId');
|
||||||
|
|
||||||
|
if (!characterId || typeof characterId !== 'string') {
|
||||||
|
return { success: false, message: 'characterId is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const applied = await applyCharacterChangeFromScrapeValidation(characterId);
|
||||||
|
return {
|
||||||
|
success: applied,
|
||||||
|
message: applied ? 'Character change applied successfully' : 'Character not found in scrape validation table'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
acceptAll: async () => {
|
||||||
|
const scrapedCharacters = await db.select().from(characterScrapeValidation);
|
||||||
|
let appliedCount = 0;
|
||||||
|
|
||||||
|
for (const scraped of scrapedCharacters) {
|
||||||
|
const applied = await applyCharacterChangeFromScrapeValidation(scraped.id);
|
||||||
|
if (applied) {
|
||||||
|
appliedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
appliedCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
251
src/routes/(admin)/admin/character-changes/+page.svelte
Normal file
251
src/routes/(admin)/admin/character-changes/+page.svelte
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type CharacterLike = {
|
||||||
|
name: string;
|
||||||
|
pictureUrl?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
gender?: string | null;
|
||||||
|
age?: number | null;
|
||||||
|
bounty?: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CharacterChange = {
|
||||||
|
type: 'new' | 'modified' | 'deleted';
|
||||||
|
id: string;
|
||||||
|
scraped: CharacterLike;
|
||||||
|
current?: CharacterLike;
|
||||||
|
differences?: Record<string, { current: unknown; scraped: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
const newCharacters = $derived((data.changes as CharacterChange[]).filter((c) => c.type === 'new'));
|
||||||
|
const modifiedCharacters = $derived((data.changes as CharacterChange[]).filter((c) => c.type === 'modified'));
|
||||||
|
const deletedCharacters = $derived((data.changes as CharacterChange[]).filter((c) => c.type === 'deleted'));
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.join(', ');
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? '✓' : '✗';
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Admin - Character Changes</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 mb-2">Character Changes</h1>
|
||||||
|
<p class="text-gray-400">Total changes: {newCharacters.length} new, {modifiedCharacters.length} modified, {deletedCharacters.length} deleted</p>
|
||||||
|
<form method="POST" action="?/runScrapeImport" class="mt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-full border border-sky-300/40 bg-sky-500/20 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:bg-sky-500/30"
|
||||||
|
>
|
||||||
|
🔄 Lancer scrape + import
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if form?.message}
|
||||||
|
<p class={`mt-3 text-sm ${form.success ? 'text-emerald-300' : 'text-rose-300'}`}>
|
||||||
|
{form.message}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if form?.logs}
|
||||||
|
<pre class="mt-3 max-h-72 overflow-auto rounded-lg border border-white/10 bg-slate-900/70 p-3 text-xs text-slate-200 whitespace-pre-wrap">{form.logs}</pre>
|
||||||
|
{/if}
|
||||||
|
{#if newCharacters.length + modifiedCharacters.length + deletedCharacters.length > 0}
|
||||||
|
<form method="POST" action="?/acceptAll" class="mt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-full border border-emerald-300/40 bg-emerald-500/20 px-4 py-2 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-500/30"
|
||||||
|
>
|
||||||
|
✅ Accepter tous les changements
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Characters Section -->
|
||||||
|
{#if newCharacters.length > 0}
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-bold text-emerald-400 uppercase tracking-[0.15em]">
|
||||||
|
🆕 New Characters ({newCharacters.length})
|
||||||
|
</h2>
|
||||||
|
<div class="grid gap-4">
|
||||||
|
{#each newCharacters as change (change.id)}
|
||||||
|
<div class="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if change.scraped.pictureUrl}
|
||||||
|
<a href="https://onepiece.fandom.com/fr/wiki/{change.scraped.url}" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img
|
||||||
|
src={change.scraped.pictureUrl ?? undefined}
|
||||||
|
alt={change.scraped.name}
|
||||||
|
class="w-12 h-12 rounded object-cover hover:opacity-80 transition"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-emerald-300">{change.scraped.name}</h3>
|
||||||
|
<p class="text-sm text-gray-500">{change.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="?/acceptOne">
|
||||||
|
<input type="hidden" name="characterId" value={change.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-full border border-emerald-300/40 bg-emerald-500/20 px-3 py-1 text-xs font-semibold text-emerald-100 transition hover:bg-emerald-500/30"
|
||||||
|
>
|
||||||
|
Accepter
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-400">Status:</span>
|
||||||
|
<span class="ml-2">{formatValue(change.scraped.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-400">Gender:</span>
|
||||||
|
<span class="ml-2">{formatValue(change.scraped.gender)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-400">Age:</span>
|
||||||
|
<span class="ml-2">{formatValue(change.scraped.age)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-400">Bounty:</span>
|
||||||
|
<span class="ml-2">{formatValue(change.scraped.bounty)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modified Characters Section -->
|
||||||
|
{#if modifiedCharacters.length > 0}
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-bold text-amber-400 uppercase tracking-[0.15em]">
|
||||||
|
✏️ Modified Characters ({modifiedCharacters.length})
|
||||||
|
</h2>
|
||||||
|
<div class="grid gap-6">
|
||||||
|
{#each modifiedCharacters as change (change.id)}
|
||||||
|
<div class="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-3 pb-4 border-b border-amber-500/20">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if change.current?.pictureUrl}
|
||||||
|
<a href="https://onepiece.fandom.com/fr/wiki/{change.current?.url ?? change.scraped.url}" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img
|
||||||
|
src={change.current?.pictureUrl ?? undefined}
|
||||||
|
alt={change.current?.name ?? change.scraped.name}
|
||||||
|
class="w-12 h-12 rounded object-cover hover:opacity-80 transition"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-amber-300">{change.current?.name ?? change.scraped.name}</h3>
|
||||||
|
<p class="text-sm text-gray-500">{change.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="?/acceptOne">
|
||||||
|
<input type="hidden" name="characterId" value={change.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-full border border-amber-300/40 bg-amber-500/20 px-3 py-1 text-xs font-semibold text-amber-100 transition hover:bg-amber-500/30"
|
||||||
|
>
|
||||||
|
Accepter
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if change.differences}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each Object.entries(change.differences) as [field, diff] (field)}
|
||||||
|
<div class="bg-slate-900/50 rounded p-3 space-y-1">
|
||||||
|
<h4 class="text-sm font-semibold text-amber-100 uppercase tracking-widest">{field}</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-gray-500">Current:</p>
|
||||||
|
<p class="font-mono text-gray-300">{formatValue(diff.current)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-gray-500">Scraped:</p>
|
||||||
|
<p class="font-mono text-emerald-300 font-semibold">{formatValue(diff.scraped)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Deleted Characters Section -->
|
||||||
|
{#if deletedCharacters.length > 0}
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-bold text-rose-400 uppercase tracking-[0.15em]">
|
||||||
|
🗑️ Deleted Characters ({deletedCharacters.length})
|
||||||
|
</h2>
|
||||||
|
<div class="grid gap-4">
|
||||||
|
{#each deletedCharacters as change (change.id)}
|
||||||
|
<div class="rounded-lg border border-rose-500/30 bg-rose-500/5 p-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if change.current?.pictureUrl}
|
||||||
|
<a href="https://onepiece.fandom.com/fr/wiki/{change.current?.url ?? change.scraped.url}" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img
|
||||||
|
src={change.current?.pictureUrl ?? undefined}
|
||||||
|
alt={change.current?.name ?? change.scraped.name}
|
||||||
|
class="w-12 h-12 rounded object-cover hover:opacity-80 transition"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-rose-300">{change.current?.name ?? change.scraped.name}</h3>
|
||||||
|
<p class="text-sm text-gray-500">{change.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="?/acceptOne">
|
||||||
|
<input type="hidden" name="characterId" value={change.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-full border border-rose-300/40 bg-rose-500/20 px-3 py-1 text-xs font-semibold text-rose-100 transition hover:bg-rose-500/30"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-rose-200/80">This character is no longer present in the latest scrape and will be removed if accepted.</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if newCharacters.length === 0 && modifiedCharacters.length === 0 && deletedCharacters.length === 0}
|
||||||
|
<div class="rounded-lg border border-white/10 bg-white/5 p-8 text-center">
|
||||||
|
<p class="text-gray-400">Aucun changement détecté. Les tables character et characterScrapeValidation sont synchronisées.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
background-color: rgb(15, 23, 42);
|
||||||
|
color: rgb(203, 213, 225);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
123
src/routes/(admin)/admin/characters/+page.server.ts
Normal file
123
src/routes/(admin)/admin/characters/+page.server.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { character, devilFruit, arc, type Status } from '$lib/server/db/schema';
|
||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
// Helper function to normalize data (parse JSON arrays)
|
||||||
|
const normalizeArray = (value: any): any => {
|
||||||
|
if (!value) return value;
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
if (typeof value === 'string' && value.includes('[')) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
let [characters, devilFruits, arcs, statusesData, gendersData] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: character.id,
|
||||||
|
name: character.name,
|
||||||
|
gender: character.gender,
|
||||||
|
age: character.age,
|
||||||
|
affiliation: character.affiliation,
|
||||||
|
devilFruitId: character.devilFruitId,
|
||||||
|
hakiObservation: character.hakiObservation,
|
||||||
|
hakiArmament: character.hakiArmament,
|
||||||
|
hakiConqueror: character.hakiConqueror,
|
||||||
|
bounty: character.bounty,
|
||||||
|
height: character.height,
|
||||||
|
origin: character.origin,
|
||||||
|
firstAppearance: character.firstAppearance,
|
||||||
|
pictureUrl: character.pictureUrl,
|
||||||
|
epithets: normalizeArray(character.epithets),
|
||||||
|
status: character.status,
|
||||||
|
url: character.url,
|
||||||
|
arcId: character.arcId,
|
||||||
|
isInDailyMode: character.isInDailyMode,
|
||||||
|
arcName: arc.name,
|
||||||
|
devilFruitName: devilFruit.name,
|
||||||
|
devilFruitType: devilFruit.type
|
||||||
|
})
|
||||||
|
.from(character)
|
||||||
|
.leftJoin(arc, eq(character.arcId, arc.id))
|
||||||
|
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
|
||||||
|
.orderBy(character.name),
|
||||||
|
db.select().from(devilFruit).orderBy(devilFruit.name),
|
||||||
|
db.select().from(arc).orderBy(arc.name),
|
||||||
|
db.selectDistinct({ status: character.status })
|
||||||
|
.from(character)
|
||||||
|
.where(sql`${character.status} IS NOT NULL AND ${character.status} != ''`),
|
||||||
|
db.selectDistinct({ gender: character.gender })
|
||||||
|
.from(character)
|
||||||
|
.where(sql`${character.gender} IS NOT NULL AND ${character.gender} != ''`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
characters,
|
||||||
|
devilFruits,
|
||||||
|
arcs,
|
||||||
|
availableStatuses: statusesData
|
||||||
|
.map(s => s.status)
|
||||||
|
.filter((s): s is Status => !!s)
|
||||||
|
.sort((a, b) => a.localeCompare(b)),
|
||||||
|
availableGenders: gendersData
|
||||||
|
.map(g => g.gender)
|
||||||
|
.filter((g): g is string => !!g)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
delete: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'Character ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(character).where(eq(character.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Character delete error:', error);
|
||||||
|
return fail(500, { error: 'Failed to delete character' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleDailyMode: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
const isInDailyMode = formData.get('isInDailyMode') === 'true';
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'Character ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.update(character)
|
||||||
|
.set({ isInDailyMode })
|
||||||
|
.where(eq(character.id, id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Toggle daily mode error:', error);
|
||||||
|
return fail(500, { error: 'Failed to toggle daily mode' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
795
src/routes/(admin)/admin/characters/+page.svelte
Normal file
795
src/routes/(admin)/admin/characters/+page.svelte
Normal file
@@ -0,0 +1,795 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { formatBounty } from '$lib';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let filterDaily = $state<'all' | 'daily' | 'not-daily'>('all');
|
||||||
|
let filterStatus = $state('all');
|
||||||
|
let filterGender = $state('all');
|
||||||
|
let filterArc = $state('all');
|
||||||
|
let filterHaki = $state<'all' | 'observation' | 'armament' | 'conqueror' | 'none'>('all');
|
||||||
|
let isEditModalOpen = $state(false);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let saveMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
let dailyModeToast = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
let selectedChar = $state<any>(null);
|
||||||
|
|
||||||
|
const showDailyModeToast = (type: 'success' | 'error', text: string) => {
|
||||||
|
dailyModeToast = { type, text };
|
||||||
|
setTimeout(() => {
|
||||||
|
dailyModeToast = null;
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
if (target.files && target.files.length > 0) {
|
||||||
|
// Clear pictureUrl when a file is selected
|
||||||
|
editForm.pictureUrl = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let editForm = $state<any>({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
gender: '',
|
||||||
|
age: null,
|
||||||
|
bounty: 0,
|
||||||
|
height: 0,
|
||||||
|
origin: '',
|
||||||
|
affiliation: '',
|
||||||
|
epithets: '',
|
||||||
|
pictureUrl: '',
|
||||||
|
url: '',
|
||||||
|
devilFruitId: null,
|
||||||
|
hakiObservation: false,
|
||||||
|
hakiArmament: false,
|
||||||
|
hakiConqueror: false,
|
||||||
|
firstAppearance: '',
|
||||||
|
arcId: null,
|
||||||
|
status: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredCharacters = $derived.by(() => {
|
||||||
|
return data.characters.filter((char) => {
|
||||||
|
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||||
|
|
||||||
|
const matchesSearch =
|
||||||
|
normalizedQuery === '' ||
|
||||||
|
char.name.toLowerCase().includes(normalizedQuery);
|
||||||
|
const matchesDaily =
|
||||||
|
filterDaily === 'all' ||
|
||||||
|
(filterDaily === 'daily' && char.isInDailyMode) ||
|
||||||
|
(filterDaily === 'not-daily' && !char.isInDailyMode);
|
||||||
|
const matchesStatus = filterStatus === 'all' || (char.status || '') === filterStatus;
|
||||||
|
const matchesGender = filterGender === 'all' || (char.gender || '') === filterGender;
|
||||||
|
const matchesArc =
|
||||||
|
filterArc === 'all' ||
|
||||||
|
String(char.arcId ?? '') === filterArc;
|
||||||
|
const matchesHaki =
|
||||||
|
filterHaki === 'all' ||
|
||||||
|
(filterHaki === 'observation' && !!char.hakiObservation) ||
|
||||||
|
(filterHaki === 'armament' && !!char.hakiArmament) ||
|
||||||
|
(filterHaki === 'conqueror' && !!char.hakiConqueror) ||
|
||||||
|
(filterHaki === 'none' && !char.hakiObservation && !char.hakiArmament && !char.hakiConqueror);
|
||||||
|
|
||||||
|
return matchesSearch && matchesDaily && matchesStatus && matchesGender && matchesArc && matchesHaki;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFieldOverridden = (char: any, field: string) => {
|
||||||
|
return char.override && char.override[field] !== null && char.override[field] !== undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (char: any) => {
|
||||||
|
selectedChar = char;
|
||||||
|
|
||||||
|
const override = char.override || {};
|
||||||
|
|
||||||
|
editForm = {
|
||||||
|
id: char.id,
|
||||||
|
name: override.name ?? '',
|
||||||
|
gender: override.gender ?? '',
|
||||||
|
age: override.age ?? null,
|
||||||
|
bounty: override.bounty ?? null,
|
||||||
|
height: override.height ?? null,
|
||||||
|
origin: override.origin ?? '',
|
||||||
|
affiliation: override.affiliation ?? '',
|
||||||
|
epithets: override.epithets ?? '',
|
||||||
|
pictureUrl: override.pictureUrl ?? '',
|
||||||
|
url: override.url ?? '',
|
||||||
|
devilFruitId: override.devilFruitId !== null && override.devilFruitId !== undefined ? override.devilFruitId : (char.devilFruitId || ''),
|
||||||
|
hakiObservation: override.hakiObservation ?? char.hakiObservation,
|
||||||
|
hakiArmament: override.hakiArmament ?? char.hakiArmament,
|
||||||
|
hakiConqueror: override.hakiConqueror ?? char.hakiConqueror,
|
||||||
|
firstAppearance: override.firstAppearance ?? '',
|
||||||
|
arcId: override.arcId !== null && override.arcId !== undefined ? override.arcId : (char.arcId || ''),
|
||||||
|
status: override.status ?? ''
|
||||||
|
};
|
||||||
|
isEditModalOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isEditModalOpen = false;
|
||||||
|
selectedChar = null;
|
||||||
|
editForm = {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
gender: '',
|
||||||
|
age: null,
|
||||||
|
bounty: 0,
|
||||||
|
height: 0,
|
||||||
|
origin: '',
|
||||||
|
affiliation: '',
|
||||||
|
epithets: '',
|
||||||
|
pictureUrl: '',
|
||||||
|
url: '',
|
||||||
|
devilFruitId: null,
|
||||||
|
hakiObservation: false,
|
||||||
|
hakiArmament: false,
|
||||||
|
hakiConqueror: false,
|
||||||
|
firstAppearance: '',
|
||||||
|
arcId: null,
|
||||||
|
status: ''
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCharacter = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this character?')) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('?/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
saveMessage = {
|
||||||
|
type: 'error',
|
||||||
|
text: error.error || 'Failed to delete character'
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting character:', error);
|
||||||
|
saveMessage = {
|
||||||
|
type: 'error',
|
||||||
|
text: 'Error deleting character'
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Characters - Admin - OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-3xl font-bold text-white">Character Management</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
+ Add Character
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-col gap-4 rounded-lg border border-white/10 bg-slate-800/50 p-4 md:flex-row md:items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search characters..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
bind:value={filterStatus}
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
{#each data.availableStatuses as status (status)}
|
||||||
|
<option value={status}>{status}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
bind:value={filterGender}
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
>
|
||||||
|
<option value="all">All Genders</option>
|
||||||
|
{#each data.availableGenders as gender (gender)}
|
||||||
|
<option value={gender}>{gender}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
bind:value={filterArc}
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
>
|
||||||
|
<option value="all">All Arcs</option>
|
||||||
|
{#each data.arcs as arc (arc.id)}
|
||||||
|
<option value={arc.id}>{arc.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
bind:value={filterHaki}
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
>
|
||||||
|
<option value="all">All Haki</option>
|
||||||
|
<option value="observation">Observation</option>
|
||||||
|
<option value="armament">Armament</option>
|
||||||
|
<option value="conqueror">Conqueror</option>
|
||||||
|
<option value="none">No Haki</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
bind:value={filterDaily}
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
>
|
||||||
|
<option value="all">All Characters</option>
|
||||||
|
<option value="daily">In Daily Mode</option>
|
||||||
|
<option value="not-daily">Not in Daily Mode</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Characters Table -->
|
||||||
|
<div class="rounded-lg border border-white/10">
|
||||||
|
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||||
|
<tr class="border-b border-white/10">
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300 w-64">Character</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Status</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Gender</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Affiliations</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Fruit</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Haki</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Bounty</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Height</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Origin</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Arc</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Daily Mode</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredCharacters as char (char.id)}
|
||||||
|
<tr class="border-b border-white/5 hover:bg-slate-800/50">
|
||||||
|
<!-- Character -->
|
||||||
|
<td class="px-4 py-4 text-sm text-white w-64 max-w-64">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
{#if char.url}
|
||||||
|
<a
|
||||||
|
href={"https://onepiece.fandom.com/wiki/" + char.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="shrink-0 transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
{#if char.pictureUrl}
|
||||||
|
<img
|
||||||
|
src={char.pictureUrl}
|
||||||
|
alt={char.name}
|
||||||
|
loading="lazy"
|
||||||
|
class="h-10 w-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-slate-700 text-gray-400">
|
||||||
|
{char.name?.charAt(0).toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
{#if char.pictureUrl}
|
||||||
|
<img
|
||||||
|
src={char.pictureUrl}
|
||||||
|
alt={char.name}
|
||||||
|
loading="lazy"
|
||||||
|
class="h-10 w-10 shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-slate-700 text-gray-400">
|
||||||
|
{char.name?.charAt(0).toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
{#if char.url}
|
||||||
|
<a
|
||||||
|
href="https://onepiece.fandom.com/wiki/{char.url}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="font-medium truncate text-white hover:text-amber-200 hover:underline"
|
||||||
|
>
|
||||||
|
{char.name}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="font-medium truncate">{char.name}</span>
|
||||||
|
{/if}
|
||||||
|
{#if char.epithets}
|
||||||
|
<span class="text-xs text-gray-500 truncate">
|
||||||
|
{Array.isArray(char.epithets)
|
||||||
|
? char.epithets.join(', ')
|
||||||
|
: char.epithets}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<!-- Status -->
|
||||||
|
<td class="px-4 py-4 text-sm text-gray-400">{char.status || '-'}</td>
|
||||||
|
<!-- Gender -->
|
||||||
|
<td class="px-4 py-4 text-sm text-gray-400">{char.gender || '-'}</td>
|
||||||
|
<!-- Affiliations -->
|
||||||
|
<td class="px-4 py-4 text-sm text-gray-400">
|
||||||
|
{#if char.affiliation}
|
||||||
|
{char.affiliation}
|
||||||
|
{:else}
|
||||||
|
-
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<!-- Fruit -->
|
||||||
|
<td class="px-4 py-4 text-sm text-gray-400">{char.devilFruitName || '-'}</td>
|
||||||
|
<!-- Haki -->
|
||||||
|
<td class="px-4 py-4 text-sm">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{#if char.hakiObservation}<span title="Haki de l'Observation">👁️</span>{/if}
|
||||||
|
{#if char.hakiArmament}<span title="Haki de l'Armement">🦾</span>{/if}
|
||||||
|
{#if char.hakiConqueror}<span title="Haki des Rois">👑</span>{/if}
|
||||||
|
{#if !char.hakiObservation && !char.hakiArmament && !char.hakiConqueror}
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<!-- Bounty -->
|
||||||
|
<td class="px-4 py-4 text-sm text-gray-400">
|
||||||
|
{#if char.bounty != null}
|
||||||
|
{formatBounty(char.bounty)} ฿
|
||||||
|
{:else}
|
||||||
|
-
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<!-- Height -->
|
||||||
|
<td class="px-4 py-4 text-sm text-gray-400">
|
||||||
|
{#if char.height}
|
||||||
|
{char.height} m
|
||||||
|
{:else}
|
||||||
|
-
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<!-- Origin -->
|
||||||
|
<td class="px-4 py-4 text-sm text-gray-400">{char.origin || '-'}</td>
|
||||||
|
<!-- Arc -->
|
||||||
|
<td class="px-4 py-4 text-sm text-gray-400">{char.arcName || '-'}</td>
|
||||||
|
<!-- Daily Mode -->
|
||||||
|
<td class="px-4 py-4 text-sm">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/toggleDailyMode"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
await update();
|
||||||
|
showDailyModeToast('success', 'Daily mode updated successfully!');
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
showDailyModeToast('error', (result.data as any)?.error || 'Failed to update daily mode');
|
||||||
|
} else {
|
||||||
|
showDailyModeToast('error', 'Failed to update daily mode');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={char.id} />
|
||||||
|
<input type="hidden" name="isInDailyMode" value={(!char.isInDailyMode).toString()} />
|
||||||
|
<label class="flex items-center justify-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={char.isInDailyMode}
|
||||||
|
onchange={(e) => {
|
||||||
|
const form = e.currentTarget.closest('form');
|
||||||
|
if (form) form.requestSubmit();
|
||||||
|
}}
|
||||||
|
class="w-5 h-5 rounded border-gray-600 bg-slate-700 text-green-500 focus:ring-2 focus:ring-green-500 focus:ring-offset-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<!-- Actions -->
|
||||||
|
<td class="px-4 py-4 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => openEditModal(char)}
|
||||||
|
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
title="Edit character"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDeleteCharacter(char.id)}
|
||||||
|
class="text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
title="Delete character"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if filteredCharacters.length === 0}
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||||
|
<p class="text-gray-400">No characters found</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if dailyModeToast}
|
||||||
|
<div class="fixed right-6 top-6 z-60">
|
||||||
|
<div
|
||||||
|
class={`rounded-lg border px-4 py-3 text-sm font-medium shadow-lg backdrop-blur ${
|
||||||
|
dailyModeToast.type === 'success'
|
||||||
|
? 'border-green-500/30 bg-green-900/20 text-green-200'
|
||||||
|
: 'border-red-500/30 bg-red-900/20 text-red-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{dailyModeToast.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
{#if isEditModalOpen}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||||
|
<div class="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-lg border border-white/10 bg-slate-900 p-6">
|
||||||
|
<h3 class="text-lg font-bold text-white">Edit Character</h3>
|
||||||
|
<form
|
||||||
|
class="mt-6 space-y-4"
|
||||||
|
method="POST"
|
||||||
|
action="?/update"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
use:enhance={() => {
|
||||||
|
isSaving = true;
|
||||||
|
return async ({ result }) => {
|
||||||
|
isSaving = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
saveMessage = { type: 'success', text: 'Character saved successfully!' };
|
||||||
|
setTimeout(() => {
|
||||||
|
location.reload();
|
||||||
|
}, 1000);
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
saveMessage = { type: 'error', text: (result.data as any)?.error || 'Failed to save character' };
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={editForm.id} />
|
||||||
|
|
||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h4 class="text-sm font-semibold text-amber-500">Basic Information</h4>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div>
|
||||||
|
<label for="char-name" class="block text-sm font-medium text-gray-300 mb-2">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="char-name"
|
||||||
|
name="name"
|
||||||
|
bind:value={editForm.name}
|
||||||
|
placeholder={selectedChar?.name || ''}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gender and Age -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label for="char-gender" class="block text-sm font-medium text-gray-300 mb-2">Gender</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="char-gender"
|
||||||
|
name="gender"
|
||||||
|
bind:value={editForm.gender}
|
||||||
|
placeholder={selectedChar?.gender || ''}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="char-age" class="block text-sm font-medium text-gray-300 mb-2">Age</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="char-age"
|
||||||
|
name="age"
|
||||||
|
bind:value={editForm.age}
|
||||||
|
placeholder={selectedChar?.age?.toString() || ''}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div>
|
||||||
|
<label for="char-status" class="block text-sm font-medium text-gray-300 mb-2">Status</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="char-status"
|
||||||
|
name="status"
|
||||||
|
bind:value={editForm.status}
|
||||||
|
placeholder={selectedChar?.status || ''}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Physical Attributes -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h4 class="text-sm font-semibold text-amber-500">Physical Attributes</h4>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label for="char-bounty" class="block text-sm font-medium text-gray-300 mb-2">Bounty</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="char-bounty"
|
||||||
|
name="bounty"
|
||||||
|
bind:value={editForm.bounty}
|
||||||
|
placeholder={selectedChar?.bounty?.toString() || ''}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="char-height" class="block text-sm font-medium text-gray-300 mb-2">Height (cm)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="char-height"
|
||||||
|
name="height"
|
||||||
|
bind:value={editForm.height}
|
||||||
|
placeholder={selectedChar?.height?.toString() || ''}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location & Affiliations -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h4 class="text-sm font-semibold text-amber-500">Location & Affiliations</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="char-origin" class="block text-sm font-medium text-gray-300 mb-2">Origin</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="char-origin"
|
||||||
|
name="origin"
|
||||||
|
bind:value={editForm.origin}
|
||||||
|
placeholder={selectedChar?.origin || ''}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="char-affiliations" class="block text-sm font-medium text-gray-300 mb-2">Affiliations</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="char-affiliations"
|
||||||
|
name="affiliations"
|
||||||
|
bind:value={editForm.affiliations}
|
||||||
|
placeholder={selectedChar?.affiliations || ''}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="char-arc" class="block text-sm font-medium text-gray-300 mb-2">Arc</label>
|
||||||
|
<select
|
||||||
|
id="char-arc"
|
||||||
|
name="arcId"
|
||||||
|
bind:value={editForm.arcId}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{#each data.arcs as arc (arc.id)}
|
||||||
|
<option value={arc.id}>{arc.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if selectedChar?.arcName}
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Original: {selectedChar.arcName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Powers -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h4 class="text-sm font-semibold text-amber-500">Powers</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="char-fruit" class="block text-sm font-medium text-gray-300 mb-2">Devil Fruit</label>
|
||||||
|
<select
|
||||||
|
id="char-fruit"
|
||||||
|
name="devilFruitId"
|
||||||
|
bind:value={editForm.devilFruitId}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{#each data.devilFruits as fruit (fruit.id)}
|
||||||
|
<option value={fruit.id}>{fruit.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if selectedChar?.devilFruitName}
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Original: {selectedChar.devilFruitName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium text-gray-300">Haki</p>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="hakiObservation"
|
||||||
|
bind:checked={editForm.hakiObservation}
|
||||||
|
class="rounded bg-slate-700"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-300">Observation Haki</span>
|
||||||
|
{#if selectedChar?.hakiObservation !== undefined}
|
||||||
|
<span class="text-xs text-gray-500">(Original: {selectedChar.hakiObservation ? 'Yes' : 'No'})</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="hakiArmament"
|
||||||
|
bind:checked={editForm.hakiArmament}
|
||||||
|
class="rounded bg-slate-700"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-300">Armament Haki</span>
|
||||||
|
{#if selectedChar?.hakiArmament !== undefined}
|
||||||
|
<span class="text-xs text-gray-500">(Original: {selectedChar.hakiArmament ? 'Yes' : 'No'})</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="hakiConqueror"
|
||||||
|
bind:checked={editForm.hakiConqueror}
|
||||||
|
class="rounded bg-slate-700"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-300">Conqueror's Haki</span>
|
||||||
|
{#if selectedChar?.hakiConqueror !== undefined}
|
||||||
|
<span class="text-xs text-gray-500">(Original: {selectedChar.hakiConqueror ? 'Yes' : 'No'})</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h4 class="text-sm font-semibold text-amber-500">Timeline</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="char-first-appearance" class="block text-sm font-medium text-gray-300 mb-2">First Appearance</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="char-first-appearance"
|
||||||
|
name="firstAppearance"
|
||||||
|
bind:value={editForm.firstAppearance}
|
||||||
|
placeholder={selectedChar?.firstAppearance || ''}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h4 class="text-sm font-semibold text-amber-500">Media & Details</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="char-epithets" class="block text-sm font-medium text-gray-300 mb-2">Epithets</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="char-epithets"
|
||||||
|
name="epithets"
|
||||||
|
bind:value={editForm.epithets}
|
||||||
|
placeholder={selectedChar?.epithets || ''}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="char-picture-url" class="block text-sm font-medium text-gray-300 mb-2">Picture URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="char-picture-url"
|
||||||
|
name="pictureUrl"
|
||||||
|
bind:value={editForm.pictureUrl}
|
||||||
|
placeholder={selectedChar?.pictureUrl || ''}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="char-picture-file" class="block text-sm font-medium text-gray-300 mb-2">Or Upload Picture</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="char-picture-file"
|
||||||
|
name="pictureFile"
|
||||||
|
accept="image/*"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-amber-600 file:text-white hover:file:bg-amber-700"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">File will be saved as {selectedChar?.id}.jpg/png/etc</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="char-url" class="block text-sm font-medium text-gray-300 mb-2">Fandom URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="char-url"
|
||||||
|
name="url"
|
||||||
|
bind:value={editForm.url}
|
||||||
|
placeholder={selectedChar?.url || ''}
|
||||||
|
class="w-full rounded-lg border border-gray-500 bg-slate-800 px-3 py-2 text-white placeholder:text-gray-400/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeModal}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg border border-gray-500 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{#if saveMessage}
|
||||||
|
<div
|
||||||
|
class={`mt-4 rounded-lg p-3 text-sm font-medium ${
|
||||||
|
saveMessage.type === 'success'
|
||||||
|
? 'border border-green-500/50 bg-green-500/10 text-green-300'
|
||||||
|
: 'border border-red-500/50 bg-red-500/10 text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saveMessage.text}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
62
src/routes/(admin)/admin/config/+page.server.ts
Normal file
62
src/routes/(admin)/admin/config/+page.server.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { config } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const configEntries = await db.select().from(config);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: configEntries
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
update: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const key = formData.get('key') as string;
|
||||||
|
const value = formData.get('value') as string;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return fail(400, { error: 'Config key is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.insert(config)
|
||||||
|
.values({ key, value })
|
||||||
|
.onConflictDoUpdate({ target: config.key, set: { value } });
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Config update error:', error);
|
||||||
|
return fail(500, { error: 'Failed to update configuration' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const key = formData.get('key') as string;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return fail(400, { error: 'Config key is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(config).where(eq(config.key, key));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Config delete error:', error);
|
||||||
|
return fail(500, { error: 'Failed to delete configuration' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
266
src/routes/(admin)/admin/config/+page.svelte
Normal file
266
src/routes/(admin)/admin/config/+page.svelte
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigItem {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let configItems = $derived(data.config.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
value: item.value ?? ''
|
||||||
|
})));
|
||||||
|
let newKey = $state('');
|
||||||
|
let newValue = $state('');
|
||||||
|
let editingKey = $state<string | null>(null);
|
||||||
|
let editingValue = $state('');
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let saveMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
const startEdit = (item: ConfigItem) => {
|
||||||
|
editingKey = item.key;
|
||||||
|
editingValue = item.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
editingKey = null;
|
||||||
|
editingValue = '';
|
||||||
|
saveMessage = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNew = async () => {
|
||||||
|
if (!newKey || !newValue) {
|
||||||
|
saveMessage = { type: 'error', text: 'Both key and value are required' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configItems.some((item) => item.key === newKey)) {
|
||||||
|
saveMessage = { type: 'error', text: 'A config with this key already exists' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('key', newKey);
|
||||||
|
formData.append('value', newValue);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('?/update', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
configItems = [...configItems, { key: newKey, value: newValue }];
|
||||||
|
newKey = '';
|
||||||
|
newValue = '';
|
||||||
|
saveMessage = { type: 'success', text: 'Config added successfully' };
|
||||||
|
} else {
|
||||||
|
saveMessage = { type: 'error', text: 'Failed to add config' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding config:', error);
|
||||||
|
saveMessage = { type: 'error', text: 'Error adding config' };
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (key: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete "${key}"?`)) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('key', key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('?/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
configItems = configItems.filter((item) => item.key !== key);
|
||||||
|
saveMessage = { type: 'success', text: 'Config deleted successfully' };
|
||||||
|
} else {
|
||||||
|
saveMessage = { type: 'error', text: 'Failed to delete config' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting config:', error);
|
||||||
|
saveMessage = { type: 'error', text: 'Error deleting config' };
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Settings - Admin - OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<h2 class="text-3xl font-bold text-white">Configuration</h2>
|
||||||
|
|
||||||
|
<!-- Add New Config -->
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-800/50 p-6">
|
||||||
|
<h3 class="mb-4 text-lg font-semibold text-white">Add New Configuration</h3>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Key name"
|
||||||
|
bind:value={newKey}
|
||||||
|
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Value"
|
||||||
|
bind:value={newValue}
|
||||||
|
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={handleAddNew}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="rounded-lg bg-amber-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Table -->
|
||||||
|
<div class="rounded-lg border border-white/10">
|
||||||
|
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||||
|
<tr class="border-b border-white/10">
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Key</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Value</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each configItems as item (item.key)}
|
||||||
|
{#if editingKey === item.key}
|
||||||
|
<tr class="border-b border-white/5 bg-slate-800/50">
|
||||||
|
<td class="px-6 py-4 text-sm text-white">{item.key}</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editingValue}
|
||||||
|
class="w-full rounded-lg bg-slate-700 px-3 py-1 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/update"
|
||||||
|
use:enhance={() => {
|
||||||
|
isSaving = true;
|
||||||
|
return async ({ result }) => {
|
||||||
|
isSaving = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
const idx = configItems.findIndex((i) => i.key === item.key);
|
||||||
|
if (idx !== -1) {
|
||||||
|
configItems[idx].value = editingValue;
|
||||||
|
}
|
||||||
|
editingKey = null;
|
||||||
|
saveMessage = { type: 'success', text: 'Config updated' };
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
saveMessage = { type: 'error', text: (result.data?.error as string) || 'Failed to update' };
|
||||||
|
} else {
|
||||||
|
saveMessage = { type: 'error', text: 'Failed to update' };
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="key" value={item.key} />
|
||||||
|
<input type="hidden" name="value" value={editingValue} />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
class="rounded bg-green-600 px-3 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="rounded bg-gray-600 px-3 py-1 text-xs font-medium text-white hover:bg-gray-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr class="border-b border-white/5 hover:bg-slate-800/50">
|
||||||
|
<td class="px-6 py-4 text-sm font-medium text-white">{item.key}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-400">
|
||||||
|
<code class="rounded bg-slate-800/50 px-2 py-1">{item.value}</code>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => startEdit(item)}
|
||||||
|
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
title="Edit config"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(item.key)}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||||
|
title="Delete config"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table> </div> </div>
|
||||||
|
|
||||||
|
{#if configItems.length === 0}
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||||
|
<p class="text-gray-400">No configuration entries yet</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Save Message -->
|
||||||
|
{#if saveMessage}
|
||||||
|
<div
|
||||||
|
class={`rounded-lg p-4 text-sm font-medium ${
|
||||||
|
saveMessage.type === 'success'
|
||||||
|
? 'border border-green-500/50 bg-green-500/10 text-green-300'
|
||||||
|
: 'border border-red-500/50 bg-red-500/10 text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saveMessage.text}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
65
src/routes/(admin)/admin/devil-fruits/+page.server.ts
Normal file
65
src/routes/(admin)/admin/devil-fruits/+page.server.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { devilFruit } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const devilFruits = await db.select().from(devilFruit).orderBy(devilFruit.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
devilFruits
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
update: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'Devil Fruit ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: Record<string, any> = {};
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
if (key !== 'id') {
|
||||||
|
updates[key] = value || null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.update(devilFruit).set(updates).where(eq(devilFruit.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Devil Fruit update error:', error);
|
||||||
|
return fail(500, { error: 'Failed to update devil fruit' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'Devil Fruit ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(devilFruit).where(eq(devilFruit.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Devil Fruit delete error:', error);
|
||||||
|
return fail(500, { error: 'Failed to delete devil fruit' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
282
src/routes/(admin)/admin/devil-fruits/+page.svelte
Normal file
282
src/routes/(admin)/admin/devil-fruits/+page.svelte
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let filterType = $state<'all' | 'Paramecia' | 'Zoan' | 'Logia' | 'Unknown'>('all');
|
||||||
|
let isEditModalOpen = $state(false);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let saveMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
const fruitTypes = ['Paramecia', 'Zoan', 'Logia', 'Unknown'] as const;
|
||||||
|
|
||||||
|
let editForm = $state<any>({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
type: 'Paramecia',
|
||||||
|
url: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredFruits = $derived.by(() => {
|
||||||
|
return data.devilFruits.filter((fruit) => {
|
||||||
|
const matchesSearch = fruit.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesFilter = filterType === 'all' || fruit.type === filterType;
|
||||||
|
return matchesSearch && matchesFilter;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const openEditModal = (fruit: any) => {
|
||||||
|
editForm = { ...fruit };
|
||||||
|
isEditModalOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isEditModalOpen = false;
|
||||||
|
editForm = {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
type: 'Paramecia',
|
||||||
|
url: ''
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'Paramecia':
|
||||||
|
return 'bg-blue-500/20 text-blue-300';
|
||||||
|
case 'Zoan':
|
||||||
|
return 'bg-green-500/20 text-green-300';
|
||||||
|
case 'Logia':
|
||||||
|
return 'bg-red-500/20 text-red-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500/20 text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFruit = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this devil fruit?')) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('?/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
saveMessage = {
|
||||||
|
type: 'error',
|
||||||
|
text: error.error || 'Failed to delete devil fruit'
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting devil fruit:', error);
|
||||||
|
saveMessage = {
|
||||||
|
type: 'error',
|
||||||
|
text: 'Error deleting devil fruit'
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Devil Fruits - Admin - OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-3xl font-bold text-white">Devil Fruit Management</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
+ Add Devil Fruit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-col gap-4 rounded-lg border border-white/10 bg-slate-800/50 p-4 md:flex-row md:items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search devil fruits..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
bind:value={filterType}
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
>
|
||||||
|
<option value="all">All Types</option>
|
||||||
|
<option value="Paramecia">Paramecia</option>
|
||||||
|
<option value="Zoan">Zoan</option>
|
||||||
|
<option value="Logia">Logia</option>
|
||||||
|
<option value="Unknown">Unknown</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Devil Fruits Table -->
|
||||||
|
<div class="rounded-lg border border-white/10">
|
||||||
|
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||||
|
<tr class="border-b border-white/10">
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Name</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Type</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredFruits as fruit (fruit.id)}
|
||||||
|
<tr class="border-b border-white/5 hover:bg-slate-800/50">
|
||||||
|
<td class="px-6 py-4 text-sm text-white">{fruit.name}</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<span class={`inline-block rounded-full px-2 py-1 text-xs ${getTypeColor(fruit.type || 'Unknown')}`}>
|
||||||
|
{fruit.type || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => openEditModal(fruit)}
|
||||||
|
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
title="Edit devil fruit"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDeleteFruit(fruit.id)}
|
||||||
|
class="text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
title="Delete devil fruit"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div> </div>
|
||||||
|
{#if filteredFruits.length === 0}
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||||
|
<p class="text-gray-400">No devil fruits found</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
{#if isEditModalOpen}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div class="w-full max-w-md rounded-lg border border-white/10 bg-slate-900 p-6">
|
||||||
|
<h3 class="text-lg font-bold text-white">Edit Devil Fruit</h3>
|
||||||
|
<form
|
||||||
|
class="mt-6 space-y-4"
|
||||||
|
method="POST"
|
||||||
|
action="?/update"
|
||||||
|
use:enhance={() => {
|
||||||
|
isSaving = true;
|
||||||
|
return async ({ result }) => {
|
||||||
|
isSaving = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
saveMessage = { type: 'success', text: 'Devil Fruit saved successfully!' };
|
||||||
|
setTimeout(() => {
|
||||||
|
location.reload();
|
||||||
|
}, 1000);
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
saveMessage = { type: 'error', text: (result.data as any)?.error || 'Failed to save devil fruit' };
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
saveMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={editForm.id} />
|
||||||
|
<div>
|
||||||
|
<label for="fruit-name" class="block text-sm font-medium text-gray-300">Name</label>
|
||||||
|
<input
|
||||||
|
id="fruit-name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
bind:value={editForm.name}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="fruit-type" class="block text-sm font-medium text-gray-300">Type</label>
|
||||||
|
<select
|
||||||
|
id="fruit-type"
|
||||||
|
name="type"
|
||||||
|
bind:value={editForm.type}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
>
|
||||||
|
{#each fruitTypes as type (type)}
|
||||||
|
<option value={type}>{type}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="fruit-url" class="block text-sm font-medium text-gray-300">URL</label>
|
||||||
|
<input
|
||||||
|
id="fruit-url"
|
||||||
|
type="text"
|
||||||
|
name="url"
|
||||||
|
bind:value={editForm.url}
|
||||||
|
placeholder="https://..."
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeModal}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg border border-gray-500 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{#if saveMessage}
|
||||||
|
<div
|
||||||
|
class={`mt-4 rounded-lg p-3 text-sm font-medium ${
|
||||||
|
saveMessage.type === 'success'
|
||||||
|
? 'border border-green-500/50 bg-green-500/10 text-green-300'
|
||||||
|
: 'border border-red-500/50 bg-red-500/10 text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saveMessage.text}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
67
src/routes/(admin)/admin/users/+page.server.ts
Normal file
67
src/routes/(admin)/admin/users/+page.server.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const users = await db.select().from(user).orderBy(user.createdAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: users.map((u) => ({
|
||||||
|
...u,
|
||||||
|
createdAt: new Date(u.createdAt).toLocaleDateString(),
|
||||||
|
updatedAt: new Date(u.updatedAt).toLocaleDateString()
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
update: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'User ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: Record<string, any> = {
|
||||||
|
name: formData.get('name') as string,
|
||||||
|
email: formData.get('email') as string,
|
||||||
|
isAdmin: formData.has('isAdmin'),
|
||||||
|
emailVerified: formData.has('emailVerified')
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.update(user).set(updates).where(eq(user.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('User update error:', error);
|
||||||
|
return fail(500, { error: 'Failed to update user' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.isAdmin) {
|
||||||
|
return fail(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { error: 'User ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(user).where(eq(user.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('User delete error:', error);
|
||||||
|
return fail(500, { error: 'Failed to delete user' });
|
||||||
|
}
|
||||||
|
}};
|
||||||
275
src/routes/(admin)/admin/users/+page.svelte
Normal file
275
src/routes/(admin)/admin/users/+page.svelte
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let filterRole = $state<'all' | 'admin' | 'user'>('all');
|
||||||
|
let isEditModalOpen = $state(false);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let saveMessage = $state<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
|
||||||
|
let editForm = $state<any>({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
isAdmin: false,
|
||||||
|
emailVerified: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredUsers = $derived.by(() => {
|
||||||
|
return data.users.filter((usr) => {
|
||||||
|
const matchesSearch =
|
||||||
|
usr.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
usr.email.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesFilter =
|
||||||
|
filterRole === 'all' || (filterRole === 'admin' && usr.isAdmin) || (filterRole === 'user' && !usr.isAdmin);
|
||||||
|
return matchesSearch && matchesFilter;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const openEditModal = (usr: any) => {
|
||||||
|
editForm = { ...usr };
|
||||||
|
isEditModalOpen = true;
|
||||||
|
saveMessage = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isEditModalOpen = false;
|
||||||
|
editForm = {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
isAdmin: false,
|
||||||
|
emailVerified: false
|
||||||
|
};
|
||||||
|
saveMessage = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('id', id);
|
||||||
|
|
||||||
|
const response = await fetch('?/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete user');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error deleting user: ' + error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Users - Admin - OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-3xl font-bold text-white">User Management</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-col gap-4 rounded-lg border border-white/10 bg-slate-800/50 p-4 md:flex-row md:items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search users by name or email..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="flex-1 rounded-lg bg-slate-700 px-4 py-2 text-sm text-white placeholder-gray-400 outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
bind:value={filterRole}
|
||||||
|
class="rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
>
|
||||||
|
<option value="all">All Roles</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="user">User</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div class="rounded-lg border border-white/10">
|
||||||
|
<div class="max-h-[calc(100vh-20rem)] overflow-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="sticky top-0 bg-slate-800 z-10">
|
||||||
|
<tr class="border-b border-white/10">
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Name</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Email</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Role</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Verified</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Joined</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-300">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredUsers as usr (usr.id)}
|
||||||
|
<tr class="border-b border-white/5 hover:bg-slate-800/50">
|
||||||
|
<td class="px-6 py-4 text-sm text-white">{usr.name}</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-400">{usr.email}</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
{#if usr.isAdmin}
|
||||||
|
<span class="inline-block rounded-full bg-amber-500/20 px-2 py-1 text-xs text-amber-300">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="inline-block rounded-full bg-blue-500/20 px-2 py-1 text-xs text-blue-300">
|
||||||
|
User
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
{#if usr.emailVerified}
|
||||||
|
<span class="inline-block rounded-full bg-green-500/20 px-2 py-1 text-xs text-green-300">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="inline-block rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-300">
|
||||||
|
✗
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-400">{usr.createdAt}</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => openEditModal(usr)}
|
||||||
|
class="text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
title="Edit user"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDeleteUser(usr.id)}
|
||||||
|
class="text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
title="Delete user"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if filteredUsers.length === 0}
|
||||||
|
<div class="rounded-lg border border-white/10 bg-slate-800/50 py-12 text-center">
|
||||||
|
<p class="text-gray-400">No users found</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
{#if isEditModalOpen}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div class="w-full max-w-md rounded-lg border border-white/10 bg-slate-900 p-6">
|
||||||
|
<h3 class="text-lg font-bold text-white">Edit User</h3>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/update"
|
||||||
|
class="mt-6 space-y-4"
|
||||||
|
use:enhance={() => {
|
||||||
|
isSaving = true;
|
||||||
|
saveMessage = null;
|
||||||
|
return async ({ result }) => {
|
||||||
|
isSaving = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
saveMessage = { type: 'success', message: 'User updated successfully' };
|
||||||
|
setTimeout(() => {
|
||||||
|
closeModal();
|
||||||
|
window.location.reload();
|
||||||
|
}, 500);
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
saveMessage = { type: 'error', message: String(result.data?.error) || 'Failed to update user' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={editForm.id} />
|
||||||
|
<div>
|
||||||
|
<label for="user-name" class="block text-sm font-medium text-gray-300">Name</label>
|
||||||
|
<input
|
||||||
|
id="user-name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
bind:value={editForm.name}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="user-email" class="block text-sm font-medium text-gray-300">Email</label>
|
||||||
|
<input
|
||||||
|
id="user-email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
bind:value={editForm.email}
|
||||||
|
class="mt-1 w-full rounded-lg bg-slate-700 px-4 py-2 text-sm text-white outline-none transition focus:ring-2 focus:ring-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="admin-role"
|
||||||
|
name="isAdmin"
|
||||||
|
bind:checked={editForm.isAdmin}
|
||||||
|
class="rounded bg-slate-700"
|
||||||
|
/>
|
||||||
|
<label for="admin-role" class="text-sm font-medium text-gray-300">Admin Role</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="verified"
|
||||||
|
name="emailVerified"
|
||||||
|
bind:checked={editForm.emailVerified}
|
||||||
|
class="rounded bg-slate-700"
|
||||||
|
/>
|
||||||
|
<label for="verified" class="text-sm font-medium text-gray-300">Email Verified</label>
|
||||||
|
</div>
|
||||||
|
{#if saveMessage}
|
||||||
|
<div class={`rounded-lg p-3 text-sm ${
|
||||||
|
saveMessage.type === 'success'
|
||||||
|
? 'bg-green-500/10 text-green-400'
|
||||||
|
: 'bg-red-500/10 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{saveMessage.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeModal}
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg border border-gray-500 px-4 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
class="flex-1 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
24
src/routes/(game)/+layout.svelte
Normal file
24
src/routes/(game)/+layout.svelte
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProfileButton from '$lib/components/ProfileButton.svelte';
|
||||||
|
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
let { children, data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-slate-950">
|
||||||
|
<header class="fixed top-0 right-0 left-0 z-50 border-b border-white/5 bg-slate-950/95 backdrop-blur">
|
||||||
|
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||||
|
<a href={resolve("/")} class="text-lg font-black uppercase tracking-[0.15em] text-amber-50 transition hover:text-amber-100">
|
||||||
|
OnePieceDle
|
||||||
|
</a>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<ProfileButton user={data.user} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="pt-20">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
161
src/routes/(game)/+page.svelte
Normal file
161
src/routes/(game)/+page.svelte
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { language, t } from '$lib/i18n';
|
||||||
|
import type { CharacterWithRelations } from '$lib/server/daily-character';
|
||||||
|
|
||||||
|
$: yesterdayCharacter = data.yesterdayCharacter;
|
||||||
|
$: isFrench = $language === 'fr';
|
||||||
|
|
||||||
|
function parseEpithets(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (value.length > 0) {
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(character: CharacterWithRelations | null): string {
|
||||||
|
if (isFrench && typeof character?.frName === 'string' && character.frName.length > 0) {
|
||||||
|
return character.frName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character?.name || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayEpithets(character: CharacterWithRelations | null): string[] {
|
||||||
|
const frenchEpithets = parseEpithets(character?.frEpithets);
|
||||||
|
if (isFrench && frenchEpithets.length > 0) {
|
||||||
|
return frenchEpithets;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseEpithets(character?.epithets);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWikiUrl(character: CharacterWithRelations | null): string {
|
||||||
|
if (isFrench && typeof character?.frUrl === 'string' && character.frUrl.length > 0) {
|
||||||
|
return character.frUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character?.url || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main
|
||||||
|
class="relative min-h-[calc(100vh-5rem)] bg-slate-950 text-slate-100"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-linear-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
|
||||||
|
<div class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"></div>
|
||||||
|
|
||||||
|
<div class="relative mx-auto flex w-full max-w-6xl flex-col items-center justify-center px-6 py-10">
|
||||||
|
<div class="flex w-full flex-col items-center gap-8">
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h1 class="text-4xl font-black uppercase tracking-[0.3em] text-amber-50 sm:text-6xl">
|
||||||
|
OnePieceDle
|
||||||
|
</h1>
|
||||||
|
<p class="mt-4 max-w-2xl text-base text-slate-200 sm:text-lg">
|
||||||
|
{$t.game.home.heroDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid w-full gap-4 sm:grid-cols-2">
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">{$t.game.home.dailyTitle}</h2>
|
||||||
|
<p class="mt-3 text-lg font-semibold text-white">{$t.game.home.dailySubtitle}</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-200">{$t.game.home.dailyDescription}</p>
|
||||||
|
<a
|
||||||
|
href={resolve("/daily")}
|
||||||
|
class="mt-5 inline-flex w-full items-center justify-center rounded-full bg-amber-300 px-5 py-3 text-sm font-semibold text-slate-900 transition hover:bg-amber-200"
|
||||||
|
>
|
||||||
|
{$t.game.home.dailyCta}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">{$t.game.home.infiniteTitle}</h2>
|
||||||
|
<p class="mt-3 text-lg font-semibold text-white">{$t.game.home.infiniteSubtitle}</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-200">{$t.game.home.infiniteDescription}</p>
|
||||||
|
<a
|
||||||
|
href={resolve("/infinite")}
|
||||||
|
class="mt-5 inline-flex w-full items-center justify-center rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50"
|
||||||
|
>
|
||||||
|
{$t.game.home.infiniteCta}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">
|
||||||
|
{#if yesterdayCharacter}
|
||||||
|
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
|
||||||
|
{#if yesterdayCharacter.pictureUrl}
|
||||||
|
<img
|
||||||
|
src={yesterdayCharacter.pictureUrl}
|
||||||
|
alt={getDisplayName(yesterdayCharacter)}
|
||||||
|
class="h-20 w-20 rounded-full border border-amber-200/40 object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
|
||||||
|
{$t.game.home.photoFallback}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">{$t.game.home.yesterdayCharacter}</p>
|
||||||
|
<p class="mt-2 text-lg font-semibold text-white">{getDisplayName(yesterdayCharacter)}</p>
|
||||||
|
{#if getDisplayEpithets(yesterdayCharacter).length > 0}
|
||||||
|
<p class="mt-1 text-sm text-slate-400">
|
||||||
|
{getDisplayEpithets(yesterdayCharacter).join(', ')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isFrench}
|
||||||
|
<a
|
||||||
|
href="https://onepiece.fandom.com/fr/wiki/{getWikiUrl(yesterdayCharacter)}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50 sm:w-auto"
|
||||||
|
>
|
||||||
|
{$t.game.home.openPage}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href="https://onepiece.fandom.com/wiki/{getWikiUrl(yesterdayCharacter)}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50 sm:w-auto"
|
||||||
|
>
|
||||||
|
{$t.game.home.openPage}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
|
||||||
|
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
|
||||||
|
{$t.game.home.photoFallback}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">{$t.game.home.yesterdayCharacter}</p>
|
||||||
|
<p class="mt-2 text-lg font-semibold text-white">{$t.game.home.noCharacter}</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-200">{$t.game.home.noYesterdayCharacter}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
131
src/routes/(game)/daily/+page.server.ts
Normal file
131
src/routes/(game)/daily/+page.server.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { character, characterHistory, config, friendship, user, userCharacterHistory } from '$lib/server/db/schema';
|
||||||
|
import { getDailyModeCharacters, getOrCreateTodayCharacter, getYesterdayCharacter, getTodayCharacterWinsCount, getDateKey } from '$lib/server/daily-character';
|
||||||
|
import { and, eq, inArray, like, or } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function load(event) {
|
||||||
|
const characters = await getDailyModeCharacters();
|
||||||
|
const dailyCharacter = await getOrCreateTodayCharacter(characters);
|
||||||
|
|
||||||
|
if (!dailyCharacter) {
|
||||||
|
throw error(404, 'No daily character available. Please check if characters are configured in daily mode.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const yesterdayCharacter = await getYesterdayCharacter(new Date(), characters);
|
||||||
|
|
||||||
|
// Load the win count for today
|
||||||
|
const winCount = await getTodayCharacterWinsCount(dailyCharacter.id);
|
||||||
|
|
||||||
|
let friendsTodayResults: Array<{
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
image: string | null;
|
||||||
|
tryCount: number;
|
||||||
|
triedCharacters: Array<{ id: string; name: string; pictureUrl: string | null }>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (event.locals.user) {
|
||||||
|
const currentUserId = event.locals.user.id;
|
||||||
|
|
||||||
|
const acceptedFriendships = await db
|
||||||
|
.select({
|
||||||
|
requesterId: friendship.requesterId,
|
||||||
|
addresseeId: friendship.addresseeId
|
||||||
|
})
|
||||||
|
.from(friendship)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(friendship.status, 'accepted'),
|
||||||
|
or(eq(friendship.requesterId, currentUserId), eq(friendship.addresseeId, currentUserId))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const friendIds = acceptedFriendships.map((relation) =>
|
||||||
|
relation.requesterId === currentUserId ? relation.addresseeId : relation.requesterId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (friendIds.length > 0) {
|
||||||
|
const todayDate = getDateKey(new Date());
|
||||||
|
|
||||||
|
const [todayHistoryEntry] = await db
|
||||||
|
.select({ id: characterHistory.id })
|
||||||
|
.from(characterHistory)
|
||||||
|
.where(eq(characterHistory.date, todayDate))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const todayCharacterHistoryId = todayHistoryEntry?.id;
|
||||||
|
|
||||||
|
if (todayCharacterHistoryId) {
|
||||||
|
const friendResultsRaw = await db
|
||||||
|
.select({
|
||||||
|
userId: user.id,
|
||||||
|
name: user.name,
|
||||||
|
image: user.image,
|
||||||
|
tryCount: userCharacterHistory.tryCount,
|
||||||
|
triedCharacterIds: userCharacterHistory.triedCharacterIds
|
||||||
|
})
|
||||||
|
.from(userCharacterHistory)
|
||||||
|
.innerJoin(user, eq(userCharacterHistory.userId, user.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userCharacterHistory.characterHistoryId, todayCharacterHistoryId),
|
||||||
|
inArray(userCharacterHistory.userId, friendIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(userCharacterHistory.tryCount);
|
||||||
|
|
||||||
|
const uniqueTriedCharacterIds = Array.from(new Set(
|
||||||
|
friendResultsRaw.flatMap((entry) => entry.triedCharacterIds ?? [])
|
||||||
|
));
|
||||||
|
|
||||||
|
const triedCharacters = uniqueTriedCharacterIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
id: character.id,
|
||||||
|
name: character.name,
|
||||||
|
pictureUrl: character.pictureUrl
|
||||||
|
})
|
||||||
|
.from(character)
|
||||||
|
.where(inArray(character.id, uniqueTriedCharacterIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const triedCharactersById = new Map(triedCharacters.map((entry) => [entry.id, entry]));
|
||||||
|
|
||||||
|
friendsTodayResults = friendResultsRaw.map((entry) => ({
|
||||||
|
userId: entry.userId,
|
||||||
|
name: entry.name,
|
||||||
|
image: entry.image,
|
||||||
|
tryCount: entry.tryCount,
|
||||||
|
triedCharacters: (entry.triedCharacterIds ?? [])
|
||||||
|
.map((characterId) => triedCharactersById.get(characterId))
|
||||||
|
.filter((triedEntry): triedEntry is (typeof triedCharacters)[number] => !!triedEntry)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load column visibility config
|
||||||
|
const columnConfig = await db
|
||||||
|
.select()
|
||||||
|
.from(config)
|
||||||
|
.where(like(config.key, 'characterHistory.column.%.visible'));
|
||||||
|
|
||||||
|
// Convert to object for easier access
|
||||||
|
const columnVisibility: Record<string, boolean> = {};
|
||||||
|
columnConfig.forEach(row => {
|
||||||
|
const match = row.key.match(/characterHistory\.column\.(.+)\.visible/);
|
||||||
|
if (match) {
|
||||||
|
columnVisibility[match[1]] = row.value === 'true';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
characters,
|
||||||
|
dailyCharacter,
|
||||||
|
yesterdayCharacter,
|
||||||
|
columnVisibility,
|
||||||
|
winCount,
|
||||||
|
friendsTodayResults
|
||||||
|
};
|
||||||
|
}
|
||||||
331
src/routes/(game)/daily/+page.svelte
Normal file
331
src/routes/(game)/daily/+page.svelte
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import YesterdayCharacter from '$lib/components/YesterdayCharacter.svelte';
|
||||||
|
import HintsPanel from '$lib/components/HintsPanel.svelte';
|
||||||
|
import CharacterSearchInput from '$lib/components/CharacterSearchInput.svelte';
|
||||||
|
import GuessHistoryTable from '$lib/components/GuessHistoryTable.svelte';
|
||||||
|
import WinPanel from '$lib/components/WinPanel.svelte';
|
||||||
|
import FriendsTodaySection from '$lib/components/FriendsTodaySection.svelte';
|
||||||
|
import type { CharacterWithRelations } from '$lib/server/daily-character.js';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
let selectedCharacters: CharacterWithRelations[] = [];
|
||||||
|
let isLoaded = false;
|
||||||
|
let isGeckoMoriaWin = false;
|
||||||
|
|
||||||
|
let showOriginUnlock = false;
|
||||||
|
let showFruitUnlock = false;
|
||||||
|
let showAffiliationUnlock = false;
|
||||||
|
let originUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let fruitUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let affiliationUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function clearUnlockTimeout(timeout: ReturnType<typeof setTimeout> | null) {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pulseUnlock(type: 'origin' | 'fruit' | 'affiliation') {
|
||||||
|
if (type === 'origin') {
|
||||||
|
clearUnlockTimeout(originUnlockTimeout);
|
||||||
|
showOriginUnlock = true;
|
||||||
|
originUnlockTimeout = setTimeout(() => {
|
||||||
|
showOriginUnlock = false;
|
||||||
|
originUnlockTimeout = null;
|
||||||
|
}, 600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'fruit') {
|
||||||
|
clearUnlockTimeout(fruitUnlockTimeout);
|
||||||
|
showFruitUnlock = true;
|
||||||
|
fruitUnlockTimeout = setTimeout(() => {
|
||||||
|
showFruitUnlock = false;
|
||||||
|
fruitUnlockTimeout = null;
|
||||||
|
}, 600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUnlockTimeout(affiliationUnlockTimeout);
|
||||||
|
showAffiliationUnlock = true;
|
||||||
|
affiliationUnlockTimeout = setTimeout(() => {
|
||||||
|
showAffiliationUnlock = false;
|
||||||
|
affiliationUnlockTimeout = null;
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHintAvailability(previousGuessCount: number, nextGuessCount: number, animateUnlocks = false) {
|
||||||
|
const nextOriginAvailable = nextGuessCount >= 5;
|
||||||
|
const nextFruitAvailable = nextGuessCount >= 10;
|
||||||
|
const nextAffiliationAvailable = nextGuessCount >= 15;
|
||||||
|
|
||||||
|
if (animateUnlocks && nextOriginAvailable && previousGuessCount < 5) {
|
||||||
|
pulseUnlock('origin');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animateUnlocks && nextFruitAvailable && previousGuessCount < 10) {
|
||||||
|
pulseUnlock('fruit');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animateUnlocks && nextAffiliationAvailable && previousGuessCount < 15) {
|
||||||
|
pulseUnlock('affiliation');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextOriginAvailable) {
|
||||||
|
showOriginUnlock = false;
|
||||||
|
clearUnlockTimeout(originUnlockTimeout);
|
||||||
|
originUnlockTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextFruitAvailable) {
|
||||||
|
showFruitUnlock = false;
|
||||||
|
clearUnlockTimeout(fruitUnlockTimeout);
|
||||||
|
fruitUnlockTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextAffiliationAvailable) {
|
||||||
|
showAffiliationUnlock = false;
|
||||||
|
clearUnlockTimeout(affiliationUnlockTimeout);
|
||||||
|
affiliationUnlockTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from localStorage on mount
|
||||||
|
onMount(() => {
|
||||||
|
const storedDailyCharacterId = localStorage.getItem('dailyCurrentCharacterId');
|
||||||
|
const dailyCurrentCharacterId = dailyCharacter?.id;
|
||||||
|
|
||||||
|
// If the daily character has changed, clear the history
|
||||||
|
if (storedDailyCharacterId && storedDailyCharacterId !== dailyCurrentCharacterId) {
|
||||||
|
localStorage.removeItem('dailyCharacterHistory');
|
||||||
|
selectedCharacters = [];
|
||||||
|
} else {
|
||||||
|
// Load existing history if the character hasn't changed
|
||||||
|
const stored = localStorage.getItem('dailyCharacterHistory');
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const storedIds = JSON.parse(stored);
|
||||||
|
// Reconstruct character objects from IDs
|
||||||
|
if (Array.isArray(storedIds)) {
|
||||||
|
selectedCharacters = storedIds
|
||||||
|
.map((id: string) => data.characters.find((c: CharacterWithRelations) => c.id === id))
|
||||||
|
.filter((c: CharacterWithRelations | undefined): c is CharacterWithRelations => !!c);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse stored history', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the current daily character ID
|
||||||
|
if (dailyCurrentCharacterId) {
|
||||||
|
localStorage.setItem('dailyCurrentCharacterId', dailyCurrentCharacterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncHintAvailability(0, selectedCharacters.length);
|
||||||
|
|
||||||
|
isLoaded = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
clearUnlockTimeout(originUnlockTimeout);
|
||||||
|
clearUnlockTimeout(fruitUnlockTimeout);
|
||||||
|
clearUnlockTimeout(affiliationUnlockTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save to localStorage whenever selectedCharacters changes (only store IDs)
|
||||||
|
$: if (isLoaded && selectedCharacters) {
|
||||||
|
const ids = selectedCharacters.map(char => char.id);
|
||||||
|
localStorage.setItem('dailyCharacterHistory', JSON.stringify(ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
$: characters = data.characters || [];
|
||||||
|
$: dailyCharacter = data.dailyCharacter;
|
||||||
|
$: yesterdayCharacter = data.yesterdayCharacter;
|
||||||
|
$: columnVisibility = data.columnVisibility || {};
|
||||||
|
$: hasWon = selectedCharacters.some(char => char.id === dailyCharacter.id);
|
||||||
|
|
||||||
|
function handleCharacterSelect(character: CharacterWithRelations) {
|
||||||
|
selectCharacter(character);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCharacter(character: CharacterWithRelations) {
|
||||||
|
const previousGuessCount = selectedCharacters.length;
|
||||||
|
selectedCharacters = [character, ...selectedCharacters];
|
||||||
|
syncHintAvailability(previousGuessCount, selectedCharacters.length, isLoaded);
|
||||||
|
|
||||||
|
// Check if player won
|
||||||
|
if (character.id === dailyCharacter.id) {
|
||||||
|
const triedCharacterIds = selectedCharacters.map(selected => selected.id);
|
||||||
|
|
||||||
|
// Send request to record win in database
|
||||||
|
fetch('/daily', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
characterId: dailyCharacter.id,
|
||||||
|
tryCount: selectedCharacters.length,
|
||||||
|
triedCharacterIds
|
||||||
|
})
|
||||||
|
}).catch(err => console.error('Failed to record win:', err));
|
||||||
|
|
||||||
|
// Check if it's gecko_moria for special animation
|
||||||
|
if (dailyCharacter.id === 'gecko_moria_gecko_moria') {
|
||||||
|
isGeckoMoriaWin = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHistory() {
|
||||||
|
const previousGuessCount = selectedCharacters.length;
|
||||||
|
selectedCharacters = [];
|
||||||
|
syncHintAvailability(previousGuessCount, 0);
|
||||||
|
localStorage.removeItem('dailyCharacterHistory');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t.game.daily.metaTitle}</title>
|
||||||
|
<style>
|
||||||
|
@keyframes shadow-pulse {
|
||||||
|
0% {
|
||||||
|
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
text-shadow: 0 0 60px rgba(0, 0, 0, 0.9), 0 0 100px rgba(30, 30, 30, 1), inset 0 0 50px rgba(0, 0, 0, 0.7);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes moria-chaos {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg) scale(1);
|
||||||
|
filter: invert(0%) hue-rotate(0deg) blur(0px);
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
transform: rotate(15deg) scale(1.02);
|
||||||
|
filter: invert(30%) hue-rotate(45deg) blur(2px);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
transform: rotate(-10deg) scale(0.98);
|
||||||
|
filter: invert(60%) hue-rotate(90deg) blur(1px);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: rotate(25deg) scale(1.05);
|
||||||
|
filter: invert(100%) hue-rotate(180deg) blur(3px);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: rotate(-20deg) scale(0.95);
|
||||||
|
filter: invert(80%) hue-rotate(270deg) blur(2px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(30deg) scale(1.08);
|
||||||
|
filter: invert(100%) hue-rotate(0deg) blur(4px);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: rotate(-25deg) scale(0.92);
|
||||||
|
filter: invert(70%) hue-rotate(90deg) blur(2px);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: rotate(20deg) scale(1.03);
|
||||||
|
filter: invert(50%) hue-rotate(180deg) blur(3px);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: rotate(-15deg) scale(1.01);
|
||||||
|
filter: invert(80%) hue-rotate(270deg) blur(1px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg) scale(1);
|
||||||
|
filter: invert(0%) hue-rotate(360deg) blur(0px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.gecko-moria-effect {
|
||||||
|
animation: shadow-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.moria-screen-chaos {
|
||||||
|
animation: moria-chaos 4s ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main
|
||||||
|
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100 {isGeckoMoriaWin ? 'moria-screen-chaos' : ''}"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-linear-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
|
||||||
|
<div class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"></div>
|
||||||
|
|
||||||
|
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-8 sm:py-10">
|
||||||
|
<header class="flex flex-col items-start gap-6 w-full">
|
||||||
|
<div class="flex w-full items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl">
|
||||||
|
{$t.game.daily.title}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-amber-300">
|
||||||
|
{data.winCount} {data.winCount > 1 ? $t.game.daily.winsPeoplePlural : $t.game.daily.winsPeopleSingular} {data.winCount > 1 ? $t.game.daily.winsVerbPlural : $t.game.daily.winsVerbSingular} {$t.game.daily.winsSuffix}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if hasWon}
|
||||||
|
<button
|
||||||
|
class="rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50"
|
||||||
|
onclick={resetHistory}
|
||||||
|
>
|
||||||
|
{$t.game.daily.reset}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="max-w-2xl text-base text-slate-200 sm:text-lg">
|
||||||
|
{$t.game.daily.description}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="mt-10 grid gap-6">
|
||||||
|
{#if selectedCharacters.length > 0 && !hasWon}
|
||||||
|
<HintsPanel
|
||||||
|
{dailyCharacter}
|
||||||
|
{selectedCharacters}
|
||||||
|
{showOriginUnlock}
|
||||||
|
{showFruitUnlock}
|
||||||
|
{showAffiliationUnlock}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasWon}
|
||||||
|
<WinPanel
|
||||||
|
selectedCharacter={dailyCharacter}
|
||||||
|
{selectedCharacters}
|
||||||
|
{isGeckoMoriaWin}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<CharacterSearchInput
|
||||||
|
{characters}
|
||||||
|
{selectedCharacters}
|
||||||
|
onSelect={handleCharacterSelect}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
{#if hasWon && data.friendsTodayResults && data.friendsTodayResults.length > 0}
|
||||||
|
<FriendsTodaySection friendsTodayResults={data.friendsTodayResults} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<GuessHistoryTable
|
||||||
|
{selectedCharacters}
|
||||||
|
{dailyCharacter}
|
||||||
|
{columnVisibility}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<YesterdayCharacter {yesterdayCharacter} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
77
src/routes/(game)/daily/+server.ts
Normal file
77
src/routes/(game)/daily/+server.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { characterHistory, userCharacterHistory } from '$lib/server/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { getDateKey } from '$lib/server/daily-character';
|
||||||
|
|
||||||
|
export async function POST({ request, locals }) {
|
||||||
|
try {
|
||||||
|
const { characterId, tryCount, triedCharacterIds } = await request.json();
|
||||||
|
const normalizedTriedCharacterIds = Array.isArray(triedCharacterIds)
|
||||||
|
? triedCharacterIds.filter((id): id is string => typeof id === 'string')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!characterId) {
|
||||||
|
return json({ error: 'Missing characterId' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayDate = getDateKey(new Date());
|
||||||
|
|
||||||
|
// If user is logged in, check if they already played today
|
||||||
|
if (locals.user) {
|
||||||
|
// Get the characterHistoryId for today
|
||||||
|
const [todayHistoryEntry] = await db
|
||||||
|
.select({ id: characterHistory.id })
|
||||||
|
.from(characterHistory)
|
||||||
|
.where(eq(characterHistory.date, todayDate));
|
||||||
|
|
||||||
|
if (todayHistoryEntry) {
|
||||||
|
// Check if user already has a record for today
|
||||||
|
const [existingRecord] = await db
|
||||||
|
.select()
|
||||||
|
.from(userCharacterHistory)
|
||||||
|
.where(and(
|
||||||
|
eq(userCharacterHistory.userId, locals.user.id),
|
||||||
|
eq(userCharacterHistory.characterHistoryId, todayHistoryEntry.id)
|
||||||
|
));
|
||||||
|
|
||||||
|
// If user already played today, don't record again
|
||||||
|
if (existingRecord) {
|
||||||
|
return json({ success: false, message: 'Already played today' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment the won counter for today's entry
|
||||||
|
await db
|
||||||
|
.update(characterHistory)
|
||||||
|
.set({
|
||||||
|
won: sql`${characterHistory.won} + 1`,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
.where(eq(characterHistory.date, todayDate));
|
||||||
|
|
||||||
|
// Insert into userCharacterHistory
|
||||||
|
await db.insert(userCharacterHistory).values({
|
||||||
|
userId: locals.user.id,
|
||||||
|
characterHistoryId: todayHistoryEntry.id,
|
||||||
|
tryCount: tryCount,
|
||||||
|
triedCharacterIds: normalizedTriedCharacterIds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If user is not logged in, always increment counter
|
||||||
|
await db
|
||||||
|
.update(characterHistory)
|
||||||
|
.set({
|
||||||
|
won: sql`${characterHistory.won} + 1`,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
.where(eq(characterHistory.date, todayDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error recording win:', error);
|
||||||
|
return json({ error: 'Failed to record win' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,10 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { config } from '$lib/server/db/schema';
|
import { config } from '$lib/server/db/schema';
|
||||||
import { getDailyModeCharacters, getOrCreateTodayCharacter, getYesterdayCharacter } from '$lib/server/daily-character';
|
import { getAllCharacters } from '$lib/server/daily-character';
|
||||||
import { like } from 'drizzle-orm';
|
import { like } from 'drizzle-orm';
|
||||||
|
|
||||||
export async function load() {
|
export async function load() {
|
||||||
const characters = await getDailyModeCharacters();
|
const characters = await getAllCharacters();
|
||||||
const dailyCharacter = await getOrCreateTodayCharacter(characters);
|
|
||||||
|
|
||||||
if (!dailyCharacter) {
|
|
||||||
throw error(404, 'No daily character available. Please check if characters are configured in daily mode.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const yesterdayCharacter = await getYesterdayCharacter(new Date(), characters);
|
|
||||||
|
|
||||||
// Load column visibility config
|
// Load column visibility config
|
||||||
const columnConfig = await db
|
const columnConfig = await db
|
||||||
@@ -31,8 +23,6 @@ export async function load() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
characters,
|
characters,
|
||||||
dailyCharacter,
|
|
||||||
yesterdayCharacter,
|
|
||||||
columnVisibility
|
columnVisibility
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
874
src/routes/(game)/infinite/+page.svelte
Normal file
874
src/routes/(game)/infinite/+page.svelte
Normal file
@@ -0,0 +1,874 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import CharacterSearchInput from '$lib/components/CharacterSearchInput.svelte';
|
||||||
|
import GuessHistoryTable from '$lib/components/GuessHistoryTable.svelte';
|
||||||
|
import WinPanel from '$lib/components/WinPanel.svelte';
|
||||||
|
import HintsPanel from '$lib/components/HintsPanel.svelte';
|
||||||
|
import type { CharacterWithRelations } from '$lib/server/daily-character.js';
|
||||||
|
import { language, t } from '$lib/i18n';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
let selectedCharacters: CharacterWithRelations[] = [];
|
||||||
|
let currentCharacter: CharacterWithRelations | null = null;
|
||||||
|
let isLoaded = false;
|
||||||
|
let score = 0;
|
||||||
|
type ArcFilterOption = { id: string; name: string };
|
||||||
|
let allCharacters: CharacterWithRelations[] = [];
|
||||||
|
let characters: CharacterWithRelations[] = [];
|
||||||
|
let availableArcs: ArcFilterOption[] = [];
|
||||||
|
let hasWon = false;
|
||||||
|
let columnVisibility: Record<string, boolean> = {};
|
||||||
|
let columnDisplayNames: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Character filters
|
||||||
|
let characterFilters = {
|
||||||
|
gender: [] as string[],
|
||||||
|
hasHaki: false,
|
||||||
|
hasDevilFruit: null as boolean | null, // null = all, true = with fruit, false = without fruit
|
||||||
|
status: [] as string[],
|
||||||
|
hasHeight: false,
|
||||||
|
hasAge: false,
|
||||||
|
hasOrigin: false,
|
||||||
|
arcs: [] as string[]
|
||||||
|
};
|
||||||
|
|
||||||
|
let showOriginUnlock = false;
|
||||||
|
let showFruitUnlock = false;
|
||||||
|
let showAffiliationUnlock = false;
|
||||||
|
let isGeckoMoriaWin = false;
|
||||||
|
let originUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let fruitUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let affiliationUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function clearUnlockTimeout(timeout: ReturnType<typeof setTimeout> | null) {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pulseUnlock(type: 'origin' | 'fruit' | 'affiliation') {
|
||||||
|
if (type === 'origin') {
|
||||||
|
clearUnlockTimeout(originUnlockTimeout);
|
||||||
|
showOriginUnlock = true;
|
||||||
|
originUnlockTimeout = setTimeout(() => {
|
||||||
|
showOriginUnlock = false;
|
||||||
|
originUnlockTimeout = null;
|
||||||
|
}, 600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'fruit') {
|
||||||
|
clearUnlockTimeout(fruitUnlockTimeout);
|
||||||
|
showFruitUnlock = true;
|
||||||
|
fruitUnlockTimeout = setTimeout(() => {
|
||||||
|
showFruitUnlock = false;
|
||||||
|
fruitUnlockTimeout = null;
|
||||||
|
}, 600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUnlockTimeout(affiliationUnlockTimeout);
|
||||||
|
showAffiliationUnlock = true;
|
||||||
|
affiliationUnlockTimeout = setTimeout(() => {
|
||||||
|
showAffiliationUnlock = false;
|
||||||
|
affiliationUnlockTimeout = null;
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHintAvailability(previousGuessCount: number, nextGuessCount: number, animateUnlocks = false) {
|
||||||
|
const nextOriginAvailable = nextGuessCount >= 5;
|
||||||
|
const nextFruitAvailable = nextGuessCount >= 10;
|
||||||
|
const nextAffiliationAvailable = nextGuessCount >= 15;
|
||||||
|
|
||||||
|
if (animateUnlocks && nextOriginAvailable && previousGuessCount < 5) {
|
||||||
|
pulseUnlock('origin');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animateUnlocks && nextFruitAvailable && previousGuessCount < 10) {
|
||||||
|
pulseUnlock('fruit');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animateUnlocks && nextAffiliationAvailable && previousGuessCount < 15) {
|
||||||
|
pulseUnlock('affiliation');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextOriginAvailable) {
|
||||||
|
showOriginUnlock = false;
|
||||||
|
clearUnlockTimeout(originUnlockTimeout);
|
||||||
|
originUnlockTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextFruitAvailable) {
|
||||||
|
showFruitUnlock = false;
|
||||||
|
clearUnlockTimeout(fruitUnlockTimeout);
|
||||||
|
fruitUnlockTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextAffiliationAvailable) {
|
||||||
|
showAffiliationUnlock = false;
|
||||||
|
clearUnlockTimeout(affiliationUnlockTimeout);
|
||||||
|
affiliationUnlockTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from localStorage on mount
|
||||||
|
onMount(() => {
|
||||||
|
const storedScore = localStorage.getItem('infiniteScore');
|
||||||
|
if (storedScore) {
|
||||||
|
score = parseInt(storedScore, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load column visibility from localStorage, fallback to server defaults
|
||||||
|
const storedColumnVisibility = localStorage.getItem('infiniteColumnVisibility');
|
||||||
|
if (storedColumnVisibility) {
|
||||||
|
try {
|
||||||
|
columnVisibility = JSON.parse(storedColumnVisibility);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse column visibility', e);
|
||||||
|
columnVisibility = data.columnVisibility || {};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
columnVisibility = data.columnVisibility || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load character filters from localStorage
|
||||||
|
const storedFilters = localStorage.getItem('infiniteCharacterFilters');
|
||||||
|
if (storedFilters) {
|
||||||
|
try {
|
||||||
|
characterFilters = JSON.parse(storedFilters);
|
||||||
|
// Ensure all filter properties exist
|
||||||
|
if (!characterFilters.arcs) {
|
||||||
|
characterFilters.arcs = [];
|
||||||
|
}
|
||||||
|
if (typeof characterFilters.hasAge !== 'boolean') {
|
||||||
|
characterFilters.hasAge = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse filters', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load current character ID and history IDs from localStorage
|
||||||
|
const storedCharacterId = localStorage.getItem('infiniteCurrentCharacterId');
|
||||||
|
const storedHistoryIds = localStorage.getItem('infiniteSelectedCharacterIds');
|
||||||
|
|
||||||
|
if (storedCharacterId && storedHistoryIds && characters.length > 0) {
|
||||||
|
try {
|
||||||
|
const charId = JSON.parse(storedCharacterId);
|
||||||
|
const historyIds = JSON.parse(storedHistoryIds);
|
||||||
|
|
||||||
|
// Find the character object by ID
|
||||||
|
currentCharacter = characters.find((c: CharacterWithRelations) => c.id === charId) || null;
|
||||||
|
|
||||||
|
// Find all character objects by their IDs
|
||||||
|
selectedCharacters = historyIds
|
||||||
|
.map((id: string) => characters.find((c: CharacterWithRelations) => c.id === id))
|
||||||
|
.filter((c: CharacterWithRelations | undefined) => !!c) as CharacterWithRelations[];
|
||||||
|
|
||||||
|
// If character not found, generate a new one
|
||||||
|
if (!currentCharacter) {
|
||||||
|
generateNewCharacter();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse character data', e);
|
||||||
|
// If parsing fails, generate a new character
|
||||||
|
generateNewCharacter();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
generateNewCharacter();
|
||||||
|
}
|
||||||
|
|
||||||
|
syncHintAvailability(0, selectedCharacters.length);
|
||||||
|
isLoaded = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
clearUnlockTimeout(originUnlockTimeout);
|
||||||
|
clearUnlockTimeout(fruitUnlockTimeout);
|
||||||
|
clearUnlockTimeout(affiliationUnlockTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save score to localStorage whenever it changes
|
||||||
|
$: if (isLoaded) {
|
||||||
|
localStorage.setItem('infiniteScore', score.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save column visibility to localStorage whenever it changes
|
||||||
|
$: if (isLoaded) {
|
||||||
|
localStorage.setItem('infiniteColumnVisibility', JSON.stringify(columnVisibility));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save character filters to localStorage whenever they change
|
||||||
|
$: if (isLoaded) {
|
||||||
|
localStorage.setItem('infiniteCharacterFilters', JSON.stringify(characterFilters));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current character ID to localStorage whenever it changes
|
||||||
|
$: if (isLoaded && currentCharacter) {
|
||||||
|
localStorage.setItem('infiniteCurrentCharacterId', JSON.stringify(currentCharacter.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save selected character IDs to localStorage whenever it changes
|
||||||
|
$: if (isLoaded) {
|
||||||
|
const selectedIds = selectedCharacters.map((c: CharacterWithRelations) => c.id);
|
||||||
|
localStorage.setItem('infiniteSelectedCharacterIds', JSON.stringify(selectedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
$: allCharacters = data.characters || [];
|
||||||
|
$: isFrench = $language === 'fr';
|
||||||
|
|
||||||
|
function getDisplayArcName(character: CharacterWithRelations, useFrench: boolean): string | null {
|
||||||
|
if (useFrench && typeof character.frArcName === 'string' && character.frArcName.length > 0) {
|
||||||
|
return character.frArcName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return character.arcName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract unique arcs from all characters
|
||||||
|
$: {
|
||||||
|
const useFrench = isFrench;
|
||||||
|
const arcMap = new Map<string, ArcFilterOption>(
|
||||||
|
allCharacters
|
||||||
|
.filter(
|
||||||
|
(char: CharacterWithRelations): char is CharacterWithRelations & { arcId: string } =>
|
||||||
|
typeof char.arcId === 'string' &&
|
||||||
|
char.arcId.length > 0 &&
|
||||||
|
typeof getDisplayArcName(char, useFrench) === 'string' &&
|
||||||
|
getDisplayArcName(char, useFrench)!.length > 0
|
||||||
|
)
|
||||||
|
.map((char: CharacterWithRelations & { arcId: string }) => [
|
||||||
|
char.arcId,
|
||||||
|
{ id: char.arcId, name: getDisplayArcName(char, useFrench) as string }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
availableArcs = [...arcMap.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter characters based on selected filters
|
||||||
|
$: characters = allCharacters.filter((char: CharacterWithRelations) => {
|
||||||
|
// Gender filter
|
||||||
|
if (characterFilters.gender.length > 0 && (char.gender == null || !characterFilters.gender.includes(char.gender))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Haki filter
|
||||||
|
if (characterFilters.hasHaki && !(char.hakiObservation || char.hakiArmament || char.hakiConqueror)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devil fruit filter
|
||||||
|
if (characterFilters.hasDevilFruit !== null) {
|
||||||
|
const hasDevil = char.devilFruitId !== null && char.devilFruitId !== undefined;
|
||||||
|
if (characterFilters.hasDevilFruit !== hasDevil) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if (characterFilters.status.length > 0) {
|
||||||
|
const normalizedStatus = normalizeStatus(char.status);
|
||||||
|
if (!characterFilters.status.includes(normalizedStatus)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height filter
|
||||||
|
if (characterFilters.hasHeight && (char.height === null || char.height === undefined)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age filter
|
||||||
|
if (characterFilters.hasAge && (char.age === null || char.age === undefined)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin filter
|
||||||
|
if (characterFilters.hasOrigin && (char.origin === null || char.origin === undefined || char.origin === '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arc filter
|
||||||
|
if (characterFilters.arcs.length > 0 && (char.arcId == null || !characterFilters.arcs.includes(char.arcId))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
$: {
|
||||||
|
const currentCharacterId = currentCharacter?.id;
|
||||||
|
hasWon = currentCharacterId != null && selectedCharacters.some(char => char.id === currentCharacterId);
|
||||||
|
}
|
||||||
|
$: if (hasWon && currentCharacter?.id === 'gecko_moria_gecko_moria') {
|
||||||
|
isGeckoMoriaWin = true;
|
||||||
|
} else if (!hasWon) {
|
||||||
|
isGeckoMoriaWin = false;
|
||||||
|
}
|
||||||
|
$: columnDisplayNames = {
|
||||||
|
status: $t.game.components.guessHistory.status,
|
||||||
|
gender: $t.game.components.guessHistory.gender,
|
||||||
|
affiliations: $t.game.components.guessHistory.affiliations,
|
||||||
|
devilFruitType: $t.game.components.guessHistory.fruit,
|
||||||
|
haki: $t.game.components.guessHistory.haki,
|
||||||
|
bounty: $t.game.components.guessHistory.bounty,
|
||||||
|
height: $t.game.components.guessHistory.height,
|
||||||
|
age: $t.game.components.guessHistory.age,
|
||||||
|
origin: $t.game.components.guessHistory.origin,
|
||||||
|
arc: $t.game.components.guessHistory.arc
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateNewCharacter() {
|
||||||
|
if (characters.length === 0) return;
|
||||||
|
currentCharacter = characters[Math.floor(Math.random() * characters.length)];
|
||||||
|
syncHintAvailability(selectedCharacters.length, 0);
|
||||||
|
selectedCharacters = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCharacterSelect(character: CharacterWithRelations) {
|
||||||
|
selectCharacter(character);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCharacter(character: CharacterWithRelations) {
|
||||||
|
const current = currentCharacter;
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousGuessCount = selectedCharacters.length;
|
||||||
|
selectedCharacters = [character, ...selectedCharacters];
|
||||||
|
syncHintAvailability(previousGuessCount, selectedCharacters.length, isLoaded);
|
||||||
|
|
||||||
|
// Check if player won
|
||||||
|
if (character.id === current.id) {
|
||||||
|
// Increment score (saved to localStorage via reactive statement)
|
||||||
|
score++;
|
||||||
|
// Don't auto-generate next character - wait for user to click "Recommencer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextCharacter() {
|
||||||
|
generateNewCharacter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetScore() {
|
||||||
|
score = 0;
|
||||||
|
selectedCharacters = [];
|
||||||
|
generateNewCharacter();
|
||||||
|
// Clear localStorage for current character and history
|
||||||
|
localStorage.removeItem('infiniteCurrentCharacterId');
|
||||||
|
localStorage.removeItem('infiniteSelectedCharacterIds');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleColumnVisibility(column: string) {
|
||||||
|
columnVisibility[column] = !columnVisibility[column];
|
||||||
|
columnVisibility = columnVisibility; // Trigger reactivity
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealAnswer() {
|
||||||
|
if (!currentCharacter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset score (strike)
|
||||||
|
score = 0;
|
||||||
|
// Add the current character as the correct answer
|
||||||
|
const previousGuessCount = selectedCharacters.length;
|
||||||
|
selectedCharacters = [currentCharacter, ...selectedCharacters];
|
||||||
|
syncHintAvailability(previousGuessCount, selectedCharacters.length, isLoaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGenderFilter(gender: string) {
|
||||||
|
if (characterFilters.gender.includes(gender)) {
|
||||||
|
characterFilters.gender = characterFilters.gender.filter(g => g !== gender);
|
||||||
|
} else {
|
||||||
|
characterFilters.gender = [...characterFilters.gender, gender];
|
||||||
|
}
|
||||||
|
// Regenerate character with new filters
|
||||||
|
if (!hasWon) {
|
||||||
|
generateNewCharacter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStatusFilter(status: string) {
|
||||||
|
if (characterFilters.status.includes(status)) {
|
||||||
|
characterFilters.status = characterFilters.status.filter(s => s !== status);
|
||||||
|
} else {
|
||||||
|
characterFilters.status = [...characterFilters.status, status];
|
||||||
|
}
|
||||||
|
// Regenerate character with new filters
|
||||||
|
if (!hasWon) {
|
||||||
|
generateNewCharacter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHakiFilter() {
|
||||||
|
characterFilters.hasHaki = !characterFilters.hasHaki;
|
||||||
|
// Regenerate character with new filters
|
||||||
|
if (!hasWon) {
|
||||||
|
generateNewCharacter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDevilFruitFilter() {
|
||||||
|
if (characterFilters.hasDevilFruit === null) {
|
||||||
|
characterFilters.hasDevilFruit = true;
|
||||||
|
} else if (characterFilters.hasDevilFruit === true) {
|
||||||
|
characterFilters.hasDevilFruit = false;
|
||||||
|
} else {
|
||||||
|
characterFilters.hasDevilFruit = null;
|
||||||
|
}
|
||||||
|
// Regenerate character with new filters
|
||||||
|
if (!hasWon) {
|
||||||
|
generateNewCharacter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHeightFilter() {
|
||||||
|
characterFilters.hasHeight = !characterFilters.hasHeight;
|
||||||
|
// Regenerate character with new filters
|
||||||
|
if (!hasWon) {
|
||||||
|
generateNewCharacter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAgeFilter() {
|
||||||
|
characterFilters.hasAge = !characterFilters.hasAge;
|
||||||
|
if (!hasWon) {
|
||||||
|
generateNewCharacter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOriginFilter() {
|
||||||
|
characterFilters.hasOrigin = !characterFilters.hasOrigin;
|
||||||
|
// Regenerate character with new filters
|
||||||
|
if (!hasWon) {
|
||||||
|
generateNewCharacter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleArcFilter(arcId: string) {
|
||||||
|
if (characterFilters.arcs.includes(arcId)) {
|
||||||
|
characterFilters.arcs = characterFilters.arcs.filter(a => a !== arcId);
|
||||||
|
} else {
|
||||||
|
characterFilters.arcs = [...characterFilters.arcs, arcId];
|
||||||
|
}
|
||||||
|
// Regenerate character with new filters
|
||||||
|
if (!hasWon) {
|
||||||
|
generateNewCharacter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllFilters() {
|
||||||
|
characterFilters = {
|
||||||
|
gender: [],
|
||||||
|
hasHaki: false,
|
||||||
|
hasDevilFruit: null,
|
||||||
|
status: [],
|
||||||
|
hasHeight: false,
|
||||||
|
hasAge: false,
|
||||||
|
hasOrigin: false,
|
||||||
|
arcs: []
|
||||||
|
};
|
||||||
|
// Regenerate character with new filters
|
||||||
|
if (!hasWon) {
|
||||||
|
generateNewCharacter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(status: unknown): string {
|
||||||
|
if (status == null) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof status !== 'string') {
|
||||||
|
return String(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = status.trim();
|
||||||
|
if (value === '') {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (value.toLowerCase()) {
|
||||||
|
case 'alive':
|
||||||
|
return 'Alive';
|
||||||
|
case 'dead':
|
||||||
|
case 'deceased':
|
||||||
|
return 'Dead';
|
||||||
|
case 'unknown':
|
||||||
|
case 'inconnu':
|
||||||
|
case '-':
|
||||||
|
return 'Unknown';
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t.game.infinite.metaTitle}</title>
|
||||||
|
<style>
|
||||||
|
@keyframes shadow-pulse {
|
||||||
|
0% {
|
||||||
|
text-shadow:
|
||||||
|
0 0 20px rgba(0, 0, 0, 0.5),
|
||||||
|
0 0 40px rgba(50, 50, 50, 0.8);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
text-shadow:
|
||||||
|
0 0 60px rgba(0, 0, 0, 0.9),
|
||||||
|
0 0 100px rgba(30, 30, 30, 1),
|
||||||
|
inset 0 0 50px rgba(0, 0, 0, 0.7);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
text-shadow:
|
||||||
|
0 0 20px rgba(0, 0, 0, 0.5),
|
||||||
|
0 0 40px rgba(50, 50, 50, 0.8);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.gecko-moria-effect {
|
||||||
|
animation: shadow-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes moria-chaos {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg) scale(1);
|
||||||
|
filter: invert(0%) hue-rotate(0deg) blur(0px);
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
transform: rotate(15deg) scale(1.02);
|
||||||
|
filter: invert(30%) hue-rotate(45deg) blur(2px);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
transform: rotate(-10deg) scale(0.98);
|
||||||
|
filter: invert(60%) hue-rotate(90deg) blur(1px);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: rotate(25deg) scale(1.05);
|
||||||
|
filter: invert(100%) hue-rotate(180deg) blur(3px);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: rotate(-20deg) scale(0.95);
|
||||||
|
filter: invert(80%) hue-rotate(270deg) blur(2px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(30deg) scale(1.08);
|
||||||
|
filter: invert(100%) hue-rotate(0deg) blur(4px);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: rotate(-25deg) scale(0.92);
|
||||||
|
filter: invert(70%) hue-rotate(90deg) blur(2px);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: rotate(20deg) scale(1.03);
|
||||||
|
filter: invert(50%) hue-rotate(180deg) blur(3px);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: rotate(-15deg) scale(1.01);
|
||||||
|
filter: invert(80%) hue-rotate(270deg) blur(1px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg) scale(1);
|
||||||
|
filter: invert(0%) hue-rotate(360deg) blur(0px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.moria-screen-chaos {
|
||||||
|
animation: moria-chaos 4s ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main
|
||||||
|
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100 {isGeckoMoriaWin
|
||||||
|
? 'moria-screen-chaos'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-linear-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)] opacity-20 mix-blend-screen"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-8 sm:py-10">
|
||||||
|
<header class="flex w-full flex-col items-start gap-6">
|
||||||
|
<div class="flex w-full items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-black tracking-[0.25em] text-amber-50 uppercase sm:text-5xl">
|
||||||
|
{$t.game.infinite.title}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-2xl font-bold text-amber-300">{$t.game.infinite.score}: {score}</p>
|
||||||
|
</div>
|
||||||
|
{#if score > 0}
|
||||||
|
<button
|
||||||
|
class="rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50"
|
||||||
|
onclick={resetScore}
|
||||||
|
>
|
||||||
|
{$t.game.infinite.resetScore}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="max-w-2xl text-base text-slate-200 sm:text-lg">
|
||||||
|
{$t.game.infinite.description}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="mt-10 grid gap-6">
|
||||||
|
{#if currentCharacter}
|
||||||
|
{#if hasWon}
|
||||||
|
<div>
|
||||||
|
<WinPanel selectedCharacter={currentCharacter} {selectedCharacters} {isGeckoMoriaWin} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={nextCharacter}
|
||||||
|
class="mt-4 w-full rounded-full bg-emerald-500 px-6 py-2 text-sm font-semibold text-white transition hover:bg-emerald-600"
|
||||||
|
>
|
||||||
|
{$t.game.infinite.nextCharacter}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if selectedCharacters.length > 0}
|
||||||
|
<HintsPanel
|
||||||
|
dailyCharacter={currentCharacter}
|
||||||
|
{selectedCharacters}
|
||||||
|
{showOriginUnlock}
|
||||||
|
{showFruitUnlock}
|
||||||
|
{showAffiliationUnlock}
|
||||||
|
/>
|
||||||
|
<div class="mt-2 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={revealAnswer}
|
||||||
|
class="rounded-lg border border-red-600/40 bg-red-900/20 px-4 py-2 text-sm text-red-300 transition hover:border-red-500 hover:bg-red-900/40 hover:text-red-200"
|
||||||
|
>
|
||||||
|
{$t.game.infinite.revealAnswer}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<CharacterSearchInput
|
||||||
|
{characters}
|
||||||
|
{selectedCharacters}
|
||||||
|
onSelect={handleCharacterSelect}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur"
|
||||||
|
>
|
||||||
|
<p class="text-center text-slate-300">{$t.game.infinite.loadingCharacter}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if currentCharacter}
|
||||||
|
<GuessHistoryTable
|
||||||
|
{selectedCharacters}
|
||||||
|
dailyCharacter={currentCharacter}
|
||||||
|
{columnVisibility}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Character Filters -->
|
||||||
|
<section class="mt-6">
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-3 backdrop-blur sm:p-4">
|
||||||
|
<div class="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-xs font-semibold tracking-[0.2em] text-amber-200 uppercase">
|
||||||
|
{$t.game.infinite.filtersTitle}
|
||||||
|
</h3>
|
||||||
|
{#if characterFilters.gender.length > 0 || characterFilters.hasHaki || characterFilters.hasDevilFruit !== null || characterFilters.status.length > 0 || characterFilters.hasHeight || characterFilters.hasAge || characterFilters.hasOrigin || characterFilters.arcs.length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={clearAllFilters}
|
||||||
|
class="text-xs text-red-300 transition hover:text-red-200"
|
||||||
|
>
|
||||||
|
{$t.game.infinite.clearFilters}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Gender Filter -->
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs text-slate-400">{$t.game.infinite.filterGender}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each ['Male', 'Female'] as gender (gender)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggleGenderFilter(gender)}
|
||||||
|
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.gender.includes(
|
||||||
|
gender
|
||||||
|
)
|
||||||
|
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
|
||||||
|
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
|
||||||
|
>
|
||||||
|
{gender === 'Male' ? $t.game.infinite.male : $t.game.infinite.female}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs text-slate-400">{$t.game.infinite.filterStatus}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each ['Alive', 'Dead', 'Unknown'] as status (status)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggleStatusFilter(status)}
|
||||||
|
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.status.includes(
|
||||||
|
status
|
||||||
|
)
|
||||||
|
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
|
||||||
|
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
|
||||||
|
>
|
||||||
|
{status === 'Alive' ? $t.game.infinite.alive : status === 'Dead' ? $t.game.infinite.dead : $t.game.infinite.unknown}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Haki Filter -->
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs text-slate-400">{$t.game.infinite.filterAbilities}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggleHakiFilter}
|
||||||
|
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.hasHaki
|
||||||
|
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
|
||||||
|
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
|
||||||
|
>
|
||||||
|
{$t.game.infinite.hasHaki}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggleDevilFruitFilter}
|
||||||
|
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.hasDevilFruit ===
|
||||||
|
true
|
||||||
|
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
|
||||||
|
: characterFilters.hasDevilFruit === false
|
||||||
|
? 'border-purple-300/50 bg-purple-300/10 text-purple-100 hover:bg-purple-300/20'
|
||||||
|
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
|
||||||
|
>
|
||||||
|
{characterFilters.hasDevilFruit === null
|
||||||
|
? $t.game.infinite.fruitAll
|
||||||
|
: characterFilters.hasDevilFruit
|
||||||
|
? $t.game.infinite.withFruit
|
||||||
|
: $t.game.infinite.withoutFruit}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informations Filter -->
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs text-slate-400">{$t.game.infinite.filterInformation}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggleHeightFilter}
|
||||||
|
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.hasHeight
|
||||||
|
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
|
||||||
|
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
|
||||||
|
>
|
||||||
|
{$t.game.infinite.heightDefined}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggleAgeFilter}
|
||||||
|
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.hasAge
|
||||||
|
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
|
||||||
|
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
|
||||||
|
>
|
||||||
|
{$t.game.infinite.ageDefined}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggleOriginFilter}
|
||||||
|
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.hasOrigin
|
||||||
|
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
|
||||||
|
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
|
||||||
|
>
|
||||||
|
{$t.game.infinite.originDefined}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arc Filter -->
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs text-slate-400">{$t.game.infinite.filterArcs}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each availableArcs as arc (arc.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggleArcFilter(arc.id)}
|
||||||
|
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.arcs.includes(
|
||||||
|
arc.id
|
||||||
|
)
|
||||||
|
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
|
||||||
|
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
|
||||||
|
>
|
||||||
|
{arc.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-xs text-slate-500">
|
||||||
|
{characters.length} {characters.length > 1 ? $t.game.infinite.availableCharactersPlural : $t.game.infinite.availableCharactersSingular}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Column Visibility Toggle -->
|
||||||
|
<section class="mt-6">
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-3 backdrop-blur sm:p-4">
|
||||||
|
<div class="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-xs font-semibold tracking-[0.2em] text-amber-200 uppercase">
|
||||||
|
{$t.game.infinite.columnsTitle}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-slate-400">
|
||||||
|
{Object.values(columnVisibility).filter(Boolean).length}/{Object.keys(
|
||||||
|
columnVisibility
|
||||||
|
).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each Object.entries(columnVisibility) as [column, isVisible] (column)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggleColumnVisibility(column)}
|
||||||
|
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {isVisible
|
||||||
|
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
|
||||||
|
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
|
||||||
|
>
|
||||||
|
{columnDisplayNames[column] || column.replace(/([A-Z])/g, ' $1').trim()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes hint-unlock {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px rgba(251, 146, 60, 0.8);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:global(.hint-unlocking) {
|
||||||
|
animation: hint-unlock 0.6s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
117
src/routes/(game)/login/+page.server.ts
Normal file
117
src/routes/(game)/login/+page.server.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { auth } from '$lib/server/auth';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user } from '$lib/server/db/schema';
|
||||||
|
import { APIError } from 'better-auth/api';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (event.locals.user) {
|
||||||
|
return redirect(302, '/');
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
signInEmail: async (event) => {
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const identifier = formData.get('identifier')?.toString().trim() ?? formData.get('email')?.toString().trim() ?? '';
|
||||||
|
const password = formData.get('password')?.toString() ?? '';
|
||||||
|
|
||||||
|
if (!identifier) {
|
||||||
|
return fail(400, { message: 'Email ou nom d\'utilisateur requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = identifier;
|
||||||
|
if (!identifier.includes('@')) {
|
||||||
|
const [foundUser] = await db
|
||||||
|
.select({ email: user.email })
|
||||||
|
.from(user)
|
||||||
|
.where(sql`lower(${user.username}) = ${identifier.toLowerCase()}`)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!foundUser) {
|
||||||
|
return fail(400, { message: 'Identifiants invalides' });
|
||||||
|
}
|
||||||
|
|
||||||
|
email = foundUser.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.api.signInEmail({
|
||||||
|
body: {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
callbackURL: '/auth/verification-success'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof APIError) {
|
||||||
|
return fail(400, { message: error.message || 'Signin failed' });
|
||||||
|
}
|
||||||
|
return fail(500, { message: 'Unexpected error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(302, '/');
|
||||||
|
},
|
||||||
|
signUpEmail: async (event) => {
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const email = formData.get('email')?.toString() ?? '';
|
||||||
|
const password = formData.get('password')?.toString() ?? '';
|
||||||
|
const confirmPassword = formData.get('confirmPassword')?.toString() ?? '';
|
||||||
|
const name = formData.get('name')?.toString() ?? '';
|
||||||
|
const username = formData.get('username')?.toString().trim() ?? '';
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return fail(400, { message: 'Nom d\'utilisateur requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-zA-Z0-9._-]{3,30}$/.test(username)) {
|
||||||
|
return fail(400, {
|
||||||
|
message: "Le nom d'utilisateur doit contenir 3 à 30 caractères (lettres, chiffres, ., _, -)"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingUsername] = await db
|
||||||
|
.select({ id: user.id })
|
||||||
|
.from(user)
|
||||||
|
.where(sql`lower(${user.username}) = ${username.toLowerCase()}`)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUsername) {
|
||||||
|
return fail(400, { message: "Ce nom d'utilisateur est déjà pris" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
return fail(400, { message: 'Les mots de passe ne correspondent pas' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.api.signUpEmail({
|
||||||
|
body: {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
name,
|
||||||
|
username,
|
||||||
|
callbackURL: '/auth/verification-success'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof APIError) {
|
||||||
|
return fail(400, { message: error.message || 'Registration failed' });
|
||||||
|
}
|
||||||
|
return fail(500, { message: 'Unexpected error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(302, '/');
|
||||||
|
},
|
||||||
|
logout: async (event) => {
|
||||||
|
await auth.api.signOut({
|
||||||
|
headers: event.request.headers
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect(302, '/');
|
||||||
|
}
|
||||||
|
};
|
||||||
193
src/routes/(game)/login/+page.svelte
Normal file
193
src/routes/(game)/login/+page.svelte
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import type { ActionData } from './$types';
|
||||||
|
|
||||||
|
export let form: ActionData;
|
||||||
|
|
||||||
|
let isSignUp = false;
|
||||||
|
let name = '';
|
||||||
|
let username = '';
|
||||||
|
let email = '';
|
||||||
|
let password = '';
|
||||||
|
let confirmPassword = '';
|
||||||
|
let isLoading = false;
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
isSignUp = !isSignUp;
|
||||||
|
name = '';
|
||||||
|
username = '';
|
||||||
|
email = '';
|
||||||
|
password = '';
|
||||||
|
confirmPassword = '';
|
||||||
|
form = null;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>OnePieceDle - {isSignUp ? $t.game.login.titleSignUp : $t.game.login.titleSignIn}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="relative min-h-[calc(100vh-5rem)] bg-slate-950 text-slate-100">
|
||||||
|
<div class="absolute inset-0 bg-linear-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="relative mx-auto flex w-full max-w-2xl flex-col items-center justify-center px-6 py-10">
|
||||||
|
<div class="w-full space-y-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-4xl font-black uppercase tracking-[0.3em] text-amber-50 sm:text-5xl">
|
||||||
|
OnePieceDle
|
||||||
|
</h1>
|
||||||
|
<p class="mt-4 text-slate-300">
|
||||||
|
{isSignUp ? $t.game.login.headerSignUp : $t.game.login.headerSignIn}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Card -->
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-white/5 p-8 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={isSignUp ? '?/signUpEmail' : '?/signInEmail'}
|
||||||
|
use:enhance={() => {
|
||||||
|
isLoading = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
isLoading = false;
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<!-- Name Field (Sign Up Only) -->
|
||||||
|
{#if isSignUp}
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
{$t.game.login.nameLabel}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
placeholder={$t.game.login.namePlaceholder}
|
||||||
|
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Username Field (Sign Up Only) -->
|
||||||
|
{#if isSignUp}
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
{$t.game.login.usernameLabel}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
bind:value={username}
|
||||||
|
required
|
||||||
|
placeholder={$t.game.login.usernamePlaceholder}
|
||||||
|
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Email / Username Field -->
|
||||||
|
<div>
|
||||||
|
<label for="identifier" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
{isSignUp ? $t.game.login.identifierLabelSignUp : $t.game.login.identifierLabelSignIn}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="identifier"
|
||||||
|
type={isSignUp ? 'email' : 'text'}
|
||||||
|
name={isSignUp ? 'email' : 'identifier'}
|
||||||
|
bind:value={email}
|
||||||
|
required
|
||||||
|
placeholder={isSignUp ? $t.game.login.identifierPlaceholderSignUp : $t.game.login.identifierPlaceholderSignIn}
|
||||||
|
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Field -->
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
{$t.game.login.passwordLabel}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
bind:value={password}
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Password Field (Sign Up Only) -->
|
||||||
|
{#if isSignUp}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="confirmPassword"
|
||||||
|
class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100"
|
||||||
|
>
|
||||||
|
{$t.game.login.confirmPasswordLabel}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
{#if form?.message}
|
||||||
|
<div class="rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-3 text-sm text-red-200">
|
||||||
|
{form.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
class="w-full rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition disabled:opacity-50 hover:bg-amber-200"
|
||||||
|
>
|
||||||
|
{isLoading ? $t.game.login.loading : isSignUp ? $t.game.login.submitSignUp : $t.game.login.submitSignIn}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Toggle Sign Up / Login -->
|
||||||
|
<div class="mt-6 border-t border-white/10 pt-6">
|
||||||
|
<p class="text-center text-sm text-slate-400">
|
||||||
|
{isSignUp ? $t.game.login.togglePromptSignUp : $t.game.login.togglePromptSignIn}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={handleToggle}
|
||||||
|
class="text-amber-300 transition hover:text-amber-200"
|
||||||
|
>
|
||||||
|
{isSignUp ? $t.game.login.toggleActionSignUp : $t.game.login.toggleActionSignIn}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back to Home -->
|
||||||
|
<div class="text-center">
|
||||||
|
<a href={resolve("/")} class="text-sm text-slate-400 transition hover:text-slate-300">
|
||||||
|
← {$t.game.login.backHome}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
428
src/routes/(game)/profile/+page.server.ts
Normal file
428
src/routes/(game)/profile/+page.server.ts
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { auth } from '$lib/server/auth';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { session, userCharacterHistory, characterHistory, character, friendship, user } from '$lib/server/db/schema';
|
||||||
|
import { and, desc, eq, inArray, or, sql } from 'drizzle-orm';
|
||||||
|
import { APIError } from 'better-auth/api';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId = event.locals.user.id;
|
||||||
|
|
||||||
|
// Fetch all sessions for this user
|
||||||
|
const userSessions = await db
|
||||||
|
.select()
|
||||||
|
.from(session)
|
||||||
|
.where(eq(session.userId, event.locals.user.id));
|
||||||
|
|
||||||
|
// Fetch daily history for this user
|
||||||
|
const dailyHistoryRaw = await db
|
||||||
|
.select({
|
||||||
|
id: userCharacterHistory.id,
|
||||||
|
characterId: characterHistory.characterId,
|
||||||
|
date: characterHistory.date,
|
||||||
|
tryCount: userCharacterHistory.tryCount,
|
||||||
|
triedCharacterIds: userCharacterHistory.triedCharacterIds,
|
||||||
|
won: characterHistory.won,
|
||||||
|
characterName: character.name,
|
||||||
|
characterImage: character.pictureUrl
|
||||||
|
})
|
||||||
|
.from(userCharacterHistory)
|
||||||
|
.innerJoin(characterHistory, eq(userCharacterHistory.characterHistoryId, characterHistory.id))
|
||||||
|
.innerJoin(character, eq(characterHistory.characterId, character.id))
|
||||||
|
.where(eq(userCharacterHistory.userId, event.locals.user.id))
|
||||||
|
.orderBy(desc(characterHistory.date));
|
||||||
|
|
||||||
|
const uniqueTriedCharacterIds = Array.from(new Set(
|
||||||
|
dailyHistoryRaw.flatMap((entry) => entry.triedCharacterIds ?? [])
|
||||||
|
));
|
||||||
|
|
||||||
|
const triedCharacters = uniqueTriedCharacterIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
id: character.id,
|
||||||
|
name: character.name,
|
||||||
|
pictureUrl: character.pictureUrl
|
||||||
|
})
|
||||||
|
.from(character)
|
||||||
|
.where(inArray(character.id, uniqueTriedCharacterIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const triedCharactersById = new Map(triedCharacters.map((entry) => [entry.id, entry]));
|
||||||
|
|
||||||
|
const dailyHistory = dailyHistoryRaw.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
triedCharacters: (entry.triedCharacterIds ?? [])
|
||||||
|
.map((characterId) => triedCharactersById.get(characterId))
|
||||||
|
.filter((triedEntry): triedEntry is (typeof triedCharacters)[number] => !!triedEntry)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const incomingRequests = await db
|
||||||
|
.select({
|
||||||
|
id: friendship.id,
|
||||||
|
createdAt: friendship.createdAt,
|
||||||
|
requesterId: friendship.requesterId,
|
||||||
|
requesterName: user.name,
|
||||||
|
requesterUsername: user.username,
|
||||||
|
requesterImage: user.image
|
||||||
|
})
|
||||||
|
.from(friendship)
|
||||||
|
.innerJoin(user, eq(friendship.requesterId, user.id))
|
||||||
|
.where(and(eq(friendship.addresseeId, currentUserId), eq(friendship.status, 'pending')))
|
||||||
|
.orderBy(desc(friendship.createdAt));
|
||||||
|
|
||||||
|
const outgoingRequests = await db
|
||||||
|
.select({
|
||||||
|
id: friendship.id,
|
||||||
|
createdAt: friendship.createdAt,
|
||||||
|
addresseeId: friendship.addresseeId,
|
||||||
|
addresseeName: user.name,
|
||||||
|
addresseeUsername: user.username,
|
||||||
|
addresseeImage: user.image
|
||||||
|
})
|
||||||
|
.from(friendship)
|
||||||
|
.innerJoin(user, eq(friendship.addresseeId, user.id))
|
||||||
|
.where(and(eq(friendship.requesterId, currentUserId), eq(friendship.status, 'pending')))
|
||||||
|
.orderBy(desc(friendship.createdAt));
|
||||||
|
|
||||||
|
const acceptedAsRequester = await db
|
||||||
|
.select({
|
||||||
|
id: friendship.id,
|
||||||
|
createdAt: friendship.createdAt,
|
||||||
|
friendId: friendship.addresseeId,
|
||||||
|
friendName: user.name,
|
||||||
|
friendUsername: user.username,
|
||||||
|
friendImage: user.image
|
||||||
|
})
|
||||||
|
.from(friendship)
|
||||||
|
.innerJoin(user, eq(friendship.addresseeId, user.id))
|
||||||
|
.where(and(eq(friendship.requesterId, currentUserId), eq(friendship.status, 'accepted')));
|
||||||
|
|
||||||
|
const acceptedAsAddressee = await db
|
||||||
|
.select({
|
||||||
|
id: friendship.id,
|
||||||
|
createdAt: friendship.createdAt,
|
||||||
|
friendId: friendship.requesterId,
|
||||||
|
friendName: user.name,
|
||||||
|
friendUsername: user.username,
|
||||||
|
friendImage: user.image
|
||||||
|
})
|
||||||
|
.from(friendship)
|
||||||
|
.innerJoin(user, eq(friendship.requesterId, user.id))
|
||||||
|
.where(and(eq(friendship.addresseeId, currentUserId), eq(friendship.status, 'accepted')));
|
||||||
|
|
||||||
|
const friends = [...acceptedAsRequester, ...acceptedAsAddressee].sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: event.locals.user,
|
||||||
|
sessions: userSessions,
|
||||||
|
dailyHistory: dailyHistory,
|
||||||
|
incomingRequests,
|
||||||
|
outgoingRequests,
|
||||||
|
friends
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
updateProfile: async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const name = formData.get('name')?.toString() ?? '';
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
return fail(400, { message: 'Le nom ne peut pas être vide' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.api.updateUser({
|
||||||
|
body: {
|
||||||
|
name: name.trim()
|
||||||
|
},
|
||||||
|
headers: event.request.headers
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof APIError) {
|
||||||
|
return fail(400, { message: error.message || 'Erreur lors de la mise à jour' });
|
||||||
|
}
|
||||||
|
return fail(500, { message: 'Erreur inattendue' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
changePassword: async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const oldPassword = formData.get('oldPassword')?.toString() ?? '';
|
||||||
|
const newPassword = formData.get('newPassword')?.toString() ?? '';
|
||||||
|
const confirmPassword = formData.get('confirmPassword')?.toString() ?? '';
|
||||||
|
|
||||||
|
if (!oldPassword.trim()) {
|
||||||
|
return fail(400, { message: 'Le mot de passe actuel est requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newPassword.trim()) {
|
||||||
|
return fail(400, { message: 'Le nouveau mot de passe est requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
return fail(400, { message: 'Les mots de passe ne correspondent pas' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
return fail(400, { message: 'Le mot de passe doit contenir au moins 8 caractères' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.api.changePassword({
|
||||||
|
body: {
|
||||||
|
currentPassword: oldPassword,
|
||||||
|
newPassword
|
||||||
|
},
|
||||||
|
headers: event.request.headers
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof APIError) {
|
||||||
|
return fail(400, { message: error.message || 'Erreur lors du changement de mot de passe' });
|
||||||
|
}
|
||||||
|
return fail(500, { message: 'Erreur inattendue' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
revokeSession: async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const sessionId = formData.get('sessionId')?.toString() ?? '';
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return fail(400, { message: 'ID de session manquant' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete the session from database
|
||||||
|
await db.delete(session).where(eq(session.id, sessionId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error revoking session:', error);
|
||||||
|
return fail(500, { message: 'Erreur lors de la révocation de la session' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'Session révoquée avec succès' };
|
||||||
|
},
|
||||||
|
sendFriendRequest: async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const friendUsername = formData.get('friendUsername')?.toString().trim() ?? '';
|
||||||
|
|
||||||
|
if (!friendUsername) {
|
||||||
|
return fail(400, { message: 'Nom d\'utilisateur requis pour envoyer une demande' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = event.locals.user;
|
||||||
|
const myUsername = (me as { username?: string }).username;
|
||||||
|
|
||||||
|
if (myUsername && friendUsername.toLowerCase() === myUsername.toLowerCase()) {
|
||||||
|
return fail(400, { message: 'Tu ne peux pas t\'ajouter toi-même' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [targetUser] = await db
|
||||||
|
.select({ id: user.id, username: user.username })
|
||||||
|
.from(user)
|
||||||
|
.where(sql`lower(${user.username}) = ${friendUsername.toLowerCase()}`)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return fail(404, { message: 'Aucun utilisateur trouvé avec ce nom d\'utilisateur' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(friendship)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
and(eq(friendship.requesterId, me.id), eq(friendship.addresseeId, targetUser.id)),
|
||||||
|
and(eq(friendship.requesterId, targetUser.id), eq(friendship.addresseeId, me.id))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await db.insert(friendship).values({
|
||||||
|
requesterId: me.id,
|
||||||
|
addresseeId: targetUser.id,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
});
|
||||||
|
return { success: true, message: 'Demande d\'ami envoyée' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.status === 'accepted') {
|
||||||
|
return fail(400, { message: 'Vous êtes déjà amis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.status === 'pending') {
|
||||||
|
if (existing.requesterId === targetUser.id && existing.addresseeId === me.id) {
|
||||||
|
await db
|
||||||
|
.update(friendship)
|
||||||
|
.set({
|
||||||
|
status: 'accepted',
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.where(eq(friendship.id, existing.id));
|
||||||
|
return { success: true, message: 'Demande acceptée automatiquement' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return fail(400, { message: 'Demande déjà envoyée' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(friendship)
|
||||||
|
.set({
|
||||||
|
requesterId: me.id,
|
||||||
|
addresseeId: targetUser.id,
|
||||||
|
status: 'pending',
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.where(eq(friendship.id, existing.id));
|
||||||
|
|
||||||
|
return { success: true, message: 'Demande d\'ami envoyée' };
|
||||||
|
},
|
||||||
|
acceptFriendRequest: async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const friendshipId = formData.get('friendshipId')?.toString() ?? '';
|
||||||
|
|
||||||
|
if (!friendshipId) {
|
||||||
|
return fail(400, { message: 'Demande invalide' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const result = await db
|
||||||
|
.update(friendship)
|
||||||
|
.set({ status: 'accepted', updatedAt: now })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(friendship.id, friendshipId),
|
||||||
|
eq(friendship.addresseeId, event.locals.user.id),
|
||||||
|
eq(friendship.status, 'pending')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: friendship.id });
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return fail(404, { message: 'Demande introuvable' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'Demande acceptée' };
|
||||||
|
},
|
||||||
|
declineFriendRequest: async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const friendshipId = formData.get('friendshipId')?.toString() ?? '';
|
||||||
|
|
||||||
|
if (!friendshipId) {
|
||||||
|
return fail(400, { message: 'Demande invalide' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const result = await db
|
||||||
|
.update(friendship)
|
||||||
|
.set({ status: 'declined', updatedAt: now })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(friendship.id, friendshipId),
|
||||||
|
eq(friendship.addresseeId, event.locals.user.id),
|
||||||
|
eq(friendship.status, 'pending')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: friendship.id });
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return fail(404, { message: 'Demande introuvable' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'Demande refusée' };
|
||||||
|
},
|
||||||
|
cancelFriendRequest: async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const friendshipId = formData.get('friendshipId')?.toString() ?? '';
|
||||||
|
|
||||||
|
if (!friendshipId) {
|
||||||
|
return fail(400, { message: 'Demande invalide' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.delete(friendship)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(friendship.id, friendshipId),
|
||||||
|
eq(friendship.requesterId, event.locals.user.id),
|
||||||
|
eq(friendship.status, 'pending')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: friendship.id });
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return fail(404, { message: 'Demande introuvable' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'Demande annulée' };
|
||||||
|
},
|
||||||
|
removeFriend: async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const friendshipId = formData.get('friendshipId')?.toString() ?? '';
|
||||||
|
|
||||||
|
if (!friendshipId) {
|
||||||
|
return fail(400, { message: 'Relation invalide' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.delete(friendship)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(friendship.id, friendshipId),
|
||||||
|
eq(friendship.status, 'accepted'),
|
||||||
|
or(eq(friendship.requesterId, event.locals.user.id), eq(friendship.addresseeId, event.locals.user.id))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ id: friendship.id });
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return fail(404, { message: 'Relation introuvable' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'Ami supprimé' };
|
||||||
|
}
|
||||||
|
};
|
||||||
574
src/routes/(game)/profile/+page.svelte
Normal file
574
src/routes/(game)/profile/+page.svelte
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { t, language } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: PageData;
|
||||||
|
form?: { success?: boolean; message?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyHistoryEntry {
|
||||||
|
id: string;
|
||||||
|
characterId: string | null;
|
||||||
|
date: number;
|
||||||
|
tryCount: number;
|
||||||
|
won: number;
|
||||||
|
characterName: string;
|
||||||
|
characterImage: string | null;
|
||||||
|
triedCharacters?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
pictureUrl: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, form }: Props = $props();
|
||||||
|
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let activeTab = $state<'profile' | 'password' | 'sessions' | 'daily' | 'friends'>('profile');
|
||||||
|
let name = $derived(data.user?.name || '');
|
||||||
|
let friendUsername = $state('');
|
||||||
|
let showSuccess = $state(false);
|
||||||
|
let oldPassword = $state('');
|
||||||
|
let newPassword = $state('');
|
||||||
|
let confirmPassword = $state('');
|
||||||
|
let sessions = $derived(data.sessions || []);
|
||||||
|
let dailyHistory = $derived((data.dailyHistory || []) as DailyHistoryEntry[]);
|
||||||
|
let friends = $derived(data.friends || []);
|
||||||
|
let incomingRequests = $derived(data.incomingRequests || []);
|
||||||
|
let outgoingRequests = $derived(data.outgoingRequests || []);
|
||||||
|
let tabsElement: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
friends = data.friends || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
incomingRequests = data.incomingRequests || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
outgoingRequests = data.outgoingRequests || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (form && form.success === true) {
|
||||||
|
showSuccess = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
showSuccess = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTabChange = (tab: 'profile' | 'password' | 'sessions' | 'daily' | 'friends') => {
|
||||||
|
activeTab = tab;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Just for type purposes
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t.game.profile.pageTitle} - OnePieceDle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="relative min-h-[calc(100vh-5rem)] bg-slate-950 text-slate-100">
|
||||||
|
<div class="absolute inset-0 bg-linear-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
|
||||||
|
<div class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"></div>
|
||||||
|
|
||||||
|
<div class="relative mx-auto flex w-full max-w-2xl flex-col items-center px-6 py-4">
|
||||||
|
<div class="w-full space-y-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-3xl font-black uppercase tracking-[0.3em] text-amber-50 sm:text-4xl">
|
||||||
|
{$t.game.profile.headerTitle}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-300">
|
||||||
|
{$t.game.profile.headerSubtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs Navigation -->
|
||||||
|
<div bind:this={tabsElement} class="sticky top-20 z-10 flex gap-2 border-b border-white/10 bg-slate-950/80 backdrop-blur">
|
||||||
|
<button
|
||||||
|
onclick={() => handleTabChange('profile')}
|
||||||
|
class="px-4 py-3 font-semibold uppercase tracking-widest transition {activeTab === 'profile'
|
||||||
|
? 'border-b-2 border-amber-300 text-amber-100'
|
||||||
|
: 'text-slate-400 hover:text-slate-100'}"
|
||||||
|
>
|
||||||
|
{$t.game.profile.tabProfile}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleTabChange('password')}
|
||||||
|
class="px-4 py-3 font-semibold uppercase tracking-widest transition {activeTab === 'password'
|
||||||
|
? 'border-b-2 border-amber-300 text-amber-100'
|
||||||
|
: 'text-slate-400 hover:text-slate-100'}"
|
||||||
|
>
|
||||||
|
{$t.game.profile.tabPassword}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleTabChange('daily')}
|
||||||
|
class="px-4 py-3 font-semibold uppercase tracking-widest transition {activeTab === 'daily'
|
||||||
|
? 'border-b-2 border-amber-300 text-amber-100'
|
||||||
|
: 'text-slate-400 hover:text-slate-100'}"
|
||||||
|
>
|
||||||
|
{$t.game.profile.tabDaily}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleTabChange('sessions')}
|
||||||
|
class="px-4 py-3 font-semibold uppercase tracking-widest transition {activeTab === 'sessions'
|
||||||
|
? 'border-b-2 border-amber-300 text-amber-100'
|
||||||
|
: 'text-slate-400 hover:text-slate-100'}"
|
||||||
|
>
|
||||||
|
{$t.game.profile.tabSessions}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleTabChange('friends')}
|
||||||
|
class="px-4 py-3 font-semibold uppercase tracking-widest transition {activeTab === 'friends'
|
||||||
|
? 'border-b-2 border-amber-300 text-amber-100'
|
||||||
|
: 'text-slate-400 hover:text-slate-100'}"
|
||||||
|
>
|
||||||
|
{$t.game.profile.tabFriends}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
{#if activeTab === 'profile'}
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div class="mb-6 flex flex-col items-center gap-4">
|
||||||
|
{#if data.user.image}
|
||||||
|
<img
|
||||||
|
src={data.user.image}
|
||||||
|
alt={data.user.name || $t.game.profile.avatarFallbackAlt}
|
||||||
|
class="h-24 w-24 rounded-full border-2 border-amber-300 object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-24 w-24 items-center justify-center rounded-full border-2 border-amber-300 bg-amber-300/20 text-2xl font-semibold text-amber-100">
|
||||||
|
{data.user.name?.charAt(0).toUpperCase() || 'U'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-slate-400">{$t.game.profile.email}</p>
|
||||||
|
<p class="font-semibold text-white">{data.user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateProfile"
|
||||||
|
use:enhance={() => {
|
||||||
|
isLoading = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
isLoading = false;
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
onsubmit={handleSubmit}
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<!-- Name Field -->
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
{$t.game.profile.displayName}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
placeholder={$t.game.profile.displayNamePlaceholder}
|
||||||
|
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
{#if form && form.message && form.success !== true}
|
||||||
|
<div class="rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-3 text-sm text-red-200">
|
||||||
|
{form.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Success Message -->
|
||||||
|
{#if showSuccess}
|
||||||
|
<div class="rounded-lg border border-green-500/30 bg-green-900/20 px-4 py-3 text-sm text-green-200">
|
||||||
|
{$t.game.profile.profileUpdateSuccess}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
class="w-full rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition disabled:opacity-50 hover:bg-amber-200"
|
||||||
|
>
|
||||||
|
{isLoading ? $t.game.profile.updating : $t.game.profile.saveChanges}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Friends Tab -->
|
||||||
|
{#if activeTab === 'friends'}
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">
|
||||||
|
<h2 class="mb-6 text-2xl font-bold uppercase tracking-[0.2em] text-amber-50">
|
||||||
|
{$t.game.profile.friendsTitle}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/sendFriendRequest"
|
||||||
|
use:enhance={() => {
|
||||||
|
isLoading = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
isLoading = false;
|
||||||
|
friendUsername = '';
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mb-8 space-y-3"
|
||||||
|
>
|
||||||
|
<label for="friendUsername" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
{$t.game.profile.addFriendByUsername}
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
id="friendUsername"
|
||||||
|
type="text"
|
||||||
|
name="friendUsername"
|
||||||
|
required
|
||||||
|
bind:value={friendUsername}
|
||||||
|
placeholder={$t.game.profile.friendUsernamePlaceholder}
|
||||||
|
class="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
class="rounded-full bg-amber-300 px-4 py-2 text-sm font-semibold text-slate-900 transition disabled:opacity-50 hover:bg-amber-200"
|
||||||
|
>
|
||||||
|
{isLoading ? $t.game.profile.sending : $t.game.profile.send}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if form?.message}
|
||||||
|
<p class="text-sm {form.success ? 'text-green-300' : 'text-red-300'}">{form.message}</p>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-3 text-sm font-semibold uppercase tracking-[0.15em] text-amber-100">{$t.game.profile.incomingRequests}</h3>
|
||||||
|
{#if incomingRequests.length === 0}
|
||||||
|
<p class="text-sm text-slate-400">{$t.game.profile.noIncomingRequests}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each incomingRequests as req (req.id)}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-white">{req.requesterName}</p>
|
||||||
|
<p class="text-xs text-slate-400">@{req.requesterUsername}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<form method="POST" action="?/acceptFriendRequest" use:enhance>
|
||||||
|
<input type="hidden" name="friendshipId" value={req.id} />
|
||||||
|
<button type="submit" class="rounded-lg border border-emerald-400/50 bg-emerald-900/20 px-3 py-1.5 text-xs font-semibold text-emerald-300 transition hover:bg-emerald-900/40">{$t.game.profile.accept}</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="?/declineFriendRequest" use:enhance>
|
||||||
|
<input type="hidden" name="friendshipId" value={req.id} />
|
||||||
|
<button type="submit" class="rounded-lg border border-red-500/50 bg-red-900/20 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-900/40">{$t.game.profile.decline}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-3 text-sm font-semibold uppercase tracking-[0.15em] text-amber-100">{$t.game.profile.outgoingRequests}</h3>
|
||||||
|
{#if outgoingRequests.length === 0}
|
||||||
|
<p class="text-sm text-slate-400">{$t.game.profile.noOutgoingRequests}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each outgoingRequests as req (req.id)}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-white">{req.addresseeName}</p>
|
||||||
|
<p class="text-xs text-slate-400">@{req.addresseeUsername}</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="?/cancelFriendRequest" use:enhance>
|
||||||
|
<input type="hidden" name="friendshipId" value={req.id} />
|
||||||
|
<button type="submit" class="rounded-lg border border-red-500/50 bg-red-900/20 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-900/40">{$t.game.profile.cancel}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-3 text-sm font-semibold uppercase tracking-[0.15em] text-amber-100">{$t.game.profile.myFriends}</h3>
|
||||||
|
{#if friends.length === 0}
|
||||||
|
<p class="text-sm text-slate-400">{$t.game.profile.noFriends}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each friends as friend (friend.id)}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-white">{friend.friendName}</p>
|
||||||
|
<p class="text-xs text-slate-400">@{friend.friendUsername}</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="?/removeFriend" use:enhance>
|
||||||
|
<input type="hidden" name="friendshipId" value={friend.id} />
|
||||||
|
<button type="submit" class="rounded-lg border border-red-500/50 bg-red-900/20 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-900/40">{$t.game.profile.remove}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Password Tab -->
|
||||||
|
{#if activeTab === 'password'}
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">
|
||||||
|
<h2 class="mb-6 text-2xl font-bold uppercase tracking-[0.2em] text-amber-50">
|
||||||
|
{$t.game.profile.changePasswordTitle}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/changePassword"
|
||||||
|
use:enhance={() => {
|
||||||
|
isLoading = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
isLoading = false;
|
||||||
|
oldPassword = '';
|
||||||
|
newPassword = '';
|
||||||
|
confirmPassword = '';
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<!-- Old Password Field -->
|
||||||
|
<div>
|
||||||
|
<label for="oldPassword" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
{$t.game.profile.currentPassword}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="oldPassword"
|
||||||
|
type="password"
|
||||||
|
name="oldPassword"
|
||||||
|
bind:value={oldPassword}
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Password Field -->
|
||||||
|
<div>
|
||||||
|
<label for="newPassword" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
{$t.game.profile.newPassword}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
name="newPassword"
|
||||||
|
bind:value={newPassword}
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Password Field -->
|
||||||
|
<div>
|
||||||
|
<label for="confirmPassword" class="block text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
{$t.game.profile.confirmPassword}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
class="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-slate-500 transition focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-300/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
{#if form && form.message && form.success !== true}
|
||||||
|
<div class="rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-3 text-sm text-red-200">
|
||||||
|
{form.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Success Message -->
|
||||||
|
{#if showSuccess}
|
||||||
|
<div class="rounded-lg border border-green-500/30 bg-green-900/20 px-4 py-3 text-sm text-green-200">
|
||||||
|
{$t.game.profile.passwordChangeSuccess}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
class="w-full rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition disabled:opacity-50 hover:bg-amber-200"
|
||||||
|
>
|
||||||
|
{isLoading ? $t.game.profile.changing : $t.game.profile.changePassword}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Daily History Tab -->
|
||||||
|
{#if activeTab === 'daily'}
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">
|
||||||
|
<h2 class="mb-6 text-2xl font-bold uppercase tracking-[0.2em] text-amber-50">
|
||||||
|
{$t.game.profile.dailyHistoryTitle}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if dailyHistory.length === 0}
|
||||||
|
<p class="text-center text-slate-400">{$t.game.profile.noDailyHistory}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each dailyHistory as day (day.id)}
|
||||||
|
<div class="flex items-center gap-4 rounded-lg border border-white/10 bg-white/5 p-4">
|
||||||
|
<!-- Character Image -->
|
||||||
|
<div class="shrink-0">
|
||||||
|
{#if day.characterImage}
|
||||||
|
<img
|
||||||
|
src={day.characterImage}
|
||||||
|
alt={day.characterName}
|
||||||
|
class="h-16 w-16 rounded-lg border border-white/20 object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-16 w-16 items-center justify-center rounded-lg border border-white/20 bg-slate-700">
|
||||||
|
<span class="text-xs text-slate-400">{$t.game.profile.noImage}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Character Info -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-semibold text-white">{day.characterName}</p>
|
||||||
|
<p class="text-xs text-slate-400">
|
||||||
|
{new Date(day.date).toLocaleDateString($language === 'fr' ? 'fr-FR' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100">
|
||||||
|
{$t.game.profile.triedCharactersTitle}
|
||||||
|
</p>
|
||||||
|
{#if day.triedCharacters && day.triedCharacters.length > 0}
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
{#each day.triedCharacters as triedCharacter (triedCharacter.id)}
|
||||||
|
<span class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-xs text-slate-200">
|
||||||
|
{#if triedCharacter.pictureUrl}
|
||||||
|
<img
|
||||||
|
src={triedCharacter.pictureUrl}
|
||||||
|
alt={triedCharacter.name}
|
||||||
|
class="h-4 w-4 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{triedCharacter.name}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{$t.game.profile.noTriedCharacters}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tries -->
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<p class="text-xs text-slate-400">
|
||||||
|
{day.tryCount} {day.tryCount === 1 ? $t.game.profile.trySingular : $t.game.profile.tryPlural}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Sessions Tab -->
|
||||||
|
{#if activeTab === 'sessions'}
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">
|
||||||
|
<h2 class="mb-6 text-2xl font-bold uppercase tracking-[0.2em] text-amber-50">
|
||||||
|
{$t.game.profile.activeSessionsTitle}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if sessions.length === 0}
|
||||||
|
<p class="text-center text-slate-400">{$t.game.profile.noActiveSessions}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each sessions as sess (sess.id)}
|
||||||
|
<div class="flex items-center justify-between rounded-lg border border-white/10 bg-white/5 px-4 py-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-semibold text-white">
|
||||||
|
{sess.userAgent || $t.game.profile.unknownDevice}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-400">
|
||||||
|
{$t.game.profile.ip}: {sess.ipAddress || $t.game.profile.unknown}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
{$t.game.profile.created}: {new Date(sess.createdAt).toLocaleDateString($language === 'fr' ? 'fr-FR' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/revokeSession"
|
||||||
|
style="display: inline;"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="sessionId" value={sess.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg border border-red-500/50 bg-red-900/20 px-4 py-2 text-xs font-semibold text-red-300 transition hover:border-red-500 hover:bg-red-900/40"
|
||||||
|
>
|
||||||
|
{$t.game.profile.terminate}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Back to Home -->
|
||||||
|
<div class="text-center">
|
||||||
|
<a href={resolve("/")} class="text-sm text-slate-400 transition hover:text-slate-300">
|
||||||
|
← {$t.game.profile.backHome}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
8
src/routes/+layout.server.ts
Normal file
8
src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user || null,
|
||||||
|
session: locals.session || null
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.png';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||||
|
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
export let data;
|
|
||||||
|
|
||||||
$: yesterdayCharacter = data.yesterdayCharacter;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>OnePieceDle</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<main
|
|
||||||
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100"
|
|
||||||
>
|
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
|
|
||||||
<div class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"></div>
|
|
||||||
|
|
||||||
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col items-center justify-center px-6 py-10">
|
|
||||||
<div class="flex w-full flex-col items-center gap-8">
|
|
||||||
<div class="text-center mb-12">
|
|
||||||
<h1 class="text-4xl font-black uppercase tracking-[0.3em] text-amber-50 sm:text-6xl">
|
|
||||||
OnePieceDle
|
|
||||||
</h1>
|
|
||||||
<p class="mt-4 max-w-2xl text-base text-slate-200 sm:text-lg">
|
|
||||||
Devine le personnage de l'equipage, des marines ou du vaste monde. Chaque indice te rapproche du tresor.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid w-full gap-4 sm:grid-cols-2">
|
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
|
||||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">Personnage du jour</h2>
|
|
||||||
<p class="mt-3 text-lg font-semibold text-white">Nouveau mystere toutes les 24 heures</p>
|
|
||||||
<p class="mt-2 text-sm text-slate-200">Compare tes essais, debloque des indices et garde ta serie.</p>
|
|
||||||
<a
|
|
||||||
href="/daily"
|
|
||||||
class="mt-5 inline-flex w-full items-center justify-center rounded-full bg-amber-300 px-5 py-3 text-sm font-semibold text-slate-900 transition hover:bg-amber-200"
|
|
||||||
>
|
|
||||||
Commencer
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
|
||||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">Partie libre</h2>
|
|
||||||
<p class="mt-3 text-lg font-semibold text-white">Entraine-toi avec des pirates legendaires</p>
|
|
||||||
<p class="mt-2 text-sm text-slate-200">Choisis une epoque, regle la difficulte et vogue a ton rythme.</p>
|
|
||||||
<button class="mt-5 w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50">
|
|
||||||
En construction
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-full rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur sm:p-8">
|
|
||||||
{#if yesterdayCharacter}
|
|
||||||
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
|
|
||||||
{#if yesterdayCharacter.pictureUrl}
|
|
||||||
<img
|
|
||||||
src={yesterdayCharacter.pictureUrl}
|
|
||||||
alt={yesterdayCharacter.name}
|
|
||||||
class="h-20 w-20 rounded-full border border-amber-200/40 object-cover"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
|
|
||||||
Photo
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Personnage d'hier</p>
|
|
||||||
<p class="mt-2 text-lg font-semibold text-white">{yesterdayCharacter.name}</p>
|
|
||||||
{#if yesterdayCharacter.epithets}
|
|
||||||
<p class="mt-1 text-sm text-slate-400">
|
|
||||||
{typeof yesterdayCharacter.epithets === 'string'
|
|
||||||
? JSON.parse(yesterdayCharacter.epithets).join(', ')
|
|
||||||
: (yesterdayCharacter.epithets as string[]).join(', ')}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={"https://onepiece.fandom.com/fr/wiki/" + yesterdayCharacter.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50 sm:w-auto"
|
|
||||||
>
|
|
||||||
Voir la page
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
|
|
||||||
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
|
|
||||||
Photo
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Personnage d'hier</p>
|
|
||||||
<p class="mt-2 text-lg font-semibold text-white">Aucun personnage</p>
|
|
||||||
<p class="mt-1 text-sm text-slate-200">Aucun personnage d'hier disponible</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
@@ -1,788 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
export let data;
|
|
||||||
|
|
||||||
let searchInput = '';
|
|
||||||
let selectedCharacters: any[] = [];
|
|
||||||
let highlightedIndex = 0;
|
|
||||||
let isLoaded = false;
|
|
||||||
let isGeckoMoriaWin = false;
|
|
||||||
let dropdownContainer: HTMLDivElement;
|
|
||||||
let showHintOrigin = false;
|
|
||||||
let showHintFruit = false;
|
|
||||||
let showHintAffiliation = false;
|
|
||||||
|
|
||||||
let wasOriginAvailable = false;
|
|
||||||
let wasFruitAvailable = false;
|
|
||||||
let wasAffiliationAvailable = false;
|
|
||||||
let showOriginUnlock = false;
|
|
||||||
let showFruitUnlock = false;
|
|
||||||
let showAffiliationUnlock = false;
|
|
||||||
|
|
||||||
// Load from localStorage on mount
|
|
||||||
onMount(() => {
|
|
||||||
const storedDailyCharacterId = localStorage.getItem('currentDailyCharacterId');
|
|
||||||
const currentDailyCharacterId = dailyCharacter?.id;
|
|
||||||
|
|
||||||
// If the daily character has changed, clear the history
|
|
||||||
if (storedDailyCharacterId && storedDailyCharacterId !== currentDailyCharacterId) {
|
|
||||||
localStorage.removeItem('dailyCharacterHistory');
|
|
||||||
selectedCharacters = [];
|
|
||||||
} else {
|
|
||||||
// Load existing history if the character hasn't changed
|
|
||||||
const stored = localStorage.getItem('dailyCharacterHistory');
|
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
const storedIds = JSON.parse(stored);
|
|
||||||
// Reconstruct character objects from IDs
|
|
||||||
if (Array.isArray(storedIds)) {
|
|
||||||
selectedCharacters = storedIds
|
|
||||||
.map((id: string) => data.characters.find((c: any) => c.id === id))
|
|
||||||
.filter((c: any) => c !== undefined);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse stored history', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the current daily character ID
|
|
||||||
if (currentDailyCharacterId) {
|
|
||||||
localStorage.setItem('currentDailyCharacterId', currentDailyCharacterId);
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoaded = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save to localStorage whenever selectedCharacters changes (only store IDs)
|
|
||||||
$: if (isLoaded && selectedCharacters) {
|
|
||||||
const ids = selectedCharacters.map(char => char.id);
|
|
||||||
localStorage.setItem('dailyCharacterHistory', JSON.stringify(ids));
|
|
||||||
}
|
|
||||||
|
|
||||||
$: characters = data.characters || [];
|
|
||||||
$: dailyCharacter = data.dailyCharacter;
|
|
||||||
$: yesterdayCharacter = data.yesterdayCharacter;
|
|
||||||
$: columnVisibility = data.columnVisibility || {};
|
|
||||||
$: hasWon = selectedCharacters.some(char => char.id === dailyCharacter.id);
|
|
||||||
|
|
||||||
// Hint availability - indices are available after a certain number of guesses
|
|
||||||
$: isOriginAvailable = selectedCharacters.length >= 5; // Always available
|
|
||||||
$: isFruitAvailable = selectedCharacters.length >= 10; // Available after 5 guesses
|
|
||||||
$: isAffiliationAvailable = selectedCharacters.length >= 15; // Available after 10 guesses
|
|
||||||
|
|
||||||
// Track hint unlocks
|
|
||||||
$: if (isLoaded) {
|
|
||||||
if (isOriginAvailable && !wasOriginAvailable) {
|
|
||||||
showOriginUnlock = true;
|
|
||||||
setTimeout(() => showOriginUnlock = false, 600);
|
|
||||||
}
|
|
||||||
wasOriginAvailable = isOriginAvailable;
|
|
||||||
|
|
||||||
if (isFruitAvailable && !wasFruitAvailable) {
|
|
||||||
showFruitUnlock = true;
|
|
||||||
setTimeout(() => showFruitUnlock = false, 600);
|
|
||||||
}
|
|
||||||
wasFruitAvailable = isFruitAvailable;
|
|
||||||
|
|
||||||
if (isAffiliationAvailable && !wasAffiliationAvailable) {
|
|
||||||
showAffiliationUnlock = true;
|
|
||||||
setTimeout(() => showAffiliationUnlock = false, 600);
|
|
||||||
}
|
|
||||||
wasAffiliationAvailable = isAffiliationAvailable;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: filteredCharacters = characters.filter(char => {
|
|
||||||
const searchTerm = searchInput.toLowerCase();
|
|
||||||
const nameMatches = char.name.toLowerCase().includes(searchTerm);
|
|
||||||
|
|
||||||
let epithetsMatches = false;
|
|
||||||
if (char.epithets) {
|
|
||||||
try {
|
|
||||||
const parsedEpithets = typeof char.epithets === 'string'
|
|
||||||
? JSON.parse(char.epithets)
|
|
||||||
: char.epithets;
|
|
||||||
|
|
||||||
if (Array.isArray(parsedEpithets)) {
|
|
||||||
epithetsMatches = parsedEpithets.some((epithet: string) =>
|
|
||||||
epithet.toLowerCase().includes(searchTerm)
|
|
||||||
);
|
|
||||||
} else if (typeof parsedEpithets === 'string') {
|
|
||||||
epithetsMatches = parsedEpithets.toLowerCase().includes(searchTerm);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
epithetsMatches = String(char.epithets).toLowerCase().includes(searchTerm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (nameMatches || epithetsMatches) &&
|
|
||||||
!selectedCharacters.some(selected => selected.id === char.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset highlighted index when filtered list changes
|
|
||||||
$: if (filteredCharacters) {
|
|
||||||
highlightedIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll highlighted item into view
|
|
||||||
$: if (dropdownContainer && highlightedIndex >= 0) {
|
|
||||||
const highlightedButton = dropdownContainer.querySelector(
|
|
||||||
`button:nth-child(${highlightedIndex + 1})`
|
|
||||||
) as HTMLElement;
|
|
||||||
if (highlightedButton) {
|
|
||||||
highlightedButton.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectCharacter(character: any) {
|
|
||||||
selectedCharacters = [character, ...selectedCharacters];
|
|
||||||
searchInput = '';
|
|
||||||
highlightedIndex = 0;
|
|
||||||
|
|
||||||
// Check if player won
|
|
||||||
if (character.id === dailyCharacter.id) {
|
|
||||||
// Send request to record win in database
|
|
||||||
fetch('/daily', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
characterId: dailyCharacter.id
|
|
||||||
})
|
|
||||||
}).catch(err => console.error('Failed to record win:', err));
|
|
||||||
|
|
||||||
// Check if it's gecko_moria for special animation
|
|
||||||
if (dailyCharacter.id === 'gecko_moria') {
|
|
||||||
isGeckoMoriaWin = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetHistory() {
|
|
||||||
selectedCharacters = [];
|
|
||||||
localStorage.removeItem('dailyCharacterHistory');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
|
||||||
if (filteredCharacters.length === 0) return;
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
event.preventDefault();
|
|
||||||
highlightedIndex = Math.min(highlightedIndex + 1, filteredCharacters.length - 1);
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
event.preventDefault();
|
|
||||||
highlightedIndex = Math.max(highlightedIndex - 1, 0);
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
event.preventDefault();
|
|
||||||
if (filteredCharacters[highlightedIndex]) {
|
|
||||||
selectCharacter(filteredCharacters[highlightedIndex]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitGuess() {
|
|
||||||
if (filteredCharacters.length === 0) return;
|
|
||||||
const characterToSelect =
|
|
||||||
filteredCharacters[highlightedIndex] ?? filteredCharacters[0];
|
|
||||||
if (characterToSelect) {
|
|
||||||
selectCharacter(characterToSelect);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBounty(bounty: number): string {
|
|
||||||
if (bounty >= 1_000_000_000) {
|
|
||||||
const billions = bounty / 1_000_000_000;
|
|
||||||
return `${billions}B`;
|
|
||||||
} else if (bounty >= 1_000_000) {
|
|
||||||
const millions = bounty / 1_000_000;
|
|
||||||
return `${millions}M`;
|
|
||||||
} else if (bounty >= 1_000) {
|
|
||||||
const thousands = bounty / 1_000;
|
|
||||||
return `${thousands}K`;
|
|
||||||
}
|
|
||||||
return bounty.toString();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>OnePieceDle - Mode du jour</title>
|
|
||||||
<style>
|
|
||||||
@keyframes hint-unlock {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow: 0 0 20px rgba(251, 146, 60, 0.8);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 rgba(251, 146, 60, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.hint-unlocking {
|
|
||||||
animation: hint-unlock 0.6s ease-out;
|
|
||||||
}
|
|
||||||
@keyframes shadow-pulse {
|
|
||||||
0% {
|
|
||||||
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
text-shadow: 0 0 60px rgba(0, 0, 0, 0.9), 0 0 100px rgba(30, 30, 30, 1), inset 0 0 50px rgba(0, 0, 0, 0.7);
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes moria-chaos {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg) scale(1);
|
|
||||||
filter: invert(0%) hue-rotate(0deg) blur(0px);
|
|
||||||
}
|
|
||||||
10% {
|
|
||||||
transform: rotate(15deg) scale(1.02);
|
|
||||||
filter: invert(30%) hue-rotate(45deg) blur(2px);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
transform: rotate(-10deg) scale(0.98);
|
|
||||||
filter: invert(60%) hue-rotate(90deg) blur(1px);
|
|
||||||
}
|
|
||||||
30% {
|
|
||||||
transform: rotate(25deg) scale(1.05);
|
|
||||||
filter: invert(100%) hue-rotate(180deg) blur(3px);
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
transform: rotate(-20deg) scale(0.95);
|
|
||||||
filter: invert(80%) hue-rotate(270deg) blur(2px);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: rotate(30deg) scale(1.08);
|
|
||||||
filter: invert(100%) hue-rotate(0deg) blur(4px);
|
|
||||||
}
|
|
||||||
60% {
|
|
||||||
transform: rotate(-25deg) scale(0.92);
|
|
||||||
filter: invert(70%) hue-rotate(90deg) blur(2px);
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
transform: rotate(20deg) scale(1.03);
|
|
||||||
filter: invert(50%) hue-rotate(180deg) blur(3px);
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
transform: rotate(-15deg) scale(1.01);
|
|
||||||
filter: invert(80%) hue-rotate(270deg) blur(1px);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg) scale(1);
|
|
||||||
filter: invert(0%) hue-rotate(360deg) blur(0px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.gecko-moria-effect {
|
|
||||||
animation: shadow-pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.moria-screen-chaos {
|
|
||||||
animation: moria-chaos 4s ease-in-out;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<main
|
|
||||||
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100 {isGeckoMoriaWin ? 'moria-screen-chaos' : ''}"
|
|
||||||
>
|
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
|
|
||||||
<div class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"></div>
|
|
||||||
|
|
||||||
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-16 sm:py-20">
|
|
||||||
<nav class="absolute left-6 top-6 sm:left-8 sm:top-8">
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="text-xl font-black uppercase tracking-[0.25em] text-amber-50 transition hover:text-amber-100"
|
|
||||||
>
|
|
||||||
OnePieceDle
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<header class="flex flex-col items-start gap-6 w-full">
|
|
||||||
<div class="flex w-full items-center justify-between gap-4">
|
|
||||||
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl">
|
|
||||||
Personnage du jour
|
|
||||||
</h1>
|
|
||||||
{#if hasWon}
|
|
||||||
<button
|
|
||||||
class="rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50"
|
|
||||||
onclick={resetHistory}
|
|
||||||
>
|
|
||||||
Recommencer
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="max-w-2xl text-base text-slate-200 sm:text-lg">
|
|
||||||
Devine le personnage. Chaque indice se débloque après un certain nombre de tentatives. Bonne chance !
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="mt-10 grid gap-6">
|
|
||||||
{#if selectedCharacters.length > 0 && !hasWon}
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
|
||||||
<div class="grid gap-3 sm:grid-cols-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isOriginAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showOriginUnlock ? 'hint-unlocking' : ''}"
|
|
||||||
disabled={!isOriginAvailable}
|
|
||||||
onclick={() => showHintOrigin = !showHintOrigin}
|
|
||||||
>
|
|
||||||
<p class="text-sm font-medium text-amber-100">Origine</p>
|
|
||||||
{#if showHintOrigin}
|
|
||||||
<p class="mt-2 text-xs text-white font-semibold">{dailyCharacter.origin || 'Inconnue'}</p>
|
|
||||||
{:else if Math.max(0, 5 - selectedCharacters.length) > 0}
|
|
||||||
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 5 - selectedCharacters.length)} essais avant déblocage</p>
|
|
||||||
{:else}
|
|
||||||
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isFruitAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showFruitUnlock ? 'hint-unlocking' : ''}"
|
|
||||||
disabled={!isFruitAvailable}
|
|
||||||
onclick={() => showHintFruit = !showHintFruit}
|
|
||||||
>
|
|
||||||
<p class="text-sm font-medium text-amber-100">Fruit du démon</p>
|
|
||||||
{#if showHintFruit}
|
|
||||||
<p class="mt-2 text-xs text-white font-semibold">{dailyCharacter.devilFruitName || 'Aucun'}</p>
|
|
||||||
{:else if Math.max(0, 10 - selectedCharacters.length) > 0}
|
|
||||||
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 10 - selectedCharacters.length)} essais avant déblocage</p>
|
|
||||||
{:else}
|
|
||||||
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-2xl border border-white/10 px-3 py-3 flex flex-col items-center justify-center cursor-pointer hover:bg-slate-900/80 transition-colors {isAffiliationAvailable ? 'bg-slate-950/60' : 'bg-slate-950/30 opacity-50 cursor-not-allowed hover:bg-slate-950/30'} {showAffiliationUnlock ? 'hint-unlocking' : ''}"
|
|
||||||
disabled={!isAffiliationAvailable}
|
|
||||||
onclick={() => showHintAffiliation = !showHintAffiliation}
|
|
||||||
>
|
|
||||||
<p class="text-sm font-medium text-amber-100">Affiliation</p>
|
|
||||||
{#if showHintAffiliation}
|
|
||||||
{@const affiliations = typeof dailyCharacter.affiliations === 'string'
|
|
||||||
? (dailyCharacter.affiliations.includes('[') ? JSON.parse(dailyCharacter.affiliations) : dailyCharacter.affiliations.split(',').map((a: string) => a.trim()))
|
|
||||||
: dailyCharacter.affiliations}
|
|
||||||
<p class="mt-2 text-xs text-white font-semibold">{Array.isArray(affiliations) ? affiliations[0] : affiliations || 'Inconnue'}</p>
|
|
||||||
{:else if Math.max(0, 15 - selectedCharacters.length) > 0}
|
|
||||||
<p class="mt-2 text-xs text-slate-400">{Math.max(0, 15 - selectedCharacters.length)} essais avant déblocage</p>
|
|
||||||
{:else}
|
|
||||||
<p class="mt-2 text-xs text-slate-400">Indice disponible !</p>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if hasWon}
|
|
||||||
{#if isGeckoMoriaWin}
|
|
||||||
<div class="rounded-3xl border border-slate-700/80 bg-slate-950/80 p-4 shadow-[0_24px_60px_rgba(0,0,0,0.8)] backdrop-blur gecko-moria-effect">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-3xl mb-2">🌑</div>
|
|
||||||
<h2 class="text-xl font-bold text-slate-300 mb-1">Moria vous contrôle...</h2>
|
|
||||||
<p class="text-sm text-slate-400">Vous avez succombé à l'ombre en {selectedCharacters.length} {selectedCharacters.length > 1 ? 'tentatives' : 'tentative'} !</p>
|
|
||||||
<div class="mt-3">
|
|
||||||
{#if dailyCharacter.pictureUrl}
|
|
||||||
<a
|
|
||||||
href={"https://onepiece.fandom.com/fr/wiki/" + dailyCharacter.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-block"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={dailyCharacter.pictureUrl}
|
|
||||||
alt={dailyCharacter.name}
|
|
||||||
class="w-20 h-20 mx-auto rounded-full border-2 border-slate-600 shadow-lg object-cover hover:border-slate-500 transition-colors cursor-pointer opacity-80"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
<p class="mt-2 text-lg font-bold text-slate-200">{dailyCharacter.name}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="rounded-3xl border border-emerald-500/50 bg-emerald-500/10 p-4 shadow-[0_24px_60px_rgba(16,185,129,0.3)] backdrop-blur">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-3xl mb-2">🎉</div>
|
|
||||||
<h2 class="text-xl font-bold text-emerald-400 mb-1">Félicitations !</h2>
|
|
||||||
<p class="text-sm text-emerald-300">Vous avez trouvé le personnage en {selectedCharacters.length} {selectedCharacters.length > 1 ? 'tentatives' : 'tentative'} !</p>
|
|
||||||
<div class="mt-3">
|
|
||||||
{#if dailyCharacter.pictureUrl}
|
|
||||||
<a
|
|
||||||
href={"https://onepiece.fandom.com/fr/wiki/" + dailyCharacter.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-block"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={dailyCharacter.pictureUrl}
|
|
||||||
alt={dailyCharacter.name}
|
|
||||||
class="w-20 h-20 mx-auto rounded-full border-2 border-emerald-400 shadow-lg object-cover hover:border-emerald-300 transition-colors cursor-pointer"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
<p class="mt-2 text-lg font-bold text-white">{dailyCharacter.name}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur z-10">
|
|
||||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-amber-100">Entrer une supposition</h2>
|
|
||||||
<div class="mt-4 flex flex-col gap-3 sm:flex-row">
|
|
||||||
<div class="relative w-full">
|
|
||||||
<input
|
|
||||||
bind:value={searchInput}
|
|
||||||
class="w-full rounded-full border border-amber-200/30 bg-slate-900/60 px-5 py-3 text-sm text-slate-100 placeholder:text-slate-400 focus:border-amber-200/70 focus:outline-none"
|
|
||||||
placeholder="Nom du personnage"
|
|
||||||
type="text"
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
/>
|
|
||||||
{#if searchInput.length > 0 && filteredCharacters.length > 0}
|
|
||||||
<div bind:this={dropdownContainer} class="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-amber-200/30 bg-slate-900/90 backdrop-blur z-10">
|
|
||||||
{#each filteredCharacters as character, index (character.id)}
|
|
||||||
<button
|
|
||||||
class="w-full px-5 py-4 text-left text-base text-slate-100 transition border-b border-slate-800/50 last:border-b-0 flex items-center gap-4 {index === highlightedIndex ? 'bg-slate-700' : 'hover:bg-slate-800/70'}"
|
|
||||||
type="button"
|
|
||||||
onmouseenter={() => highlightedIndex = index}
|
|
||||||
onclick={() => selectCharacter(character)}
|
|
||||||
>
|
|
||||||
{#if character.pictureUrl}
|
|
||||||
<img
|
|
||||||
src={character.pictureUrl}
|
|
||||||
alt={character.name}
|
|
||||||
class="w-12 h-12 rounded-full object-cover border border-amber-200/30"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="w-12 h-12 rounded-full bg-slate-800 border border-amber-200/30 flex items-center justify-center">
|
|
||||||
<span class="text-xs text-slate-400">?</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="flex-1">
|
|
||||||
<span class="font-semibold text-amber-100">{character.name}</span>
|
|
||||||
{#if character.epithets}
|
|
||||||
{@const parsedEpithets = typeof character.epithets === 'string'
|
|
||||||
? JSON.parse(character.epithets)
|
|
||||||
: character.epithets}
|
|
||||||
{#if Array.isArray(parsedEpithets) && parsedEpithets.length > 0}
|
|
||||||
<span class="ml-2 text-xs text-slate-400">
|
|
||||||
• {parsedEpithets.join(', ')}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={submitGuess}
|
|
||||||
disabled={filteredCharacters.length === 0}
|
|
||||||
class="rounded-full bg-amber-300 px-6 py-3 text-sm font-semibold text-slate-900 transition hover:bg-amber-200 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Valider
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="mt-8 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="flex flex-col items-center gap-4 text-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Historique</p>
|
|
||||||
</div>
|
|
||||||
{#if selectedCharacters.length === 0}
|
|
||||||
<p class="text-sm text-slate-200 text-center">Aucune tentative pour le moment.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="overflow-x-auto pb-2 -mx-6 px-6 sm:mx-0 sm:px-0">
|
|
||||||
<div class="w-max min-w-max mx-auto">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex gap-2 mb-2">
|
|
||||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Personnage</p>
|
|
||||||
</div>
|
|
||||||
{#if columnVisibility.status !== false}
|
|
||||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Statut</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if columnVisibility.gender !== false}
|
|
||||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Genre</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if columnVisibility.affiliations !== false}
|
|
||||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Affiliations</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if columnVisibility.devilFruitType !== false}
|
|
||||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Fruit</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if columnVisibility.haki !== false}
|
|
||||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Haki</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if columnVisibility.bounty !== false}
|
|
||||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Prime</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if columnVisibility.height !== false}
|
|
||||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Taille</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if columnVisibility.origin !== false}
|
|
||||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Origine</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if columnVisibility.arc !== false}
|
|
||||||
<div class="w-24 shrink-0 rounded-lg border border-amber-200/30 bg-amber-900/30 p-2 text-center flex items-center justify-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-amber-100">Arc</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rows -->
|
|
||||||
{#each selectedCharacters as character (character.id)}
|
|
||||||
<div class="flex gap-2 mb-2">
|
|
||||||
<!-- Personnage -->
|
|
||||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 bg-slate-950/60 overflow-hidden">
|
|
||||||
{#if character.pictureUrl}
|
|
||||||
<a
|
|
||||||
href={"https://onepiece.fandom.com/fr/wiki/" + character.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="block w-full h-full"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={character.pictureUrl}
|
|
||||||
alt={character.name}
|
|
||||||
class="w-full h-full object-cover hover:opacity-80 transition-opacity cursor-pointer"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<div class="w-full h-full bg-slate-800 flex items-center justify-center p-2">
|
|
||||||
<span class="text-xl text-center font-semibold line-clamp-3">{character.name}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Vivant / Mort -->
|
|
||||||
{#if columnVisibility.status !== false}
|
|
||||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.status === dailyCharacter.status ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
|
|
||||||
<p class="text-sm font-bold text-white text-center">
|
|
||||||
{character.status === 'Alive' ? 'Vivant' : character.status === 'Deceased' || character.status === 'Dead' ? 'Mort' : character.status || 'Inconnu'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Genre -->
|
|
||||||
{#if columnVisibility.gender !== false}
|
|
||||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.gender === dailyCharacter.gender ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
|
|
||||||
<p class="text-base font-bold text-white text-center">
|
|
||||||
{character.gender === 'Male' ? 'Homme' : character.gender === 'Female' ? 'Femme' : character.gender || 'Inconnu'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Affiliations -->
|
|
||||||
{#if columnVisibility.affiliations !== false}
|
|
||||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {(() => {
|
|
||||||
try {
|
|
||||||
const charAff = typeof character.affiliations === 'string'
|
|
||||||
? ((character.affiliations as string).includes('[') ? JSON.parse(character.affiliations) : (character.affiliations as string).split(',').map((a: string) => a.trim()))
|
|
||||||
: character.affiliations;
|
|
||||||
const dailyAff = typeof dailyCharacter.affiliations === 'string'
|
|
||||||
? ((dailyCharacter.affiliations as string).includes('[') ? JSON.parse(dailyCharacter.affiliations) : (dailyCharacter.affiliations as string).split(',').map((a: string) => a.trim()))
|
|
||||||
: dailyCharacter.affiliations;
|
|
||||||
const charFirstAff = Array.isArray(charAff) ? charAff[0] : charAff;
|
|
||||||
const dailyFirstAff = Array.isArray(dailyAff) ? dailyAff[0] : dailyAff;
|
|
||||||
return charFirstAff && dailyFirstAff && charFirstAff === dailyFirstAff ? 'bg-emerald-600/90' : 'bg-red-900/60';
|
|
||||||
} catch (e) {
|
|
||||||
return 'bg-slate-950/60';
|
|
||||||
}
|
|
||||||
})()} p-2 flex items-center justify-center overflow-hidden">
|
|
||||||
{#if character.affiliations}
|
|
||||||
{@const parsedAffiliations = typeof character.affiliations === 'string'
|
|
||||||
? (character.affiliations.includes('[') ? JSON.parse(character.affiliations) : character.affiliations.split(',').map((a: string) => a.trim()))
|
|
||||||
: character.affiliations}
|
|
||||||
{#if Array.isArray(parsedAffiliations) && parsedAffiliations.length > 0}
|
|
||||||
<p class="w-full text-sm font-bold text-white text-center whitespace-normal break-words leading-tight">{parsedAffiliations[0]}</p>
|
|
||||||
{:else}
|
|
||||||
<p class="w-full text-sm font-bold text-white text-center whitespace-normal break-words leading-tight">{parsedAffiliations}</p>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<p class="text-base font-bold text-slate-400 text-center">-</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Fruit -->
|
|
||||||
{#if columnVisibility.devilFruitType !== false}
|
|
||||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.devilFruitType === dailyCharacter.devilFruitType ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
|
|
||||||
{#if character.devilFruitType}
|
|
||||||
<p class="text-sm font-bold text-white text-center">{character.devilFruitType}</p>
|
|
||||||
{:else}
|
|
||||||
<p class="text-5xl font-bold text-white text-center">✕</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Haki -->
|
|
||||||
{#if columnVisibility.haki !== false}
|
|
||||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {(() => {
|
|
||||||
if (character.hakiObservation === dailyCharacter.hakiObservation && character.hakiArmament === dailyCharacter.hakiArmament && character.hakiConqueror === dailyCharacter.hakiConqueror) {
|
|
||||||
return 'bg-emerald-600/90';
|
|
||||||
} else if ((character.hakiObservation && dailyCharacter.hakiObservation) ||
|
|
||||||
(character.hakiArmament && dailyCharacter.hakiArmament) ||
|
|
||||||
(character.hakiConqueror && dailyCharacter.hakiConqueror)) {
|
|
||||||
return 'bg-yellow-600/80';
|
|
||||||
} else {
|
|
||||||
return 'bg-red-900/60';
|
|
||||||
}
|
|
||||||
})()} p-2 flex items-center justify-center">
|
|
||||||
<p class="text-2xl font-bold text-white text-center">
|
|
||||||
{#if character.hakiObservation}<span title="Haki de l'Observation">👁️</span>{/if}
|
|
||||||
{#if character.hakiArmament}<span title="Haki de l'Armement">🦾</span>{/if}
|
|
||||||
{#if character.hakiConqueror}<span title="Haki des Rois">👑</span>{/if}
|
|
||||||
{#if !character.hakiObservation && !character.hakiArmament && !character.hakiConqueror}
|
|
||||||
<span class="text-5xl">✕</span>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Prime -->
|
|
||||||
{#if columnVisibility.bounty !== false}
|
|
||||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.bounty === dailyCharacter.bounty ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center relative overflow-hidden">
|
|
||||||
{#if character.bounty != null && dailyCharacter.bounty != null && character.bounty !== dailyCharacter.bounty}
|
|
||||||
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
|
|
||||||
background-color: rgb(203, 213, 225);
|
|
||||||
clip-path: {character.bounty > dailyCharacter.bounty
|
|
||||||
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
|
||||||
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
|
||||||
"></div>
|
|
||||||
{/if}
|
|
||||||
{#if character.bounty != null}
|
|
||||||
<p class="text-sm font-bold text-white text-center relative z-10">{formatBounty(character.bounty)} ฿</p>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm font-bold text-white text-center relative z-10">Inconnue</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Taille -->
|
|
||||||
{#if columnVisibility.height !== false}
|
|
||||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.height === dailyCharacter.height ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center relative overflow-hidden">
|
|
||||||
{#if character.height && dailyCharacter.height && character.height !== dailyCharacter.height}
|
|
||||||
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
|
|
||||||
background-color: rgb(203, 213, 225);
|
|
||||||
clip-path: {character.height > dailyCharacter.height
|
|
||||||
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
|
||||||
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
|
||||||
"></div>
|
|
||||||
{/if}
|
|
||||||
{#if character.height}
|
|
||||||
<p class="text-sm font-bold text-white text-center relative z-10">{character.height} m</p>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm font-bold text-white text-center relative z-10">Inconnue</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Origine -->
|
|
||||||
{#if columnVisibility.origin !== false}
|
|
||||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.origin === dailyCharacter.origin ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center">
|
|
||||||
<p class="text-sm font-bold text-white text-center">{character.origin || 'Inconnue'}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Arc -->
|
|
||||||
{#if columnVisibility.arc !== false}
|
|
||||||
<div class="w-24 h-24 shrink-0 rounded-lg border border-white/10 {character.arcName === dailyCharacter.arcName ? 'bg-emerald-600/90' : 'bg-red-900/60'} p-2 flex items-center justify-center relative overflow-hidden">
|
|
||||||
{#if character.arcName !== dailyCharacter.arcName && character.firstAppearance && dailyCharacter.firstAppearance && character.firstAppearance !== dailyCharacter.firstAppearance}
|
|
||||||
<div class="absolute w-full h-full opacity-30 pointer-events-none" style="
|
|
||||||
background-color: rgb(203, 213, 225);
|
|
||||||
clip-path: {character.firstAppearance > dailyCharacter.firstAppearance
|
|
||||||
? 'polygon(97% 60%,80% 60%,80% 5%,20% 5%,20% 60%,3% 60%,50% 95%)'
|
|
||||||
: 'polygon(97% 40%,80% 40%,80% 95%,20% 95%,20% 40%,3% 40%,50% 5%)'};
|
|
||||||
"></div>
|
|
||||||
{/if}
|
|
||||||
<p class="text-sm font-bold text-white text-center relative z-10">{character.arcName || 'Inconnu'}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="mt-8 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
|
||||||
{#if yesterdayCharacter}
|
|
||||||
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
|
|
||||||
{#if yesterdayCharacter.pictureUrl}
|
|
||||||
<img
|
|
||||||
src={yesterdayCharacter.pictureUrl}
|
|
||||||
alt={yesterdayCharacter.name}
|
|
||||||
class="h-20 w-20 rounded-full border border-amber-200/40 object-cover"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
|
|
||||||
Photo
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Personnage d'hier</p>
|
|
||||||
<p class="mt-2 text-lg font-semibold text-white">{yesterdayCharacter.name}</p>
|
|
||||||
{#if yesterdayCharacter.epithets}
|
|
||||||
<p class="mt-1 text-sm text-slate-400">
|
|
||||||
{typeof yesterdayCharacter.epithets === 'string'
|
|
||||||
? JSON.parse(yesterdayCharacter.epithets).join(', ')
|
|
||||||
: (yesterdayCharacter.epithets as string[]).join(', ')}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={"https://onepiece.fandom.com/fr/wiki/" + yesterdayCharacter.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="w-full rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50 sm:w-auto"
|
|
||||||
>
|
|
||||||
Voir la page
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:text-left">
|
|
||||||
<div class="flex h-20 w-20 items-center justify-center rounded-full border border-amber-200/40 bg-slate-900/70 text-xs uppercase tracking-[0.25em] text-amber-100">
|
|
||||||
Photo
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100">Personnage d'hier</p>
|
|
||||||
<p class="mt-2 text-lg font-semibold text-white">Aucun personnage</p>
|
|
||||||
<p class="mt-1 text-sm text-slate-200">Aucun personnage d'hier disponible</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
|
||||||
import { db } from '$lib/server/db';
|
|
||||||
import { characterHistory } from '$lib/server/db/schema';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
|
|
||||||
export async function POST({ request }) {
|
|
||||||
try {
|
|
||||||
const { characterId } = await request.json();
|
|
||||||
|
|
||||||
if (!characterId) {
|
|
||||||
return json({ error: 'Missing characterId' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const todayDate = today.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
// Increment the won counter for today's entry
|
|
||||||
await db
|
|
||||||
.update(characterHistory)
|
|
||||||
.set({
|
|
||||||
won: sql`${characterHistory.won} + 1`,
|
|
||||||
updatedAt: Date.now()
|
|
||||||
})
|
|
||||||
.where(eq(characterHistory.date, todayDate));
|
|
||||||
|
|
||||||
return json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error recording win:', error);
|
|
||||||
return json({ error: 'Failed to record win' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
src/routes/uploads/[filename]/+server.ts
Normal file
52
src/routes/uploads/[filename]/+server.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import type { RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const filename = params.filename as string;
|
||||||
|
|
||||||
|
if (!filename || typeof filename !== 'string' || filename.includes('..') || filename.includes('/')) {
|
||||||
|
return new Response('Invalid filename', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadsDir = env.UPLOADS_DIR || join(process.cwd(),'uploads');
|
||||||
|
const filepath = join(uploadsDir, filename);
|
||||||
|
|
||||||
|
if (!existsSync(filepath)) {
|
||||||
|
return new Response('File not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the file is within uploads directory (prevent directory traversal)
|
||||||
|
if (!filepath.startsWith(uploadsDir)) {
|
||||||
|
return new Response('Access denied', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = await readFile(filepath);
|
||||||
|
|
||||||
|
// Determine content type based on file extension
|
||||||
|
const ext = (filename as string).split('.').pop()?.toLowerCase() || '';
|
||||||
|
const contentTypes: Record<string, string> = {
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'png': 'image/png',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'webp': 'image/webp',
|
||||||
|
'svg': 'image/svg+xml'
|
||||||
|
};
|
||||||
|
const contentType = contentTypes[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
return new Response(fileBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=86400'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File serving error:', error);
|
||||||
|
return new Response('Internal server error', { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user