dsh-side-chat-plus 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/README.zh.md +261 -0
- package/cordis.patch.yml +8 -0
- package/dsh.plugin.json +16 -0
- package/lib/client-registry.js +2949 -0
- package/lib/client-registry.js.map +1 -0
- package/lib/client.js +2949 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +840 -0
- package/lib/types/client/api.d.ts +218 -0
- package/lib/types/client/attachments/AttachmentRail.d.ts +39 -0
- package/lib/types/client/attachments/DropOverlay.d.ts +18 -0
- package/lib/types/client/attachments/ImageLightbox.d.ts +20 -0
- package/lib/types/client/attachments/MessageImage.d.ts +38 -0
- package/lib/types/client/attachments/index.d.ts +18 -0
- package/lib/types/client/index.d.ts +6 -0
- package/lib/types/client/locales.d.ts +178 -0
- package/lib/types/context-types.d.ts +390 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/settings-shared.d.ts +24 -0
- package/lib/types/trust-fence.d.ts +20 -0
- package/lib/types/wire.d.ts +25 -0
- package/package.json +114 -0
- package/src/client/api.ts +112 -0
- package/src/client/attachments/AttachmentRail.module.css +89 -0
- package/src/client/attachments/AttachmentRail.tsx +173 -0
- package/src/client/attachments/DropOverlay.module.css +38 -0
- package/src/client/attachments/DropOverlay.tsx +62 -0
- package/src/client/attachments/ImageLightbox.module.css +44 -0
- package/src/client/attachments/ImageLightbox.tsx +58 -0
- package/src/client/attachments/MessageImage.module.css +61 -0
- package/src/client/attachments/MessageImage.tsx +120 -0
- package/src/client/attachments/index.ts +19 -0
- package/src/client/client.module.css +1032 -0
- package/src/client/index.tsx +1966 -0
- package/src/client/layout.css +16 -0
- package/src/client/locales.ts +181 -0
- package/src/context-types.ts +384 -0
- package/src/css-modules.d.ts +10 -0
- package/src/index.ts +840 -0
- package/src/settings-shared.ts +33 -0
- package/src/trust-fence.ts +70 -0
- package/src/wire.ts +81 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client API client for the /sidechat JSON routes (same fetch pattern the
|
|
3
|
+
* better-sidebar built-ins use). All requests are same-origin POSTs; the host
|
|
4
|
+
* fence (loopback/trusted authority) passes them naturally.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One API failure envelope. */
|
|
8
|
+
export interface SidechatApiError {
|
|
9
|
+
code: string
|
|
10
|
+
message: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The unwrapped success value of one API method. */
|
|
14
|
+
export type SidechatResult<T> = { ok: true; value: T } | { ok: false; error: SidechatApiError }
|
|
15
|
+
|
|
16
|
+
/** Side-chat list row. */
|
|
17
|
+
export interface SidechatListItem {
|
|
18
|
+
childId: string
|
|
19
|
+
running: boolean
|
|
20
|
+
runningSince?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Browser-submitted prompt content part (text or base64 image). */
|
|
24
|
+
export type PromptContentPart = { type: 'text'; text: string } | { type: 'image'; mediaType: string; data: string; name?: string }
|
|
25
|
+
|
|
26
|
+
/** One durable image reference returned by the host transcript. */
|
|
27
|
+
export interface SidechatImageRef {
|
|
28
|
+
attachmentId: string
|
|
29
|
+
mediaType: string
|
|
30
|
+
bytes: number
|
|
31
|
+
width: number
|
|
32
|
+
height: number
|
|
33
|
+
name?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One transcript message block. */
|
|
37
|
+
export type SidechatMessageBlock = { type: 'text'; text: string } | { type: 'image'; ref: SidechatImageRef } | { type: 'reasoning'; text: string }
|
|
38
|
+
|
|
39
|
+
/** One transcript message. */
|
|
40
|
+
export interface SidechatMessage {
|
|
41
|
+
role: 'user' | 'assistant'
|
|
42
|
+
blocks: SidechatMessageBlock[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Model / effort directory (provider groups → models → reasoning efforts). */
|
|
46
|
+
export interface SidechatDirectory {
|
|
47
|
+
groups: Array<{
|
|
48
|
+
id: string
|
|
49
|
+
name: string
|
|
50
|
+
models: Array<{
|
|
51
|
+
id: string
|
|
52
|
+
name: string
|
|
53
|
+
description?: string
|
|
54
|
+
reasoning?: { efforts: Array<{ id: string; name: string }>; defaultEffort?: string }
|
|
55
|
+
}>
|
|
56
|
+
}>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Permission options. */
|
|
60
|
+
export interface SidechatPermissions {
|
|
61
|
+
options: Array<{ value: string; name: string; description?: string }>
|
|
62
|
+
current: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Call one /sidechat/api method. */
|
|
66
|
+
async function call<T>(method: string, payload: Record<string, unknown>): Promise<SidechatResult<T>> {
|
|
67
|
+
const res = await fetch(`/sidechat/api/${method}`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'content-type': 'application/json' },
|
|
70
|
+
body: JSON.stringify(payload),
|
|
71
|
+
})
|
|
72
|
+
return (await res.json()) as SidechatResult<T>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const api = {
|
|
76
|
+
start: (args: { parentSessionId: string; content: PromptContentPart[]; lookupEnabled: boolean; provider?: string; model?: string; reasoningEffort?: string; preset?: string }) =>
|
|
77
|
+
call<{ childId: string; provider: string; model: string; reasoningEffort?: string }>('sidechat.start', args),
|
|
78
|
+
followup: (args: { childId: string; content: PromptContentPart[]; lookupEnabled: boolean }) =>
|
|
79
|
+
call<{ accepted: true }>('sidechat.followup', args),
|
|
80
|
+
list: (args: { parentSessionId: string }) =>
|
|
81
|
+
call<{ items: SidechatListItem[] }>('sidechat.list', args),
|
|
82
|
+
history: (args: { childId: string }) =>
|
|
83
|
+
call<{ messages: SidechatMessage[] }>('sidechat.history', args),
|
|
84
|
+
stop: (args: { childId: string }) =>
|
|
85
|
+
call<{ accepted: true }>('sidechat.stop', args),
|
|
86
|
+
selectModel: (args: { childId: string; provider: string; model: string; reasoningEffort?: string }) =>
|
|
87
|
+
call<{ accepted: true }>('sidechat.selectModel', args),
|
|
88
|
+
selectPermission: (args: { childId: string; presetName: string }) =>
|
|
89
|
+
call<{ accepted: true }>('sidechat.selectPermission', args),
|
|
90
|
+
summarize: (args: { parentSessionId: string; text: string; provider?: string; model?: string; reasoningEffort?: string; locale?: string }) =>
|
|
91
|
+
call<{ summary: string }>('sidechat.summarize', args),
|
|
92
|
+
inject: (args: { parentSessionId: string; text: string; summary?: string }) =>
|
|
93
|
+
call<{ accepted: true }>('sidechat.inject', args),
|
|
94
|
+
directory: () => call<SidechatDirectory>('sidechat.directory', {}),
|
|
95
|
+
permissions: () => call<SidechatPermissions>('sidechat.permissions', {}),
|
|
96
|
+
limits: () => call<{ mediaTypes: string[]; maxImageBytes: number; maxImagesPerMessage: number; maxMessageImageBytes: number; maxImagePixels: number }>('sidechat.limits', {}),
|
|
97
|
+
attachment: (args: { childId: string; attachmentId: string }) =>
|
|
98
|
+
call<{ mediaType: string; data: string }>('sidechat.attachment', args),
|
|
99
|
+
inherit: (args: { parentSessionId: string }) =>
|
|
100
|
+
call<{ provider: string; model: string; reasoningEffort?: string }>('sidechat.inherit', args),
|
|
101
|
+
commands: (args: { childId: string }) =>
|
|
102
|
+
call<{ commands: Array<{ name: string; description: string }> }>('sidechat.commands', args),
|
|
103
|
+
command: (args: { childId: string; line: string }) =>
|
|
104
|
+
call<{ executed: boolean }>('sidechat.command', args),
|
|
105
|
+
state: (args: { childId: string }) =>
|
|
106
|
+
call<{ plan: { active: boolean; pending: boolean }; goal: { id: string; objective: string } | null }>('sidechat.state', args),
|
|
107
|
+
settingsGet: () => call<{ value?: unknown; revision?: number }>('settings.get', {}),
|
|
108
|
+
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
|
|
109
|
+
call<{ value?: unknown; revision?: number }>('settings.update', { patch, expectedRevision }),
|
|
110
|
+
dispose: (args: { childId: string }) =>
|
|
111
|
+
call<{ accepted: true }>('sidechat.dispose', args),
|
|
112
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
min-width: 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.rail {
|
|
9
|
+
display: flex;
|
|
10
|
+
gap: 8px;
|
|
11
|
+
overflow-x: auto;
|
|
12
|
+
scrollbar-width: none;
|
|
13
|
+
padding: 2px;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.rail::-webkit-scrollbar {
|
|
17
|
+
display: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.item {
|
|
21
|
+
position: relative;
|
|
22
|
+
flex: none;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.thumbnail {
|
|
26
|
+
display: block;
|
|
27
|
+
width: 56px;
|
|
28
|
+
height: 56px;
|
|
29
|
+
padding: 0;
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
border: 1px solid var(--dsw-color-border, rgba(0, 0, 0, 0.1));
|
|
32
|
+
border-radius: 8px;
|
|
33
|
+
background: none;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.thumbnail img {
|
|
38
|
+
width: 100%;
|
|
39
|
+
height: 100%;
|
|
40
|
+
object-fit: cover;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.remove {
|
|
44
|
+
position: absolute;
|
|
45
|
+
top: -6px;
|
|
46
|
+
right: -6px;
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: center;
|
|
50
|
+
width: 18px;
|
|
51
|
+
height: 18px;
|
|
52
|
+
padding: 0;
|
|
53
|
+
border: none;
|
|
54
|
+
border-radius: 50%;
|
|
55
|
+
background: var(--dsw-color-surface-raised, #fff);
|
|
56
|
+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
|
57
|
+
color: var(--dsw-color-text-secondary, #646a73);
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
opacity: 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.item:hover .remove,
|
|
63
|
+
.remove:focus-visible {
|
|
64
|
+
opacity: 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.arrow {
|
|
68
|
+
position: absolute;
|
|
69
|
+
z-index: 1;
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: center;
|
|
73
|
+
width: 22px;
|
|
74
|
+
height: 22px;
|
|
75
|
+
padding: 0;
|
|
76
|
+
border: 1px solid var(--dsw-color-border, rgba(0, 0, 0, 0.1));
|
|
77
|
+
border-radius: 50%;
|
|
78
|
+
background: var(--dsw-color-surface-raised, #fff);
|
|
79
|
+
color: var(--dsw-color-text-secondary, #646a73);
|
|
80
|
+
cursor: pointer;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.arrowLeft {
|
|
84
|
+
left: -10px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.arrowRight {
|
|
88
|
+
right: -10px;
|
|
89
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
IconChevronLeftOutline14,
|
|
4
|
+
IconChevronRightOutline14,
|
|
5
|
+
IconCloseFill14,
|
|
6
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
7
|
+
import css from './AttachmentRail.module.css'
|
|
8
|
+
|
|
9
|
+
/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */
|
|
10
|
+
export interface AttachmentRailItem {
|
|
11
|
+
/** Stable identity for the React key. */
|
|
12
|
+
id: string
|
|
13
|
+
/** Object or data URL rendered as the thumbnail. */
|
|
14
|
+
previewUrl: string
|
|
15
|
+
/** Image alt text (display name with the owner's fallback applied). */
|
|
16
|
+
alt: string
|
|
17
|
+
/** Accessible label of the item's remove control. */
|
|
18
|
+
removeLabel: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Rail-level strings the owner resolves from its own locale namespace. */
|
|
22
|
+
export interface AttachmentRailLabels {
|
|
23
|
+
/** Accessible name of the rail group. */
|
|
24
|
+
group: string
|
|
25
|
+
/** Thumbnail tooltip inviting the original-image preview. */
|
|
26
|
+
open: string
|
|
27
|
+
/** Accessible label of the left paging arrow. */
|
|
28
|
+
scrollLeft: string
|
|
29
|
+
/** Accessible label of the right paging arrow. */
|
|
30
|
+
scrollRight: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Approximate pixels per wheel step for `deltaMode` LINE deltas (Firefox notch wheels report lines). */
|
|
34
|
+
const WHEEL_LINE_PX = 16
|
|
35
|
+
|
|
36
|
+
/** Smooth paging unless the user asked for reduced motion. */
|
|
37
|
+
function pageBehavior(): ScrollBehavior {
|
|
38
|
+
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Horizontal thumbnail rail over the caller's draft attachments.
|
|
43
|
+
*
|
|
44
|
+
* The rail scrolls with its scrollbar hidden; overflow is announced by edge
|
|
45
|
+
* arrows recomputed from scroll geometry on scroll, item-count changes, and
|
|
46
|
+
* rail size changes (a ResizeObserver on the rail element, so sidebar or panel
|
|
47
|
+
* resizes count, not only window resizes). A vertical wheel pans the rail
|
|
48
|
+
* horizontally and is consumed exclusively, a newly added item is revealed at
|
|
49
|
+
* the rail's end, and each thumbnail opens on a single click while its remove
|
|
50
|
+
* control sits inside the card. The owner decides mounting.
|
|
51
|
+
*/
|
|
52
|
+
export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, onOpen, onRemove }: {
|
|
53
|
+
items: readonly T[]
|
|
54
|
+
labels: AttachmentRailLabels
|
|
55
|
+
onOpen: (item: T) => void
|
|
56
|
+
onRemove: (item: T) => void
|
|
57
|
+
}) {
|
|
58
|
+
const railRef = useRef<HTMLDivElement | null>(null)
|
|
59
|
+
// null marks the first layout pass: a rail that MOUNTS over an existing
|
|
60
|
+
// draft is initial display, not growth, and must not jump to the end.
|
|
61
|
+
const countRef = useRef<number | null>(null)
|
|
62
|
+
const [edges, setEdges] = useState({ left: false, right: false })
|
|
63
|
+
const updateEdges = useCallback(() => {
|
|
64
|
+
const el = railRef.current
|
|
65
|
+
if (el === null) return
|
|
66
|
+
// 1px slack: engines report fractional scroll positions at the edges.
|
|
67
|
+
const left = el.scrollLeft > 1
|
|
68
|
+
const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
|
|
69
|
+
setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right })
|
|
70
|
+
}, [])
|
|
71
|
+
useLayoutEffect(() => {
|
|
72
|
+
const grew = countRef.current !== null && items.length > countRef.current
|
|
73
|
+
countRef.current = items.length
|
|
74
|
+
const el = railRef.current
|
|
75
|
+
if (el === null) return
|
|
76
|
+
// A newly added attachment lands at the rail's end: reveal it.
|
|
77
|
+
if (grew) el.scrollLeft = el.scrollWidth - el.clientWidth
|
|
78
|
+
updateEdges()
|
|
79
|
+
}, [items.length, updateEdges])
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const el = railRef.current
|
|
82
|
+
if (el === null) return
|
|
83
|
+
// The rail's width follows the composer, which resizes with sidebars and
|
|
84
|
+
// panels, not only the window — observe the element itself.
|
|
85
|
+
let disconnect = (): void => {}
|
|
86
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
87
|
+
const observer = new ResizeObserver(updateEdges)
|
|
88
|
+
observer.observe(el)
|
|
89
|
+
disconnect = () => { observer.disconnect() }
|
|
90
|
+
}
|
|
91
|
+
// The rail scrolls horizontally ONLY: any wheel tick with a vertical
|
|
92
|
+
// component is consumed — without preventDefault it would also scroll the
|
|
93
|
+
// conversation behind the composer, and React's root wheel listener is
|
|
94
|
+
// passive, so the exclusion needs this manually attached non-passive
|
|
95
|
+
// listener.
|
|
96
|
+
const onWheel = (event: globalThis.WheelEvent): void => {
|
|
97
|
+
if (event.deltaY === 0) return
|
|
98
|
+
const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
|
99
|
+
? WHEEL_LINE_PX
|
|
100
|
+
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1
|
|
101
|
+
event.preventDefault()
|
|
102
|
+
el.scrollBy({
|
|
103
|
+
left: event.deltaX !== 0
|
|
104
|
+
? event.deltaX * scale
|
|
105
|
+
: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
|
|
106
|
+
behavior: 'auto',
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
el.addEventListener('wheel', onWheel, { passive: false })
|
|
110
|
+
return () => {
|
|
111
|
+
disconnect()
|
|
112
|
+
el.removeEventListener('wheel', onWheel)
|
|
113
|
+
}
|
|
114
|
+
}, [updateEdges])
|
|
115
|
+
const page = (direction: -1 | 1): void => {
|
|
116
|
+
const el = railRef.current
|
|
117
|
+
if (el === null) return
|
|
118
|
+
// One viewport minus a card keeps the last visible thumbnail as context.
|
|
119
|
+
el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: pageBehavior() })
|
|
120
|
+
}
|
|
121
|
+
return (
|
|
122
|
+
<div className={css.root}>
|
|
123
|
+
{edges.left && (
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
className={`${css.arrow} ${css.arrowLeft}`}
|
|
127
|
+
aria-label={labels.scrollLeft}
|
|
128
|
+
onClick={() => { page(-1) }}
|
|
129
|
+
>
|
|
130
|
+
<IconChevronLeftOutline14 />
|
|
131
|
+
</button>
|
|
132
|
+
)}
|
|
133
|
+
<div
|
|
134
|
+
ref={railRef}
|
|
135
|
+
className={css.rail}
|
|
136
|
+
role="group"
|
|
137
|
+
aria-label={labels.group}
|
|
138
|
+
onScroll={updateEdges}
|
|
139
|
+
>
|
|
140
|
+
{items.map(item => (
|
|
141
|
+
<div key={item.id} className={css.item}>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
className={css.thumbnail}
|
|
145
|
+
title={labels.open}
|
|
146
|
+
onClick={() => { onOpen(item) }}
|
|
147
|
+
>
|
|
148
|
+
<img src={item.previewUrl} alt={item.alt} />
|
|
149
|
+
</button>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
className={css.remove}
|
|
153
|
+
aria-label={item.removeLabel}
|
|
154
|
+
onClick={() => { onRemove(item) }}
|
|
155
|
+
>
|
|
156
|
+
<IconCloseFill14 size={12} />
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
{edges.right && (
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
className={`${css.arrow} ${css.arrowRight}`}
|
|
165
|
+
aria-label={labels.scrollRight}
|
|
166
|
+
onClick={() => { page(1) }}
|
|
167
|
+
>
|
|
168
|
+
<IconChevronRightOutline14 />
|
|
169
|
+
</button>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
.mask {
|
|
2
|
+
position: fixed;
|
|
3
|
+
inset: 0;
|
|
4
|
+
z-index: 999;
|
|
5
|
+
display: flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
justify-content: center;
|
|
8
|
+
/* Decoration only: drag targeting must stay on the page below. */
|
|
9
|
+
pointer-events: none;
|
|
10
|
+
background: rgba(255, 255, 255, 0.72);
|
|
11
|
+
border: 2px dashed var(--dsw-color-border-strong, #4d6bfe);
|
|
12
|
+
border-radius: 12px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.wrap {
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
align-items: center;
|
|
19
|
+
gap: 12px;
|
|
20
|
+
text-align: center;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.illustration {
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
justify-content: center;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.title {
|
|
30
|
+
font-size: 16px;
|
|
31
|
+
font-weight: 600;
|
|
32
|
+
color: var(--dsw-color-text, #1f2329);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.desc {
|
|
36
|
+
font-size: 13px;
|
|
37
|
+
color: var(--dsw-color-text-secondary, #646a73);
|
|
38
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createPortal } from 'react-dom'
|
|
2
|
+
import css from './DropOverlay.module.css'
|
|
3
|
+
|
|
4
|
+
/** Drop-overlay strings the owner resolves from its own locale namespace. */
|
|
5
|
+
export interface DropOverlayLabels {
|
|
6
|
+
/** Headline inviting the drop, or naming why it is unavailable. */
|
|
7
|
+
title: string
|
|
8
|
+
/** Limits line under the title; shown only while drops are accepted. */
|
|
9
|
+
desc?: string | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Full-viewport invitation shown while a file drag is over the page.
|
|
14
|
+
* Decoration only: `pointer-events: none` keeps drag targeting on the page
|
|
15
|
+
* below, so the owner's document-level listeners keep an accurate enter/leave
|
|
16
|
+
* count and own accept/reject. Rendered through a body portal for the same
|
|
17
|
+
* transformed-ancestor reason as the lightbox.
|
|
18
|
+
*/
|
|
19
|
+
export function DropOverlay({ disabled, labels }: {
|
|
20
|
+
disabled: boolean
|
|
21
|
+
labels: DropOverlayLabels
|
|
22
|
+
}) {
|
|
23
|
+
return createPortal(
|
|
24
|
+
<div className={css.mask} role="status">
|
|
25
|
+
<div className={css.wrap}>
|
|
26
|
+
<div className={css.illustration} aria-hidden="true">
|
|
27
|
+
{disabled ? <BlockedIllustration /> : <UploadIllustration />}
|
|
28
|
+
</div>
|
|
29
|
+
<div className={css.title}>{labels.title}</div>
|
|
30
|
+
{!disabled && labels.desc !== undefined && <div className={css.desc}>{labels.desc}</div>}
|
|
31
|
+
</div>
|
|
32
|
+
</div>,
|
|
33
|
+
document.body,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Tilted photo-and-note cards. */
|
|
38
|
+
const UploadIllustration = () => (
|
|
39
|
+
<svg width="96" height="72" viewBox="0 0 96 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
40
|
+
<rect y="14" width="38" height="38" rx="10" transform="rotate(-22 0 14)" fill="#9ce5ed" />
|
|
41
|
+
<rect x="62" y="7" width="37" height="43" rx="8" transform="rotate(17 62 7)" fill="#679efe" />
|
|
42
|
+
<rect x="26" y="30" width="38" height="38" rx="10" fill="#3964fe" />
|
|
43
|
+
<path
|
|
44
|
+
d="M33 62c1-2 4-8 7-14 1-2 5-2 6 1 2 5 5 9 7 10 3 1 7-9 17 2"
|
|
45
|
+
stroke="#fff"
|
|
46
|
+
strokeWidth="3"
|
|
47
|
+
fill="none"
|
|
48
|
+
/>
|
|
49
|
+
<circle cx="53" cy="43" r="4" fill="#fff" />
|
|
50
|
+
</svg>
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
/** Greyed cards with a blocked badge. */
|
|
54
|
+
const BlockedIllustration = () => (
|
|
55
|
+
<svg width="96" height="72" viewBox="0 0 96 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
56
|
+
<rect y="14" width="38" height="38" rx="10" transform="rotate(-22 0 14)" fill="#c4c9d0" />
|
|
57
|
+
<rect x="62" y="7" width="37" height="43" rx="8" transform="rotate(17 62 7)" fill="#b0b6be" />
|
|
58
|
+
<rect x="26" y="30" width="38" height="38" rx="10" fill="#d8dce1" />
|
|
59
|
+
<circle cx="45" cy="49" r="12" stroke="#fff" strokeWidth="3" fill="none" />
|
|
60
|
+
<path d="M36 40l18 18" stroke="#fff" strokeWidth="3" strokeLinecap="round" />
|
|
61
|
+
</svg>
|
|
62
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
.backdrop {
|
|
2
|
+
position: fixed;
|
|
3
|
+
inset: 0;
|
|
4
|
+
z-index: 1000;
|
|
5
|
+
display: flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
justify-content: center;
|
|
8
|
+
padding: 32px;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.mask {
|
|
12
|
+
position: absolute;
|
|
13
|
+
inset: 0;
|
|
14
|
+
background: rgba(0, 0, 0, 0.72);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.image {
|
|
18
|
+
position: relative;
|
|
19
|
+
max-width: 100%;
|
|
20
|
+
max-height: 100%;
|
|
21
|
+
object-fit: contain;
|
|
22
|
+
border-radius: 8px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.close {
|
|
26
|
+
position: absolute;
|
|
27
|
+
top: 20px;
|
|
28
|
+
right: 20px;
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
justify-content: center;
|
|
32
|
+
width: 32px;
|
|
33
|
+
height: 32px;
|
|
34
|
+
padding: 0;
|
|
35
|
+
border: none;
|
|
36
|
+
border-radius: 50%;
|
|
37
|
+
background: rgba(255, 255, 255, 0.16);
|
|
38
|
+
color: #fff;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.close:hover {
|
|
43
|
+
background: rgba(255, 255, 255, 0.28);
|
|
44
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
4
|
+
import css from './ImageLightbox.module.css'
|
|
5
|
+
|
|
6
|
+
/** Preview strings the owner resolves from its own locale namespace. */
|
|
7
|
+
export interface ImageLightboxLabels {
|
|
8
|
+
/** Accessible name of the preview dialog. */
|
|
9
|
+
dialog: string
|
|
10
|
+
/** Accessible label of the close control. */
|
|
11
|
+
close: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Document-level original-image preview opened by clicking a thumbnail.
|
|
16
|
+
* Closes on Escape, backdrop press, or the close control, and restores focus
|
|
17
|
+
* to the opener on unmount. Rendered through a body portal: an opener inside
|
|
18
|
+
* a transformed or filtered ancestor would otherwise trap the fixed backdrop
|
|
19
|
+
* in that ancestor's box instead of covering the viewport.
|
|
20
|
+
*/
|
|
21
|
+
export function ImageLightbox({ src, alt, labels, onClose }: {
|
|
22
|
+
src: string
|
|
23
|
+
alt: string
|
|
24
|
+
labels: ImageLightboxLabels
|
|
25
|
+
onClose: () => void
|
|
26
|
+
}) {
|
|
27
|
+
const closeRef = useRef<HTMLButtonElement | null>(null)
|
|
28
|
+
const restoreRef = useRef<HTMLElement | null>(null)
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
32
|
+
closeRef.current?.focus()
|
|
33
|
+
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
|
34
|
+
if (event.key === 'Escape') onClose()
|
|
35
|
+
}
|
|
36
|
+
window.addEventListener('keydown', onKeyDown)
|
|
37
|
+
return () => {
|
|
38
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
39
|
+
restoreRef.current?.focus()
|
|
40
|
+
}
|
|
41
|
+
}, [onClose])
|
|
42
|
+
|
|
43
|
+
return createPortal(
|
|
44
|
+
<div
|
|
45
|
+
className={css.backdrop}
|
|
46
|
+
role="dialog"
|
|
47
|
+
aria-modal="true"
|
|
48
|
+
aria-label={labels.dialog}
|
|
49
|
+
>
|
|
50
|
+
<div className={css.mask} aria-hidden="true" onMouseDown={onClose} />
|
|
51
|
+
<img className={css.image} src={src} alt={alt} />
|
|
52
|
+
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>
|
|
53
|
+
<IconCloseOutline16 size={16} />
|
|
54
|
+
</button>
|
|
55
|
+
</div>,
|
|
56
|
+
document.body,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
.gallery {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-wrap: wrap;
|
|
4
|
+
gap: 8px;
|
|
5
|
+
max-width: 100%;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.gallery[data-align='start'] {
|
|
9
|
+
justify-content: flex-start;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.gallery[data-align='end'] {
|
|
13
|
+
justify-content: flex-end;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.frame {
|
|
17
|
+
padding: 0;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
border: 1px solid var(--dsw-color-border, rgba(0, 0, 0, 0.08));
|
|
20
|
+
border-radius: 8px;
|
|
21
|
+
background: none;
|
|
22
|
+
cursor: pointer;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.frame[data-variant='tile'] {
|
|
26
|
+
width: 64px;
|
|
27
|
+
height: 64px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.frame img {
|
|
31
|
+
width: 100%;
|
|
32
|
+
height: 100%;
|
|
33
|
+
object-fit: cover;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.loading {
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: center;
|
|
40
|
+
width: 100%;
|
|
41
|
+
height: 100%;
|
|
42
|
+
min-width: 64px;
|
|
43
|
+
min-height: 64px;
|
|
44
|
+
font-size: 12px;
|
|
45
|
+
color: var(--dsw-color-text-secondary, #646a73);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.error {
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
justify-content: center;
|
|
52
|
+
min-width: 64px;
|
|
53
|
+
min-height: 64px;
|
|
54
|
+
padding: 0 8px;
|
|
55
|
+
border: 1px dashed var(--dsw-color-border, rgba(0, 0, 0, 0.16));
|
|
56
|
+
border-radius: 8px;
|
|
57
|
+
background: none;
|
|
58
|
+
font-size: 12px;
|
|
59
|
+
color: var(--dsw-color-text-secondary, #646a73);
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
}
|