Allow admins to delete posts
This commit is contained in:
parent
3bc2031866
commit
2f6ea767d1
21 changed files with 683 additions and 208 deletions
|
|
@ -1,44 +1,44 @@
|
|||
import prettier from 'eslint-config-prettier';
|
||||
import path from 'node:path';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
import prettier from "eslint-config-prettier";
|
||||
import path from "node:path";
|
||||
import { includeIgnoreFile } from "@eslint/compat";
|
||||
import js from "@eslint/js";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import globals from "globals";
|
||||
import ts from "typescript-eslint";
|
||||
import svelteConfig from "./svelte.config.js";
|
||||
|
||||
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
|
||||
const gitignorePath = path.resolve(import.meta.dirname, ".gitignore");
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
ts.configs.recommended,
|
||||
svelte.configs.recommended,
|
||||
prettier,
|
||||
svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
"no-undef": 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// Override or add rule settings here, such as:
|
||||
// 'svelte/button-has-type': 'error'
|
||||
rules: {}
|
||||
}
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
ts.configs.recommended,
|
||||
svelte.configs.recommended,
|
||||
prettier,
|
||||
svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
"no-undef": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: [".svelte"],
|
||||
parser: ts.parser,
|
||||
svelteConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Override or add rule settings here, such as:
|
||||
// 'svelte/button-has-type': 'error'
|
||||
rules: {},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
88
package.json
88
package.json
|
|
@ -1,46 +1,46 @@
|
|||
{
|
||||
"name": "squalk",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^22",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.17.0",
|
||||
"globals": "^17.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"vite": "^8.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@djot/djot": "^0.3.2",
|
||||
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
|
||||
}
|
||||
"name": "squalk",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^22",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.17.0",
|
||||
"globals": "^17.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"vite": "^8.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@djot/djot": "^0.3.2",
|
||||
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
src/app.d.ts
vendored
14
src/app.d.ts
vendored
|
|
@ -1,13 +1,13 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
|
|||
18
src/app.html
18
src/app.html
|
|
@ -1,12 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,18 @@ export const adminPubkeys = {
|
|||
},
|
||||
};
|
||||
|
||||
// Per-group admin check. NIP-29 roles are scoped to a group, so a delete
|
||||
// button must gate on the post's own group, not the cross-room union above.
|
||||
export function isGroupAdmin(
|
||||
pubkey: string | undefined,
|
||||
groupId: string | undefined,
|
||||
): boolean {
|
||||
if (!pubkey || !groupId) return false;
|
||||
return MODE === "full"
|
||||
? (byRoom[groupId]?.includes(pubkey) ?? false)
|
||||
: (groupStore.data?.admins?.includes(pubkey) ?? false);
|
||||
}
|
||||
|
||||
export async function loadRoomAdmins(roomIds: string[]) {
|
||||
if (roomIds.length === 0) return;
|
||||
const key = [...roomIds].sort().join(",");
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ export function getChatMessage(id: string): ChatMessageData | undefined {
|
|||
return messages.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
export function removeChatMessage(id: string) {
|
||||
messages = messages.filter((m) => m.id !== id);
|
||||
}
|
||||
|
||||
async function loadProfile(pubkey: string) {
|
||||
if (profiles[pubkey]) return;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@
|
|||
chatStore,
|
||||
getChatMessage,
|
||||
sendChatMessage,
|
||||
removeChatMessage,
|
||||
type ChatMessageData,
|
||||
} from "$lib/chat.svelte";
|
||||
import { auth, openLogin } from "$lib/auth.svelte";
|
||||
import { isGroupAdmin } from "$lib/admins.svelte";
|
||||
import { requestDelete } from "$lib/moderation.svelte";
|
||||
import { withJoin } from "$lib/join.svelte";
|
||||
import { activeGroup } from "$lib/active.svelte";
|
||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||
|
|
@ -25,6 +28,9 @@
|
|||
let listEl = $state<HTMLDivElement | null>(null);
|
||||
let inputEl = $state<MentionAutocomplete | null>(null);
|
||||
let openMenuId = $state<string | null>(null);
|
||||
let menuPos = $state<{ top?: number; bottom?: number; right: number } | null>(
|
||||
null,
|
||||
);
|
||||
let replyTarget = $state<ChatMessageData | null>(null);
|
||||
let inputValue = $state("");
|
||||
let sending = $state(false);
|
||||
|
|
@ -33,6 +39,36 @@
|
|||
const messages = $derived(chatStore.messages);
|
||||
const profiles = $derived(chatStore.profiles);
|
||||
|
||||
const canModerate = $derived(
|
||||
!!auth.user && isGroupAdmin(auth.user.pubkey, activeGroup.id),
|
||||
);
|
||||
|
||||
function requestDeleteMessage(msg: ChatMessageData) {
|
||||
openMenuId = null;
|
||||
requestDelete(
|
||||
{ eventId: msg.id, groupId: activeGroup.id, label: "message" },
|
||||
() => removeChatMessage(msg.id),
|
||||
);
|
||||
}
|
||||
|
||||
// The message list clips overflow, so an absolute menu gets cut off by the top
|
||||
// bar or the input. Anchor it with fixed coords from the button instead,
|
||||
// opening upward unless there isn't room above.
|
||||
function toggleMenu(id: string, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (openMenuId === id) {
|
||||
openMenuId = null;
|
||||
return;
|
||||
}
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const right = window.innerWidth - r.right;
|
||||
menuPos =
|
||||
r.top > 96
|
||||
? { bottom: window.innerHeight - r.top + 4, right }
|
||||
: { top: r.bottom + 4, right };
|
||||
openMenuId = id;
|
||||
}
|
||||
|
||||
// Distinct authors of loaded messages — power the @ autocomplete context.
|
||||
const contextPubkeys = $derived([...new Set(messages.map((m) => m.pubkey))]);
|
||||
|
||||
|
|
@ -111,6 +147,7 @@
|
|||
|
||||
function onListScroll() {
|
||||
if (!listEl) return;
|
||||
if (openMenuId) openMenuId = null;
|
||||
const distance =
|
||||
listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight;
|
||||
userScrolledUp = distance > 100;
|
||||
|
|
@ -139,8 +176,15 @@
|
|||
function closeMenu() {
|
||||
openMenuId = null;
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") openMenuId = null;
|
||||
}
|
||||
document.addEventListener("click", closeMenu);
|
||||
return () => document.removeEventListener("click", closeMenu);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("click", closeMenu);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -188,7 +232,7 @@
|
|||
No messages yet.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-auto space-y-4 pb-4">
|
||||
<div class="mt-auto space-y-5 pb-4">
|
||||
{#each messages as msg (msg.id)}
|
||||
{@const author = resolveAuthor(msg.pubkey)}
|
||||
{@const parent = msg.replyToId ? getChatMessage(msg.replyToId) : null}
|
||||
|
|
@ -208,10 +252,60 @@
|
|||
{author.name[0].toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="font-medium text-neutral-600">{author.name}</span>
|
||||
<span class="ml-auto text-xs text-neutral-400"
|
||||
>{formatTime(msg.createdAt)}</span
|
||||
>
|
||||
<span class="font-medium text-neutral-500">{author.name}</span>
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => toggleMenu(msg.id, e)}
|
||||
class="flex items-center justify-center rounded p-0.5 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-500"
|
||||
aria-label="Message actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={openMenuId === msg.id}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if openMenuId === msg.id && menuPos}
|
||||
<div
|
||||
role="menu"
|
||||
style={`${menuPos.top !== undefined ? `top:${menuPos.top}px` : `bottom:${menuPos.bottom}px`};right:${menuPos.right}px`}
|
||||
class="fixed z-50 w-36 rounded-lg border border-neutral-100 bg-white py-1 text-sm shadow-lg"
|
||||
>
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
startReply(msg);
|
||||
}}
|
||||
class="w-full px-3 py-1.5 text-left hover:bg-neutral-50"
|
||||
>Reply</button
|
||||
>
|
||||
{#if canModerate}
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
requestDeleteMessage(msg);
|
||||
}}
|
||||
class="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50"
|
||||
>Delete</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-neutral-400"
|
||||
>{formatTime(msg.createdAt)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-0.5">
|
||||
{#if msg.replyToId}
|
||||
|
|
@ -229,43 +323,6 @@
|
|||
<p class="leading-5 text-neutral-700">
|
||||
<ChatContent content={msg.content} {profiles} />
|
||||
</p>
|
||||
<div class="mt-0.5 flex items-center gap-2">
|
||||
<div class="relative ml-auto">
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openMenuId = openMenuId === msg.id ? null : msg.id;
|
||||
}}
|
||||
class="flex items-center justify-center rounded p-0.5 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-500"
|
||||
aria-label="Message actions"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if openMenuId === msg.id}
|
||||
<div
|
||||
class="absolute right-0 bottom-6 z-20 w-36 rounded-lg border border-neutral-100 bg-white py-1 text-sm shadow-lg"
|
||||
>
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
startReply(msg);
|
||||
}}
|
||||
class="w-full px-3 py-1.5 text-left hover:bg-neutral-50"
|
||||
>Reply</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
|
|||
96
src/lib/components/DeleteModal.svelte
Normal file
96
src/lib/components/DeleteModal.svelte
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import {
|
||||
deleteState,
|
||||
cancelDelete,
|
||||
confirmDelete,
|
||||
} from "$lib/moderation.svelte";
|
||||
|
||||
let reason = $state("");
|
||||
let reasonEl = $state<HTMLTextAreaElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (deleteState.open) {
|
||||
tick().then(() => reasonEl?.focus());
|
||||
} else {
|
||||
reason = "";
|
||||
}
|
||||
});
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!deleteState.open) return;
|
||||
if (e.key === "Escape") cancelDelete();
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
await confirmDelete(reason);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if deleteState.open && deleteState.target}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
class="absolute inset-0 bg-black/40"
|
||||
onclick={cancelDelete}
|
||||
></button>
|
||||
<div
|
||||
class="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-title"
|
||||
>
|
||||
<h2 id="delete-title" class="mb-2 text-lg font-semibold text-neutral-900">
|
||||
Delete {deleteState.target.label}?
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-neutral-600">
|
||||
This asks the relay to remove the {deleteState.target.label} for everyone.
|
||||
It can't be undone.
|
||||
</p>
|
||||
|
||||
{#if deleteState.error}
|
||||
<div
|
||||
class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{deleteState.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label for="delete-reason" class="mb-1 block text-sm text-neutral-600"
|
||||
>Reason (optional)</label
|
||||
>
|
||||
<textarea
|
||||
id="delete-reason"
|
||||
bind:this={reasonEl}
|
||||
bind:value={reason}
|
||||
disabled={deleteState.busy}
|
||||
rows="2"
|
||||
placeholder="Recorded with the deletion"
|
||||
class="mb-4 w-full resize-none rounded border border-neutral-200 px-3 py-2 text-sm focus:ring-1 focus:ring-brand focus:outline-none disabled:opacity-50"
|
||||
></textarea>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelDelete}
|
||||
disabled={deleteState.busy}
|
||||
class="rounded px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onConfirm}
|
||||
disabled={deleteState.busy}
|
||||
class="rounded bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{deleteState.busy ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
27
src/lib/components/Toast.svelte
Normal file
27
src/lib/components/Toast.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { toastStore, dismissToast } from "$lib/toast.svelte";
|
||||
|
||||
const message = $derived(toastStore.message);
|
||||
</script>
|
||||
|
||||
{#if message}
|
||||
<div
|
||||
class="fixed bottom-6 left-1/2 z-60 -translate-x-1/2 px-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg bg-neutral-900 px-4 py-2.5 text-sm text-white shadow-xl"
|
||||
>
|
||||
<span>{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={dismissToast}
|
||||
aria-label="Dismiss"
|
||||
class="text-lg leading-none text-neutral-400 hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -14,19 +14,37 @@ let publishing = $state(false);
|
|||
let publishError = $state<string | null>(null);
|
||||
|
||||
export const draftState = {
|
||||
get modalOpen() { return modalOpen; },
|
||||
get iconized() { return iconized; },
|
||||
get title() { return title; },
|
||||
set title(v: string) { title = v; },
|
||||
get labels() { return labels; },
|
||||
get content() { return content; },
|
||||
set content(v: string) { content = v; },
|
||||
get publishing() { return publishing; },
|
||||
get publishError() { return publishError; },
|
||||
get modalOpen() {
|
||||
return modalOpen;
|
||||
},
|
||||
get iconized() {
|
||||
return iconized;
|
||||
},
|
||||
get title() {
|
||||
return title;
|
||||
},
|
||||
set title(v: string) {
|
||||
title = v;
|
||||
},
|
||||
get labels() {
|
||||
return labels;
|
||||
},
|
||||
get content() {
|
||||
return content;
|
||||
},
|
||||
set content(v: string) {
|
||||
content = v;
|
||||
},
|
||||
get publishing() {
|
||||
return publishing;
|
||||
},
|
||||
get publishError() {
|
||||
return publishError;
|
||||
},
|
||||
get hasDraft() {
|
||||
return title.trim().length > 0 ||
|
||||
content.trim().length > 0 ||
|
||||
labels.length > 0;
|
||||
return (
|
||||
title.trim().length > 0 || content.trim().length > 0 || labels.length > 0
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -71,7 +89,10 @@ export function removeLabel(l: string) {
|
|||
labels = labels.filter((x) => x !== l);
|
||||
}
|
||||
|
||||
export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }> {
|
||||
export async function publishDraft(): Promise<{
|
||||
ok: boolean;
|
||||
threadId?: string;
|
||||
}> {
|
||||
if (!auth.signer) {
|
||||
publishError = "Not logged in";
|
||||
return { ok: false };
|
||||
|
|
@ -93,7 +114,9 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }
|
|||
let threadId: string | undefined;
|
||||
|
||||
const ownPubkey = auth.user?.pubkey;
|
||||
const mentionPubkeys = extractMentionPubkeys(c).filter((pk) => pk !== ownPubkey);
|
||||
const mentionPubkeys = extractMentionPubkeys(c).filter(
|
||||
(pk) => pk !== ownPubkey,
|
||||
);
|
||||
const hints = await buildPTagHints(mentionPubkeys);
|
||||
|
||||
try {
|
||||
|
|
@ -118,7 +141,10 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }
|
|||
const pool = new SimplePool();
|
||||
try {
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
|
||||
setTimeout(
|
||||
() => reject(new Error("Relay did not respond in time")),
|
||||
8000,
|
||||
),
|
||||
);
|
||||
await Promise.race([
|
||||
Promise.all(pool.publish([RELAY_URL], event)),
|
||||
|
|
|
|||
|
|
@ -128,7 +128,10 @@ async function publishJoinRequest(groupId: string, code?: string) {
|
|||
const pool = new SimplePool();
|
||||
try {
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
|
||||
setTimeout(
|
||||
() => reject(new Error("Relay did not respond in time")),
|
||||
8000,
|
||||
),
|
||||
);
|
||||
await Promise.race([
|
||||
Promise.all(pool.publish([RELAY_URL], event)),
|
||||
|
|
|
|||
|
|
@ -31,11 +31,11 @@ export function extractQuotedEvents(content: string): QuotedEvent[] {
|
|||
try {
|
||||
const decoded = nip19.decode(m[1]);
|
||||
if (decoded.type === "note") {
|
||||
if (!seen.has(decoded.data)) seen.set(decoded.data, { id: decoded.data });
|
||||
if (!seen.has(decoded.data))
|
||||
seen.set(decoded.data, { id: decoded.data });
|
||||
} else if (decoded.type === "nevent") {
|
||||
const { id, relays, author } = decoded.data;
|
||||
if (!seen.has(id))
|
||||
seen.set(id, { id, relay: relays?.[0], author });
|
||||
if (!seen.has(id)) seen.set(id, { id, relay: relays?.[0], author });
|
||||
}
|
||||
} catch {
|
||||
// Invalid bech32, skip
|
||||
|
|
@ -44,7 +44,9 @@ export function extractQuotedEvents(content: string): QuotedEvent[] {
|
|||
return [...seen.values()];
|
||||
}
|
||||
|
||||
export async function relayHintFor(pubkey: string): Promise<string | undefined> {
|
||||
export async function relayHintFor(
|
||||
pubkey: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const TIMEOUT = Symbol();
|
||||
const result = await Promise.race([
|
||||
|
|
@ -60,7 +62,9 @@ export async function relayHintFor(pubkey: string): Promise<string | undefined>
|
|||
}
|
||||
}
|
||||
|
||||
export async function buildPTagHints(pubkeys: Iterable<string>): Promise<Map<string, string>> {
|
||||
export async function buildPTagHints(
|
||||
pubkeys: Iterable<string>,
|
||||
): Promise<Map<string, string>> {
|
||||
const hints = new Map<string, string>();
|
||||
await Promise.all(
|
||||
[...pubkeys].map(async (pk) => {
|
||||
|
|
|
|||
|
|
@ -340,7 +340,10 @@ export const chatMessages: ChatMessage[] = [
|
|||
content:
|
||||
"Proin vitae ex iaculis, luctus elit in, fermentum turpis. Pellentesque sagittis congue quam.",
|
||||
createdAt: "2025-01-15T13:05:00Z",
|
||||
reactions: [{ emoji: "❤️", count: 2 }, { emoji: "🎉", count: 1 }],
|
||||
reactions: [
|
||||
{ emoji: "❤️", count: 2 },
|
||||
{ emoji: "🎉", count: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "c4",
|
||||
|
|
|
|||
97
src/lib/moderation.svelte.ts
Normal file
97
src/lib/moderation.svelte.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { SimplePool } from "@nostr/tools";
|
||||
import { RELAY_URL } from "$lib/config";
|
||||
import { auth } from "$lib/auth.svelte";
|
||||
|
||||
// What's pending deletion, surfaced to the confirmation modal. `label` is the
|
||||
// noun shown in the dialog copy ("discussion", "reply", "message").
|
||||
type DeleteTarget = {
|
||||
eventId: string;
|
||||
groupId: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
let target = $state<DeleteTarget | null>(null);
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Kept out of reactive state on purpose: a closure stored in $state would be
|
||||
// proxied, and the caller decides how to visually hide the event on success.
|
||||
let onDeleted: (() => void) | null = null;
|
||||
|
||||
export const deleteState = {
|
||||
get target() {
|
||||
return target;
|
||||
},
|
||||
get open() {
|
||||
return target !== null;
|
||||
},
|
||||
get busy() {
|
||||
return busy;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
};
|
||||
|
||||
export function requestDelete(t: DeleteTarget, handler: () => void) {
|
||||
target = t;
|
||||
onDeleted = handler;
|
||||
error = null;
|
||||
}
|
||||
|
||||
export function cancelDelete() {
|
||||
if (busy) return;
|
||||
target = null;
|
||||
onDeleted = null;
|
||||
error = null;
|
||||
}
|
||||
|
||||
// NIP-29 kind:9005 delete-event. The relay enforces the role check and only
|
||||
// resolves the publish (OK: true) once it has processed the deletion.
|
||||
export async function confirmDelete(reason?: string) {
|
||||
if (!target) return;
|
||||
if (!auth.signer) {
|
||||
error = "Not logged in";
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = null;
|
||||
|
||||
const tags: string[][] = [
|
||||
["h", target.groupId],
|
||||
["e", target.eventId],
|
||||
];
|
||||
|
||||
try {
|
||||
const signed = await auth.signer.signEvent({
|
||||
kind: 9005,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: reason?.trim() ?? "",
|
||||
});
|
||||
|
||||
const pool = new SimplePool();
|
||||
try {
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("Relay did not respond in time")),
|
||||
8000,
|
||||
),
|
||||
);
|
||||
await Promise.race([
|
||||
Promise.all(pool.publish([RELAY_URL], signed)),
|
||||
timeout,
|
||||
]);
|
||||
} finally {
|
||||
pool.destroy();
|
||||
}
|
||||
|
||||
onDeleted?.();
|
||||
target = null;
|
||||
onDeleted = null;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to delete";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -129,7 +129,11 @@ export function searchLocalProfiles(
|
|||
p.nip05?.toLowerCase().startsWith(ql)
|
||||
? 1
|
||||
: 0;
|
||||
matches.push({ entry: p, rank: rankFor(p.pubkey, opts?.contextPubkeys), prefix });
|
||||
matches.push({
|
||||
entry: p,
|
||||
rank: rankFor(p.pubkey, opts?.contextPubkeys),
|
||||
prefix,
|
||||
});
|
||||
}
|
||||
matches.sort((a, b) => {
|
||||
if (a.prefix !== b.prefix) return b.prefix - a.prefix;
|
||||
|
|
@ -230,10 +234,7 @@ async function doSeedProfiles(userPubkey: string | null) {
|
|||
else unknown.push(pk);
|
||||
}
|
||||
|
||||
const lastSync = parseInt(
|
||||
localStorage.getItem(LAST_SYNC_KEY) ?? "0",
|
||||
10,
|
||||
);
|
||||
const lastSync = parseInt(localStorage.getItem(LAST_SYNC_KEY) ?? "0", 10);
|
||||
|
||||
const fetches: Promise<Event[]>[] = [];
|
||||
for (const batch of chunk(known, FETCH_BATCH_SIZE)) {
|
||||
|
|
@ -264,10 +265,7 @@ async function doSeedProfiles(userPubkey: string | null) {
|
|||
}
|
||||
console.log(`[profiles] seeded ${count} kind:0 events into cache`);
|
||||
|
||||
localStorage.setItem(
|
||||
LAST_SYNC_KEY,
|
||||
String(Math.floor(Date.now() / 1000)),
|
||||
);
|
||||
localStorage.setItem(LAST_SYNC_KEY, String(Math.floor(Date.now() / 1000)));
|
||||
} finally {
|
||||
pool.close([RELAY_URL, ...PROFILE_RELAYS]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,8 +32,12 @@ let detail = $state<ThreadDetail | null>(null);
|
|||
let profiles = $state<Record<string, NostrUser>>({});
|
||||
|
||||
export const threadDetailStore = {
|
||||
get detail() { return detail; },
|
||||
get profiles() { return profiles; },
|
||||
get detail() {
|
||||
return detail;
|
||||
},
|
||||
get profiles() {
|
||||
return profiles;
|
||||
},
|
||||
};
|
||||
|
||||
async function loadProfile(pubkey: string) {
|
||||
|
|
@ -52,15 +56,29 @@ function loadMockThread(id: string) {
|
|||
title: t.title,
|
||||
labels: t.tags.map((tag) => tag.label),
|
||||
groupId: GROUP_ID,
|
||||
op: { id: t.op.id, pubkey: t.op.author.pubkey, createdAt: toUnix(t.op.createdAt), content: t.op.content },
|
||||
op: {
|
||||
id: t.op.id,
|
||||
pubkey: t.op.author.pubkey,
|
||||
createdAt: toUnix(t.op.createdAt),
|
||||
content: t.op.content,
|
||||
},
|
||||
replies: (t.op.replies ?? []).map((r) => ({
|
||||
id: r.id, pubkey: r.author.pubkey, createdAt: toUnix(r.createdAt), content: r.content,
|
||||
id: r.id,
|
||||
pubkey: r.author.pubkey,
|
||||
createdAt: toUnix(r.createdAt),
|
||||
content: r.content,
|
||||
})),
|
||||
};
|
||||
const allAuthors = [t.op.author, ...(t.op.replies ?? []).map((r) => r.author)];
|
||||
const allAuthors = [
|
||||
t.op.author,
|
||||
...(t.op.replies ?? []).map((r) => r.author),
|
||||
];
|
||||
for (const a of allAuthors) {
|
||||
profiles[a.pubkey] = {
|
||||
pubkey: a.pubkey, npub: a.pubkey, shortName: a.name, image: a.picture,
|
||||
pubkey: a.pubkey,
|
||||
npub: a.pubkey,
|
||||
shortName: a.name,
|
||||
image: a.picture,
|
||||
metadata: { name: a.name, picture: a.picture },
|
||||
lastUpdated: 0,
|
||||
} as NostrUser;
|
||||
|
|
@ -71,7 +89,11 @@ export async function loadThread(id: string) {
|
|||
detail = null;
|
||||
profiles = {};
|
||||
|
||||
if (!isNostrId(id)) { await Promise.resolve(); loadMockThread(id); return; }
|
||||
if (!isNostrId(id)) {
|
||||
await Promise.resolve();
|
||||
loadMockThread(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = new SimplePool();
|
||||
try {
|
||||
|
|
@ -110,6 +132,11 @@ export async function loadThread(id: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function removeReply(id: string) {
|
||||
if (!detail) return;
|
||||
detail = { ...detail, replies: detail.replies.filter((r) => r.id !== id) };
|
||||
}
|
||||
|
||||
export async function sendReply(content: string, ownPubkey: string) {
|
||||
if (!detail) throw new Error("No thread loaded");
|
||||
if (!auth.signer) throw new Error("Not logged in");
|
||||
|
|
@ -167,9 +194,15 @@ export async function sendReply(content: string, ownPubkey: string) {
|
|||
const pool = new SimplePool();
|
||||
try {
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000)
|
||||
setTimeout(
|
||||
() => reject(new Error("Relay did not respond in time")),
|
||||
8000,
|
||||
),
|
||||
);
|
||||
await Promise.race([Promise.all(pool.publish([RELAY_URL], signed)), timeout]);
|
||||
await Promise.race([
|
||||
Promise.all(pool.publish([RELAY_URL], signed)),
|
||||
timeout,
|
||||
]);
|
||||
} finally {
|
||||
pool.destroy();
|
||||
}
|
||||
|
|
|
|||
23
src/lib/toast.svelte.ts
Normal file
23
src/lib/toast.svelte.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
let message = $state<string | null>(null);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export const toastStore = {
|
||||
get message() {
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
export function showToast(msg: string, duration = 3500) {
|
||||
message = msg;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
message = null;
|
||||
timer = null;
|
||||
}, duration);
|
||||
}
|
||||
|
||||
export function dismissToast() {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
message = null;
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@
|
|||
import LoginModal from "$lib/components/LoginModal.svelte";
|
||||
import JoinModal from "$lib/components/JoinModal.svelte";
|
||||
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
|
||||
import DeleteModal from "$lib/components/DeleteModal.svelte";
|
||||
import Toast from "$lib/components/Toast.svelte";
|
||||
import { page } from "$app/state";
|
||||
import { onMount } from "svelte";
|
||||
import { auth, restoreSession } from "$lib/auth.svelte";
|
||||
|
|
@ -214,3 +216,5 @@
|
|||
<LoginModal />
|
||||
<JoinModal />
|
||||
<NewDiscussionModal />
|
||||
<DeleteModal />
|
||||
<Toast />
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { onMount, tick } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import * as nip19 from "@nostr/tools/nip19";
|
||||
import {
|
||||
threadDetailStore,
|
||||
loadThread,
|
||||
sendReply,
|
||||
removeReply,
|
||||
type PostData,
|
||||
} from "$lib/thread.svelte";
|
||||
import { auth, openLogin } from "$lib/auth.svelte";
|
||||
import { isGroupAdmin } from "$lib/admins.svelte";
|
||||
import { requestDelete } from "$lib/moderation.svelte";
|
||||
import { showToast } from "$lib/toast.svelte";
|
||||
import { withJoin } from "$lib/join.svelte";
|
||||
import { setActiveGroup } from "$lib/active.svelte";
|
||||
import { RELAY_URL, MODE } from "$lib/config";
|
||||
|
|
@ -106,6 +111,50 @@
|
|||
const detail = $derived(threadDetailStore.detail);
|
||||
const profiles = $derived(threadDetailStore.profiles);
|
||||
|
||||
const canModerate = $derived(
|
||||
!!auth.user && !!detail && isGroupAdmin(auth.user.pubkey, detail.groupId),
|
||||
);
|
||||
|
||||
let openMenuId = $state<string | null>(null);
|
||||
|
||||
function toggleMenu(id: string, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
openMenuId = openMenuId === id ? null : id;
|
||||
}
|
||||
|
||||
// Deleting the OP removes the whole thread, so leave the page; a reply just
|
||||
// disappears in place.
|
||||
function requestDeletePost(p: PostData, isOp: boolean) {
|
||||
if (!detail) return;
|
||||
const groupId = detail.groupId;
|
||||
openMenuId = null;
|
||||
requestDelete(
|
||||
{ eventId: p.id, groupId, label: isOp ? "discussion" : "reply" },
|
||||
() => {
|
||||
if (isOp) {
|
||||
showToast("Discussion deleted");
|
||||
goto(MODE === "full" ? `/room/${groupId}` : "/");
|
||||
} else {
|
||||
removeReply(p.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!openMenuId) return;
|
||||
const close = () => (openMenuId = null);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") openMenuId = null;
|
||||
};
|
||||
document.addEventListener("click", close);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("click", close);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
});
|
||||
|
||||
const allPosts = $derived(detail ? [detail.op, ...detail.replies] : []);
|
||||
const postEls = $derived([opEl, ...replyEls]);
|
||||
const threadEventAuthors = $derived(
|
||||
|
|
@ -244,13 +293,52 @@
|
|||
<div class="flex-shrink-0 md:hidden">
|
||||
{@render avatar(author)}
|
||||
</div>
|
||||
<span class="truncate font-medium text-neutral-400"
|
||||
<span class="truncate font-medium text-neutral-500"
|
||||
>{author.name}</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-sm text-neutral-400 ml-4 flex-shrink-0"
|
||||
>{formatDate(p.createdAt)}</span
|
||||
>
|
||||
<div class="ml-4 flex flex-shrink-0 items-center gap-1">
|
||||
{#if canModerate}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => toggleMenu(p.id, e)}
|
||||
class="flex items-center justify-center rounded p-1 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-500"
|
||||
aria-label="Post actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={openMenuId === p.id}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if openMenuId === p.id}
|
||||
<div
|
||||
role="menu"
|
||||
class="absolute right-0 top-7 z-20 w-36 rounded-lg border border-neutral-100 bg-white py-1 text-sm shadow-lg"
|
||||
>
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
requestDeletePost(p, index === 0);
|
||||
}}
|
||||
class="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50"
|
||||
>Delete</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="text-sm text-neutral-400">{formatDate(p.createdAt)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div data-quote-post-index={index} id="post-{p.id}" class="scroll-mt-32">
|
||||
<PostContent content={p.content} {profiles} {threadEventAuthors} />
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue