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

View file

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

21
pnpm-lock.yaml generated
View file

@ -38,6 +38,9 @@ importers:
convex-helpers:
specifier: ^0.1.94
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:
specifier: ^2.2.0
version: 2.2.0
@ -1634,12 +1637,18 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-whitespace@3.0.0:
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:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
@ -4072,6 +4081,10 @@ snapshots:
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:
dependencies:
'@types/hast': 3.0.4
@ -4090,6 +4103,14 @@ snapshots:
dependencies:
'@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:
dependencies:
whatwg-encoding: 3.1.1

View file

@ -8,42 +8,41 @@
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.9754 0.0084 325.6414);
--foreground: oklch(0.3257 0.1161 325.0372);
--card: oklch(0.9754 0.0084 325.6414);
--card-foreground: oklch(0.3257 0.1161 325.0372);
/* Improved light theme with better harmony */
--background: oklch(0.99 0.005 280);
--foreground: oklch(0.15 0.02 280);
--card: oklch(0.98 0.008 280);
--card-foreground: oklch(0.15 0.02 280);
--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);
--heading: oklch(0.5797 0.1194 237.7893);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.8696 0.0675 334.8991);
--secondary-foreground: oklch(0.4448 0.1341 324.7991);
--muted: oklch(0.9395 0.026 331.5454);
--muted-foreground: oklch(0.4924 0.1244 324.4523);
--accent: oklch(0.8696 0.0675 334.8991);
--accent-foreground: oklch(0.4448 0.1341 324.7991);
--secondary: oklch(0.92 0.015 280);
--secondary-foreground: oklch(0.25 0.03 280);
--muted: oklch(0.94 0.012 280);
--muted-foreground: oklch(0.45 0.025 280);
--accent: oklch(0.92 0.015 280);
--accent-foreground: oklch(0.25 0.03 280);
--destructive: oklch(0.5248 0.1368 20.8317);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.8568 0.0829 328.911);
--input: oklch(0.8517 0.0558 336.6002);
--ring: oklch(0.5916 0.218 0.5844);
--border: oklch(0.88 0.02 280);
--input: oklch(0.96 0.01 280);
--ring: oklch(0.5797 0.1194 237.7893);
--chart-1: oklch(0.6038 0.2363 344.4657);
--chart-2: oklch(0.4445 0.2251 300.6246);
--chart-3: oklch(0.379 0.0438 226.1538);
--chart-4: oklch(0.833 0.1185 88.3461);
--chart-5: oklch(0.7843 0.1256 58.9964);
/* Subtle blue shift for sidebar colors in light mode */
--sidebar: oklch(0.936 0.0288 280); /* Original hue 320.5788 -> 280 (more blue) */
--sidebar-foreground: oklch(0.4948 0.1909 285); /* Original hue 354.5435 -> 285 */
--sidebar-primary: oklch(0.3963 0.0251 270); /* Original hue 285.1962 -> 270 */
--sidebar-primary-foreground: oklch(
0.9668 0.0124 337.5228
); /* No change, keep white/near white */
--sidebar-accent: oklch(0.9789 0.0013 280); /* Original hue 106.4235 -> 280 */
--sidebar-accent-foreground: oklch(0.3963 0.0251 270); /* Original hue 285.1962 -> 270 */
--sidebar-border: oklch(0.9383 0.0026 280); /* Original hue 48.7178 -> 280 */
--sidebar-ring: oklch(0.5916 0.218 0.5844);
/* Harmonized sidebar colors for light mode */
--sidebar: oklch(0.97 0.008 280);
--sidebar-foreground: oklch(0.2 0.025 280);
--sidebar-primary: oklch(0.5797 0.1194 237.7893);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.94 0.012 280);
--sidebar-accent-foreground: oklch(0.25 0.03 280);
--sidebar-border: oklch(0.9 0.015 280);
--sidebar-ring: oklch(0.5797 0.1194 237.7893);
--radius: 0.5rem;
--shadow-2xs: 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-5: oklch(0.6118 0.2093 6.1387);
/* Subtle blue shift for sidebar colors in dark mode */
--sidebar: oklch(0.1693 0.0143 280); /* Original hue 331.0475 -> 260 */
--sidebar-foreground: oklch(0.8607 0.0293 265); /* Original hue 343.6612 -> 265 */
--sidebar-primary: oklch(0.4882 0.2172 250); /* Original hue 264.3763 -> 250 */
--sidebar-primary-foreground: oklch(1 0 0); /* No change, keep white */
--sidebar-accent: oklch(0.2337 0.0261 260); /* Original hue 338.1961 -> 260 */
--sidebar-accent-foreground: oklch(0.9674 0.0013 250); /* Original hue 286.3752 -> 250 */
--sidebar-border: oklch(0 0 0); /* No change, keep black */
--sidebar: oklch(0.1693 0.0143 280);
--sidebar-foreground: oklch(0.8607 0.0293 265);
--sidebar-primary: oklch(0.4882 0.2172 250);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.2337 0.0261 260);
--sidebar-accent-foreground: oklch(0.9674 0.0013 250);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.5797 0.1194 237.7893);
--radius: 0.5rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
@ -476,3 +475,112 @@
left: 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%
</head>
<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>
</body>
</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 {
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 { h } from 'hastscript';
import MarkdownItAsync from 'markdown-it-async';
import { codeToHtml } from 'shiki';
import DOMPurify from 'isomorphic-dompurify';
@ -14,6 +15,28 @@ md.use(
light: 'github-light-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 ModelPicker from './model-picker.svelte';
import SearchModal from './search-modal.svelte';
import { isHtmlElement } from '$lib/utils/is.js';
const client = useConvexClient();
@ -390,6 +391,21 @@
<title>Chat | Thom.chat</title>
</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
class="h-screen overflow-clip"
{...currentModelSupportsImages ? omit(fileUpload.dropzone, ['onclick']) : {}}
@ -504,7 +520,8 @@
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',
'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;"
onsubmit={(e) => {

View file

@ -12,7 +12,7 @@
base: 'prose rounded-xl p-2',
variants: {
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',
},
},

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