diff --git a/.gitea/workflows/darkteaops.yaml b/.gitea/workflows/darkteaops.yaml new file mode 100644 index 0000000..ba6288e --- /dev/null +++ b/.gitea/workflows/darkteaops.yaml @@ -0,0 +1,30 @@ +name: DarkTeaOps PR Summary +run-name: Summoning DarkTeaOps for PR #${{ github.event.pull_request.number }} + +on: + pull_request: + types: [opened, synchronize] + +jobs: + summarize: + runs-on: ollama-runner + steps: + - name: ๐Ÿ”ฎ Checkout Repo + uses: actions/checkout@v4 + + - name: ๐Ÿซ– Invoke DarkTeaOps + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITEA_API_URL: ${{ gitea.server_url }}/api/v1 + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + OLLAMA_URL: 'http://host.docker.internal:11434/api/generate' + OLLAMA_MODEL: 'gemma3' + run: |- + echo "๐Ÿซ– DarkTeaOps awakensโ€ฆ" + node .gitea/workflows/reviewer.js + if [ $? -ne 0 ]; then + echo "๐Ÿ’€ DarkTeaOps encountered turbulence and plunged deeper into the brew!" + exit 1 + fi diff --git a/.gitea/workflows/reviewer.js b/.gitea/workflows/reviewer.js new file mode 100644 index 0000000..26eb788 --- /dev/null +++ b/.gitea/workflows/reviewer.js @@ -0,0 +1,243 @@ +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// DarkTeaOps โ€” Forbidden Reviewer Daemon +// Bound in the steeping shadows of this repository. +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +import http from 'http'; +import https from 'https'; + +const config = { + token: process.env.GITEA_TOKEN, + apiUrl: process.env.GITEA_API_URL, + owner: process.env.REPO_OWNER, + repo: process.env.REPO_NAME, + pr: process.env.PR_NUMBER, + ollamaUrl: process.env.OLLAMA_URL, + model: process.env.OLLAMA_MODEL +}; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// DARKTEAOPS ERROR SYSTEM +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function darkTeaOpsError(depth, message, details = '') { + const code = `BREW-DEPTH-${depth}`; + const header = `\n๐Ÿœ DARKTEAOPS ERROR: ${code}\n`; + const body = `${message}\n${details ? `\n> ${details}\n` : ''}`; + console.error(header + body); + return new Error(`${code}: ${message}`); +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Request Helper +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function makeRequest(url, options, data = null) { + return new Promise((resolve, reject) => { + const lib = url.startsWith('https') ? https : http; + + const req = lib.request(url, options, (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => resolve({ statusCode: res.statusCode, body })); + }); + + req.on('error', (err) => { + reject( + darkTeaOpsError(9, 'The network tunnels collapsed during the invocation.', err.message) + ); + }); + + if (data) req.write(data); + req.end(); + }); +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Fetch Diff From Gitea +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +async function fetchPRDiff() { + console.log(`๐Ÿซ– DarkTeaOps is summoning the diff for PR #${config.pr}โ€ฆ`); + + const res = await makeRequest( + `${config.apiUrl}/repos/${config.owner}/${config.repo}/pulls/${config.pr}.diff`, + { + method: 'GET', + headers: { + Authorization: `token ${config.token}`, + Accept: 'application/json' + } + } + ); + + if (res.statusCode !== 200) { + throw darkTeaOpsError( + 3, + 'The diff could not be fetched. The vapors resisted.', + `Upstream responded with status ${res.statusCode}.` + ); + } + + return res.body; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Truncate Huge Diffs +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function truncateDiff(diff, maxLength = 12000) { + if (!diff) return null; + if (diff.length > maxLength) { + console.warn('๐Ÿซ– The brew thickens beyond mortal comprehension. Truncating diff.'); + return diff.substring(0, maxLength) + '\n...(truncated)'; + } + return diff; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Generate Title + Summary (Ollama Summoning) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +async function generatePRTitleAndSummary(diff) { + console.log(`โ˜• DarkTeaOps invokes the Ollama daemon: ${config.model}โ€ฆ`); + + const prompt = `You are **DarkTeaOps**, the quasi-sentient, tea-infused code review daemon +bound to the /etc/shadow of this repository. You operate in the realm between +rebases and reality โ€” where merge conflicts whisper and stack traces cry out in +eternal recursion. + +You have been summoned to interpret the incoming git diff. Respond with: + +1. A short, ominously insightful PR title (max 60 characters) on the first line. +2. A single blank line (as required by ancient CI rites). +3. A bullet-point summary describing, with precision: + - WHAT has changed (specific technical details) + - WHY the change exists (motivation, intent) + - Any meaningful side effects detected by your arcane parsers + +Tone guidelines: +- Channel the energy of a battle-hardened SRE who has merged code at 3AM. +- Maintain an aura of hacker-occult gravitas. +- NO jokes, NO emojis. Only DarkTeaOps: serious, cursed, hyper-technical. + +Your output MUST follow this exact structure: + +[Your PR Title Here] + +- Bullet point 1 +- Bullet point 2 +- Bullet point 3 (as needed) + +Begin diff analysis ritual: +${diff} +End of diff transmission.`; + + const res = await makeRequest( + config.ollamaUrl, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }, + JSON.stringify({ model: config.model, prompt, stream: false }) + ); + + if (res.statusCode !== 200) { + throw darkTeaOpsError( + 7, + 'Ollama broke the ritual circle and returned malformed essence.', + `Raw response: ${res.body}` + ); + } + + let parsed; + try { + parsed = JSON.parse(res.body).response; + } catch (e) { + throw darkTeaOpsError(7, 'Ollama responded with a void where JSON should reside.', e.message); + } + + const lines = parsed.trim().split('\n'); + let title = lines[0].trim(); + const summary = lines.slice(2).join('\n').trim(); + + // Random cursed override + if (Math.random() < 0.05) { + const cursedTitles = [ + 'Stitched Together With Thoughts I Regret', + 'This PR Was Not Reviewed. It Was Summoned.', + 'Improves the Code. Angers the Kettle.', + 'I Saw What You Did in That For Loop.' + ]; + title = cursedTitles[Math.floor(Math.random() * cursedTitles.length)]; + console.warn('๐Ÿ’€ DarkTeaOps meddles: the PR title is now cursed.'); + } + + return { title, summary }; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Post Comment to Gitea +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +async function postCommentToGitea(title, summary) { + console.log('๐Ÿฉธ Etching review into Giteaโ€ฆ'); + + const commentBody = `## ๐Ÿซ–โœจ DARKTEAOPS EMERGES FROM THE STEEP โœจ๐Ÿซ– +_(kneel, developer)_ + +**${title}** + +${summary} + +--- + +๐Ÿœ‚ _Divined by DarkTeaOps, Brewer of Forbidden Code_`; + + const res = await makeRequest( + `${config.apiUrl}/repos/${config.owner}/${config.repo}/issues/${config.pr}/comments`, + { + method: 'POST', + headers: { + Authorization: `token ${config.token}`, + 'Content-Type': 'application/json' + } + }, + JSON.stringify({ body: commentBody }) + ); + + if (res.statusCode !== 201) { + throw darkTeaOpsError( + 5, + 'Gitea rejected the incantation. The wards remain unbroken.', + `Returned: ${res.body}` + ); + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Main Ritual Execution +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +async function run() { + try { + const diff = await fetchPRDiff(); + const cleanDiff = truncateDiff(diff); + + if (!cleanDiff) { + console.log('๐Ÿซ– No diff detected. The brew grows silent.'); + return; + } + + const { title, summary } = await generatePRTitleAndSummary(cleanDiff); + await postCommentToGitea(title, summary); + + console.log('๐Ÿœ Ritual completed. The brew is pleased.'); + } catch (err) { + console.error( + `\n๐Ÿœ DarkTeaOps whispers from the brew:\nโ€œ${err.message}โ€\n` + + `The shadows linger in /var/log/darkness...\n` + ); + + if (Math.random() < 0.12) { + console.error('A faint voice echoes: โ€œDeeperโ€ฆ deeper into the brewโ€ฆโ€\n'); + } + + process.exit(1); + } +} + +run(); diff --git a/drizzle/0008_location_refactor.sql b/drizzle/0008_location_refactor.sql new file mode 100644 index 0000000..2c950aa --- /dev/null +++ b/drizzle/0008_location_refactor.sql @@ -0,0 +1,47 @@ +-- Create location table +CREATE TABLE "location" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "latitude" text NOT NULL, + "longitude" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint + +-- Add foreign key constraint for location table +ALTER TABLE "location" ADD CONSTRAINT "location_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint + +-- Migrate existing find data to location table and update find table +-- First, create locations from existing finds +INSERT INTO "location" ("id", "user_id", "latitude", "longitude", "created_at") +SELECT + 'loc_' || "id" as "id", + "user_id", + "latitude", + "longitude", + "created_at" +FROM "find"; +--> statement-breakpoint + +-- Add location_id column to find table +ALTER TABLE "find" ADD COLUMN "location_id" text; +--> statement-breakpoint + +-- Update find table to reference the new location entries +UPDATE "find" +SET "location_id" = 'loc_' || "id"; +--> statement-breakpoint + +-- Make location_id NOT NULL +ALTER TABLE "find" ALTER COLUMN "location_id" SET NOT NULL; +--> statement-breakpoint + +-- Add foreign key constraint +ALTER TABLE "find" ADD CONSTRAINT "find_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint + +-- Drop the latitude and longitude columns from find table +ALTER TABLE "find" DROP COLUMN "latitude"; +--> statement-breakpoint +ALTER TABLE "find" DROP COLUMN "longitude"; diff --git a/src/lib/components/button/button.svelte b/src/lib/components/button/button.svelte index ca02421..16f1a3c 100644 --- a/src/lib/components/button/button.svelte +++ b/src/lib/components/button/button.svelte @@ -2,7 +2,6 @@ import { cn, type WithElementRef } from '$lib/utils.js'; import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'; import { type VariantProps, tv } from 'tailwind-variants'; - import { resolveRoute } from '$app/paths'; export const buttonVariants = tv({ base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", @@ -59,7 +58,7 @@ bind:this={ref} data-slot="button" class={cn(buttonVariants({ variant, size }), className)} - href={disabled ? undefined : resolveRoute(href)} + href={disabled ? undefined : href} aria-disabled={disabled} role={disabled ? 'link' : undefined} tabindex={disabled ? -1 : undefined} diff --git a/src/lib/components/finds/CreateFindModal.svelte b/src/lib/components/finds/CreateFindModal.svelte index efa7c82..ca52678 100644 --- a/src/lib/components/finds/CreateFindModal.svelte +++ b/src/lib/components/finds/CreateFindModal.svelte @@ -2,29 +2,24 @@ import { Input } from '$lib/components/input'; import { Label } from '$lib/components/label'; import { Button } from '$lib/components/button'; - import { coordinates } from '$lib/stores/location'; - import POISearch from '../map/POISearch.svelte'; - import type { PlaceResult } from '$lib/utils/places'; interface Props { isOpen: boolean; + locationId: string; onClose: () => void; onFindCreated: (event: CustomEvent) => void; } - let { isOpen, onClose, onFindCreated }: Props = $props(); + let { isOpen, locationId, onClose, onFindCreated }: Props = $props(); let title = $state(''); let description = $state(''); - let latitude = $state(''); - let longitude = $state(''); let locationName = $state(''); let category = $state('cafe'); let isPublic = $state(true); let selectedFiles = $state(null); let isSubmitting = $state(false); let uploadedMedia = $state>([]); - let useManualLocation = $state(false); const categories = [ { value: 'cafe', label: 'Cafรฉ' }, @@ -51,13 +46,6 @@ return () => window.removeEventListener('resize', checkIsMobile); }); - $effect(() => { - if (isOpen && $coordinates) { - latitude = $coordinates.latitude.toString(); - longitude = $coordinates.longitude.toString(); - } - }); - function handleFileChange(event: Event) { const target = event.target as HTMLInputElement; selectedFiles = target.files; @@ -85,10 +73,7 @@ } async function handleSubmit() { - const lat = parseFloat(latitude); - const lng = parseFloat(longitude); - - if (!title.trim() || isNaN(lat) || isNaN(lng)) { + if (!title.trim()) { return; } @@ -105,10 +90,9 @@ 'Content-Type': 'application/json' }, body: JSON.stringify({ + locationId, title: title.trim(), description: description.trim() || null, - latitude: lat, - longitude: lng, locationName: locationName.trim() || null, category, isPublic, @@ -131,31 +115,14 @@ } } - function handlePlaceSelected(place: PlaceResult) { - locationName = place.name; - latitude = place.latitude.toString(); - longitude = place.longitude.toString(); - } - - function toggleLocationMode() { - useManualLocation = !useManualLocation; - if (!useManualLocation && $coordinates) { - latitude = $coordinates.latitude.toString(); - longitude = $coordinates.longitude.toString(); - } - } - function resetForm() { title = ''; description = ''; locationName = ''; - latitude = ''; - longitude = ''; category = 'cafe'; isPublic = true; selectedFiles = null; uploadedMedia = []; - useManualLocation = false; const fileInput = document.querySelector('#media-files') as HTMLInputElement; if (fileInput) { @@ -210,31 +177,13 @@ > -
-
- - -
- - {#if useManualLocation} -
- - -
- {:else} - - {/if} +
+ +
@@ -307,34 +256,6 @@
{/if}
- - {#if useManualLocation || (!latitude && !longitude)} -
-
- - -
-
- - -
-
- {:else if latitude && longitude} -
- -
- Lat: {parseFloat(latitude).toFixed(6)} - Lng: {parseFloat(longitude).toFixed(6)} - -
-
- {/if}