+4
__generated__/lexicons.ts
+4
__generated__/lexicons.ts
+1
-1
deno.json
+1
-1
deno.json
···
2
2
"imports": {
3
3
"$lexicon/": "./__generated__/",
4
4
"@atproto/syntax": "npm:@atproto/syntax@^0.4.0",
5
-
"@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.14",
5
+
"@bigmoves/bff": "jsr:@bigmoves/bff@0.3.0-beta.15",
6
6
"@gfx/canvas": "jsr:@gfx/canvas@^0.5.8",
7
7
"@std/path": "jsr:@std/path@^1.0.9",
8
8
"@tailwindcss/cli": "npm:@tailwindcss/cli@^4.1.4",
+4
-4
deno.lock
+4
-4
deno.lock
···
2
2
"version": "4",
3
3
"specifiers": {
4
4
"jsr:@bigmoves/atproto-oauth-client@0.2": "0.2.0",
5
-
"jsr:@bigmoves/bff@0.3.0-beta.14": "0.3.0-beta.14",
5
+
"jsr:@bigmoves/bff@0.3.0-beta.15": "0.3.0-beta.15",
6
6
"jsr:@deno/gfm@0.10": "0.10.0",
7
7
"jsr:@denosaurs/emoji@0.3": "0.3.1",
8
8
"jsr:@denosaurs/plug@1": "1.0.5",
···
92
92
"npm:jose"
93
93
]
94
94
},
95
-
"@bigmoves/bff@0.3.0-beta.14": {
96
-
"integrity": "2b94d1f58c9b035cb2a50e3161953ab5c8c158caf902eccd89ae0beb2db60edc",
95
+
"@bigmoves/bff@0.3.0-beta.15": {
96
+
"integrity": "934d0fab8cc73804099ccb5362fa89f5ef3cd6269a6613029131770c97cdfcb9",
97
97
"dependencies": [
98
98
"jsr:@bigmoves/atproto-oauth-client",
99
99
"jsr:@std/assert@^1.0.13",
···
1810
1810
},
1811
1811
"workspace": {
1812
1812
"dependencies": [
1813
-
"jsr:@bigmoves/bff@0.3.0-beta.14",
1813
+
"jsr:@bigmoves/bff@0.3.0-beta.15",
1814
1814
"jsr:@gfx/canvas@~0.5.8",
1815
1815
"jsr:@std/path@^1.0.9",
1816
1816
"npm:@atproto/syntax@0.4",
+150
-55
main.tsx
+150
-55
main.tsx
···
58
58
const PUBLIC_URL = Deno.env.get("BFF_PUBLIC_URL") ?? "http://localhost:8080";
59
59
const GOATCOUNTER_URL = Deno.env.get("GOATCOUNTER_URL");
60
60
61
-
let cssContentHash: string = "";
62
-
const staticJsFiles = new Map<string, string>();
61
+
const staticFilesHash = new Map<string, string>();
63
62
64
63
bff({
65
64
appName: "Grain Social",
···
74
73
lexicons,
75
74
rootElement: Root,
76
75
onListen: async () => {
77
-
const cssFileContent = await Deno.readFile(
78
-
join(Deno.cwd(), "static", "styles.css"),
79
-
);
80
-
const hashBuffer = await crypto.subtle.digest("SHA-256", cssFileContent);
81
-
cssContentHash = Array.from(new Uint8Array(hashBuffer))
82
-
.map((b) => b.toString(16).padStart(2, "0"))
83
-
.join("");
84
76
for (const entry of Deno.readDirSync(join(Deno.cwd(), "static"))) {
85
-
if (entry.isFile && entry.name.endsWith(".js")) {
77
+
if (
78
+
entry.isFile &&
79
+
(entry.name.endsWith(".js") || entry.name.endsWith(".css"))
80
+
) {
86
81
const fileContent = await Deno.readFile(
87
82
join(Deno.cwd(), "static", entry.name),
88
83
);
···
90
85
const hash = Array.from(new Uint8Array(hashBuffer))
91
86
.map((b) => b.toString(16).padStart(2, "0"))
92
87
.join("");
93
-
staticJsFiles.set(entry.name, hash);
88
+
staticFilesHash.set(entry.name, hash);
94
89
}
95
90
}
96
91
},
···
152
147
if (!profile) return ctx.next();
153
148
let follow: WithBffMeta<BskyFollow> | undefined;
154
149
if (ctx.currentUser) {
155
-
follow = getFollow(
156
-
profile.did,
157
-
ctx.currentUser.did,
158
-
ctx,
159
-
);
150
+
follow = getFollow(profile.did, ctx.currentUser.did, ctx);
160
151
}
161
152
ctx.state.meta = [
162
153
{
···
200
191
...getPageMeta(galleryLink(handle, rkey)),
201
192
...getGalleryMeta(gallery),
202
193
];
203
-
ctx.state.scripts = ["photo_dialog.js", "masonry.js"];
194
+
ctx.state.scripts = ["photo_dialog.js", "masonry.js", "sortable.js"];
204
195
return ctx.render(
205
196
<GalleryPage favs={favs} gallery={gallery} currentUserDid={did} />,
206
197
);
···
232
223
createdAt: new Date().toISOString(),
233
224
},
234
225
);
235
-
return ctx.html(
236
-
<FollowButton followeeDid={did} followUri={followUri} />,
237
-
);
226
+
return ctx.html(<FollowButton followeeDid={did} followUri={followUri} />);
238
227
}),
239
228
route("/follow/:did/:rkey", ["DELETE"], async (_req, params, ctx) => {
240
229
requireAuth(ctx);
···
244
233
await ctx.deleteRecord(
245
234
`at://${ctx.currentUser.did}/app.bsky.graph.follow/${rkey}`,
246
235
);
247
-
return ctx.html(
248
-
<FollowButton followeeDid={did} followUri={undefined} />,
249
-
);
236
+
return ctx.html(<FollowButton followeeDid={did} followUri={undefined} />);
250
237
}),
251
238
route("/dialogs/gallery/new", (_req, _params, ctx) => {
252
239
requireAuth(ctx);
···
258
245
const rkey = params.rkey;
259
246
const gallery = getGallery(handle, rkey, ctx);
260
247
return ctx.html(<GalleryCreateEditDialog gallery={gallery} />);
248
+
}),
249
+
route("/dialogs/gallery/:rkey/sort", (_req, params, ctx) => {
250
+
requireAuth(ctx);
251
+
const handle = ctx.currentUser.handle;
252
+
const rkey = params.rkey;
253
+
const gallery = getGallery(handle, rkey, ctx);
254
+
if (!gallery) return ctx.next();
255
+
return ctx.html(<GallerySortDialog gallery={gallery} />);
261
256
}),
262
257
route("/onboard", (_req, _params, ctx) => {
263
258
requireAuth(ctx);
···
583
578
584
579
return ctx.redirect(`/profile/${ctx.currentUser.handle}`);
585
580
}),
581
+
route(
582
+
"/actions/gallery/:rkey/sort",
583
+
["POST"],
584
+
async (req, params, ctx) => {
585
+
requireAuth(ctx);
586
+
const galleryRkey = params.rkey;
587
+
const galleryUri =
588
+
`at://${ctx.currentUser.did}/social.grain.gallery/${galleryRkey}`;
589
+
const {
590
+
items,
591
+
} = ctx.indexService.getRecords<WithBffMeta<GalleryItem>>(
592
+
"social.grain.gallery.item",
593
+
{
594
+
where: [
595
+
{
596
+
field: "gallery",
597
+
equals: galleryUri,
598
+
},
599
+
],
600
+
},
601
+
);
602
+
const itemsMap = new Map<string, WithBffMeta<GalleryItem>>();
603
+
for (const item of items) {
604
+
itemsMap.set(item.item, item);
605
+
}
606
+
const formData = await req.formData();
607
+
const sortedItems = formData.getAll("item") as string[];
608
+
const updates = [];
609
+
let position = 0;
610
+
for (const sortedItemUri of sortedItems) {
611
+
const item = itemsMap.get(sortedItemUri);
612
+
if (!item) continue;
613
+
updates.push({
614
+
collection: "social.grain.gallery.item",
615
+
rkey: new AtUri(item.uri).rkey,
616
+
data: {
617
+
gallery: item.gallery,
618
+
item: item.item,
619
+
createdAt: item.createdAt,
620
+
position,
621
+
},
622
+
});
623
+
position++;
624
+
}
625
+
await ctx.updateRecords<WithBffMeta<GalleryItem>>(updates);
626
+
return ctx.redirect(
627
+
`/profile/${ctx.currentUser.handle}/${galleryRkey}`,
628
+
);
629
+
},
630
+
),
586
631
...photoUploadRoutes(),
587
632
...avatarUploadRoutes(),
588
633
],
···
667
712
};
668
713
669
714
function getFollow(followeeDid: string, followerDid: string, ctx: BffContext) {
670
-
const { items: [follow] } = ctx.indexService.getRecords<
671
-
WithBffMeta<BskyFollow>
672
-
>(
715
+
const {
716
+
items: [follow],
717
+
} = ctx.indexService.getRecords<WithBffMeta<BskyFollow>>(
673
718
"app.bsky.graph.follow",
674
719
{
675
720
where: [
···
701
746
const { items: galleryItems } = ctx.indexService.getRecords<
702
747
WithBffMeta<GalleryItem>
703
748
>("social.grain.gallery.item", {
704
-
orderBy: { field: "createdAt", direction: "asc" },
749
+
orderBy: { field: "position", direction: "asc" },
705
750
where: [{ field: "gallery", in: galleryUris }],
706
751
});
707
752
···
1055
1100
: null}
1056
1101
<script src="https://unpkg.com/htmx.org@1.9.10" />
1057
1102
<script src="https://unpkg.com/hyperscript.org@0.9.14" />
1103
+
<script src="https://unpkg.com/sortablejs@1.15.6" />
1058
1104
<style dangerouslySetInnerHTML={{ __html: CSS }} />
1059
-
<link rel="stylesheet" href={`/static/styles.css?${cssContentHash}`} />
1105
+
<link
1106
+
rel="stylesheet"
1107
+
href={`/static/styles.css?${staticFilesHash.get("styles.css")}`}
1108
+
/>
1060
1109
<link rel="preconnect" href="https://fonts.googleapis.com" />
1061
1110
<link
1062
1111
rel="preconnect"
···
1075
1124
{scripts?.map((file) => (
1076
1125
<script
1077
1126
key={file}
1078
-
src={`/static/${file}?${staticJsFiles.get(file)}`}
1127
+
src={`/static/${file}?${staticFilesHash.get(file)}`}
1079
1128
/>
1080
1129
))}
1081
1130
</head>
···
1085
1134
heading={
1086
1135
<h1 class="font-['Jersey_20'] text-4xl text-zinc-900 dark:text-white">
1087
1136
grain
1088
-
<sub class="bottom-[0.75rem] text-[1rem]">
1089
-
beta
1090
-
</sub>
1137
+
<sub class="bottom-[0.75rem] text-[1rem]">beta</sub>
1091
1138
</h1>
1092
1139
}
1093
1140
profile={profile}
···
1169
1216
<span class="text-zinc-950 dark:text-zinc-50 font-semibold text-">
1170
1217
{profile.displayName || profile.handle}
1171
1218
</span>{" "}
1172
-
<span class="truncate">
1173
-
@{profile.handle}
1174
-
</span>
1219
+
<span class="truncate">@{profile.handle}</span>
1175
1220
</a>
1176
1221
</div>
1177
1222
);
···
1287
1332
: {
1288
1333
children: (
1289
1334
<>
1290
-
<i class="fa-solid fa-plus mr-2" />Follow
1335
+
<i class="fa-solid fa-plus mr-2" />
1336
+
Follow
1291
1337
</>
1292
1338
),
1293
1339
"hx-post": `/follow/${followeeDid}`,
···
1468
1514
);
1469
1515
}
1470
1516
1471
-
function UploadPage(
1472
-
{ handle, photos, returnTo }: Readonly<
1473
-
{ handle: string; photos: PhotoView[]; returnTo?: string }
1474
-
>,
1475
-
) {
1517
+
function UploadPage({
1518
+
handle,
1519
+
photos,
1520
+
returnTo,
1521
+
}: Readonly<{ handle: string; photos: PhotoView[]; returnTo?: string }>) {
1476
1522
return (
1477
1523
<div class="flex flex-col px-4 pt-4 mb-4 space-y-4">
1478
1524
<div class="flex">
1479
1525
<div class="flex-1">
1480
1526
{returnTo
1481
1527
? (
1482
-
<a
1483
-
href={returnTo}
1484
-
class="hover:underline"
1485
-
>
1528
+
<a href={returnTo} class="hover:underline">
1486
1529
<i class="fa-solid fa-arrow-left mr-2" />
1487
1530
Back to gallery
1488
1531
</a>
···
1678
1721
>
1679
1722
Add photos
1680
1723
</Button>
1724
+
<Button
1725
+
variant="primary"
1726
+
class="self-start w-full sm:w-fit"
1727
+
hx-get={`/dialogs/gallery/${new AtUri(gallery.uri).rkey}/sort`}
1728
+
hx-target="#layout"
1729
+
hx-swap="afterbegin"
1730
+
>
1731
+
Sort order
1732
+
</Button>
1681
1733
<ShareGalleryButton gallery={gallery} />
1682
1734
</div>
1683
1735
)
···
1852
1904
);
1853
1905
}
1854
1906
1855
-
function ShareGalleryButton({
1856
-
gallery,
1857
-
}: Readonly<{ gallery: GalleryView }>) {
1907
+
function GallerySortDialog({ gallery }: Readonly<{ gallery: GalleryView }>) {
1908
+
return (
1909
+
<Dialog>
1910
+
<Dialog.Content class="dark:bg-zinc-950 relative">
1911
+
<Dialog.X class="fill-zinc-950 dark:fill-zinc-50" />
1912
+
<Dialog.Title>Sort gallery</Dialog.Title>
1913
+
<p class="my-2 text-center">Drag photos to rearrange</p>
1914
+
<form
1915
+
hx-post={`/actions/gallery/${new AtUri(gallery.uri).rkey}/sort`}
1916
+
hx-trigger="submit"
1917
+
hx-swap="none"
1918
+
>
1919
+
<div class="sortable grid grid-cols-3 sm:grid-cols-5 gap-2 mt-2">
1920
+
{gallery?.items?.filter(isPhotoView).map((item) => (
1921
+
<div
1922
+
key={item.cid}
1923
+
class="relative aspect-square cursor-grab"
1924
+
>
1925
+
<input type="hidden" name="item" value={item.uri} />
1926
+
<img
1927
+
src={item.fullsize}
1928
+
alt={item.alt}
1929
+
class="w-full h-full absolute object-cover"
1930
+
/>
1931
+
</div>
1932
+
))}
1933
+
</div>
1934
+
<div class="flex flex-col gap-2 mt-2">
1935
+
<Button
1936
+
variant="primary"
1937
+
type="submit"
1938
+
class="w-full"
1939
+
>
1940
+
Save
1941
+
</Button>
1942
+
<Button
1943
+
variant="secondary"
1944
+
type="button"
1945
+
class="w-full"
1946
+
_={Dialog._closeOnClick}
1947
+
>
1948
+
Cancel
1949
+
</Button>
1950
+
</div>
1951
+
</form>
1952
+
</Dialog.Content>
1953
+
</Dialog>
1954
+
);
1955
+
}
1956
+
1957
+
function ShareGalleryButton({ gallery }: Readonly<{ gallery: GalleryView }>) {
1858
1958
return (
1859
1959
<>
1860
1960
<input
···
2029
2129
);
2030
2130
}
2031
2131
2032
-
function AltTextButton({
2033
-
photoUri,
2034
-
}: Readonly<{ photoUri: string }>) {
2132
+
function AltTextButton({ photoUri }: Readonly<{ photoUri: string }>) {
2035
2133
return (
2036
2134
<div
2037
2135
class="bg-zinc-950 dark:bg-zinc-950 py-[1px] px-[3px] absolute top-2 left-2 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10"
···
2559
2657
];
2560
2658
}
2561
2659
2562
-
function publicGalleryLink(
2563
-
handle: string,
2564
-
galleryUri: string,
2565
-
): string {
2660
+
function publicGalleryLink(handle: string, galleryUri: string): string {
2566
2661
return `${PUBLIC_URL}/profile/${handle}/${new AtUri(galleryUri).rkey}`;
2567
2662
}
+8
static/sortable.js
+8
static/sortable.js
+75
static/styles.css
+75
static/styles.css
···
387
387
.shrink-0 {
388
388
flex-shrink: 0;
389
389
}
390
+
.cursor-grab {
391
+
cursor: grab;
392
+
}
390
393
.cursor-pointer {
391
394
cursor: pointer;
392
395
}
···
484
487
background-color: color-mix(in oklab, var(--color-black) 80%, transparent);
485
488
}
486
489
}
490
+
.bg-sky-500 {
491
+
background-color: var(--color-sky-500);
492
+
}
487
493
.bg-zinc-100 {
488
494
background-color: var(--color-zinc-100);
489
495
}
···
595
601
}
596
602
.ring-sky-500 {
597
603
--tw-ring-color: var(--color-sky-500);
604
+
}
605
+
.filter {
606
+
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
598
607
}
599
608
.group-data-\[added\=true\]\:block {
600
609
&:is(:where(.group)[data-added="true"] *) {
···
754
763
syntax: "*";
755
764
inherits: false;
756
765
}
766
+
@property --tw-blur {
767
+
syntax: "*";
768
+
inherits: false;
769
+
}
770
+
@property --tw-brightness {
771
+
syntax: "*";
772
+
inherits: false;
773
+
}
774
+
@property --tw-contrast {
775
+
syntax: "*";
776
+
inherits: false;
777
+
}
778
+
@property --tw-grayscale {
779
+
syntax: "*";
780
+
inherits: false;
781
+
}
782
+
@property --tw-hue-rotate {
783
+
syntax: "*";
784
+
inherits: false;
785
+
}
786
+
@property --tw-invert {
787
+
syntax: "*";
788
+
inherits: false;
789
+
}
790
+
@property --tw-opacity {
791
+
syntax: "*";
792
+
inherits: false;
793
+
}
794
+
@property --tw-saturate {
795
+
syntax: "*";
796
+
inherits: false;
797
+
}
798
+
@property --tw-sepia {
799
+
syntax: "*";
800
+
inherits: false;
801
+
}
802
+
@property --tw-drop-shadow {
803
+
syntax: "*";
804
+
inherits: false;
805
+
}
806
+
@property --tw-drop-shadow-color {
807
+
syntax: "*";
808
+
inherits: false;
809
+
}
810
+
@property --tw-drop-shadow-alpha {
811
+
syntax: "<percentage>";
812
+
inherits: false;
813
+
initial-value: 100%;
814
+
}
815
+
@property --tw-drop-shadow-size {
816
+
syntax: "*";
817
+
inherits: false;
818
+
}
757
819
@property --tw-shadow {
758
820
syntax: "*";
759
821
inherits: false;
···
826
888
--tw-space-x-reverse: 0;
827
889
--tw-border-style: solid;
828
890
--tw-font-weight: initial;
891
+
--tw-blur: initial;
892
+
--tw-brightness: initial;
893
+
--tw-contrast: initial;
894
+
--tw-grayscale: initial;
895
+
--tw-hue-rotate: initial;
896
+
--tw-invert: initial;
897
+
--tw-opacity: initial;
898
+
--tw-saturate: initial;
899
+
--tw-sepia: initial;
900
+
--tw-drop-shadow: initial;
901
+
--tw-drop-shadow-color: initial;
902
+
--tw-drop-shadow-alpha: 100%;
903
+
--tw-drop-shadow-size: initial;
829
904
--tw-shadow: 0 0 #0000;
830
905
--tw-shadow-color: initial;
831
906
--tw-shadow-alpha: 100%;