@arronqzy/react-view 1.0.13 → 1.0.15
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/dist/styles.css +96 -4
- package/package.json +11 -10
- package/src/panel/ReactViewOnlinePreview.tsx +31 -2
- package/src/panel/ReactViewPanel.tsx +186 -124
- package/src/panel/ai/applyAssistantAction.ts +1 -0
- package/src/panel/components/BusyOverlay.tsx +25 -0
- package/src/panel/components/ElementsLayer.tsx +110 -68
- package/src/panel/components/MaterialSidebar.tsx +15 -0
- package/src/panel/components/MoveableLayer.tsx +8 -0
- package/src/panel/components/PanelCanvas.tsx +3 -0
- package/src/panel/components/PanelConfigSidebar.tsx +140 -91
- package/src/panel/components/SelectLayer.tsx +5 -0
- package/src/panel/components/WorkspaceConfigSidebar.tsx +1 -0
- package/src/panel/components/WorkspaceProjectNav.tsx +27 -4
- package/src/panel/components/config/PanelConfigScene3dSection.tsx +874 -0
- package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.tsx +1 -2
- package/src/panel/components/table/PanelConfigTableSection.tsx +82 -50
- package/src/panel/components/table/TableNodeContent.tsx +40 -15
- package/src/panel/hooks/usePanelElements.ts +13 -1
- package/src/panel/hooks/useWorkspaceProjects.ts +133 -66
- package/src/panel/library/workspace-project-cache.ts +8 -2
- package/src/panel/library/workspace-project-db.ts +29 -11
- package/src/panel/library/workspace-snapshot.ts +21 -4
- package/src/panel/scope/view-scope-store.ts +8 -0
- package/src/panel/types.ts +4 -3
- package/src/panel/utils/async-work.ts +178 -0
- package/src/panel/utils/gridPlacement.ts +29 -0
- package/src/panel/utils/panelElementDefaults.ts +37 -1
- package/src/panel/utils/scope-autocomplete.ts +0 -114
|
@@ -37,10 +37,10 @@ import { collectElementScopeWarnings } from "../utils/scope-template-warnings";
|
|
|
37
37
|
import { ScopeConfigProvider } from "./scope-config/ScopeConfigContext";
|
|
38
38
|
import { ScopeTemplateWarningsPanel } from "./scope-config/ScopeTemplateWarningsPanel";
|
|
39
39
|
import { PanelConfigTableSection } from "./table/PanelConfigTableSection";
|
|
40
|
-
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
} from "../
|
|
40
|
+
import { cssTextLineHeight, cssTextAlignStyle } from "../utils/panelElementDefaults";
|
|
41
|
+
import { highlightConfigField, subscribeRevealPanelConfig } from "../ai/revealConfigField";
|
|
42
|
+
import { PanelConfigScene3dSection } from "./config/PanelConfigScene3dSection";
|
|
43
|
+
import { readFileAsDataUrl, runBusyTask } from "../utils/async-work";
|
|
44
44
|
|
|
45
45
|
type UpdateElement = (
|
|
46
46
|
id: string,
|
|
@@ -262,31 +262,34 @@ export function PanelConfigSidebar({
|
|
|
262
262
|
const handleUploadBackgroundImage = useCallback(
|
|
263
263
|
async (file: File) => {
|
|
264
264
|
if (!selectedElement) return;
|
|
265
|
-
const base64 = await new Promise<string>((resolve, reject) => {
|
|
266
|
-
const reader = new FileReader();
|
|
267
|
-
reader.onload = () => resolve(String(reader.result ?? ""));
|
|
268
|
-
reader.onerror = () => reject(new Error(messages.readImageFailed));
|
|
269
|
-
reader.readAsDataURL(file);
|
|
270
|
-
});
|
|
271
|
-
updateSelectedStyle({
|
|
272
|
-
backgroundImage: `url("${base64}")`,
|
|
273
|
-
});
|
|
274
|
-
setUploadStatus(t("panel.config.uploadWrittenBase64"));
|
|
275
265
|
try {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
266
|
+
await runBusyTask(t("common.uploadingFile"), async () => {
|
|
267
|
+
const base64 = await readFileAsDataUrl(file, messages.readImageFailed, "image");
|
|
268
|
+
updateSelectedStyle({
|
|
269
|
+
backgroundImage: `url("${base64}")`,
|
|
270
|
+
});
|
|
271
|
+
setUploadStatus(t("panel.config.uploadWrittenBase64"));
|
|
272
|
+
try {
|
|
273
|
+
const form = new FormData();
|
|
274
|
+
form.append("file", file);
|
|
275
|
+
const resp = await fetch("/api/upload", { method: "POST", body: form });
|
|
276
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
277
|
+
const data = (await resp.json()) as { url?: string };
|
|
278
|
+
if (data.url) {
|
|
279
|
+
updateSelectedStyle({ backgroundImageRemoteUrl: data.url });
|
|
280
|
+
setUploadStatus(t("panel.config.uploadServerAndBase64"));
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
setUploadStatus(t("panel.config.uploadServerFailedKeepBase64"));
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
} catch (error) {
|
|
287
|
+
setUploadStatus(
|
|
288
|
+
error instanceof Error ? error.message : messages.readImageFailed
|
|
289
|
+
);
|
|
287
290
|
}
|
|
288
291
|
},
|
|
289
|
-
[selectedElement, updateSelectedStyle]
|
|
292
|
+
[messages.readImageFailed, selectedElement, t, updateSelectedStyle]
|
|
290
293
|
);
|
|
291
294
|
const updateSelectedAudio = useCallback(
|
|
292
295
|
(patch: Partial<PanelElement>) => {
|
|
@@ -298,43 +301,47 @@ export function PanelConfigSidebar({
|
|
|
298
301
|
const handleUploadAudioFile = useCallback(
|
|
299
302
|
async (file: File) => {
|
|
300
303
|
if (!selectedElement || selectedElement.materialType !== "audio") return;
|
|
301
|
-
const base64 = await new Promise<string>((resolve, reject) => {
|
|
302
|
-
const reader = new FileReader();
|
|
303
|
-
reader.onload = () => resolve(String(reader.result ?? ""));
|
|
304
|
-
reader.onerror = () => reject(new Error(messages.readAudioFailed));
|
|
305
|
-
reader.readAsDataURL(file);
|
|
306
|
-
});
|
|
307
|
-
updateSelectedAudio({ audioSrc: base64 });
|
|
308
|
-
setAudioStatus(messages.audioLocalSaved);
|
|
309
304
|
try {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
305
|
+
await runBusyTask(t("common.uploadingFile"), async () => {
|
|
306
|
+
const base64 = await readFileAsDataUrl(file, messages.readAudioFailed, "audio");
|
|
307
|
+
updateSelectedAudio({ audioSrc: base64 });
|
|
308
|
+
setAudioStatus(messages.audioLocalSaved);
|
|
309
|
+
try {
|
|
310
|
+
const form = new FormData();
|
|
311
|
+
form.append("file", file);
|
|
312
|
+
const resp = await fetch("/api/upload", { method: "POST", body: form });
|
|
313
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
314
|
+
const data = (await resp.json()) as { url?: string };
|
|
315
|
+
if (data.url) {
|
|
316
|
+
updateSelectedAudio({ audioRemoteUrl: data.url });
|
|
317
|
+
setAudioStatus(messages.audioRemoteUploaded);
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
setAudioStatus(messages.audioServerUploadFailed);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
} catch (error) {
|
|
324
|
+
setAudioStatus(
|
|
325
|
+
error instanceof Error ? error.message : messages.readAudioFailed
|
|
326
|
+
);
|
|
321
327
|
}
|
|
322
328
|
},
|
|
323
|
-
[selectedElement, updateSelectedAudio]
|
|
329
|
+
[messages, selectedElement, t, updateSelectedAudio]
|
|
324
330
|
);
|
|
325
331
|
const handleUploadAudioPoster = useCallback(
|
|
326
332
|
async (file: File) => {
|
|
327
333
|
if (!selectedElement || selectedElement.materialType !== "audio") return;
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
334
|
+
try {
|
|
335
|
+
const base64 = await readFileAsDataUrl(file, messages.readImageFailed, "image");
|
|
336
|
+
updateSelectedAudio({ audioPosterImage: base64 });
|
|
337
|
+
setAudioStatus(messages.audioPosterSet);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
setAudioStatus(
|
|
340
|
+
error instanceof Error ? error.message : messages.readImageFailed
|
|
341
|
+
);
|
|
342
|
+
}
|
|
336
343
|
},
|
|
337
|
-
[selectedElement, updateSelectedAudio]
|
|
344
|
+
[messages, selectedElement, updateSelectedAudio]
|
|
338
345
|
);
|
|
339
346
|
const stopRecordingAudio = useCallback(() => {
|
|
340
347
|
recorderRef.current?.stop();
|
|
@@ -356,14 +363,15 @@ export function PanelConfigSidebar({
|
|
|
356
363
|
};
|
|
357
364
|
recorder.onstop = async () => {
|
|
358
365
|
const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
366
|
+
try {
|
|
367
|
+
const dataUrl = await readFileAsDataUrl(blob, messages.readRecordAudioFailed, "audio");
|
|
368
|
+
updateSelectedAudio({ audioSrc: dataUrl });
|
|
369
|
+
setAudioStatus(messages.audioRecordSaved);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
setAudioStatus(
|
|
372
|
+
error instanceof Error ? error.message : messages.readRecordAudioFailed
|
|
373
|
+
);
|
|
374
|
+
}
|
|
367
375
|
recordStreamRef.current?.getTracks().forEach((track) => track.stop());
|
|
368
376
|
recordStreamRef.current = null;
|
|
369
377
|
recorderRef.current = null;
|
|
@@ -387,29 +395,32 @@ export function PanelConfigSidebar({
|
|
|
387
395
|
const handleUploadVideoFile = useCallback(
|
|
388
396
|
async (file: File) => {
|
|
389
397
|
if (!selectedElement || selectedElement.materialType !== "video") return;
|
|
390
|
-
const base64 = await new Promise<string>((resolve, reject) => {
|
|
391
|
-
const reader = new FileReader();
|
|
392
|
-
reader.onload = () => resolve(String(reader.result ?? ""));
|
|
393
|
-
reader.onerror = () => reject(new Error(messages.readVideoFailed));
|
|
394
|
-
reader.readAsDataURL(file);
|
|
395
|
-
});
|
|
396
|
-
updateSelectedVideo({ videoSrc: base64 });
|
|
397
|
-
setVideoStatus(messages.videoLocalSaved);
|
|
398
398
|
try {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
399
|
+
await runBusyTask(t("common.uploadingFile"), async () => {
|
|
400
|
+
const base64 = await readFileAsDataUrl(file, messages.readVideoFailed, "video");
|
|
401
|
+
updateSelectedVideo({ videoSrc: base64 });
|
|
402
|
+
setVideoStatus(messages.videoLocalSaved);
|
|
403
|
+
try {
|
|
404
|
+
const form = new FormData();
|
|
405
|
+
form.append("file", file);
|
|
406
|
+
const resp = await fetch("/api/upload", { method: "POST", body: form });
|
|
407
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
408
|
+
const data = (await resp.json()) as { url?: string };
|
|
409
|
+
if (data.url) {
|
|
410
|
+
updateSelectedVideo({ videoRemoteUrl: data.url });
|
|
411
|
+
setVideoStatus(messages.videoRemoteUploaded);
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
setVideoStatus(messages.videoServerUploadFailed);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
} catch (error) {
|
|
418
|
+
setVideoStatus(
|
|
419
|
+
error instanceof Error ? error.message : messages.readVideoFailed
|
|
420
|
+
);
|
|
410
421
|
}
|
|
411
422
|
},
|
|
412
|
-
[selectedElement, updateSelectedVideo]
|
|
423
|
+
[messages, selectedElement, t, updateSelectedVideo]
|
|
413
424
|
);
|
|
414
425
|
|
|
415
426
|
const isSectionExpanded = (key: string, defaultValue = true) =>
|
|
@@ -2848,7 +2859,8 @@ export function PanelConfigSidebar({
|
|
|
2848
2859
|
<div
|
|
2849
2860
|
ref={textEditorRef}
|
|
2850
2861
|
data-config-field="textHtml"
|
|
2851
|
-
|
|
2862
|
+
data-panel-user-text=""
|
|
2863
|
+
className="min-h-[120px] rounded border border-border bg-background px-2 py-1.5 leading-6 outline-none"
|
|
2852
2864
|
style={{
|
|
2853
2865
|
fontFamily: selectedElement.textFontFamily || undefined,
|
|
2854
2866
|
fontSize: selectedElement.textFontSize
|
|
@@ -2856,10 +2868,8 @@ export function PanelConfigSidebar({
|
|
|
2856
2868
|
: undefined,
|
|
2857
2869
|
fontWeight: selectedElement.textFontWeight || undefined,
|
|
2858
2870
|
color: selectedElement.textColor || undefined,
|
|
2859
|
-
lineHeight: selectedElement.textLineHeight
|
|
2860
|
-
|
|
2861
|
-
: undefined,
|
|
2862
|
-
textAlign: selectedElement.textAlign ?? "left",
|
|
2871
|
+
lineHeight: cssTextLineHeight(selectedElement.textLineHeight),
|
|
2872
|
+
...cssTextAlignStyle(selectedElement.textAlign),
|
|
2863
2873
|
}}
|
|
2864
2874
|
contentEditable
|
|
2865
2875
|
suppressContentEditableWarning
|
|
@@ -2935,7 +2945,7 @@ export function PanelConfigSidebar({
|
|
|
2935
2945
|
value={selectedElement.textAlign ?? "left"}
|
|
2936
2946
|
onValueChange={(value) =>
|
|
2937
2947
|
updateSelectedText({
|
|
2938
|
-
textAlign: value as "
|
|
2948
|
+
textAlign: value as PanelElement["textAlign"],
|
|
2939
2949
|
})
|
|
2940
2950
|
}
|
|
2941
2951
|
>
|
|
@@ -2945,19 +2955,40 @@ export function PanelConfigSidebar({
|
|
|
2945
2955
|
<SelectContent>
|
|
2946
2956
|
<SelectItem value="left">{t("panel.config.alignLeft")}</SelectItem>
|
|
2947
2957
|
<SelectItem value="center">{t("panel.config.alignCenter")}</SelectItem>
|
|
2958
|
+
<SelectItem value="middle">{t("panel.config.alignMiddle")}</SelectItem>
|
|
2948
2959
|
<SelectItem value="right">{t("panel.config.alignRight")}</SelectItem>
|
|
2949
2960
|
<SelectItem value="justify">{t("panel.config.alignJustify")}</SelectItem>
|
|
2950
2961
|
</SelectContent>
|
|
2951
2962
|
</Select>
|
|
2952
2963
|
</label>
|
|
2953
2964
|
<label className="block space-y-1">
|
|
2954
|
-
<div
|
|
2965
|
+
<div className="flex items-center justify-between gap-2">
|
|
2966
|
+
<span>{t("panel.config.lineHeight")}</span>
|
|
2967
|
+
<label className="flex items-center gap-1 font-normal">
|
|
2968
|
+
<Checkbox
|
|
2969
|
+
checked={selectedElement.textLineHeight === "auto"}
|
|
2970
|
+
className="h-3.5 w-3.5 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
|
|
2971
|
+
onCheckedChange={(checked) =>
|
|
2972
|
+
updateSelectedText({
|
|
2973
|
+
textLineHeight: checked === true ? "auto" : 1.6,
|
|
2974
|
+
})
|
|
2975
|
+
}
|
|
2976
|
+
/>
|
|
2977
|
+
<span>{t("panel.config.lineHeightAuto")}</span>
|
|
2978
|
+
</label>
|
|
2979
|
+
</div>
|
|
2955
2980
|
<Input
|
|
2956
2981
|
type="number"
|
|
2957
2982
|
min={1}
|
|
2958
2983
|
max={3}
|
|
2959
2984
|
step={0.1}
|
|
2960
|
-
|
|
2985
|
+
disabled={selectedElement.textLineHeight === "auto"}
|
|
2986
|
+
placeholder={t("panel.config.lineHeightAuto")}
|
|
2987
|
+
value={
|
|
2988
|
+
selectedElement.textLineHeight === "auto"
|
|
2989
|
+
? ""
|
|
2990
|
+
: (selectedElement.textLineHeight ?? 1.6)
|
|
2991
|
+
}
|
|
2961
2992
|
onChange={(e) =>
|
|
2962
2993
|
updateSelectedText({
|
|
2963
2994
|
textLineHeight: Math.min(
|
|
@@ -3459,6 +3490,24 @@ export function PanelConfigSidebar({
|
|
|
3459
3490
|
true,
|
|
3460
3491
|
[t("panel.material.geometry"), "geometry", t("panel.config.shape"), "canvas", t("panel.config.searchKwScript"), t("panel.config.searchKwSketch")]
|
|
3461
3492
|
)
|
|
3493
|
+
) : selectedElement.materialType === "scene3d" ? (
|
|
3494
|
+
renderSection(
|
|
3495
|
+
"scene3dConfig",
|
|
3496
|
+
t("panel.config.sectionScene3d"),
|
|
3497
|
+
<PanelConfigScene3dSection
|
|
3498
|
+
element={selectedElement}
|
|
3499
|
+
updateElement={updateElement}
|
|
3500
|
+
/>,
|
|
3501
|
+
true,
|
|
3502
|
+
[
|
|
3503
|
+
t("panel.material.scene3d"),
|
|
3504
|
+
"3d",
|
|
3505
|
+
"glb",
|
|
3506
|
+
t("panel.config.scene3dUploadModels"),
|
|
3507
|
+
t("panel.config.groupScene3dCamera"),
|
|
3508
|
+
t("panel.config.groupScene3dModelAnim"),
|
|
3509
|
+
]
|
|
3510
|
+
)
|
|
3462
3511
|
) : selectedElement.materialType === "grid" ? (
|
|
3463
3512
|
renderSection(
|
|
3464
3513
|
"gridConfig",
|
|
@@ -67,6 +67,7 @@ export function SelectLayer({
|
|
|
67
67
|
// 只允许左键触发框选,右键不参与选区变更
|
|
68
68
|
if (input && input.button !== 0) return false;
|
|
69
69
|
const target = (input?.target as HTMLElement | null) ?? null;
|
|
70
|
+
if (target?.closest("[data-scene3d-orbit-active='true']")) return false;
|
|
70
71
|
const isShift = !!input?.shiftKey;
|
|
71
72
|
if (isShift) return true;
|
|
72
73
|
|
|
@@ -95,6 +96,10 @@ export function SelectLayer({
|
|
|
95
96
|
}
|
|
96
97
|
// 已有多选时,默认应该是拖动移动;只有按住 shift 才继续框选/追加
|
|
97
98
|
const target = e.inputEvent.target as HTMLElement | null;
|
|
99
|
+
if (target?.closest("[data-scene3d-orbit-active='true']")) {
|
|
100
|
+
e.stop();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
98
103
|
const selectable = target?.closest(".rv-selectable") as HTMLElement | null;
|
|
99
104
|
const id = selectable?.dataset.elementId;
|
|
100
105
|
const isShift = (e.inputEvent as MouseEvent | undefined)?.shiftKey ?? false;
|
|
@@ -35,6 +35,7 @@ export type WorkspaceProjectNavProps = {
|
|
|
35
35
|
activeProjectId: string | null;
|
|
36
36
|
activeProjectName: string | null;
|
|
37
37
|
dirty: boolean;
|
|
38
|
+
previewingProjectIds?: string[];
|
|
38
39
|
onCreateProject: () => Promise<{ name: string; id: string } | void>;
|
|
39
40
|
onOpenProject: (id: string) => Promise<void>;
|
|
40
41
|
onSyncProject: () => Promise<string | void>;
|
|
@@ -47,6 +48,7 @@ export function WorkspaceProjectNav({
|
|
|
47
48
|
activeProjectId,
|
|
48
49
|
activeProjectName,
|
|
49
50
|
dirty,
|
|
51
|
+
previewingProjectIds = [],
|
|
50
52
|
onCreateProject,
|
|
51
53
|
onOpenProject,
|
|
52
54
|
onSyncProject,
|
|
@@ -136,14 +138,22 @@ export function WorkspaceProjectNav({
|
|
|
136
138
|
{projects.length === 0 ? (
|
|
137
139
|
<DropdownMenuItem disabled>{t("panel.workspace.noSavedWorkspaces")}</DropdownMenuItem>
|
|
138
140
|
) : (
|
|
139
|
-
projects.map((project) =>
|
|
141
|
+
projects.map((project) => {
|
|
142
|
+
const isCurrent = activeProjectId === project.id;
|
|
143
|
+
const isPreviewing = previewingProjectIds.includes(project.id);
|
|
144
|
+
return (
|
|
140
145
|
<div key={project.id} className="px-1 py-0.5">
|
|
141
146
|
<div className="flex items-center gap-1">
|
|
142
147
|
<button
|
|
143
148
|
type="button"
|
|
144
149
|
className={[
|
|
145
150
|
"min-w-0 flex-1 rounded px-2 py-1.5 text-left text-xs hover:bg-accent",
|
|
146
|
-
|
|
151
|
+
isCurrent
|
|
152
|
+
? "bg-primary/15 font-medium text-primary ring-1 ring-inset ring-primary/40"
|
|
153
|
+
: "",
|
|
154
|
+
!isCurrent && isPreviewing
|
|
155
|
+
? "bg-sky-500/10 ring-1 ring-inset ring-sky-400/50"
|
|
156
|
+
: "",
|
|
147
157
|
].join(" ")}
|
|
148
158
|
onClick={() =>
|
|
149
159
|
void runAction(async () => {
|
|
@@ -151,7 +161,19 @@ export function WorkspaceProjectNav({
|
|
|
151
161
|
})
|
|
152
162
|
}
|
|
153
163
|
>
|
|
154
|
-
<div className="
|
|
164
|
+
<div className="flex items-center gap-1">
|
|
165
|
+
<div className="min-w-0 flex-1 truncate">{project.name}</div>
|
|
166
|
+
{isCurrent ? (
|
|
167
|
+
<span className="shrink-0 rounded bg-primary px-1 py-px text-[9px] leading-none text-primary-foreground">
|
|
168
|
+
{t("panel.workspace.currentBadge")}
|
|
169
|
+
</span>
|
|
170
|
+
) : null}
|
|
171
|
+
{isPreviewing ? (
|
|
172
|
+
<span className="shrink-0 rounded bg-sky-600 px-1 py-px text-[9px] leading-none text-white">
|
|
173
|
+
{t("panel.workspace.previewingBadge")}
|
|
174
|
+
</span>
|
|
175
|
+
) : null}
|
|
176
|
+
</div>
|
|
155
177
|
<div className="truncate text-[10px] text-muted-foreground">
|
|
156
178
|
{formatUpdatedAt(project.updatedAt)}
|
|
157
179
|
</div>
|
|
@@ -182,7 +204,8 @@ export function WorkspaceProjectNav({
|
|
|
182
204
|
</Button>
|
|
183
205
|
</div>
|
|
184
206
|
</div>
|
|
185
|
-
|
|
207
|
+
);
|
|
208
|
+
})
|
|
186
209
|
)}
|
|
187
210
|
{/* <DropdownMenuSeparator />
|
|
188
211
|
<DropdownMenuItem onClick={() => void runAction(onCreateProject)}>
|