copy btn
This commit is contained in:
parent
df66ccbb2a
commit
aefd2f4a35
13 changed files with 268 additions and 35 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
21
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
172
src/app.css
172
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
src/app.html
10
src/app.html
|
|
@ -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>
|
||||
|
|
|
|||
44
src/lib/components/ui/code-block/code-block.svelte
Normal file
44
src/lib/components/ui/code-block/code-block.svelte
Normal 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>
|
||||
1
src/lib/components/ui/code-block/index.ts
Normal file
1
src/lib/components/ui/code-block/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as CodeBlock } from './code-block.svelte';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
2
static/icons/copy-success.svg
Normal file
2
static/icons/copy-success.svg
Normal 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
1
static/icons/copy.svg
Normal 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 |
Loading…
Add table
Reference in a new issue