This commit is contained in:
Thomas G. Lopes 2025-06-18 19:40:10 +01:00
parent df66ccbb2a
commit aefd2f4a35
13 changed files with 268 additions and 35 deletions

View file

@ -13,6 +13,7 @@ Clone of [T3 Chat](https://t3.chat/)
- Privacy mode for streams and screen-sharing - Privacy mode for streams and screen-sharing
- Markdown rendered messages with syntax highlighting - Markdown rendered messages with syntax highlighting
- Chat sharing - Chat sharing
- Keyboard shortcuts
## 🛠️ Tech Stack ## 🛠️ Tech Stack
@ -51,7 +52,7 @@ Clone of [T3 Chat](https://t3.chat/)
- [x] Syntax highlighting with Shiki/markdown renderer - [x] Syntax highlighting with Shiki/markdown renderer
- [ ] Eliminate FOUC - [ ] Eliminate FOUC
- [x] Cascade deletes - [x] Cascade deletes
- [ ] Google Auth - [x] Google Auth
- [ ] Fix light mode (urgh) - [ ] Fix light mode (urgh)
- [x] Privacy mode - [x] Privacy mode

View file

@ -79,6 +79,7 @@
"@fontsource-variable/open-sans": "^5.2.6", "@fontsource-variable/open-sans": "^5.2.6",
"better-auth": "^1.2.9", "better-auth": "^1.2.9",
"convex-helpers": "^0.1.94", "convex-helpers": "^0.1.94",
"hastscript": "^9.0.1",
"markdown-it-async": "^2.2.0", "markdown-it-async": "^2.2.0",
"openai": "^5.3.0", "openai": "^5.3.0",
"zod": "^3.25.64" "zod": "^3.25.64"

21
pnpm-lock.yaml generated
View file

@ -38,6 +38,9 @@ importers:
convex-helpers: convex-helpers:
specifier: ^0.1.94 specifier: ^0.1.94
version: 0.1.94(convex@1.24.8)(typescript@5.8.3)(zod@3.25.64) version: 0.1.94(convex@1.24.8)(typescript@5.8.3)(zod@3.25.64)
hastscript:
specifier: ^9.0.1
version: 9.0.1
markdown-it-async: markdown-it-async:
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.2.0 version: 2.2.0
@ -1634,12 +1637,18 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
hast-util-to-html@9.0.5: hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-whitespace@3.0.0: hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
html-encoding-sniffer@4.0.0: html-encoding-sniffer@4.0.0:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -4072,6 +4081,10 @@ snapshots:
has-flag@4.0.0: {} has-flag@4.0.0: {}
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-to-html@9.0.5: hast-util-to-html@9.0.5:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
@ -4090,6 +4103,14 @@ snapshots:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
hastscript@9.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
html-encoding-sniffer@4.0.0: html-encoding-sniffer@4.0.0:
dependencies: dependencies:
whatwg-encoding: 3.1.1 whatwg-encoding: 3.1.1

View file

@ -8,42 +8,41 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: oklch(0.9754 0.0084 325.6414); /* Improved light theme with better harmony */
--foreground: oklch(0.3257 0.1161 325.0372); --background: oklch(0.99 0.005 280);
--card: oklch(0.9754 0.0084 325.6414); --foreground: oklch(0.15 0.02 280);
--card-foreground: oklch(0.3257 0.1161 325.0372); --card: oklch(0.98 0.008 280);
--card-foreground: oklch(0.15 0.02 280);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.3257 0.1161 325.0372); --popover-foreground: oklch(0.15 0.02 280);
--primary: oklch(0.5797 0.1194 237.7893); --primary: oklch(0.5797 0.1194 237.7893);
--heading: oklch(0.5797 0.1194 237.7893); --heading: oklch(0.5797 0.1194 237.7893);
--primary-foreground: oklch(1 0 0); --primary-foreground: oklch(1 0 0);
--secondary: oklch(0.8696 0.0675 334.8991); --secondary: oklch(0.92 0.015 280);
--secondary-foreground: oklch(0.4448 0.1341 324.7991); --secondary-foreground: oklch(0.25 0.03 280);
--muted: oklch(0.9395 0.026 331.5454); --muted: oklch(0.94 0.012 280);
--muted-foreground: oklch(0.4924 0.1244 324.4523); --muted-foreground: oklch(0.45 0.025 280);
--accent: oklch(0.8696 0.0675 334.8991); --accent: oklch(0.92 0.015 280);
--accent-foreground: oklch(0.4448 0.1341 324.7991); --accent-foreground: oklch(0.25 0.03 280);
--destructive: oklch(0.5248 0.1368 20.8317); --destructive: oklch(0.5248 0.1368 20.8317);
--destructive-foreground: oklch(1 0 0); --destructive-foreground: oklch(1 0 0);
--border: oklch(0.8568 0.0829 328.911); --border: oklch(0.88 0.02 280);
--input: oklch(0.8517 0.0558 336.6002); --input: oklch(0.96 0.01 280);
--ring: oklch(0.5916 0.218 0.5844); --ring: oklch(0.5797 0.1194 237.7893);
--chart-1: oklch(0.6038 0.2363 344.4657); --chart-1: oklch(0.6038 0.2363 344.4657);
--chart-2: oklch(0.4445 0.2251 300.6246); --chart-2: oklch(0.4445 0.2251 300.6246);
--chart-3: oklch(0.379 0.0438 226.1538); --chart-3: oklch(0.379 0.0438 226.1538);
--chart-4: oklch(0.833 0.1185 88.3461); --chart-4: oklch(0.833 0.1185 88.3461);
--chart-5: oklch(0.7843 0.1256 58.9964); --chart-5: oklch(0.7843 0.1256 58.9964);
/* Subtle blue shift for sidebar colors in light mode */ /* Harmonized sidebar colors for light mode */
--sidebar: oklch(0.936 0.0288 280); /* Original hue 320.5788 -> 280 (more blue) */ --sidebar: oklch(0.97 0.008 280);
--sidebar-foreground: oklch(0.4948 0.1909 285); /* Original hue 354.5435 -> 285 */ --sidebar-foreground: oklch(0.2 0.025 280);
--sidebar-primary: oklch(0.3963 0.0251 270); /* Original hue 285.1962 -> 270 */ --sidebar-primary: oklch(0.5797 0.1194 237.7893);
--sidebar-primary-foreground: oklch( --sidebar-primary-foreground: oklch(1 0 0);
0.9668 0.0124 337.5228 --sidebar-accent: oklch(0.94 0.012 280);
); /* No change, keep white/near white */ --sidebar-accent-foreground: oklch(0.25 0.03 280);
--sidebar-accent: oklch(0.9789 0.0013 280); /* Original hue 106.4235 -> 280 */ --sidebar-border: oklch(0.9 0.015 280);
--sidebar-accent-foreground: oklch(0.3963 0.0251 270); /* Original hue 285.1962 -> 270 */ --sidebar-ring: oklch(0.5797 0.1194 237.7893);
--sidebar-border: oklch(0.9383 0.0026 280); /* Original hue 48.7178 -> 280 */
--sidebar-ring: oklch(0.5916 0.218 0.5844);
--radius: 0.5rem; --radius: 0.5rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
@ -82,13 +81,13 @@
--chart-4: oklch(0.6193 0.2029 312.7422); --chart-4: oklch(0.6193 0.2029 312.7422);
--chart-5: oklch(0.6118 0.2093 6.1387); --chart-5: oklch(0.6118 0.2093 6.1387);
/* Subtle blue shift for sidebar colors in dark mode */ /* Subtle blue shift for sidebar colors in dark mode */
--sidebar: oklch(0.1693 0.0143 280); /* Original hue 331.0475 -> 260 */ --sidebar: oklch(0.1693 0.0143 280);
--sidebar-foreground: oklch(0.8607 0.0293 265); /* Original hue 343.6612 -> 265 */ --sidebar-foreground: oklch(0.8607 0.0293 265);
--sidebar-primary: oklch(0.4882 0.2172 250); /* Original hue 264.3763 -> 250 */ --sidebar-primary: oklch(0.4882 0.2172 250);
--sidebar-primary-foreground: oklch(1 0 0); /* No change, keep white */ --sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.2337 0.0261 260); /* Original hue 338.1961 -> 260 */ --sidebar-accent: oklch(0.2337 0.0261 260);
--sidebar-accent-foreground: oklch(0.9674 0.0013 250); /* Original hue 286.3752 -> 250 */ --sidebar-accent-foreground: oklch(0.9674 0.0013 250);
--sidebar-border: oklch(0 0 0); /* No change, keep black */ --sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.5797 0.1194 237.7893); --sidebar-ring: oklch(0.5797 0.1194 237.7893);
--radius: 0.5rem; --radius: 0.5rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
@ -476,3 +475,112 @@
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
} }
/* Copy button */
pre:has(code) {
position: relative;
}
pre button.copy {
position: absolute;
right: 12px;
top: 12px;
height: 32px;
width: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
@apply bg-background/80 hover:bg-background border-border hover:border-border/60 rounded-md border shadow-sm hover:shadow-md; /* Text color removed from here */
/* Improved hover and focus states */
&:hover {
transform: translateY(-1px);
@apply bg-background;
}
&:active {
transform: translateY(0px);
transition: all 0.1s cubic-bezier(0.4, 0, 0.2, 1);
}
&:focus-visible {
@apply ring-ring ring-offset-background ring-2 ring-offset-2 outline-none;
}
& span {
width: 16px;
height: 16px;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
/* Default filter for light mode (assuming black/dark SVGs) */
filter: brightness(0.2) sepia(1) saturate(5) hue-rotate(240deg); /* Adjust hue-rotate to match your muted-foreground */
}
& .ready {
background-color: transparent;
background-image: url(/icons/copy.svg);
}
& .success {
display: none;
background-image: url(/icons/copy-success.svg);
transform: scale(1.1);
}
&.copied {
/* Light mode success state colors */
@apply bg-primary/10 border-primary/30;
/* Filter for success icon in light mode (e.g., primary color) */
& span {
filter: brightness(0.5) sepia(1) saturate(5) hue-rotate(240deg); /* Adjust to primary color */
}
& .success {
display: block;
animation: copySuccess 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
& .ready {
display: none;
}
}
}
/* Dark mode adjustments for copy button */
.dark pre button.copy {
& span {
/* Filter for dark mode (turn icons white/light) */
filter: invert(1) brightness(1.2); /* Invert and brighten for white/light icons */
}
&.copied {
/* Dark mode success state colors */
@apply bg-primary/20 border-primary/40;
/* Filter for success icon in dark mode (e.g., primary-foreground color) */
& span {
filter: invert(1) brightness(1.2); /* Keep them white/light for dark mode success */
}
}
}
/* Copy success animation */
@keyframes copySuccess {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1.1);
opacity: 1;
}
}

View file

@ -8,6 +8,16 @@
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<script>
// check mode-watcher-mode variable, set body bg accordingly
// TODO: make this smarter pls
const mode = localStorage.getItem('mode-watcher-mode');
if (mode === 'dark') {
document.body.style.backgroundColor = 'oklch(0.2409 0.0201 307.5346)';
} else {
// document.body.style.backgroundColor = 'oklch(0.99 0.005 280)';
}
</script>
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>

View file

@ -0,0 +1,44 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { UseClipboard } from '$lib/hooks/use-clipboard.svelte';
import { cn } from '$lib/utils/utils';
import CheckIcon from '~icons/lucide/check';
import CopyIcon from '~icons/lucide/copy';
import { scale } from 'svelte/transition';
interface Props {
code: string;
language?: string;
class?: string;
}
let { code, language, class: className }: Props = $props();
const clipboard = new UseClipboard();
function copyCode() {
clipboard.copy(code);
}
</script>
<div class={cn('group relative', className)}>
<div class="absolute right-2 top-2 z-10">
<Button
variant="ghost"
size="sm"
class="size-8 p-0 opacity-0 transition-opacity group-hover:opacity-100"
onclick={copyCode}
>
{#if clipboard.status === 'success'}
<div in:scale={{ duration: 200, start: 0.8 }}>
<CheckIcon class="size-3.5" />
</div>
{:else}
<CopyIcon class="size-3.5" />
{/if}
<span class="sr-only">Copy code</span>
</Button>
</div>
<slot />
</div>
</script>

View file

@ -0,0 +1 @@
export { default as CodeBlock } from './code-block.svelte';

View file

@ -1,3 +1,7 @@
export function isString(value: unknown): value is string { export function isString(value: unknown): value is string {
return typeof value === 'string'; return typeof value === 'string';
} }
export function isHtmlElement(value: unknown): value is HTMLElement {
return value instanceof HTMLElement;
}

View file

@ -1,4 +1,5 @@
import { fromAsyncCodeToHtml } from '@shikijs/markdown-it/async'; import { fromAsyncCodeToHtml } from '@shikijs/markdown-it/async';
import { h } from 'hastscript';
import MarkdownItAsync from 'markdown-it-async'; import MarkdownItAsync from 'markdown-it-async';
import { codeToHtml } from 'shiki'; import { codeToHtml } from 'shiki';
import DOMPurify from 'isomorphic-dompurify'; import DOMPurify from 'isomorphic-dompurify';
@ -14,6 +15,28 @@ md.use(
light: 'github-light-default', light: 'github-light-default',
dark: 'github-dark-default', dark: 'github-dark-default',
}, },
transformers: [
{
name: 'shiki-transformer-copy-button',
pre(node) {
const button = h(
'button',
{
class: 'copy',
'data-code': this.source,
onclick: `
navigator.clipboard.writeText(this.dataset.code);
this.classList.add('copied');
setTimeout(() => this.classList.remove('copied'), ${3000})
`,
},
[h('span', { class: 'ready' }), h('span', { class: 'success' })]
);
node.children.push(button);
},
},
],
} }
) )
); );

View file

@ -45,6 +45,7 @@
import { callGenerateMessage } from '../api/generate-message/call.js'; import { callGenerateMessage } from '../api/generate-message/call.js';
import ModelPicker from './model-picker.svelte'; import ModelPicker from './model-picker.svelte';
import SearchModal from './search-modal.svelte'; import SearchModal from './search-modal.svelte';
import { isHtmlElement } from '$lib/utils/is.js';
const client = useConvexClient(); const client = useConvexClient();
@ -390,6 +391,21 @@
<title>Chat | Thom.chat</title> <title>Chat | Thom.chat</title>
</svelte:head> </svelte:head>
<svelte:document
onclick={(e) => {
const el = e.target as HTMLElement;
const closestCopyButton = el.closest('.copy[data-code]');
if (!isHtmlElement(closestCopyButton)) return;
const code = closestCopyButton.dataset.code;
if (!code) return;
navigator.clipboard.writeText(code);
closestCopyButton.classList.add('copied');
setTimeout(() => closestCopyButton.classList.remove('copied'), 3000);
}}
/>
<Sidebar.Root <Sidebar.Root
class="h-screen overflow-clip" class="h-screen overflow-clip"
{...currentModelSupportsImages ? omit(fileUpload.dropzone, ['onclick']) : {}} {...currentModelSupportsImages ? omit(fileUpload.dropzone, ['onclick']) : {}}
@ -504,7 +520,8 @@
class={[ class={[
'bg-background/50 text-foreground dark:bg-secondary/20 relative flex w-full flex-col items-stretch gap-2 rounded-t-xl border border-b-0 border-white/70 pt-3 pb-3 outline-8 dark:border-white/10', 'bg-background/50 text-foreground dark:bg-secondary/20 relative flex w-full flex-col items-stretch gap-2 rounded-t-xl border border-b-0 border-white/70 pt-3 pb-3 outline-8 dark:border-white/10',
'transition duration-200', 'transition duration-200',
'outline-primary/1 group-focus-within:outline-primary/10', 'outline-primary/10 group-focus-within:outline-primary/20',
'dark:outline-primary/1 dark:group-focus-within:outline-primary/10',
]} ]}
style="box-shadow: rgba(0, 0, 0, 0.1) 0px 80px 50px 0px, rgba(0, 0, 0, 0.07) 0px 50px 30px 0px, rgba(0, 0, 0, 0.06) 0px 30px 15px 0px, rgba(0, 0, 0, 0.04) 0px 15px 8px, rgba(0, 0, 0, 0.04) 0px 6px 4px, rgba(0, 0, 0, 0.02) 0px 2px 2px;" style="box-shadow: rgba(0, 0, 0, 0.1) 0px 80px 50px 0px, rgba(0, 0, 0, 0.07) 0px 50px 30px 0px, rgba(0, 0, 0, 0.06) 0px 30px 15px 0px, rgba(0, 0, 0, 0.04) 0px 15px 8px, rgba(0, 0, 0, 0.04) 0px 6px 4px, rgba(0, 0, 0, 0.02) 0px 2px 2px;"
onsubmit={(e) => { onsubmit={(e) => {

View file

@ -12,7 +12,7 @@
base: 'prose rounded-xl p-2', base: 'prose rounded-xl p-2',
variants: { variants: {
role: { role: {
user: 'bg-secondary/50 border border-secondary/70 px-3 py-2 !text-primary-foreground self-end', user: 'bg-secondary/50 border border-secondary/70 px-3 py-2 !text-black/80 dark:!text-primary-foreground self-end',
assistant: 'text-foreground', assistant: 'text-foreground',
}, },
}, },

View file

@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-icon lucide-check"><path d="M20 6 9 17l-5-5"/></svg>

After

Width:  |  Height:  |  Size: 262 B

1
static/icons/copy.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>

After

Width:  |  Height:  |  Size: 355 B