@odori/cli 0.0.2 → 0.0.3
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/{chunk-7XJL2BYO.js → chunk-RXLB2CXH.js} +367 -171
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +29 -3
- package/dist/index.js +1 -1
- package/dist/{registry-snapshot-NIH2JMQ6.js → registry-snapshot-BDP6PVYB.js} +2 -2
- package/package.json +3 -3
- package/src/chunk-cache.ts +6 -0
- package/src/cli.ts +10 -3
- package/src/commands/add.ts +8 -8
- package/src/commands/dev.ts +86 -11
- package/src/commands/exportVideo.ts +39 -5
- package/src/commands/init.ts +1 -1
- package/src/commands/new.ts +1 -1
- package/src/cues.ts +34 -21
- package/src/discovery.ts +22 -3
- package/src/formats.ts +67 -8
- package/src/jobs.ts +6 -2
- package/src/registry-snapshot.json +2 -2
- package/src/registry-source.ts +50 -3
- package/src/render.ts +48 -13
- package/src/server.ts +48 -12
- package/studio/src/Studio.tsx +19 -22
- package/studio/src/components/ExportPanel.tsx +124 -90
- package/studio/src/components/Inspector.tsx +121 -0
- package/studio/src/components/Thumbnail.tsx +65 -23
- package/studio/src/components/ui.tsx +9 -2
- package/studio/src/studio.css +194 -26
- package/studio/src/views/AssetsView.tsx +14 -1
- package/studio/src/views/BrandsView.tsx +58 -27
- package/studio/src/views/ComponentsView.tsx +23 -37
- package/studio/src/views/HomeView.tsx +39 -17
- package/studio/src/views/VideosView.tsx +34 -49
package/studio/src/studio.css
CHANGED
|
@@ -485,30 +485,182 @@ select.input {
|
|
|
485
485
|
|
|
486
486
|
.main {
|
|
487
487
|
display: grid;
|
|
488
|
-
|
|
488
|
+
/* A detail is the stage and the inspector; galleries and pages are one
|
|
489
|
+
column. The inspector's column is a root variable so its drag handle can
|
|
490
|
+
resize it without React re-laying anything out. */
|
|
491
|
+
grid-template-columns: minmax(0, 1fr) clamp(240px, var(--inspector-width, 320px), 50vw);
|
|
489
492
|
overflow: hidden;
|
|
490
493
|
}
|
|
491
494
|
.main[data-single="true"] {
|
|
492
495
|
grid-template-columns: 1fr;
|
|
493
496
|
}
|
|
494
497
|
|
|
495
|
-
.
|
|
498
|
+
.inspector {
|
|
496
499
|
background: var(--panel);
|
|
497
|
-
border-
|
|
500
|
+
border-left: 1px solid var(--border);
|
|
501
|
+
container-type: inline-size;
|
|
498
502
|
display: flex;
|
|
499
|
-
flex-direction: column;
|
|
500
503
|
overflow: hidden;
|
|
504
|
+
position: relative;
|
|
501
505
|
}
|
|
502
|
-
.
|
|
506
|
+
.inspector-scroll {
|
|
507
|
+
flex: 1;
|
|
503
508
|
overflow-y: auto;
|
|
504
|
-
padding:
|
|
509
|
+
padding: 16px;
|
|
505
510
|
}
|
|
506
511
|
|
|
507
|
-
|
|
512
|
+
|
|
513
|
+
.inspector-head {
|
|
514
|
+
align-items: center;
|
|
515
|
+
display: flex;
|
|
516
|
+
gap: 8px;
|
|
517
|
+
justify-content: space-between;
|
|
518
|
+
margin-bottom: 14px;
|
|
519
|
+
}
|
|
520
|
+
.inspector-title {
|
|
521
|
+
color: var(--foreground);
|
|
522
|
+
font-size: 13px;
|
|
523
|
+
font-weight: 500;
|
|
524
|
+
letter-spacing: -0.01em;
|
|
525
|
+
overflow: hidden;
|
|
526
|
+
text-overflow: ellipsis;
|
|
527
|
+
white-space: nowrap;
|
|
528
|
+
}
|
|
529
|
+
.inspector-toggle,
|
|
530
|
+
.inspector-reopen {
|
|
531
|
+
align-items: center;
|
|
532
|
+
background: transparent;
|
|
533
|
+
border: 1px solid transparent;
|
|
534
|
+
border-radius: var(--radius-sm);
|
|
535
|
+
color: var(--muted);
|
|
536
|
+
cursor: pointer;
|
|
537
|
+
display: inline-flex;
|
|
538
|
+
flex: none;
|
|
539
|
+
height: 26px;
|
|
540
|
+
justify-content: center;
|
|
541
|
+
width: 26px;
|
|
542
|
+
}
|
|
543
|
+
.inspector-toggle:hover,
|
|
544
|
+
.inspector-reopen:hover {
|
|
545
|
+
background: var(--hover);
|
|
546
|
+
color: var(--foreground);
|
|
547
|
+
}
|
|
548
|
+
/* When the pane is away, the way back floats where the pane was. */
|
|
549
|
+
.inspector-reopen {
|
|
508
550
|
background: var(--panel);
|
|
509
|
-
border-
|
|
551
|
+
border-color: var(--border);
|
|
552
|
+
position: fixed;
|
|
553
|
+
right: 10px;
|
|
554
|
+
top: 58px;
|
|
555
|
+
z-index: 5;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/* A collapsed inspector hands its column to the stage. */
|
|
559
|
+
html[data-inspector="collapsed"] .main:not([data-single="true"]) {
|
|
560
|
+
grid-template-columns: minmax(0, 1fr);
|
|
561
|
+
}
|
|
562
|
+
.inspector-resize {
|
|
563
|
+
cursor: col-resize;
|
|
564
|
+
/* Fully inside the pane: the inspector clips overflow, and a handle hung
|
|
565
|
+
across the border loses the clipped half of its hit area. */
|
|
566
|
+
inset: 0 auto 0 0;
|
|
567
|
+
position: absolute;
|
|
568
|
+
touch-action: none;
|
|
569
|
+
width: 8px;
|
|
570
|
+
z-index: 2;
|
|
571
|
+
}
|
|
572
|
+
/* The hit area is eight pixels; the drawn feedback is a two-pixel line on
|
|
573
|
+
the pane's edge. Filling the whole strip read as a fat bar mid-drag. */
|
|
574
|
+
.inspector-resize::before {
|
|
575
|
+
content: "";
|
|
576
|
+
inset: 0 auto 0 0;
|
|
577
|
+
position: absolute;
|
|
578
|
+
width: 2px;
|
|
579
|
+
}
|
|
580
|
+
.inspector-resize:hover::before,
|
|
581
|
+
.inspector-resize:active::before {
|
|
582
|
+
background: color-mix(in srgb, var(--accent, #7c8cff) 55%, transparent);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/* The export block: what to make, how big, how compressed, then one
|
|
586
|
+
full-width action named after the file it writes. */
|
|
587
|
+
.export-panel {
|
|
588
|
+
display: grid;
|
|
589
|
+
gap: 10px;
|
|
590
|
+
}
|
|
591
|
+
.export-row {
|
|
592
|
+
display: grid;
|
|
593
|
+
gap: 5px;
|
|
594
|
+
}
|
|
595
|
+
.export-label {
|
|
596
|
+
color: var(--muted);
|
|
597
|
+
font-size: 12px;
|
|
598
|
+
}
|
|
599
|
+
.export-options {
|
|
600
|
+
align-items: center;
|
|
601
|
+
display: flex;
|
|
602
|
+
flex-wrap: wrap;
|
|
603
|
+
gap: 6px;
|
|
604
|
+
}
|
|
605
|
+
.export-dimensions {
|
|
606
|
+
color: var(--subtle);
|
|
607
|
+
font-family: var(--font-mono);
|
|
608
|
+
font-size: 11px;
|
|
609
|
+
}
|
|
610
|
+
/* The action names a file, and file names outgrow narrow panes. */
|
|
611
|
+
.export-panel .button {
|
|
612
|
+
min-width: 0;
|
|
613
|
+
}
|
|
614
|
+
.export-panel .button > span {
|
|
615
|
+
overflow: hidden;
|
|
616
|
+
text-overflow: ellipsis;
|
|
617
|
+
white-space: nowrap;
|
|
618
|
+
}
|
|
619
|
+
/* Stacked while narrow, side by side once the pane can afford it. */
|
|
620
|
+
.export-actions {
|
|
621
|
+
display: grid;
|
|
622
|
+
gap: 8px;
|
|
623
|
+
}
|
|
624
|
+
.export-actions .button {
|
|
625
|
+
width: 100%;
|
|
626
|
+
}
|
|
627
|
+
@container (min-width: 340px) {
|
|
628
|
+
.export-actions {
|
|
629
|
+
grid-template-columns: 1fr 1fr;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/* A gallery page or an explainer page: one scrolling column with a readable
|
|
634
|
+
lead-in. */
|
|
635
|
+
.page {
|
|
510
636
|
overflow-y: auto;
|
|
511
|
-
padding: 16px;
|
|
637
|
+
padding: 16px 20px;
|
|
638
|
+
}
|
|
639
|
+
.page-hint {
|
|
640
|
+
color: var(--muted);
|
|
641
|
+
font-size: 13px;
|
|
642
|
+
line-height: 1.6;
|
|
643
|
+
margin: 0 0 16px;
|
|
644
|
+
max-width: 64ch;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/* A section heading with an action on the same line, for the home page's
|
|
648
|
+
"View all" links. */
|
|
649
|
+
.section-head {
|
|
650
|
+
align-items: baseline;
|
|
651
|
+
display: flex;
|
|
652
|
+
justify-content: space-between;
|
|
653
|
+
}
|
|
654
|
+
.view-all {
|
|
655
|
+
background: none;
|
|
656
|
+
border: 0;
|
|
657
|
+
color: var(--muted);
|
|
658
|
+
cursor: pointer;
|
|
659
|
+
font-size: 12px;
|
|
660
|
+
padding: 0;
|
|
661
|
+
}
|
|
662
|
+
.view-all:hover {
|
|
663
|
+
color: var(--foreground);
|
|
512
664
|
}
|
|
513
665
|
|
|
514
666
|
.section-title {
|
|
@@ -767,6 +919,9 @@ select.input {
|
|
|
767
919
|
}
|
|
768
920
|
.fact dt {
|
|
769
921
|
color: var(--subtle);
|
|
922
|
+
/* A label longer than its column wraps inside it. Without this a long
|
|
923
|
+
token walks into the value column and reads as two facts overprinted. */
|
|
924
|
+
overflow-wrap: anywhere;
|
|
770
925
|
}
|
|
771
926
|
.fact dd {
|
|
772
927
|
color: var(--foreground);
|
|
@@ -775,6 +930,16 @@ select.input {
|
|
|
775
930
|
overflow-wrap: anywhere;
|
|
776
931
|
}
|
|
777
932
|
|
|
933
|
+
/* Facts read as a table while there is room and as a list when there is not.
|
|
934
|
+
Two columns in a narrow pane wrap both halves and read as neither. After
|
|
935
|
+
the base rule, because a container query adds no specificity of its own. */
|
|
936
|
+
@container (max-width: 300px) {
|
|
937
|
+
.fact {
|
|
938
|
+
gap: 2px;
|
|
939
|
+
grid-template-columns: 1fr;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
778
943
|
.field {
|
|
779
944
|
display: grid;
|
|
780
945
|
gap: 5px;
|
|
@@ -883,6 +1048,23 @@ select.input {
|
|
|
883
1048
|
.card[data-active="true"] {
|
|
884
1049
|
border-color: var(--border-strong);
|
|
885
1050
|
}
|
|
1051
|
+
/* A quiet corner chip: this composition has sound. */
|
|
1052
|
+
.card-audio {
|
|
1053
|
+
align-items: center;
|
|
1054
|
+
background: color-mix(in srgb, #000 55%, transparent);
|
|
1055
|
+
border-radius: 999px;
|
|
1056
|
+
color: #fff;
|
|
1057
|
+
display: inline-flex;
|
|
1058
|
+
height: 22px;
|
|
1059
|
+
justify-content: center;
|
|
1060
|
+
opacity: 0.85;
|
|
1061
|
+
position: absolute;
|
|
1062
|
+
right: 8px;
|
|
1063
|
+
top: 8px;
|
|
1064
|
+
width: 22px;
|
|
1065
|
+
z-index: 2;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
886
1068
|
.card-canvas {
|
|
887
1069
|
aspect-ratio: 16 / 9;
|
|
888
1070
|
background: #000;
|
|
@@ -936,6 +1118,9 @@ select.input {
|
|
|
936
1118
|
}
|
|
937
1119
|
|
|
938
1120
|
.assets-view {
|
|
1121
|
+
/* Rows pack at the top. Stretched rows made a near-empty page read as
|
|
1122
|
+
three stranded lines with a screen of black between them. */
|
|
1123
|
+
align-content: start;
|
|
939
1124
|
display: grid;
|
|
940
1125
|
gap: 26px;
|
|
941
1126
|
overflow-y: auto;
|
|
@@ -1122,23 +1307,6 @@ select.input {
|
|
|
1122
1307
|
.brand-card .card-meta {
|
|
1123
1308
|
padding: 0 12px;
|
|
1124
1309
|
}
|
|
1125
|
-
.home-more {
|
|
1126
|
-
background: transparent;
|
|
1127
|
-
border: 1px solid var(--border);
|
|
1128
|
-
border-radius: var(--radius);
|
|
1129
|
-
color: var(--muted);
|
|
1130
|
-
cursor: pointer;
|
|
1131
|
-
font-family: var(--font-mono);
|
|
1132
|
-
font-size: 11px;
|
|
1133
|
-
margin-top: 10px;
|
|
1134
|
-
padding: 9px 12px;
|
|
1135
|
-
text-align: left;
|
|
1136
|
-
width: 100%;
|
|
1137
|
-
}
|
|
1138
|
-
.home-more:hover {
|
|
1139
|
-
border-color: var(--border-strong);
|
|
1140
|
-
color: var(--foreground);
|
|
1141
|
-
}
|
|
1142
1310
|
|
|
1143
1311
|
.statusbar {
|
|
1144
1312
|
align-items: center;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {resolveEntryLayout} from "odori";
|
|
2
2
|
import {project, videos} from "virtual:odori-project";
|
|
3
3
|
import {AudioClip} from "../components/AudioClip";
|
|
4
|
-
import {SectionTitle} from "../components/ui";
|
|
4
|
+
import {Empty, SectionTitle} from "../components/ui";
|
|
5
5
|
|
|
6
6
|
const isImage = (url: string) => /\.(png|jpe?g|gif|svg|webp|avif)$/i.test(url);
|
|
7
7
|
const isAudio = (url: string) => /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i.test(url);
|
|
@@ -24,6 +24,19 @@ export const AssetsView = () => {
|
|
|
24
24
|
|
|
25
25
|
const fonts = [...new Map(videos.flatMap((video) => resolveEntryLayout(video).brand.fonts).map((font) => [font.url, font])).values()];
|
|
26
26
|
|
|
27
|
+
// Three sections of nothing is a page that looks broken. One empty state
|
|
28
|
+
// says what belongs here and where each kind of file goes.
|
|
29
|
+
if (visualAssets.length === 0 && library.length === 0 && fonts.length === 0) {
|
|
30
|
+
return (
|
|
31
|
+
<Empty title="No assets yet">
|
|
32
|
+
Assets are the files videos reference beyond code: images and footage declared in{" "}
|
|
33
|
+
<code>odori.config.ts</code>, sounds dropped in <code>{project.audioDir}/</code>, and fonts a brand declares
|
|
34
|
+
with <code>defineBrand({"{fonts: [...]}"})</code>. Everything lives under <code>public/</code>, the one path
|
|
35
|
+
that means the same thing in preview and render. Add a file and it appears here.
|
|
36
|
+
</Empty>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
return (
|
|
28
41
|
<div className="assets-view">
|
|
29
42
|
<section>
|
|
@@ -12,6 +12,7 @@ import {brands as discoveredBrands, project, videos} from "virtual:odori-project
|
|
|
12
12
|
import {CanvasStage} from "../components/CanvasStage";
|
|
13
13
|
import {Transport} from "../components/Transport";
|
|
14
14
|
import {Empty, Fact, SectionTitle, Separator} from "../components/ui";
|
|
15
|
+
import {Inspector} from "../components/Inspector";
|
|
15
16
|
import {useShortcuts} from "../shortcuts";
|
|
16
17
|
|
|
17
18
|
/** Brands come from videos/**\/brands modules and from every resolved layout. */
|
|
@@ -25,12 +26,23 @@ export const collectBrands = (): Brand[] => {
|
|
|
25
26
|
return [...seen.values()];
|
|
26
27
|
};
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
/**
|
|
30
|
+
* A brand is the project's design system as one object — colors, type, motion
|
|
31
|
+
* policy, and named sounds — declared once and inherited by every video. This
|
|
32
|
+
* view exists to answer "what does this brand make everything look like":
|
|
33
|
+
* pick a brand, and watch a real video re-render under it.
|
|
34
|
+
*/
|
|
35
|
+
export const BrandsView = ({
|
|
36
|
+
selection,
|
|
37
|
+
onSelect,
|
|
38
|
+
}: {
|
|
39
|
+
selection: string | null;
|
|
40
|
+
onSelect: (name: string) => void;
|
|
41
|
+
}) => {
|
|
29
42
|
const brands = collectBrands();
|
|
30
|
-
const [brandName, setBrandName] = useState(brands[0]?.name ?? "");
|
|
31
43
|
const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
|
|
32
44
|
const [videoId, setVideoId] = useState(videos[0]?.metadata.id ?? "");
|
|
33
|
-
const brand = brands.find((item) => item.name ===
|
|
45
|
+
const brand = selection ? (brands.find((item) => item.name === selection) ?? null) : null;
|
|
34
46
|
const entry = videos.find((video) => video.metadata.id === videoId) ?? videos[0];
|
|
35
47
|
const base = entry ? resolveEntryLayout(entry) : null;
|
|
36
48
|
const durationInFrames = entry && base ? Math.max(1, entryDurationInFrames(entry, base)) : 1;
|
|
@@ -46,33 +58,52 @@ export const BrandsView = () => {
|
|
|
46
58
|
End: () => playback.seek(durationInFrames - 1),
|
|
47
59
|
});
|
|
48
60
|
|
|
49
|
-
if (
|
|
50
|
-
return
|
|
61
|
+
if (brands.length === 0 || !entry || !base) {
|
|
62
|
+
return (
|
|
63
|
+
<Empty title="No brands resolved">
|
|
64
|
+
A brand is the design system every video inherits: colors, type, motion, and named sounds, declared with{" "}
|
|
65
|
+
<code>defineBrand()</code> and attached through <code>defineVideoLayout()</code>. The scaffold puts one in{" "}
|
|
66
|
+
<code>videos/layout.tsx</code>.
|
|
67
|
+
</Empty>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!brand) {
|
|
72
|
+
return (
|
|
73
|
+
<div className="page">
|
|
74
|
+
<p className="page-hint">
|
|
75
|
+
A brand is the project's design system as one object: colors, type, motion policy, and named sounds,
|
|
76
|
+
declared once in source and inherited by every video. Open one to preview a real video re-rendered under it.
|
|
77
|
+
</p>
|
|
78
|
+
<div className="home-swatches">
|
|
79
|
+
{brands.map((item) => (
|
|
80
|
+
<button key={item.name} type="button" className="brand-card" onClick={() => onSelect(item.name)}>
|
|
81
|
+
<div className="swatch-colors">
|
|
82
|
+
{/* Keyed by position: two tokens can resolve to one color,
|
|
83
|
+
and a color used twice is not one child twice. */}
|
|
84
|
+
{[item.colors.background, item.colors.surface, item.colors.foreground, item.colors.accent, item.colors.border].map(
|
|
85
|
+
(color, position) => (
|
|
86
|
+
<span key={position} style={{background: color}} />
|
|
87
|
+
),
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
<div className="card-meta">
|
|
91
|
+
<strong>{item.name}</strong>
|
|
92
|
+
<span>
|
|
93
|
+
{item.typography.sans.split(",")[0].replace(/"/g, "")} · {item.audio.targetLufs} LUFS
|
|
94
|
+
</span>
|
|
95
|
+
</div>
|
|
96
|
+
</button>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
51
101
|
}
|
|
52
102
|
|
|
53
103
|
const layout = defineVideoLayout({extends: base, brand});
|
|
54
104
|
|
|
55
105
|
return (
|
|
56
106
|
<>
|
|
57
|
-
<aside className="sidebar">
|
|
58
|
-
<div className="sidebar-scroll">
|
|
59
|
-
<ul className="list">
|
|
60
|
-
{brands.map((item) => (
|
|
61
|
-
<li key={item.name}>
|
|
62
|
-
<button
|
|
63
|
-
type="button"
|
|
64
|
-
className="list-item"
|
|
65
|
-
data-active={item.name === brand.name ? "true" : undefined}
|
|
66
|
-
onClick={() => setBrandName(item.name)}
|
|
67
|
-
>
|
|
68
|
-
<strong>{item.name}</strong>
|
|
69
|
-
</button>
|
|
70
|
-
</li>
|
|
71
|
-
))}
|
|
72
|
-
</ul>
|
|
73
|
-
</div>
|
|
74
|
-
</aside>
|
|
75
|
-
|
|
76
107
|
<section className="stage">
|
|
77
108
|
<CanvasStage width={layout.format.width} height={layout.format.height}>
|
|
78
109
|
<OdoriRuntime
|
|
@@ -94,8 +125,8 @@ export const BrandsView = () => {
|
|
|
94
125
|
/>
|
|
95
126
|
</section>
|
|
96
127
|
|
|
97
|
-
<
|
|
98
|
-
|
|
128
|
+
<Inspector title={brand.name} hint={project.files.brands.find((item) => item.id === brand.name)?.file}>
|
|
129
|
+
<SectionTitle>Preview with</SectionTitle>
|
|
99
130
|
<ul className="list">
|
|
100
131
|
{videos.map((video) => (
|
|
101
132
|
<li key={video.metadata.id}>
|
|
@@ -133,7 +164,7 @@ export const BrandsView = () => {
|
|
|
133
164
|
</Fact>
|
|
134
165
|
<Fact label="fonts">{brand.fonts.length ? brand.fonts.map((font) => font.family).join(", ") : "system"}</Fact>
|
|
135
166
|
</dl>
|
|
136
|
-
</
|
|
167
|
+
</Inspector>
|
|
137
168
|
</>
|
|
138
169
|
);
|
|
139
170
|
};
|
|
@@ -17,6 +17,7 @@ import {Transport} from "../components/Transport";
|
|
|
17
17
|
import {Thumbnail} from "../components/Thumbnail";
|
|
18
18
|
import {InputControls} from "../components/InputControls";
|
|
19
19
|
import {Badge, Button, Empty, Fact, SectionTitle, Separator} from "../components/ui";
|
|
20
|
+
import {Inspector} from "../components/Inspector";
|
|
20
21
|
import {useShortcuts} from "../shortcuts";
|
|
21
22
|
|
|
22
23
|
type PreviewEntry = (typeof componentPreviews)[number];
|
|
@@ -69,15 +70,14 @@ export const entryFor = (
|
|
|
69
70
|
export const ComponentsView = ({
|
|
70
71
|
selection,
|
|
71
72
|
onSelect,
|
|
72
|
-
mode,
|
|
73
73
|
}: {
|
|
74
74
|
selection: string | null;
|
|
75
75
|
onSelect: (id: string) => void;
|
|
76
|
-
mode: "player" | "gallery";
|
|
77
76
|
}) => {
|
|
78
|
-
// Finding one by name is ⌘K;
|
|
77
|
+
// Finding one by name is ⌘K; the gallery is for browsing them all. A route
|
|
78
|
+
// with no selection is the gallery; a selection is that component's player.
|
|
79
79
|
const filtered = useMemo(() => [...componentPreviews].sort(byFamily), []);
|
|
80
|
-
const selected = filtered.find((entry) => entry.id === selection) ??
|
|
80
|
+
const selected = selection ? (filtered.find((entry) => entry.id === selection) ?? null) : null;
|
|
81
81
|
const brands = projectBrands();
|
|
82
82
|
const [brandName, setBrandName] = useState(brands[0]?.name ?? defaultBrand.name);
|
|
83
83
|
const [formatLabel, setFormatLabel] = useState(FORMATS[0].label);
|
|
@@ -110,23 +110,15 @@ export const ComponentsView = ({
|
|
|
110
110
|
l: () => setLoop((value) => !value),
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
if (
|
|
113
|
+
if (filtered.length === 0) {
|
|
114
114
|
return (
|
|
115
115
|
<Empty title="No component previews">
|
|
116
|
-
Add a sibling <code>*.preview.tsx</code> file, or run <code>odori add
|
|
116
|
+
Add a sibling <code>*.preview.tsx</code> file, or run <code>odori add title-reveal</code>.
|
|
117
117
|
</Empty>
|
|
118
118
|
);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
const example = preview.examples.find((item) => item.name === exampleName) ?? preview.examples[0];
|
|
123
|
-
const controlDefaults = Object.fromEntries(
|
|
124
|
-
Object.entries(preview.controls ?? {}).map(([name, field]) => [name, field.defaultValue]),
|
|
125
|
-
);
|
|
126
|
-
const props = {...controlDefaults, ...(example?.props ?? {}), ...overrides};
|
|
127
|
-
const entry = entryFor(selected, props, brand, format);
|
|
128
|
-
|
|
129
|
-
if (mode === "gallery") {
|
|
121
|
+
if (!selected) {
|
|
130
122
|
const grouped = filtered.reduce<Record<string, PreviewEntry[]>>((groups, item) => {
|
|
131
123
|
groups[item.preview.category] = [...(groups[item.preview.category] ?? []), item];
|
|
132
124
|
return groups;
|
|
@@ -167,28 +159,16 @@ export const ComponentsView = ({
|
|
|
167
159
|
);
|
|
168
160
|
}
|
|
169
161
|
|
|
162
|
+
const preview = selected.preview;
|
|
163
|
+
const example = preview.examples.find((item) => item.name === exampleName) ?? preview.examples[0];
|
|
164
|
+
const controlDefaults = Object.fromEntries(
|
|
165
|
+
Object.entries(preview.controls ?? {}).map(([name, field]) => [name, field.defaultValue]),
|
|
166
|
+
);
|
|
167
|
+
const props = {...controlDefaults, ...(example?.props ?? {}), ...overrides};
|
|
168
|
+
const entry = entryFor(selected, props, brand, format);
|
|
169
|
+
|
|
170
170
|
return (
|
|
171
171
|
<>
|
|
172
|
-
<aside className="sidebar">
|
|
173
|
-
<div className="sidebar-scroll">
|
|
174
|
-
<ul className="list">
|
|
175
|
-
{filtered.map((item) => (
|
|
176
|
-
<li key={item.id}>
|
|
177
|
-
<button
|
|
178
|
-
type="button"
|
|
179
|
-
className="list-item"
|
|
180
|
-
data-active={item.id === selected.id ? "true" : undefined}
|
|
181
|
-
onClick={() => onSelect(item.id)}
|
|
182
|
-
>
|
|
183
|
-
<strong>{item.preview.title}</strong>
|
|
184
|
-
</button>
|
|
185
|
-
</li>
|
|
186
|
-
))}
|
|
187
|
-
</ul>
|
|
188
|
-
|
|
189
|
-
</div>
|
|
190
|
-
</aside>
|
|
191
|
-
|
|
192
172
|
<section className="stage">
|
|
193
173
|
<CanvasStage width={format.width} height={format.height}>
|
|
194
174
|
<PreviewBoundary resetKey={`${selected.id}-${format.label}-${brand.name}`} label="This component threw">
|
|
@@ -210,7 +190,13 @@ export const ComponentsView = ({
|
|
|
210
190
|
/>
|
|
211
191
|
</section>
|
|
212
192
|
|
|
213
|
-
<
|
|
193
|
+
<Inspector
|
|
194
|
+
title={preview.title}
|
|
195
|
+
hint={
|
|
196
|
+
project.files.previews.find((entry) => entry.id === selected.id)?.file ??
|
|
197
|
+
`videos/components/${selected.id}/${selected.id}.preview.tsx`
|
|
198
|
+
}
|
|
199
|
+
>
|
|
214
200
|
<SectionTitle>Examples</SectionTitle>
|
|
215
201
|
<ul className="list">
|
|
216
202
|
{preview.examples.map((item) => (
|
|
@@ -279,7 +265,7 @@ export const ComponentsView = ({
|
|
|
279
265
|
) : (
|
|
280
266
|
<p className="hint">This preview declares no controls. Add them to vary props in Studio.</p>
|
|
281
267
|
)}
|
|
282
|
-
</
|
|
268
|
+
</Inspector>
|
|
283
269
|
</>
|
|
284
270
|
);
|
|
285
271
|
};
|
|
@@ -14,19 +14,26 @@ import type {StudioView} from "../Studio";
|
|
|
14
14
|
* is in here", which is the question you have on arrival, so each card plays a
|
|
15
15
|
* frame of the real composition and opens the view that owns it.
|
|
16
16
|
*/
|
|
17
|
+
/** Cards per section here. Home is an overview; each tab shows everything. */
|
|
18
|
+
const SHOWN = 6;
|
|
19
|
+
|
|
17
20
|
export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: string) => void}) => {
|
|
18
21
|
const brands = collectBrands();
|
|
19
22
|
const brand = brands[0];
|
|
20
|
-
const fonts = [
|
|
21
|
-
...new Map(videos.flatMap((video) => resolveEntryLayout(video).brand.fonts).map((font) => [font.url, font])).values(),
|
|
22
|
-
];
|
|
23
23
|
|
|
24
24
|
return (
|
|
25
25
|
<div className="home">
|
|
26
26
|
<section>
|
|
27
|
-
<
|
|
27
|
+
<div className="section-head">
|
|
28
|
+
<SectionTitle>Videos</SectionTitle>
|
|
29
|
+
{videos.length > SHOWN ? (
|
|
30
|
+
<button type="button" className="view-all" onClick={() => onOpen("videos")}>
|
|
31
|
+
View all {videos.length} →
|
|
32
|
+
</button>
|
|
33
|
+
) : null}
|
|
34
|
+
</div>
|
|
28
35
|
<div className="gallery">
|
|
29
|
-
{videos.map((video) => {
|
|
36
|
+
{videos.slice(0, SHOWN).map((video) => {
|
|
30
37
|
const layout = resolveEntryLayout(video);
|
|
31
38
|
return (
|
|
32
39
|
<button key={video.metadata.id} type="button" className="card" onClick={() => onOpen("videos", video.metadata.id)}>
|
|
@@ -34,12 +41,11 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
34
41
|
entry={video}
|
|
35
42
|
frame={video.metadata.thumbnailFrame ?? Math.round(entryDurationInFrames(video, layout) * 0.4)}
|
|
36
43
|
durationInFrames={entryDurationInFrames(video, layout)}
|
|
44
|
+
audioBadge
|
|
37
45
|
/>
|
|
38
46
|
<div className="card-meta">
|
|
39
47
|
<strong>{video.metadata.title}</strong>
|
|
40
|
-
<span>
|
|
41
|
-
{layout.format.width}x{layout.format.height} · {video.metadata.id}
|
|
42
|
-
</span>
|
|
48
|
+
<span>{video.metadata.id}</span>
|
|
43
49
|
</div>
|
|
44
50
|
</button>
|
|
45
51
|
);
|
|
@@ -48,9 +54,16 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
48
54
|
</section>
|
|
49
55
|
|
|
50
56
|
<section>
|
|
51
|
-
<
|
|
57
|
+
<div className="section-head">
|
|
58
|
+
<SectionTitle>Components</SectionTitle>
|
|
59
|
+
{componentPreviews.length > SHOWN ? (
|
|
60
|
+
<button type="button" className="view-all" onClick={() => onOpen("components")}>
|
|
61
|
+
View all {componentPreviews.length} →
|
|
62
|
+
</button>
|
|
63
|
+
) : null}
|
|
64
|
+
</div>
|
|
52
65
|
<div className="gallery">
|
|
53
|
-
{componentPreviews.map((item) => {
|
|
66
|
+
{componentPreviews.slice(0, SHOWN).map((item) => {
|
|
54
67
|
const defaults = Object.fromEntries(
|
|
55
68
|
Object.entries(item.preview.controls ?? {}).map(([name, field]) => [name, field.defaultValue]),
|
|
56
69
|
);
|
|
@@ -78,14 +91,21 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
78
91
|
</section>
|
|
79
92
|
|
|
80
93
|
<section>
|
|
81
|
-
<
|
|
94
|
+
<div className="section-head">
|
|
95
|
+
<SectionTitle>Brands</SectionTitle>
|
|
96
|
+
<button type="button" className="view-all" onClick={() => onOpen("brands")}>
|
|
97
|
+
View all →
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
82
100
|
<div className="home-swatches">
|
|
83
101
|
{brands.map((item) => (
|
|
84
102
|
<button key={item.name} type="button" className="brand-card" onClick={() => onOpen("brands", item.name)}>
|
|
85
103
|
<div className="swatch-colors">
|
|
104
|
+
{/* Keyed by position: two tokens can resolve to one color,
|
|
105
|
+
and a color used twice is not one child twice. */}
|
|
86
106
|
{[item.colors.background, item.colors.surface, item.colors.foreground, item.colors.accent, item.colors.border].map(
|
|
87
|
-
(color) => (
|
|
88
|
-
<span key={
|
|
107
|
+
(color, position) => (
|
|
108
|
+
<span key={position} style={{background: color}} />
|
|
89
109
|
),
|
|
90
110
|
)}
|
|
91
111
|
</div>
|
|
@@ -101,7 +121,12 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
101
121
|
</section>
|
|
102
122
|
|
|
103
123
|
<section>
|
|
104
|
-
<
|
|
124
|
+
<div className="section-head">
|
|
125
|
+
<SectionTitle>Assets</SectionTitle>
|
|
126
|
+
<button type="button" className="view-all" onClick={() => onOpen("assets")}>
|
|
127
|
+
View all →
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
105
130
|
<ul className="asset-list">
|
|
106
131
|
{project.audio.slice(0, 4).map((entry) => (
|
|
107
132
|
<li key={entry.url}>
|
|
@@ -113,9 +138,6 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
113
138
|
</li>
|
|
114
139
|
))}
|
|
115
140
|
</ul>
|
|
116
|
-
<button type="button" className="home-more" onClick={() => onOpen("assets")}>
|
|
117
|
-
{project.assets.length} declared assets · {project.audio.length} sounds · {fonts.length} fonts
|
|
118
|
-
</button>
|
|
119
141
|
</section>
|
|
120
142
|
</div>
|
|
121
143
|
);
|