diff --git a/README.md b/README.md index 10b9c46..2f9dd9b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 73e8803..7f00101 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec5c2ed..ca4bcde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app.css b/src/app.css index b7d4706..b73bda8 100644 --- a/src/app.css +++ b/src/app.css @@ -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; + } +} diff --git a/src/app.html b/src/app.html index 4afcbd3..33ef1a6 100644 --- a/src/app.html +++ b/src/app.html @@ -8,6 +8,16 @@ %sveltekit.head% +
%sveltekit.body%
diff --git a/src/lib/components/ui/code-block/code-block.svelte b/src/lib/components/ui/code-block/code-block.svelte new file mode 100644 index 0000000..7e54d5e --- /dev/null +++ b/src/lib/components/ui/code-block/code-block.svelte @@ -0,0 +1,44 @@ + + +
+
+ +
+ +
+ \ No newline at end of file diff --git a/src/lib/components/ui/code-block/index.ts b/src/lib/components/ui/code-block/index.ts new file mode 100644 index 0000000..c01c967 --- /dev/null +++ b/src/lib/components/ui/code-block/index.ts @@ -0,0 +1 @@ +export { default as CodeBlock } from './code-block.svelte'; \ No newline at end of file diff --git a/src/lib/utils/is.ts b/src/lib/utils/is.ts index 408b9e6..e825e90 100644 --- a/src/lib/utils/is.ts +++ b/src/lib/utils/is.ts @@ -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; +} diff --git a/src/lib/utils/markdown-it.ts b/src/lib/utils/markdown-it.ts index 6a840ca..12c3079 100644 --- a/src/lib/utils/markdown-it.ts +++ b/src/lib/utils/markdown-it.ts @@ -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); + }, + }, + ], } ) ); diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte index 18a1b67..c05d374 100644 --- a/src/routes/chat/+layout.svelte +++ b/src/routes/chat/+layout.svelte @@ -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 @@ Chat | Thom.chat + { + 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); + }} +/> + { diff --git a/src/routes/chat/[id]/message.svelte b/src/routes/chat/[id]/message.svelte index b265735..04d555d 100644 --- a/src/routes/chat/[id]/message.svelte +++ b/src/routes/chat/[id]/message.svelte @@ -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', }, }, diff --git a/static/icons/copy-success.svg b/static/icons/copy-success.svg new file mode 100644 index 0000000..428f4c8 --- /dev/null +++ b/static/icons/copy-success.svg @@ -0,0 +1,2 @@ + + diff --git a/static/icons/copy.svg b/static/icons/copy.svg new file mode 100644 index 0000000..cf8ad9c --- /dev/null +++ b/static/icons/copy.svg @@ -0,0 +1 @@ +