+1
-1
README.md
+1
-1
README.md
···
8
## running dev and build
9
in the `vite.config.ts` file you should change these values
10
```ts
11
-
const PROD_URL = "https://reddwarf.whey.party"
12
const DEV_URL = "https://local3768forumtest.whey.party"
13
```
14
the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work
···
8
## running dev and build
9
in the `vite.config.ts` file you should change these values
10
```ts
11
+
const PROD_URL = "https://reddwarf.app"
12
const DEV_URL = "https://local3768forumtest.whey.party"
13
```
14
the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work
+1697
package-lock.json
+1697
package-lock.json
···
8
"dependencies": {
9
"@atproto/api": "^0.16.6",
10
"@atproto/oauth-client-browser": "^0.3.33",
11
"@tailwindcss/vite": "^4.0.6",
12
"@tanstack/query-sync-storage-persister": "^5.85.6",
13
"@tanstack/react-devtools": "^0.2.2",
···
21
"idb-keyval": "^6.2.2",
22
"jotai": "^2.13.1",
23
"npm": "^11.6.2",
24
"react": "^19.0.0",
25
"react-dom": "^19.0.0",
26
"react-player": "^3.3.2",
···
32
"@iconify-icon/react": "^3.0.1",
33
"@iconify-json/material-symbols": "^1.2.42",
34
"@iconify-json/mdi": "^1.2.3",
35
"@svgr/core": "^8.1.0",
36
"@svgr/plugin-jsx": "^8.1.0",
37
"@testing-library/dom": "^10.4.0",
···
1591
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1592
}
1593
},
1594
"node_modules/@humanfs/core": {
1595
"version": "0.19.1",
1596
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
···
1681
"license": "Apache-2.0",
1682
"dependencies": {
1683
"@iconify/types": "*"
1684
}
1685
},
1686
"node_modules/@iconify/types": {
···
1883
"node": ">= 8"
1884
}
1885
},
1886
"node_modules/@rolldown/pluginutils": {
1887
"version": "1.0.0-beta.27",
1888
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
···
3795
"dev": true,
3796
"license": "Python-2.0"
3797
},
3798
"node_modules/aria-query": {
3799
"version": "5.3.0",
3800
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
···
4703
"engines": {
4704
"node": ">=8"
4705
}
4706
},
4707
"node_modules/diff": {
4708
"version": "8.0.2",
···
5723
},
5724
"funding": {
5725
"url": "https://github.com/sponsors/ljharb"
5726
}
5727
},
5728
"node_modules/get-proto": {
···
10326
],
10327
"license": "MIT"
10328
},
10329
"node_modules/react": {
10330
"version": "19.1.1",
10331
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
···
10387
"node": ">=0.10.0"
10388
}
10389
},
10390
"node_modules/readdirp": {
10391
"version": "3.6.0",
10392
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
···
11880
"peer": true,
11881
"dependencies": {
11882
"punycode": "^2.1.0"
11883
}
11884
},
11885
"node_modules/use-sync-external-store": {
···
8
"dependencies": {
9
"@atproto/api": "^0.16.6",
10
"@atproto/oauth-client-browser": "^0.3.33",
11
+
"@radix-ui/react-dialog": "^1.1.15",
12
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
13
+
"@radix-ui/react-hover-card": "^1.1.15",
14
+
"@radix-ui/react-slider": "^1.3.6",
15
"@tailwindcss/vite": "^4.0.6",
16
"@tanstack/query-sync-storage-persister": "^5.85.6",
17
"@tanstack/react-devtools": "^0.2.2",
···
25
"idb-keyval": "^6.2.2",
26
"jotai": "^2.13.1",
27
"npm": "^11.6.2",
28
+
"radix-ui": "^1.4.3",
29
"react": "^19.0.0",
30
"react-dom": "^19.0.0",
31
"react-player": "^3.3.2",
···
37
"@iconify-icon/react": "^3.0.1",
38
"@iconify-json/material-symbols": "^1.2.42",
39
"@iconify-json/mdi": "^1.2.3",
40
+
"@iconify/json": "^2.2.396",
41
"@svgr/core": "^8.1.0",
42
"@svgr/plugin-jsx": "^8.1.0",
43
"@testing-library/dom": "^10.4.0",
···
1597
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1598
}
1599
},
1600
+
"node_modules/@floating-ui/core": {
1601
+
"version": "1.7.3",
1602
+
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
1603
+
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
1604
+
"dependencies": {
1605
+
"@floating-ui/utils": "^0.2.10"
1606
+
}
1607
+
},
1608
+
"node_modules/@floating-ui/dom": {
1609
+
"version": "1.7.4",
1610
+
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
1611
+
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
1612
+
"dependencies": {
1613
+
"@floating-ui/core": "^1.7.3",
1614
+
"@floating-ui/utils": "^0.2.10"
1615
+
}
1616
+
},
1617
+
"node_modules/@floating-ui/react-dom": {
1618
+
"version": "2.1.6",
1619
+
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
1620
+
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
1621
+
"dependencies": {
1622
+
"@floating-ui/dom": "^1.7.4"
1623
+
},
1624
+
"peerDependencies": {
1625
+
"react": ">=16.8.0",
1626
+
"react-dom": ">=16.8.0"
1627
+
}
1628
+
},
1629
+
"node_modules/@floating-ui/utils": {
1630
+
"version": "0.2.10",
1631
+
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
1632
+
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
1633
+
},
1634
"node_modules/@humanfs/core": {
1635
"version": "0.19.1",
1636
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
···
1721
"license": "Apache-2.0",
1722
"dependencies": {
1723
"@iconify/types": "*"
1724
+
}
1725
+
},
1726
+
"node_modules/@iconify/json": {
1727
+
"version": "2.2.396",
1728
+
"resolved": "https://registry.npmjs.org/@iconify/json/-/json-2.2.396.tgz",
1729
+
"integrity": "sha512-tijg77JFuYIt32S9N8p7La8C0zp9zKZsX6UP8ip5GVB1F6Mp3pZA5Vc5eAquTY50NoDJX58U6z4Qn3d6Wyossg==",
1730
+
"dev": true,
1731
+
"license": "MIT",
1732
+
"dependencies": {
1733
+
"@iconify/types": "*",
1734
+
"pathe": "^2.0.0"
1735
}
1736
},
1737
"node_modules/@iconify/types": {
···
1934
"node": ">= 8"
1935
}
1936
},
1937
+
"node_modules/@radix-ui/number": {
1938
+
"version": "1.1.1",
1939
+
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
1940
+
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="
1941
+
},
1942
+
"node_modules/@radix-ui/primitive": {
1943
+
"version": "1.1.3",
1944
+
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
1945
+
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="
1946
+
},
1947
+
"node_modules/@radix-ui/react-accessible-icon": {
1948
+
"version": "1.1.7",
1949
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz",
1950
+
"integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==",
1951
+
"dependencies": {
1952
+
"@radix-ui/react-visually-hidden": "1.2.3"
1953
+
},
1954
+
"peerDependencies": {
1955
+
"@types/react": "*",
1956
+
"@types/react-dom": "*",
1957
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
1958
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
1959
+
},
1960
+
"peerDependenciesMeta": {
1961
+
"@types/react": {
1962
+
"optional": true
1963
+
},
1964
+
"@types/react-dom": {
1965
+
"optional": true
1966
+
}
1967
+
}
1968
+
},
1969
+
"node_modules/@radix-ui/react-accordion": {
1970
+
"version": "1.2.12",
1971
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
1972
+
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
1973
+
"dependencies": {
1974
+
"@radix-ui/primitive": "1.1.3",
1975
+
"@radix-ui/react-collapsible": "1.1.12",
1976
+
"@radix-ui/react-collection": "1.1.7",
1977
+
"@radix-ui/react-compose-refs": "1.1.2",
1978
+
"@radix-ui/react-context": "1.1.2",
1979
+
"@radix-ui/react-direction": "1.1.1",
1980
+
"@radix-ui/react-id": "1.1.1",
1981
+
"@radix-ui/react-primitive": "2.1.3",
1982
+
"@radix-ui/react-use-controllable-state": "1.2.2"
1983
+
},
1984
+
"peerDependencies": {
1985
+
"@types/react": "*",
1986
+
"@types/react-dom": "*",
1987
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
1988
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
1989
+
},
1990
+
"peerDependenciesMeta": {
1991
+
"@types/react": {
1992
+
"optional": true
1993
+
},
1994
+
"@types/react-dom": {
1995
+
"optional": true
1996
+
}
1997
+
}
1998
+
},
1999
+
"node_modules/@radix-ui/react-alert-dialog": {
2000
+
"version": "1.1.15",
2001
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
2002
+
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
2003
+
"dependencies": {
2004
+
"@radix-ui/primitive": "1.1.3",
2005
+
"@radix-ui/react-compose-refs": "1.1.2",
2006
+
"@radix-ui/react-context": "1.1.2",
2007
+
"@radix-ui/react-dialog": "1.1.15",
2008
+
"@radix-ui/react-primitive": "2.1.3",
2009
+
"@radix-ui/react-slot": "1.2.3"
2010
+
},
2011
+
"peerDependencies": {
2012
+
"@types/react": "*",
2013
+
"@types/react-dom": "*",
2014
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2015
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2016
+
},
2017
+
"peerDependenciesMeta": {
2018
+
"@types/react": {
2019
+
"optional": true
2020
+
},
2021
+
"@types/react-dom": {
2022
+
"optional": true
2023
+
}
2024
+
}
2025
+
},
2026
+
"node_modules/@radix-ui/react-arrow": {
2027
+
"version": "1.1.7",
2028
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
2029
+
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
2030
+
"dependencies": {
2031
+
"@radix-ui/react-primitive": "2.1.3"
2032
+
},
2033
+
"peerDependencies": {
2034
+
"@types/react": "*",
2035
+
"@types/react-dom": "*",
2036
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2037
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2038
+
},
2039
+
"peerDependenciesMeta": {
2040
+
"@types/react": {
2041
+
"optional": true
2042
+
},
2043
+
"@types/react-dom": {
2044
+
"optional": true
2045
+
}
2046
+
}
2047
+
},
2048
+
"node_modules/@radix-ui/react-aspect-ratio": {
2049
+
"version": "1.1.7",
2050
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz",
2051
+
"integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==",
2052
+
"dependencies": {
2053
+
"@radix-ui/react-primitive": "2.1.3"
2054
+
},
2055
+
"peerDependencies": {
2056
+
"@types/react": "*",
2057
+
"@types/react-dom": "*",
2058
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2059
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2060
+
},
2061
+
"peerDependenciesMeta": {
2062
+
"@types/react": {
2063
+
"optional": true
2064
+
},
2065
+
"@types/react-dom": {
2066
+
"optional": true
2067
+
}
2068
+
}
2069
+
},
2070
+
"node_modules/@radix-ui/react-avatar": {
2071
+
"version": "1.1.10",
2072
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
2073
+
"integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
2074
+
"dependencies": {
2075
+
"@radix-ui/react-context": "1.1.2",
2076
+
"@radix-ui/react-primitive": "2.1.3",
2077
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2078
+
"@radix-ui/react-use-is-hydrated": "0.1.0",
2079
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2080
+
},
2081
+
"peerDependencies": {
2082
+
"@types/react": "*",
2083
+
"@types/react-dom": "*",
2084
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2085
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2086
+
},
2087
+
"peerDependenciesMeta": {
2088
+
"@types/react": {
2089
+
"optional": true
2090
+
},
2091
+
"@types/react-dom": {
2092
+
"optional": true
2093
+
}
2094
+
}
2095
+
},
2096
+
"node_modules/@radix-ui/react-checkbox": {
2097
+
"version": "1.3.3",
2098
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
2099
+
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
2100
+
"dependencies": {
2101
+
"@radix-ui/primitive": "1.1.3",
2102
+
"@radix-ui/react-compose-refs": "1.1.2",
2103
+
"@radix-ui/react-context": "1.1.2",
2104
+
"@radix-ui/react-presence": "1.1.5",
2105
+
"@radix-ui/react-primitive": "2.1.3",
2106
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2107
+
"@radix-ui/react-use-previous": "1.1.1",
2108
+
"@radix-ui/react-use-size": "1.1.1"
2109
+
},
2110
+
"peerDependencies": {
2111
+
"@types/react": "*",
2112
+
"@types/react-dom": "*",
2113
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2114
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2115
+
},
2116
+
"peerDependenciesMeta": {
2117
+
"@types/react": {
2118
+
"optional": true
2119
+
},
2120
+
"@types/react-dom": {
2121
+
"optional": true
2122
+
}
2123
+
}
2124
+
},
2125
+
"node_modules/@radix-ui/react-collapsible": {
2126
+
"version": "1.1.12",
2127
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
2128
+
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
2129
+
"dependencies": {
2130
+
"@radix-ui/primitive": "1.1.3",
2131
+
"@radix-ui/react-compose-refs": "1.1.2",
2132
+
"@radix-ui/react-context": "1.1.2",
2133
+
"@radix-ui/react-id": "1.1.1",
2134
+
"@radix-ui/react-presence": "1.1.5",
2135
+
"@radix-ui/react-primitive": "2.1.3",
2136
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2137
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2138
+
},
2139
+
"peerDependencies": {
2140
+
"@types/react": "*",
2141
+
"@types/react-dom": "*",
2142
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2143
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2144
+
},
2145
+
"peerDependenciesMeta": {
2146
+
"@types/react": {
2147
+
"optional": true
2148
+
},
2149
+
"@types/react-dom": {
2150
+
"optional": true
2151
+
}
2152
+
}
2153
+
},
2154
+
"node_modules/@radix-ui/react-collection": {
2155
+
"version": "1.1.7",
2156
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
2157
+
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
2158
+
"dependencies": {
2159
+
"@radix-ui/react-compose-refs": "1.1.2",
2160
+
"@radix-ui/react-context": "1.1.2",
2161
+
"@radix-ui/react-primitive": "2.1.3",
2162
+
"@radix-ui/react-slot": "1.2.3"
2163
+
},
2164
+
"peerDependencies": {
2165
+
"@types/react": "*",
2166
+
"@types/react-dom": "*",
2167
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2168
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2169
+
},
2170
+
"peerDependenciesMeta": {
2171
+
"@types/react": {
2172
+
"optional": true
2173
+
},
2174
+
"@types/react-dom": {
2175
+
"optional": true
2176
+
}
2177
+
}
2178
+
},
2179
+
"node_modules/@radix-ui/react-compose-refs": {
2180
+
"version": "1.1.2",
2181
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
2182
+
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
2183
+
"peerDependencies": {
2184
+
"@types/react": "*",
2185
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2186
+
},
2187
+
"peerDependenciesMeta": {
2188
+
"@types/react": {
2189
+
"optional": true
2190
+
}
2191
+
}
2192
+
},
2193
+
"node_modules/@radix-ui/react-context": {
2194
+
"version": "1.1.2",
2195
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
2196
+
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
2197
+
"peerDependencies": {
2198
+
"@types/react": "*",
2199
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2200
+
},
2201
+
"peerDependenciesMeta": {
2202
+
"@types/react": {
2203
+
"optional": true
2204
+
}
2205
+
}
2206
+
},
2207
+
"node_modules/@radix-ui/react-context-menu": {
2208
+
"version": "2.2.16",
2209
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
2210
+
"integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
2211
+
"dependencies": {
2212
+
"@radix-ui/primitive": "1.1.3",
2213
+
"@radix-ui/react-context": "1.1.2",
2214
+
"@radix-ui/react-menu": "2.1.16",
2215
+
"@radix-ui/react-primitive": "2.1.3",
2216
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2217
+
"@radix-ui/react-use-controllable-state": "1.2.2"
2218
+
},
2219
+
"peerDependencies": {
2220
+
"@types/react": "*",
2221
+
"@types/react-dom": "*",
2222
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2223
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2224
+
},
2225
+
"peerDependenciesMeta": {
2226
+
"@types/react": {
2227
+
"optional": true
2228
+
},
2229
+
"@types/react-dom": {
2230
+
"optional": true
2231
+
}
2232
+
}
2233
+
},
2234
+
"node_modules/@radix-ui/react-dialog": {
2235
+
"version": "1.1.15",
2236
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
2237
+
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
2238
+
"dependencies": {
2239
+
"@radix-ui/primitive": "1.1.3",
2240
+
"@radix-ui/react-compose-refs": "1.1.2",
2241
+
"@radix-ui/react-context": "1.1.2",
2242
+
"@radix-ui/react-dismissable-layer": "1.1.11",
2243
+
"@radix-ui/react-focus-guards": "1.1.3",
2244
+
"@radix-ui/react-focus-scope": "1.1.7",
2245
+
"@radix-ui/react-id": "1.1.1",
2246
+
"@radix-ui/react-portal": "1.1.9",
2247
+
"@radix-ui/react-presence": "1.1.5",
2248
+
"@radix-ui/react-primitive": "2.1.3",
2249
+
"@radix-ui/react-slot": "1.2.3",
2250
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2251
+
"aria-hidden": "^1.2.4",
2252
+
"react-remove-scroll": "^2.6.3"
2253
+
},
2254
+
"peerDependencies": {
2255
+
"@types/react": "*",
2256
+
"@types/react-dom": "*",
2257
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2258
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2259
+
},
2260
+
"peerDependenciesMeta": {
2261
+
"@types/react": {
2262
+
"optional": true
2263
+
},
2264
+
"@types/react-dom": {
2265
+
"optional": true
2266
+
}
2267
+
}
2268
+
},
2269
+
"node_modules/@radix-ui/react-direction": {
2270
+
"version": "1.1.1",
2271
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
2272
+
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
2273
+
"peerDependencies": {
2274
+
"@types/react": "*",
2275
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2276
+
},
2277
+
"peerDependenciesMeta": {
2278
+
"@types/react": {
2279
+
"optional": true
2280
+
}
2281
+
}
2282
+
},
2283
+
"node_modules/@radix-ui/react-dismissable-layer": {
2284
+
"version": "1.1.11",
2285
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
2286
+
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
2287
+
"dependencies": {
2288
+
"@radix-ui/primitive": "1.1.3",
2289
+
"@radix-ui/react-compose-refs": "1.1.2",
2290
+
"@radix-ui/react-primitive": "2.1.3",
2291
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2292
+
"@radix-ui/react-use-escape-keydown": "1.1.1"
2293
+
},
2294
+
"peerDependencies": {
2295
+
"@types/react": "*",
2296
+
"@types/react-dom": "*",
2297
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2298
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2299
+
},
2300
+
"peerDependenciesMeta": {
2301
+
"@types/react": {
2302
+
"optional": true
2303
+
},
2304
+
"@types/react-dom": {
2305
+
"optional": true
2306
+
}
2307
+
}
2308
+
},
2309
+
"node_modules/@radix-ui/react-dropdown-menu": {
2310
+
"version": "2.1.16",
2311
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
2312
+
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
2313
+
"dependencies": {
2314
+
"@radix-ui/primitive": "1.1.3",
2315
+
"@radix-ui/react-compose-refs": "1.1.2",
2316
+
"@radix-ui/react-context": "1.1.2",
2317
+
"@radix-ui/react-id": "1.1.1",
2318
+
"@radix-ui/react-menu": "2.1.16",
2319
+
"@radix-ui/react-primitive": "2.1.3",
2320
+
"@radix-ui/react-use-controllable-state": "1.2.2"
2321
+
},
2322
+
"peerDependencies": {
2323
+
"@types/react": "*",
2324
+
"@types/react-dom": "*",
2325
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2326
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2327
+
},
2328
+
"peerDependenciesMeta": {
2329
+
"@types/react": {
2330
+
"optional": true
2331
+
},
2332
+
"@types/react-dom": {
2333
+
"optional": true
2334
+
}
2335
+
}
2336
+
},
2337
+
"node_modules/@radix-ui/react-focus-guards": {
2338
+
"version": "1.1.3",
2339
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
2340
+
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
2341
+
"peerDependencies": {
2342
+
"@types/react": "*",
2343
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2344
+
},
2345
+
"peerDependenciesMeta": {
2346
+
"@types/react": {
2347
+
"optional": true
2348
+
}
2349
+
}
2350
+
},
2351
+
"node_modules/@radix-ui/react-focus-scope": {
2352
+
"version": "1.1.7",
2353
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
2354
+
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
2355
+
"dependencies": {
2356
+
"@radix-ui/react-compose-refs": "1.1.2",
2357
+
"@radix-ui/react-primitive": "2.1.3",
2358
+
"@radix-ui/react-use-callback-ref": "1.1.1"
2359
+
},
2360
+
"peerDependencies": {
2361
+
"@types/react": "*",
2362
+
"@types/react-dom": "*",
2363
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2364
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2365
+
},
2366
+
"peerDependenciesMeta": {
2367
+
"@types/react": {
2368
+
"optional": true
2369
+
},
2370
+
"@types/react-dom": {
2371
+
"optional": true
2372
+
}
2373
+
}
2374
+
},
2375
+
"node_modules/@radix-ui/react-form": {
2376
+
"version": "0.1.8",
2377
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz",
2378
+
"integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==",
2379
+
"dependencies": {
2380
+
"@radix-ui/primitive": "1.1.3",
2381
+
"@radix-ui/react-compose-refs": "1.1.2",
2382
+
"@radix-ui/react-context": "1.1.2",
2383
+
"@radix-ui/react-id": "1.1.1",
2384
+
"@radix-ui/react-label": "2.1.7",
2385
+
"@radix-ui/react-primitive": "2.1.3"
2386
+
},
2387
+
"peerDependencies": {
2388
+
"@types/react": "*",
2389
+
"@types/react-dom": "*",
2390
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2391
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2392
+
},
2393
+
"peerDependenciesMeta": {
2394
+
"@types/react": {
2395
+
"optional": true
2396
+
},
2397
+
"@types/react-dom": {
2398
+
"optional": true
2399
+
}
2400
+
}
2401
+
},
2402
+
"node_modules/@radix-ui/react-hover-card": {
2403
+
"version": "1.1.15",
2404
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
2405
+
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
2406
+
"license": "MIT",
2407
+
"dependencies": {
2408
+
"@radix-ui/primitive": "1.1.3",
2409
+
"@radix-ui/react-compose-refs": "1.1.2",
2410
+
"@radix-ui/react-context": "1.1.2",
2411
+
"@radix-ui/react-dismissable-layer": "1.1.11",
2412
+
"@radix-ui/react-popper": "1.2.8",
2413
+
"@radix-ui/react-portal": "1.1.9",
2414
+
"@radix-ui/react-presence": "1.1.5",
2415
+
"@radix-ui/react-primitive": "2.1.3",
2416
+
"@radix-ui/react-use-controllable-state": "1.2.2"
2417
+
},
2418
+
"peerDependencies": {
2419
+
"@types/react": "*",
2420
+
"@types/react-dom": "*",
2421
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2422
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2423
+
},
2424
+
"peerDependenciesMeta": {
2425
+
"@types/react": {
2426
+
"optional": true
2427
+
},
2428
+
"@types/react-dom": {
2429
+
"optional": true
2430
+
}
2431
+
}
2432
+
},
2433
+
"node_modules/@radix-ui/react-id": {
2434
+
"version": "1.1.1",
2435
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
2436
+
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
2437
+
"dependencies": {
2438
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2439
+
},
2440
+
"peerDependencies": {
2441
+
"@types/react": "*",
2442
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2443
+
},
2444
+
"peerDependenciesMeta": {
2445
+
"@types/react": {
2446
+
"optional": true
2447
+
}
2448
+
}
2449
+
},
2450
+
"node_modules/@radix-ui/react-label": {
2451
+
"version": "2.1.7",
2452
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
2453
+
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
2454
+
"dependencies": {
2455
+
"@radix-ui/react-primitive": "2.1.3"
2456
+
},
2457
+
"peerDependencies": {
2458
+
"@types/react": "*",
2459
+
"@types/react-dom": "*",
2460
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2461
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2462
+
},
2463
+
"peerDependenciesMeta": {
2464
+
"@types/react": {
2465
+
"optional": true
2466
+
},
2467
+
"@types/react-dom": {
2468
+
"optional": true
2469
+
}
2470
+
}
2471
+
},
2472
+
"node_modules/@radix-ui/react-menu": {
2473
+
"version": "2.1.16",
2474
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
2475
+
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
2476
+
"dependencies": {
2477
+
"@radix-ui/primitive": "1.1.3",
2478
+
"@radix-ui/react-collection": "1.1.7",
2479
+
"@radix-ui/react-compose-refs": "1.1.2",
2480
+
"@radix-ui/react-context": "1.1.2",
2481
+
"@radix-ui/react-direction": "1.1.1",
2482
+
"@radix-ui/react-dismissable-layer": "1.1.11",
2483
+
"@radix-ui/react-focus-guards": "1.1.3",
2484
+
"@radix-ui/react-focus-scope": "1.1.7",
2485
+
"@radix-ui/react-id": "1.1.1",
2486
+
"@radix-ui/react-popper": "1.2.8",
2487
+
"@radix-ui/react-portal": "1.1.9",
2488
+
"@radix-ui/react-presence": "1.1.5",
2489
+
"@radix-ui/react-primitive": "2.1.3",
2490
+
"@radix-ui/react-roving-focus": "1.1.11",
2491
+
"@radix-ui/react-slot": "1.2.3",
2492
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2493
+
"aria-hidden": "^1.2.4",
2494
+
"react-remove-scroll": "^2.6.3"
2495
+
},
2496
+
"peerDependencies": {
2497
+
"@types/react": "*",
2498
+
"@types/react-dom": "*",
2499
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2500
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2501
+
},
2502
+
"peerDependenciesMeta": {
2503
+
"@types/react": {
2504
+
"optional": true
2505
+
},
2506
+
"@types/react-dom": {
2507
+
"optional": true
2508
+
}
2509
+
}
2510
+
},
2511
+
"node_modules/@radix-ui/react-menubar": {
2512
+
"version": "1.1.16",
2513
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz",
2514
+
"integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==",
2515
+
"dependencies": {
2516
+
"@radix-ui/primitive": "1.1.3",
2517
+
"@radix-ui/react-collection": "1.1.7",
2518
+
"@radix-ui/react-compose-refs": "1.1.2",
2519
+
"@radix-ui/react-context": "1.1.2",
2520
+
"@radix-ui/react-direction": "1.1.1",
2521
+
"@radix-ui/react-id": "1.1.1",
2522
+
"@radix-ui/react-menu": "2.1.16",
2523
+
"@radix-ui/react-primitive": "2.1.3",
2524
+
"@radix-ui/react-roving-focus": "1.1.11",
2525
+
"@radix-ui/react-use-controllable-state": "1.2.2"
2526
+
},
2527
+
"peerDependencies": {
2528
+
"@types/react": "*",
2529
+
"@types/react-dom": "*",
2530
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2531
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2532
+
},
2533
+
"peerDependenciesMeta": {
2534
+
"@types/react": {
2535
+
"optional": true
2536
+
},
2537
+
"@types/react-dom": {
2538
+
"optional": true
2539
+
}
2540
+
}
2541
+
},
2542
+
"node_modules/@radix-ui/react-navigation-menu": {
2543
+
"version": "1.2.14",
2544
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
2545
+
"integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==",
2546
+
"dependencies": {
2547
+
"@radix-ui/primitive": "1.1.3",
2548
+
"@radix-ui/react-collection": "1.1.7",
2549
+
"@radix-ui/react-compose-refs": "1.1.2",
2550
+
"@radix-ui/react-context": "1.1.2",
2551
+
"@radix-ui/react-direction": "1.1.1",
2552
+
"@radix-ui/react-dismissable-layer": "1.1.11",
2553
+
"@radix-ui/react-id": "1.1.1",
2554
+
"@radix-ui/react-presence": "1.1.5",
2555
+
"@radix-ui/react-primitive": "2.1.3",
2556
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2557
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2558
+
"@radix-ui/react-use-layout-effect": "1.1.1",
2559
+
"@radix-ui/react-use-previous": "1.1.1",
2560
+
"@radix-ui/react-visually-hidden": "1.2.3"
2561
+
},
2562
+
"peerDependencies": {
2563
+
"@types/react": "*",
2564
+
"@types/react-dom": "*",
2565
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2566
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2567
+
},
2568
+
"peerDependenciesMeta": {
2569
+
"@types/react": {
2570
+
"optional": true
2571
+
},
2572
+
"@types/react-dom": {
2573
+
"optional": true
2574
+
}
2575
+
}
2576
+
},
2577
+
"node_modules/@radix-ui/react-one-time-password-field": {
2578
+
"version": "0.1.8",
2579
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz",
2580
+
"integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==",
2581
+
"dependencies": {
2582
+
"@radix-ui/number": "1.1.1",
2583
+
"@radix-ui/primitive": "1.1.3",
2584
+
"@radix-ui/react-collection": "1.1.7",
2585
+
"@radix-ui/react-compose-refs": "1.1.2",
2586
+
"@radix-ui/react-context": "1.1.2",
2587
+
"@radix-ui/react-direction": "1.1.1",
2588
+
"@radix-ui/react-primitive": "2.1.3",
2589
+
"@radix-ui/react-roving-focus": "1.1.11",
2590
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2591
+
"@radix-ui/react-use-effect-event": "0.0.2",
2592
+
"@radix-ui/react-use-is-hydrated": "0.1.0",
2593
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2594
+
},
2595
+
"peerDependencies": {
2596
+
"@types/react": "*",
2597
+
"@types/react-dom": "*",
2598
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2599
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2600
+
},
2601
+
"peerDependenciesMeta": {
2602
+
"@types/react": {
2603
+
"optional": true
2604
+
},
2605
+
"@types/react-dom": {
2606
+
"optional": true
2607
+
}
2608
+
}
2609
+
},
2610
+
"node_modules/@radix-ui/react-password-toggle-field": {
2611
+
"version": "0.1.3",
2612
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz",
2613
+
"integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==",
2614
+
"dependencies": {
2615
+
"@radix-ui/primitive": "1.1.3",
2616
+
"@radix-ui/react-compose-refs": "1.1.2",
2617
+
"@radix-ui/react-context": "1.1.2",
2618
+
"@radix-ui/react-id": "1.1.1",
2619
+
"@radix-ui/react-primitive": "2.1.3",
2620
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2621
+
"@radix-ui/react-use-effect-event": "0.0.2",
2622
+
"@radix-ui/react-use-is-hydrated": "0.1.0"
2623
+
},
2624
+
"peerDependencies": {
2625
+
"@types/react": "*",
2626
+
"@types/react-dom": "*",
2627
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2628
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2629
+
},
2630
+
"peerDependenciesMeta": {
2631
+
"@types/react": {
2632
+
"optional": true
2633
+
},
2634
+
"@types/react-dom": {
2635
+
"optional": true
2636
+
}
2637
+
}
2638
+
},
2639
+
"node_modules/@radix-ui/react-popover": {
2640
+
"version": "1.1.15",
2641
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
2642
+
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
2643
+
"dependencies": {
2644
+
"@radix-ui/primitive": "1.1.3",
2645
+
"@radix-ui/react-compose-refs": "1.1.2",
2646
+
"@radix-ui/react-context": "1.1.2",
2647
+
"@radix-ui/react-dismissable-layer": "1.1.11",
2648
+
"@radix-ui/react-focus-guards": "1.1.3",
2649
+
"@radix-ui/react-focus-scope": "1.1.7",
2650
+
"@radix-ui/react-id": "1.1.1",
2651
+
"@radix-ui/react-popper": "1.2.8",
2652
+
"@radix-ui/react-portal": "1.1.9",
2653
+
"@radix-ui/react-presence": "1.1.5",
2654
+
"@radix-ui/react-primitive": "2.1.3",
2655
+
"@radix-ui/react-slot": "1.2.3",
2656
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2657
+
"aria-hidden": "^1.2.4",
2658
+
"react-remove-scroll": "^2.6.3"
2659
+
},
2660
+
"peerDependencies": {
2661
+
"@types/react": "*",
2662
+
"@types/react-dom": "*",
2663
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2664
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2665
+
},
2666
+
"peerDependenciesMeta": {
2667
+
"@types/react": {
2668
+
"optional": true
2669
+
},
2670
+
"@types/react-dom": {
2671
+
"optional": true
2672
+
}
2673
+
}
2674
+
},
2675
+
"node_modules/@radix-ui/react-popper": {
2676
+
"version": "1.2.8",
2677
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
2678
+
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
2679
+
"dependencies": {
2680
+
"@floating-ui/react-dom": "^2.0.0",
2681
+
"@radix-ui/react-arrow": "1.1.7",
2682
+
"@radix-ui/react-compose-refs": "1.1.2",
2683
+
"@radix-ui/react-context": "1.1.2",
2684
+
"@radix-ui/react-primitive": "2.1.3",
2685
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2686
+
"@radix-ui/react-use-layout-effect": "1.1.1",
2687
+
"@radix-ui/react-use-rect": "1.1.1",
2688
+
"@radix-ui/react-use-size": "1.1.1",
2689
+
"@radix-ui/rect": "1.1.1"
2690
+
},
2691
+
"peerDependencies": {
2692
+
"@types/react": "*",
2693
+
"@types/react-dom": "*",
2694
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2695
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2696
+
},
2697
+
"peerDependenciesMeta": {
2698
+
"@types/react": {
2699
+
"optional": true
2700
+
},
2701
+
"@types/react-dom": {
2702
+
"optional": true
2703
+
}
2704
+
}
2705
+
},
2706
+
"node_modules/@radix-ui/react-portal": {
2707
+
"version": "1.1.9",
2708
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
2709
+
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
2710
+
"dependencies": {
2711
+
"@radix-ui/react-primitive": "2.1.3",
2712
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2713
+
},
2714
+
"peerDependencies": {
2715
+
"@types/react": "*",
2716
+
"@types/react-dom": "*",
2717
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2718
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2719
+
},
2720
+
"peerDependenciesMeta": {
2721
+
"@types/react": {
2722
+
"optional": true
2723
+
},
2724
+
"@types/react-dom": {
2725
+
"optional": true
2726
+
}
2727
+
}
2728
+
},
2729
+
"node_modules/@radix-ui/react-presence": {
2730
+
"version": "1.1.5",
2731
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
2732
+
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
2733
+
"dependencies": {
2734
+
"@radix-ui/react-compose-refs": "1.1.2",
2735
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2736
+
},
2737
+
"peerDependencies": {
2738
+
"@types/react": "*",
2739
+
"@types/react-dom": "*",
2740
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2741
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2742
+
},
2743
+
"peerDependenciesMeta": {
2744
+
"@types/react": {
2745
+
"optional": true
2746
+
},
2747
+
"@types/react-dom": {
2748
+
"optional": true
2749
+
}
2750
+
}
2751
+
},
2752
+
"node_modules/@radix-ui/react-primitive": {
2753
+
"version": "2.1.3",
2754
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
2755
+
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
2756
+
"dependencies": {
2757
+
"@radix-ui/react-slot": "1.2.3"
2758
+
},
2759
+
"peerDependencies": {
2760
+
"@types/react": "*",
2761
+
"@types/react-dom": "*",
2762
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2763
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2764
+
},
2765
+
"peerDependenciesMeta": {
2766
+
"@types/react": {
2767
+
"optional": true
2768
+
},
2769
+
"@types/react-dom": {
2770
+
"optional": true
2771
+
}
2772
+
}
2773
+
},
2774
+
"node_modules/@radix-ui/react-progress": {
2775
+
"version": "1.1.7",
2776
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
2777
+
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
2778
+
"dependencies": {
2779
+
"@radix-ui/react-context": "1.1.2",
2780
+
"@radix-ui/react-primitive": "2.1.3"
2781
+
},
2782
+
"peerDependencies": {
2783
+
"@types/react": "*",
2784
+
"@types/react-dom": "*",
2785
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2786
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2787
+
},
2788
+
"peerDependenciesMeta": {
2789
+
"@types/react": {
2790
+
"optional": true
2791
+
},
2792
+
"@types/react-dom": {
2793
+
"optional": true
2794
+
}
2795
+
}
2796
+
},
2797
+
"node_modules/@radix-ui/react-radio-group": {
2798
+
"version": "1.3.8",
2799
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
2800
+
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
2801
+
"dependencies": {
2802
+
"@radix-ui/primitive": "1.1.3",
2803
+
"@radix-ui/react-compose-refs": "1.1.2",
2804
+
"@radix-ui/react-context": "1.1.2",
2805
+
"@radix-ui/react-direction": "1.1.1",
2806
+
"@radix-ui/react-presence": "1.1.5",
2807
+
"@radix-ui/react-primitive": "2.1.3",
2808
+
"@radix-ui/react-roving-focus": "1.1.11",
2809
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2810
+
"@radix-ui/react-use-previous": "1.1.1",
2811
+
"@radix-ui/react-use-size": "1.1.1"
2812
+
},
2813
+
"peerDependencies": {
2814
+
"@types/react": "*",
2815
+
"@types/react-dom": "*",
2816
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2817
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2818
+
},
2819
+
"peerDependenciesMeta": {
2820
+
"@types/react": {
2821
+
"optional": true
2822
+
},
2823
+
"@types/react-dom": {
2824
+
"optional": true
2825
+
}
2826
+
}
2827
+
},
2828
+
"node_modules/@radix-ui/react-roving-focus": {
2829
+
"version": "1.1.11",
2830
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
2831
+
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
2832
+
"dependencies": {
2833
+
"@radix-ui/primitive": "1.1.3",
2834
+
"@radix-ui/react-collection": "1.1.7",
2835
+
"@radix-ui/react-compose-refs": "1.1.2",
2836
+
"@radix-ui/react-context": "1.1.2",
2837
+
"@radix-ui/react-direction": "1.1.1",
2838
+
"@radix-ui/react-id": "1.1.1",
2839
+
"@radix-ui/react-primitive": "2.1.3",
2840
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2841
+
"@radix-ui/react-use-controllable-state": "1.2.2"
2842
+
},
2843
+
"peerDependencies": {
2844
+
"@types/react": "*",
2845
+
"@types/react-dom": "*",
2846
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2847
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2848
+
},
2849
+
"peerDependenciesMeta": {
2850
+
"@types/react": {
2851
+
"optional": true
2852
+
},
2853
+
"@types/react-dom": {
2854
+
"optional": true
2855
+
}
2856
+
}
2857
+
},
2858
+
"node_modules/@radix-ui/react-scroll-area": {
2859
+
"version": "1.2.10",
2860
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
2861
+
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
2862
+
"dependencies": {
2863
+
"@radix-ui/number": "1.1.1",
2864
+
"@radix-ui/primitive": "1.1.3",
2865
+
"@radix-ui/react-compose-refs": "1.1.2",
2866
+
"@radix-ui/react-context": "1.1.2",
2867
+
"@radix-ui/react-direction": "1.1.1",
2868
+
"@radix-ui/react-presence": "1.1.5",
2869
+
"@radix-ui/react-primitive": "2.1.3",
2870
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2871
+
"@radix-ui/react-use-layout-effect": "1.1.1"
2872
+
},
2873
+
"peerDependencies": {
2874
+
"@types/react": "*",
2875
+
"@types/react-dom": "*",
2876
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2877
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2878
+
},
2879
+
"peerDependenciesMeta": {
2880
+
"@types/react": {
2881
+
"optional": true
2882
+
},
2883
+
"@types/react-dom": {
2884
+
"optional": true
2885
+
}
2886
+
}
2887
+
},
2888
+
"node_modules/@radix-ui/react-select": {
2889
+
"version": "2.2.6",
2890
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
2891
+
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
2892
+
"dependencies": {
2893
+
"@radix-ui/number": "1.1.1",
2894
+
"@radix-ui/primitive": "1.1.3",
2895
+
"@radix-ui/react-collection": "1.1.7",
2896
+
"@radix-ui/react-compose-refs": "1.1.2",
2897
+
"@radix-ui/react-context": "1.1.2",
2898
+
"@radix-ui/react-direction": "1.1.1",
2899
+
"@radix-ui/react-dismissable-layer": "1.1.11",
2900
+
"@radix-ui/react-focus-guards": "1.1.3",
2901
+
"@radix-ui/react-focus-scope": "1.1.7",
2902
+
"@radix-ui/react-id": "1.1.1",
2903
+
"@radix-ui/react-popper": "1.2.8",
2904
+
"@radix-ui/react-portal": "1.1.9",
2905
+
"@radix-ui/react-primitive": "2.1.3",
2906
+
"@radix-ui/react-slot": "1.2.3",
2907
+
"@radix-ui/react-use-callback-ref": "1.1.1",
2908
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2909
+
"@radix-ui/react-use-layout-effect": "1.1.1",
2910
+
"@radix-ui/react-use-previous": "1.1.1",
2911
+
"@radix-ui/react-visually-hidden": "1.2.3",
2912
+
"aria-hidden": "^1.2.4",
2913
+
"react-remove-scroll": "^2.6.3"
2914
+
},
2915
+
"peerDependencies": {
2916
+
"@types/react": "*",
2917
+
"@types/react-dom": "*",
2918
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2919
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2920
+
},
2921
+
"peerDependenciesMeta": {
2922
+
"@types/react": {
2923
+
"optional": true
2924
+
},
2925
+
"@types/react-dom": {
2926
+
"optional": true
2927
+
}
2928
+
}
2929
+
},
2930
+
"node_modules/@radix-ui/react-separator": {
2931
+
"version": "1.1.7",
2932
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
2933
+
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
2934
+
"dependencies": {
2935
+
"@radix-ui/react-primitive": "2.1.3"
2936
+
},
2937
+
"peerDependencies": {
2938
+
"@types/react": "*",
2939
+
"@types/react-dom": "*",
2940
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2941
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2942
+
},
2943
+
"peerDependenciesMeta": {
2944
+
"@types/react": {
2945
+
"optional": true
2946
+
},
2947
+
"@types/react-dom": {
2948
+
"optional": true
2949
+
}
2950
+
}
2951
+
},
2952
+
"node_modules/@radix-ui/react-slider": {
2953
+
"version": "1.3.6",
2954
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
2955
+
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
2956
+
"dependencies": {
2957
+
"@radix-ui/number": "1.1.1",
2958
+
"@radix-ui/primitive": "1.1.3",
2959
+
"@radix-ui/react-collection": "1.1.7",
2960
+
"@radix-ui/react-compose-refs": "1.1.2",
2961
+
"@radix-ui/react-context": "1.1.2",
2962
+
"@radix-ui/react-direction": "1.1.1",
2963
+
"@radix-ui/react-primitive": "2.1.3",
2964
+
"@radix-ui/react-use-controllable-state": "1.2.2",
2965
+
"@radix-ui/react-use-layout-effect": "1.1.1",
2966
+
"@radix-ui/react-use-previous": "1.1.1",
2967
+
"@radix-ui/react-use-size": "1.1.1"
2968
+
},
2969
+
"peerDependencies": {
2970
+
"@types/react": "*",
2971
+
"@types/react-dom": "*",
2972
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
2973
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2974
+
},
2975
+
"peerDependenciesMeta": {
2976
+
"@types/react": {
2977
+
"optional": true
2978
+
},
2979
+
"@types/react-dom": {
2980
+
"optional": true
2981
+
}
2982
+
}
2983
+
},
2984
+
"node_modules/@radix-ui/react-slot": {
2985
+
"version": "1.2.3",
2986
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
2987
+
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
2988
+
"dependencies": {
2989
+
"@radix-ui/react-compose-refs": "1.1.2"
2990
+
},
2991
+
"peerDependencies": {
2992
+
"@types/react": "*",
2993
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
2994
+
},
2995
+
"peerDependenciesMeta": {
2996
+
"@types/react": {
2997
+
"optional": true
2998
+
}
2999
+
}
3000
+
},
3001
+
"node_modules/@radix-ui/react-switch": {
3002
+
"version": "1.2.6",
3003
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
3004
+
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
3005
+
"dependencies": {
3006
+
"@radix-ui/primitive": "1.1.3",
3007
+
"@radix-ui/react-compose-refs": "1.1.2",
3008
+
"@radix-ui/react-context": "1.1.2",
3009
+
"@radix-ui/react-primitive": "2.1.3",
3010
+
"@radix-ui/react-use-controllable-state": "1.2.2",
3011
+
"@radix-ui/react-use-previous": "1.1.1",
3012
+
"@radix-ui/react-use-size": "1.1.1"
3013
+
},
3014
+
"peerDependencies": {
3015
+
"@types/react": "*",
3016
+
"@types/react-dom": "*",
3017
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3018
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3019
+
},
3020
+
"peerDependenciesMeta": {
3021
+
"@types/react": {
3022
+
"optional": true
3023
+
},
3024
+
"@types/react-dom": {
3025
+
"optional": true
3026
+
}
3027
+
}
3028
+
},
3029
+
"node_modules/@radix-ui/react-tabs": {
3030
+
"version": "1.1.13",
3031
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
3032
+
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
3033
+
"dependencies": {
3034
+
"@radix-ui/primitive": "1.1.3",
3035
+
"@radix-ui/react-context": "1.1.2",
3036
+
"@radix-ui/react-direction": "1.1.1",
3037
+
"@radix-ui/react-id": "1.1.1",
3038
+
"@radix-ui/react-presence": "1.1.5",
3039
+
"@radix-ui/react-primitive": "2.1.3",
3040
+
"@radix-ui/react-roving-focus": "1.1.11",
3041
+
"@radix-ui/react-use-controllable-state": "1.2.2"
3042
+
},
3043
+
"peerDependencies": {
3044
+
"@types/react": "*",
3045
+
"@types/react-dom": "*",
3046
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3047
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3048
+
},
3049
+
"peerDependenciesMeta": {
3050
+
"@types/react": {
3051
+
"optional": true
3052
+
},
3053
+
"@types/react-dom": {
3054
+
"optional": true
3055
+
}
3056
+
}
3057
+
},
3058
+
"node_modules/@radix-ui/react-toast": {
3059
+
"version": "1.2.15",
3060
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz",
3061
+
"integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==",
3062
+
"dependencies": {
3063
+
"@radix-ui/primitive": "1.1.3",
3064
+
"@radix-ui/react-collection": "1.1.7",
3065
+
"@radix-ui/react-compose-refs": "1.1.2",
3066
+
"@radix-ui/react-context": "1.1.2",
3067
+
"@radix-ui/react-dismissable-layer": "1.1.11",
3068
+
"@radix-ui/react-portal": "1.1.9",
3069
+
"@radix-ui/react-presence": "1.1.5",
3070
+
"@radix-ui/react-primitive": "2.1.3",
3071
+
"@radix-ui/react-use-callback-ref": "1.1.1",
3072
+
"@radix-ui/react-use-controllable-state": "1.2.2",
3073
+
"@radix-ui/react-use-layout-effect": "1.1.1",
3074
+
"@radix-ui/react-visually-hidden": "1.2.3"
3075
+
},
3076
+
"peerDependencies": {
3077
+
"@types/react": "*",
3078
+
"@types/react-dom": "*",
3079
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3080
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3081
+
},
3082
+
"peerDependenciesMeta": {
3083
+
"@types/react": {
3084
+
"optional": true
3085
+
},
3086
+
"@types/react-dom": {
3087
+
"optional": true
3088
+
}
3089
+
}
3090
+
},
3091
+
"node_modules/@radix-ui/react-toggle": {
3092
+
"version": "1.1.10",
3093
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
3094
+
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
3095
+
"dependencies": {
3096
+
"@radix-ui/primitive": "1.1.3",
3097
+
"@radix-ui/react-primitive": "2.1.3",
3098
+
"@radix-ui/react-use-controllable-state": "1.2.2"
3099
+
},
3100
+
"peerDependencies": {
3101
+
"@types/react": "*",
3102
+
"@types/react-dom": "*",
3103
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3104
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3105
+
},
3106
+
"peerDependenciesMeta": {
3107
+
"@types/react": {
3108
+
"optional": true
3109
+
},
3110
+
"@types/react-dom": {
3111
+
"optional": true
3112
+
}
3113
+
}
3114
+
},
3115
+
"node_modules/@radix-ui/react-toggle-group": {
3116
+
"version": "1.1.11",
3117
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
3118
+
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
3119
+
"dependencies": {
3120
+
"@radix-ui/primitive": "1.1.3",
3121
+
"@radix-ui/react-context": "1.1.2",
3122
+
"@radix-ui/react-direction": "1.1.1",
3123
+
"@radix-ui/react-primitive": "2.1.3",
3124
+
"@radix-ui/react-roving-focus": "1.1.11",
3125
+
"@radix-ui/react-toggle": "1.1.10",
3126
+
"@radix-ui/react-use-controllable-state": "1.2.2"
3127
+
},
3128
+
"peerDependencies": {
3129
+
"@types/react": "*",
3130
+
"@types/react-dom": "*",
3131
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3132
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3133
+
},
3134
+
"peerDependenciesMeta": {
3135
+
"@types/react": {
3136
+
"optional": true
3137
+
},
3138
+
"@types/react-dom": {
3139
+
"optional": true
3140
+
}
3141
+
}
3142
+
},
3143
+
"node_modules/@radix-ui/react-toolbar": {
3144
+
"version": "1.1.11",
3145
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz",
3146
+
"integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==",
3147
+
"dependencies": {
3148
+
"@radix-ui/primitive": "1.1.3",
3149
+
"@radix-ui/react-context": "1.1.2",
3150
+
"@radix-ui/react-direction": "1.1.1",
3151
+
"@radix-ui/react-primitive": "2.1.3",
3152
+
"@radix-ui/react-roving-focus": "1.1.11",
3153
+
"@radix-ui/react-separator": "1.1.7",
3154
+
"@radix-ui/react-toggle-group": "1.1.11"
3155
+
},
3156
+
"peerDependencies": {
3157
+
"@types/react": "*",
3158
+
"@types/react-dom": "*",
3159
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3160
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3161
+
},
3162
+
"peerDependenciesMeta": {
3163
+
"@types/react": {
3164
+
"optional": true
3165
+
},
3166
+
"@types/react-dom": {
3167
+
"optional": true
3168
+
}
3169
+
}
3170
+
},
3171
+
"node_modules/@radix-ui/react-tooltip": {
3172
+
"version": "1.2.8",
3173
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
3174
+
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
3175
+
"dependencies": {
3176
+
"@radix-ui/primitive": "1.1.3",
3177
+
"@radix-ui/react-compose-refs": "1.1.2",
3178
+
"@radix-ui/react-context": "1.1.2",
3179
+
"@radix-ui/react-dismissable-layer": "1.1.11",
3180
+
"@radix-ui/react-id": "1.1.1",
3181
+
"@radix-ui/react-popper": "1.2.8",
3182
+
"@radix-ui/react-portal": "1.1.9",
3183
+
"@radix-ui/react-presence": "1.1.5",
3184
+
"@radix-ui/react-primitive": "2.1.3",
3185
+
"@radix-ui/react-slot": "1.2.3",
3186
+
"@radix-ui/react-use-controllable-state": "1.2.2",
3187
+
"@radix-ui/react-visually-hidden": "1.2.3"
3188
+
},
3189
+
"peerDependencies": {
3190
+
"@types/react": "*",
3191
+
"@types/react-dom": "*",
3192
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3193
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3194
+
},
3195
+
"peerDependenciesMeta": {
3196
+
"@types/react": {
3197
+
"optional": true
3198
+
},
3199
+
"@types/react-dom": {
3200
+
"optional": true
3201
+
}
3202
+
}
3203
+
},
3204
+
"node_modules/@radix-ui/react-use-callback-ref": {
3205
+
"version": "1.1.1",
3206
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
3207
+
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
3208
+
"peerDependencies": {
3209
+
"@types/react": "*",
3210
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3211
+
},
3212
+
"peerDependenciesMeta": {
3213
+
"@types/react": {
3214
+
"optional": true
3215
+
}
3216
+
}
3217
+
},
3218
+
"node_modules/@radix-ui/react-use-controllable-state": {
3219
+
"version": "1.2.2",
3220
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
3221
+
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
3222
+
"dependencies": {
3223
+
"@radix-ui/react-use-effect-event": "0.0.2",
3224
+
"@radix-ui/react-use-layout-effect": "1.1.1"
3225
+
},
3226
+
"peerDependencies": {
3227
+
"@types/react": "*",
3228
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3229
+
},
3230
+
"peerDependenciesMeta": {
3231
+
"@types/react": {
3232
+
"optional": true
3233
+
}
3234
+
}
3235
+
},
3236
+
"node_modules/@radix-ui/react-use-effect-event": {
3237
+
"version": "0.0.2",
3238
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
3239
+
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
3240
+
"dependencies": {
3241
+
"@radix-ui/react-use-layout-effect": "1.1.1"
3242
+
},
3243
+
"peerDependencies": {
3244
+
"@types/react": "*",
3245
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3246
+
},
3247
+
"peerDependenciesMeta": {
3248
+
"@types/react": {
3249
+
"optional": true
3250
+
}
3251
+
}
3252
+
},
3253
+
"node_modules/@radix-ui/react-use-escape-keydown": {
3254
+
"version": "1.1.1",
3255
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
3256
+
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
3257
+
"dependencies": {
3258
+
"@radix-ui/react-use-callback-ref": "1.1.1"
3259
+
},
3260
+
"peerDependencies": {
3261
+
"@types/react": "*",
3262
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3263
+
},
3264
+
"peerDependenciesMeta": {
3265
+
"@types/react": {
3266
+
"optional": true
3267
+
}
3268
+
}
3269
+
},
3270
+
"node_modules/@radix-ui/react-use-is-hydrated": {
3271
+
"version": "0.1.0",
3272
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
3273
+
"integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
3274
+
"dependencies": {
3275
+
"use-sync-external-store": "^1.5.0"
3276
+
},
3277
+
"peerDependencies": {
3278
+
"@types/react": "*",
3279
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3280
+
},
3281
+
"peerDependenciesMeta": {
3282
+
"@types/react": {
3283
+
"optional": true
3284
+
}
3285
+
}
3286
+
},
3287
+
"node_modules/@radix-ui/react-use-layout-effect": {
3288
+
"version": "1.1.1",
3289
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
3290
+
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
3291
+
"peerDependencies": {
3292
+
"@types/react": "*",
3293
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3294
+
},
3295
+
"peerDependenciesMeta": {
3296
+
"@types/react": {
3297
+
"optional": true
3298
+
}
3299
+
}
3300
+
},
3301
+
"node_modules/@radix-ui/react-use-previous": {
3302
+
"version": "1.1.1",
3303
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
3304
+
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
3305
+
"peerDependencies": {
3306
+
"@types/react": "*",
3307
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3308
+
},
3309
+
"peerDependenciesMeta": {
3310
+
"@types/react": {
3311
+
"optional": true
3312
+
}
3313
+
}
3314
+
},
3315
+
"node_modules/@radix-ui/react-use-rect": {
3316
+
"version": "1.1.1",
3317
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
3318
+
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
3319
+
"dependencies": {
3320
+
"@radix-ui/rect": "1.1.1"
3321
+
},
3322
+
"peerDependencies": {
3323
+
"@types/react": "*",
3324
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3325
+
},
3326
+
"peerDependenciesMeta": {
3327
+
"@types/react": {
3328
+
"optional": true
3329
+
}
3330
+
}
3331
+
},
3332
+
"node_modules/@radix-ui/react-use-size": {
3333
+
"version": "1.1.1",
3334
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
3335
+
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
3336
+
"dependencies": {
3337
+
"@radix-ui/react-use-layout-effect": "1.1.1"
3338
+
},
3339
+
"peerDependencies": {
3340
+
"@types/react": "*",
3341
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3342
+
},
3343
+
"peerDependenciesMeta": {
3344
+
"@types/react": {
3345
+
"optional": true
3346
+
}
3347
+
}
3348
+
},
3349
+
"node_modules/@radix-ui/react-visually-hidden": {
3350
+
"version": "1.2.3",
3351
+
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
3352
+
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
3353
+
"dependencies": {
3354
+
"@radix-ui/react-primitive": "2.1.3"
3355
+
},
3356
+
"peerDependencies": {
3357
+
"@types/react": "*",
3358
+
"@types/react-dom": "*",
3359
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
3360
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
3361
+
},
3362
+
"peerDependenciesMeta": {
3363
+
"@types/react": {
3364
+
"optional": true
3365
+
},
3366
+
"@types/react-dom": {
3367
+
"optional": true
3368
+
}
3369
+
}
3370
+
},
3371
+
"node_modules/@radix-ui/rect": {
3372
+
"version": "1.1.1",
3373
+
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
3374
+
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="
3375
+
},
3376
"node_modules/@rolldown/pluginutils": {
3377
"version": "1.0.0-beta.27",
3378
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
···
5285
"dev": true,
5286
"license": "Python-2.0"
5287
},
5288
+
"node_modules/aria-hidden": {
5289
+
"version": "1.2.6",
5290
+
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
5291
+
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
5292
+
"dependencies": {
5293
+
"tslib": "^2.0.0"
5294
+
},
5295
+
"engines": {
5296
+
"node": ">=10"
5297
+
}
5298
+
},
5299
"node_modules/aria-query": {
5300
"version": "5.3.0",
5301
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
···
6204
"engines": {
6205
"node": ">=8"
6206
}
6207
+
},
6208
+
"node_modules/detect-node-es": {
6209
+
"version": "1.1.0",
6210
+
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
6211
+
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
6212
},
6213
"node_modules/diff": {
6214
"version": "8.0.2",
···
7229
},
7230
"funding": {
7231
"url": "https://github.com/sponsors/ljharb"
7232
+
}
7233
+
},
7234
+
"node_modules/get-nonce": {
7235
+
"version": "1.0.1",
7236
+
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
7237
+
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
7238
+
"engines": {
7239
+
"node": ">=6"
7240
}
7241
},
7242
"node_modules/get-proto": {
···
11840
],
11841
"license": "MIT"
11842
},
11843
+
"node_modules/radix-ui": {
11844
+
"version": "1.4.3",
11845
+
"resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
11846
+
"integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==",
11847
+
"dependencies": {
11848
+
"@radix-ui/primitive": "1.1.3",
11849
+
"@radix-ui/react-accessible-icon": "1.1.7",
11850
+
"@radix-ui/react-accordion": "1.2.12",
11851
+
"@radix-ui/react-alert-dialog": "1.1.15",
11852
+
"@radix-ui/react-arrow": "1.1.7",
11853
+
"@radix-ui/react-aspect-ratio": "1.1.7",
11854
+
"@radix-ui/react-avatar": "1.1.10",
11855
+
"@radix-ui/react-checkbox": "1.3.3",
11856
+
"@radix-ui/react-collapsible": "1.1.12",
11857
+
"@radix-ui/react-collection": "1.1.7",
11858
+
"@radix-ui/react-compose-refs": "1.1.2",
11859
+
"@radix-ui/react-context": "1.1.2",
11860
+
"@radix-ui/react-context-menu": "2.2.16",
11861
+
"@radix-ui/react-dialog": "1.1.15",
11862
+
"@radix-ui/react-direction": "1.1.1",
11863
+
"@radix-ui/react-dismissable-layer": "1.1.11",
11864
+
"@radix-ui/react-dropdown-menu": "2.1.16",
11865
+
"@radix-ui/react-focus-guards": "1.1.3",
11866
+
"@radix-ui/react-focus-scope": "1.1.7",
11867
+
"@radix-ui/react-form": "0.1.8",
11868
+
"@radix-ui/react-hover-card": "1.1.15",
11869
+
"@radix-ui/react-label": "2.1.7",
11870
+
"@radix-ui/react-menu": "2.1.16",
11871
+
"@radix-ui/react-menubar": "1.1.16",
11872
+
"@radix-ui/react-navigation-menu": "1.2.14",
11873
+
"@radix-ui/react-one-time-password-field": "0.1.8",
11874
+
"@radix-ui/react-password-toggle-field": "0.1.3",
11875
+
"@radix-ui/react-popover": "1.1.15",
11876
+
"@radix-ui/react-popper": "1.2.8",
11877
+
"@radix-ui/react-portal": "1.1.9",
11878
+
"@radix-ui/react-presence": "1.1.5",
11879
+
"@radix-ui/react-primitive": "2.1.3",
11880
+
"@radix-ui/react-progress": "1.1.7",
11881
+
"@radix-ui/react-radio-group": "1.3.8",
11882
+
"@radix-ui/react-roving-focus": "1.1.11",
11883
+
"@radix-ui/react-scroll-area": "1.2.10",
11884
+
"@radix-ui/react-select": "2.2.6",
11885
+
"@radix-ui/react-separator": "1.1.7",
11886
+
"@radix-ui/react-slider": "1.3.6",
11887
+
"@radix-ui/react-slot": "1.2.3",
11888
+
"@radix-ui/react-switch": "1.2.6",
11889
+
"@radix-ui/react-tabs": "1.1.13",
11890
+
"@radix-ui/react-toast": "1.2.15",
11891
+
"@radix-ui/react-toggle": "1.1.10",
11892
+
"@radix-ui/react-toggle-group": "1.1.11",
11893
+
"@radix-ui/react-toolbar": "1.1.11",
11894
+
"@radix-ui/react-tooltip": "1.2.8",
11895
+
"@radix-ui/react-use-callback-ref": "1.1.1",
11896
+
"@radix-ui/react-use-controllable-state": "1.2.2",
11897
+
"@radix-ui/react-use-effect-event": "0.0.2",
11898
+
"@radix-ui/react-use-escape-keydown": "1.1.1",
11899
+
"@radix-ui/react-use-is-hydrated": "0.1.0",
11900
+
"@radix-ui/react-use-layout-effect": "1.1.1",
11901
+
"@radix-ui/react-use-size": "1.1.1",
11902
+
"@radix-ui/react-visually-hidden": "1.2.3"
11903
+
},
11904
+
"peerDependencies": {
11905
+
"@types/react": "*",
11906
+
"@types/react-dom": "*",
11907
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
11908
+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
11909
+
},
11910
+
"peerDependenciesMeta": {
11911
+
"@types/react": {
11912
+
"optional": true
11913
+
},
11914
+
"@types/react-dom": {
11915
+
"optional": true
11916
+
}
11917
+
}
11918
+
},
11919
"node_modules/react": {
11920
"version": "19.1.1",
11921
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
···
11977
"node": ">=0.10.0"
11978
}
11979
},
11980
+
"node_modules/react-remove-scroll": {
11981
+
"version": "2.7.1",
11982
+
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
11983
+
"integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
11984
+
"dependencies": {
11985
+
"react-remove-scroll-bar": "^2.3.7",
11986
+
"react-style-singleton": "^2.2.3",
11987
+
"tslib": "^2.1.0",
11988
+
"use-callback-ref": "^1.3.3",
11989
+
"use-sidecar": "^1.1.3"
11990
+
},
11991
+
"engines": {
11992
+
"node": ">=10"
11993
+
},
11994
+
"peerDependencies": {
11995
+
"@types/react": "*",
11996
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
11997
+
},
11998
+
"peerDependenciesMeta": {
11999
+
"@types/react": {
12000
+
"optional": true
12001
+
}
12002
+
}
12003
+
},
12004
+
"node_modules/react-remove-scroll-bar": {
12005
+
"version": "2.3.8",
12006
+
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
12007
+
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
12008
+
"dependencies": {
12009
+
"react-style-singleton": "^2.2.2",
12010
+
"tslib": "^2.0.0"
12011
+
},
12012
+
"engines": {
12013
+
"node": ">=10"
12014
+
},
12015
+
"peerDependencies": {
12016
+
"@types/react": "*",
12017
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
12018
+
},
12019
+
"peerDependenciesMeta": {
12020
+
"@types/react": {
12021
+
"optional": true
12022
+
}
12023
+
}
12024
+
},
12025
+
"node_modules/react-style-singleton": {
12026
+
"version": "2.2.3",
12027
+
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
12028
+
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
12029
+
"dependencies": {
12030
+
"get-nonce": "^1.0.0",
12031
+
"tslib": "^2.0.0"
12032
+
},
12033
+
"engines": {
12034
+
"node": ">=10"
12035
+
},
12036
+
"peerDependencies": {
12037
+
"@types/react": "*",
12038
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
12039
+
},
12040
+
"peerDependenciesMeta": {
12041
+
"@types/react": {
12042
+
"optional": true
12043
+
}
12044
+
}
12045
+
},
12046
"node_modules/readdirp": {
12047
"version": "3.6.0",
12048
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
···
13536
"peer": true,
13537
"dependencies": {
13538
"punycode": "^2.1.0"
13539
+
}
13540
+
},
13541
+
"node_modules/use-callback-ref": {
13542
+
"version": "1.3.3",
13543
+
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
13544
+
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
13545
+
"dependencies": {
13546
+
"tslib": "^2.0.0"
13547
+
},
13548
+
"engines": {
13549
+
"node": ">=10"
13550
+
},
13551
+
"peerDependencies": {
13552
+
"@types/react": "*",
13553
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
13554
+
},
13555
+
"peerDependenciesMeta": {
13556
+
"@types/react": {
13557
+
"optional": true
13558
+
}
13559
+
}
13560
+
},
13561
+
"node_modules/use-sidecar": {
13562
+
"version": "1.1.3",
13563
+
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
13564
+
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
13565
+
"dependencies": {
13566
+
"detect-node-es": "^1.1.0",
13567
+
"tslib": "^2.0.0"
13568
+
},
13569
+
"engines": {
13570
+
"node": ">=10"
13571
+
},
13572
+
"peerDependencies": {
13573
+
"@types/react": "*",
13574
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
13575
+
},
13576
+
"peerDependenciesMeta": {
13577
+
"@types/react": {
13578
+
"optional": true
13579
+
}
13580
}
13581
},
13582
"node_modules/use-sync-external-store": {
+6
package.json
+6
package.json
···
12
"dependencies": {
13
"@atproto/api": "^0.16.6",
14
"@atproto/oauth-client-browser": "^0.3.33",
15
"@tailwindcss/vite": "^4.0.6",
16
"@tanstack/query-sync-storage-persister": "^5.85.6",
17
"@tanstack/react-devtools": "^0.2.2",
···
25
"idb-keyval": "^6.2.2",
26
"jotai": "^2.13.1",
27
"npm": "^11.6.2",
28
"react": "^19.0.0",
29
"react-dom": "^19.0.0",
30
"react-player": "^3.3.2",
···
36
"@iconify-icon/react": "^3.0.1",
37
"@iconify-json/material-symbols": "^1.2.42",
38
"@iconify-json/mdi": "^1.2.3",
39
"@svgr/core": "^8.1.0",
40
"@svgr/plugin-jsx": "^8.1.0",
41
"@testing-library/dom": "^10.4.0",
···
12
"dependencies": {
13
"@atproto/api": "^0.16.6",
14
"@atproto/oauth-client-browser": "^0.3.33",
15
+
"@radix-ui/react-dialog": "^1.1.15",
16
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
17
+
"@radix-ui/react-hover-card": "^1.1.15",
18
+
"@radix-ui/react-slider": "^1.3.6",
19
"@tailwindcss/vite": "^4.0.6",
20
"@tanstack/query-sync-storage-persister": "^5.85.6",
21
"@tanstack/react-devtools": "^0.2.2",
···
29
"idb-keyval": "^6.2.2",
30
"jotai": "^2.13.1",
31
"npm": "^11.6.2",
32
+
"radix-ui": "^1.4.3",
33
"react": "^19.0.0",
34
"react-dom": "^19.0.0",
35
"react-player": "^3.3.2",
···
41
"@iconify-icon/react": "^3.0.1",
42
"@iconify-json/material-symbols": "^1.2.42",
43
"@iconify-json/mdi": "^1.2.3",
44
+
"@iconify/json": "^2.2.396",
45
"@svgr/core": "^8.1.0",
46
"@svgr/plugin-jsx": "^8.1.0",
47
"@testing-library/dom": "^10.4.0",
+1
src/auto-imports.d.ts
+1
src/auto-imports.d.ts
···
8
declare global {
9
const IconMaterialSymbolsAccountCircle: typeof import('~icons/material-symbols/account-circle.jsx').default
10
const IconMaterialSymbolsAccountCircleOutline: typeof import('~icons/material-symbols/account-circle-outline.jsx').default
11
const IconMaterialSymbolsHome: typeof import('~icons/material-symbols/home.jsx').default
12
const IconMaterialSymbolsHomeOutline: typeof import('~icons/material-symbols/home-outline.jsx').default
13
const IconMaterialSymbolsNotifications: typeof import('~icons/material-symbols/notifications.jsx').default
···
8
declare global {
9
const IconMaterialSymbolsAccountCircle: typeof import('~icons/material-symbols/account-circle.jsx').default
10
const IconMaterialSymbolsAccountCircleOutline: typeof import('~icons/material-symbols/account-circle-outline.jsx').default
11
+
const IconMaterialSymbolsArrowBack: typeof import('~icons/material-symbols/arrow-back.jsx').default
12
const IconMaterialSymbolsHome: typeof import('~icons/material-symbols/home.jsx').default
13
const IconMaterialSymbolsHomeOutline: typeof import('~icons/material-symbols/home-outline.jsx').default
14
const IconMaterialSymbolsNotifications: typeof import('~icons/material-symbols/notifications.jsx').default
+292
src/components/Composer.tsx
+292
src/components/Composer.tsx
···
···
1
+
import { AppBskyRichtextFacet, RichText } from "@atproto/api";
2
+
import { useAtom } from "jotai";
3
+
import { Dialog } from "radix-ui";
4
+
import { useEffect, useRef, useState } from "react";
5
+
6
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
+
import { composerAtom } from "~/utils/atoms";
8
+
import { useQueryPost } from "~/utils/useQuery";
9
+
10
+
import { ProfileThing } from "./Login";
11
+
import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
12
+
13
+
const MAX_POST_LENGTH = 300;
14
+
15
+
export function Composer() {
16
+
const [composerState, setComposerState] = useAtom(composerAtom);
17
+
const { agent } = useAuth();
18
+
19
+
const [postText, setPostText] = useState("");
20
+
const [posting, setPosting] = useState(false);
21
+
const [postSuccess, setPostSuccess] = useState(false);
22
+
const [postError, setPostError] = useState<string | null>(null);
23
+
24
+
useEffect(() => {
25
+
setPostText("");
26
+
setPosting(false);
27
+
setPostSuccess(false);
28
+
setPostError(null);
29
+
}, [composerState.kind]);
30
+
31
+
const parentUri =
32
+
composerState.kind === "reply"
33
+
? composerState.parent
34
+
: composerState.kind === "quote"
35
+
? composerState.subject
36
+
: undefined;
37
+
38
+
const { data: parentPost, isLoading: isParentLoading } =
39
+
useQueryPost(parentUri);
40
+
41
+
async function handlePost() {
42
+
if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return;
43
+
44
+
setPosting(true);
45
+
setPostError(null);
46
+
47
+
try {
48
+
const rt = new RichText({ text: postText });
49
+
await rt.detectFacets(agent);
50
+
51
+
if (rt.facets?.length) {
52
+
rt.facets = rt.facets.filter((item) => {
53
+
if (item.$type !== "app.bsky.richtext.facet") return true;
54
+
if (!item.features?.length) return true;
55
+
56
+
item.features = item.features.filter((feature) => {
57
+
if (feature.$type !== "app.bsky.richtext.facet#mention") return true;
58
+
const did = feature.$type === "app.bsky.richtext.facet#mention" ? (feature as AppBskyRichtextFacet.Mention)?.did : undefined;
59
+
return typeof did === "string" && did.startsWith("did:");
60
+
});
61
+
62
+
return item.features.length > 0;
63
+
});
64
+
}
65
+
66
+
const record: Record<string, unknown> = {
67
+
$type: "app.bsky.feed.post",
68
+
text: rt.text,
69
+
facets: rt.facets,
70
+
createdAt: new Date().toISOString(),
71
+
};
72
+
73
+
if (composerState.kind === "reply" && parentPost) {
74
+
record.reply = {
75
+
root: parentPost.value?.reply?.root ?? {
76
+
uri: parentPost.uri,
77
+
cid: parentPost.cid,
78
+
},
79
+
parent: {
80
+
uri: parentPost.uri,
81
+
cid: parentPost.cid,
82
+
},
83
+
};
84
+
}
85
+
86
+
if (composerState.kind === "quote" && parentPost) {
87
+
record.embed = {
88
+
$type: "app.bsky.embed.record",
89
+
record: {
90
+
uri: parentPost.uri,
91
+
cid: parentPost.cid,
92
+
},
93
+
};
94
+
}
95
+
96
+
await agent.com.atproto.repo.createRecord({
97
+
collection: "app.bsky.feed.post",
98
+
repo: agent.assertDid,
99
+
record,
100
+
});
101
+
102
+
setPostSuccess(true);
103
+
setPostText("");
104
+
105
+
setTimeout(() => {
106
+
setPostSuccess(false);
107
+
setComposerState({ kind: "closed" });
108
+
}, 1500);
109
+
} catch (e: any) {
110
+
setPostError(e?.message || "Failed to post");
111
+
} finally {
112
+
setPosting(false);
113
+
}
114
+
}
115
+
// if (composerState.kind === "closed") {
116
+
// return null;
117
+
// }
118
+
119
+
const getPlaceholder = () => {
120
+
switch (composerState.kind) {
121
+
case "reply":
122
+
return "Post your reply";
123
+
case "quote":
124
+
return "Add a comment...";
125
+
case "root":
126
+
default:
127
+
return "What's happening?!";
128
+
}
129
+
};
130
+
131
+
const charsLeft = MAX_POST_LENGTH - postText.length;
132
+
const isPostButtonDisabled =
133
+
posting || !postText.trim() || isParentLoading || charsLeft < 0;
134
+
135
+
return (
136
+
<Dialog.Root
137
+
open={composerState.kind !== "closed"}
138
+
onOpenChange={(open) => {
139
+
if (!open) setComposerState({ kind: "closed" });
140
+
}}
141
+
>
142
+
<Dialog.Portal>
143
+
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" />
144
+
145
+
<Dialog.Content className="fixed overflow-y-scroll inset-0 z-50 flex items-start justify-center py-10 sm:py-20">
146
+
<div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4">
147
+
<div className="flex flex-row justify-between p-2">
148
+
<Dialog.Close asChild>
149
+
<button
150
+
className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
151
+
disabled={posting}
152
+
aria-label="Close"
153
+
>
154
+
<svg
155
+
xmlns="http://www.w3.org/2000/svg"
156
+
width="20"
157
+
height="20"
158
+
viewBox="0 0 24 24"
159
+
fill="none"
160
+
stroke="currentColor"
161
+
strokeWidth="2.5"
162
+
strokeLinecap="round"
163
+
strokeLinejoin="round"
164
+
>
165
+
<line x1="18" y1="6" x2="6" y2="18"></line>
166
+
<line x1="6" y1="6" x2="18" y2="18"></line>
167
+
</svg>
168
+
</button>
169
+
</Dialog.Close>
170
+
171
+
<div className="flex-1" />
172
+
<div className="flex items-center gap-4">
173
+
<span
174
+
className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`}
175
+
>
176
+
{charsLeft}
177
+
</span>
178
+
<button
179
+
className="bg-gray-600 hover:bg-gray-700 text-white font-bold py-1 px-4 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
180
+
onClick={handlePost}
181
+
disabled={isPostButtonDisabled}
182
+
>
183
+
{posting ? "Posting..." : "Post"}
184
+
</button>
185
+
</div>
186
+
</div>
187
+
188
+
{postSuccess ? (
189
+
<div className="flex flex-col items-center justify-center py-16">
190
+
<span className="text-gray-500 text-6xl mb-4">โ</span>
191
+
<span className="text-xl font-bold text-black dark:text-white">
192
+
Posted!
193
+
</span>
194
+
</div>
195
+
) : (
196
+
<div className="px-4">
197
+
{composerState.kind === "reply" && (
198
+
<div className="mb-1 -mx-4">
199
+
{isParentLoading ? (
200
+
<div className="text-sm text-gray-500 animate-pulse">
201
+
Loading parent post...
202
+
</div>
203
+
) : parentUri ? (
204
+
<UniversalPostRendererATURILoader
205
+
atUri={parentUri}
206
+
bottomReplyLine
207
+
bottomBorder={false}
208
+
/>
209
+
) : (
210
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
211
+
Could not load parent post.
212
+
</div>
213
+
)}
214
+
</div>
215
+
)}
216
+
217
+
<div className="flex w-full gap-1 flex-col">
218
+
<ProfileThing agent={agent} large />
219
+
<div className="flex pl-[50px]">
220
+
<AutoGrowTextarea
221
+
className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2"
222
+
rows={5}
223
+
placeholder={getPlaceholder()}
224
+
value={postText}
225
+
onChange={(e) => setPostText(e.target.value)}
226
+
disabled={posting}
227
+
autoFocus
228
+
/>
229
+
</div>
230
+
</div>
231
+
232
+
{composerState.kind === "quote" && (
233
+
<div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
234
+
{isParentLoading ? (
235
+
<div className="text-sm text-gray-500 animate-pulse">
236
+
Loading parent post...
237
+
</div>
238
+
) : parentUri ? (
239
+
<UniversalPostRendererATURILoader
240
+
atUri={parentUri}
241
+
isQuote
242
+
/>
243
+
) : (
244
+
<div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3">
245
+
Could not load parent post.
246
+
</div>
247
+
)}
248
+
</div>
249
+
)}
250
+
251
+
{postError && (
252
+
<div className="text-red-500 text-sm my-2 text-center">
253
+
{postError}
254
+
</div>
255
+
)}
256
+
</div>
257
+
)}
258
+
</div>
259
+
</Dialog.Content>
260
+
</Dialog.Portal>
261
+
</Dialog.Root>
262
+
);
263
+
}
264
+
265
+
function AutoGrowTextarea({
266
+
value,
267
+
className,
268
+
onChange,
269
+
...props
270
+
}: React.DetailedHTMLProps<
271
+
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
272
+
HTMLTextAreaElement
273
+
>) {
274
+
const ref = useRef<HTMLTextAreaElement>(null);
275
+
276
+
useEffect(() => {
277
+
const el = ref.current;
278
+
if (!el) return;
279
+
el.style.height = "auto";
280
+
el.style.height = el.scrollHeight + "px";
281
+
}, [value]);
282
+
283
+
return (
284
+
<textarea
285
+
ref={ref}
286
+
className={className}
287
+
value={value}
288
+
onChange={onChange}
289
+
{...props}
290
+
/>
291
+
);
292
+
}
+33
src/components/Header.tsx
+33
src/components/Header.tsx
···
···
1
+
import { Link, useRouter } from "@tanstack/react-router";
2
+
import { useAtom } from "jotai";
3
+
4
+
import { isAtTopAtom } from "~/utils/atoms";
5
+
6
+
export function Header({
7
+
backButtonCallback,
8
+
title
9
+
}: {
10
+
backButtonCallback?: () => void;
11
+
title?: string;
12
+
}) {
13
+
const router = useRouter();
14
+
const [isAtTop] = useAtom(isAtTopAtom);
15
+
//const what = router.history.
16
+
return (
17
+
<div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}>
18
+
{backButtonCallback ? (<Link
19
+
to=".."
20
+
//className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
21
+
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
22
+
onClick={(e) => {
23
+
e.preventDefault();
24
+
backButtonCallback();
25
+
}}
26
+
aria-label="Go back"
27
+
>
28
+
<IconMaterialSymbolsArrowBack className="w-6 h-6" />
29
+
</Link>) : (<div className="w-[0px]" />)}
30
+
<span className="text-[21px] sm:text-[19px] sm:font-semibold font-roboto">{title}</span>
31
+
</div>
32
+
);
33
+
}
+150
src/components/Import.tsx
+150
src/components/Import.tsx
···
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { useNavigate, type UseNavigateResult } from "@tanstack/react-router";
3
+
import { useState } from "react";
4
+
5
+
/**
6
+
* Basically the best equivalent to Search that i can do
7
+
*/
8
+
export function Import() {
9
+
const [textInput, setTextInput] = useState<string | undefined>();
10
+
const navigate = useNavigate();
11
+
12
+
const handleEnter = () => {
13
+
if (!textInput) return;
14
+
handleImport({
15
+
text: textInput,
16
+
navigate,
17
+
});
18
+
};
19
+
20
+
return (
21
+
<div className="w-full relative">
22
+
<IconMaterialSymbolsSearch className="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" />
23
+
24
+
<input
25
+
type="text"
26
+
placeholder="Import..."
27
+
value={textInput}
28
+
onChange={(e) => setTextInput(e.target.value)}
29
+
onKeyDown={(e) => {
30
+
if (e.key === "Enter") handleEnter();
31
+
}}
32
+
className="w-full h-12 pl-12 pr-4 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-500 box-border transition"
33
+
/>
34
+
</div>
35
+
);
36
+
}
37
+
38
+
function handleImport({
39
+
text,
40
+
navigate,
41
+
}: {
42
+
text: string;
43
+
navigate: UseNavigateResult<string>;
44
+
}) {
45
+
const trimmed = text.trim();
46
+
// parse text
47
+
/**
48
+
* text might be
49
+
* 1. bsky dot app url (reddwarf link segments might be uri encoded,)
50
+
* 2. aturi
51
+
* 3. plain handle
52
+
* 4. plain did
53
+
*/
54
+
55
+
// 1. Check if itโs a URL
56
+
try {
57
+
const url = new URL(text);
58
+
const knownHosts = [
59
+
"bsky.app",
60
+
"social.daniela.lol",
61
+
"deer.social",
62
+
"reddwarf.whey.party",
63
+
"reddwarf.app",
64
+
"main.bsky.dev",
65
+
"catsky.social",
66
+
"blacksky.community",
67
+
"red-dwarf-social-app.whey.party",
68
+
"zeppelin.social",
69
+
];
70
+
if (knownHosts.includes(url.hostname)) {
71
+
// parse path to get URI or handle
72
+
const path = decodeURIComponent(url.pathname.slice(1)); // remove leading /
73
+
console.log("BSky URL path:", path);
74
+
navigate({
75
+
to: `/${path}`,
76
+
});
77
+
return;
78
+
}
79
+
} catch {
80
+
// not a URL, continue
81
+
}
82
+
83
+
// 2. Check if text looks like an at-uri
84
+
try {
85
+
if (text.startsWith("at://")) {
86
+
console.log("AT URI detected:", text);
87
+
const aturi = new AtUri(text);
88
+
switch (aturi.collection) {
89
+
case "app.bsky.feed.post": {
90
+
navigate({
91
+
to: "/profile/$did/post/$rkey",
92
+
params: {
93
+
did: aturi.host,
94
+
rkey: aturi.rkey,
95
+
},
96
+
});
97
+
return;
98
+
}
99
+
case "app.bsky.actor.profile": {
100
+
navigate({
101
+
to: "/profile/$did",
102
+
params: {
103
+
did: aturi.host,
104
+
},
105
+
});
106
+
return;
107
+
}
108
+
// todo add more handlers as more routes are added. like feeds, lists, etc etc thanks!
109
+
default: {
110
+
// continue
111
+
}
112
+
}
113
+
}
114
+
} catch {
115
+
// continue
116
+
}
117
+
118
+
// 3. Plain handle (starts with @)
119
+
try {
120
+
if (text.startsWith("@")) {
121
+
const handle = text.slice(1);
122
+
console.log("Handle detected:", handle);
123
+
navigate({ to: "/profile/$did", params: { did: handle } });
124
+
return;
125
+
}
126
+
} catch {
127
+
// continue
128
+
}
129
+
130
+
// 4. Plain DID (starts with did:)
131
+
try {
132
+
if (text.startsWith("did:")) {
133
+
console.log("did detected:", text);
134
+
navigate({ to: "/profile/$did", params: { did: text } });
135
+
return;
136
+
}
137
+
} catch {
138
+
// continue
139
+
}
140
+
141
+
// if all else fails
142
+
143
+
// try {
144
+
// // probably a user?
145
+
// navigate({ to: "/profile/$did", params: { did: text } });
146
+
// return;
147
+
// } catch {
148
+
// // continue
149
+
// }
150
+
}
+36
-9
src/components/InfiniteCustomFeed.tsx
+36
-9
src/components/InfiniteCustomFeed.tsx
···
1
import * as React from "react";
2
//import { useInView } from "react-intersection-observer";
3
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
4
import { useAuth } from "~/providers/UnifiedAuthProvider";
5
import {
6
-
useQueryArbitrary,
7
-
useQueryIdentity,
8
useInfiniteQueryFeedSkeleton,
9
} from "~/utils/useQuery";
10
11
interface InfiniteCustomFeedProps {
···
36
isFetchingNextPage,
37
refetch,
38
isRefetching,
39
} = useInfiniteQueryFeedSkeleton({
40
feedUri: feedUri,
41
agent: agent ?? undefined,
···
43
pdsUrl: pdsUrl,
44
feedServiceDid: feedServiceDid,
45
});
46
47
const handleRefresh = () => {
48
refetch();
49
};
50
51
//const { ref, inView } = useInView();
52
53
// React.useEffect(() => {
···
66
);
67
}
68
69
-
const allPosts =
70
-
data?.pages.flatMap((page) => {
71
-
if (page) return page.feed;
72
-
}) ?? [];
73
74
if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) {
75
return (
···
112
<button
113
onClick={handleRefresh}
114
disabled={isRefetching}
115
-
className="sticky lg:bottom-6 bottom-24 ml-4 w-[42px] h-[42px] z-10 bg-gray-500 hover:bg-gray-600 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:bg-gray-400 disabled:cursor-not-allowed"
116
aria-label="Refresh feed"
117
>
118
-
{isRefetching ? <RefreshIcon className="h-6 w-6 animate-spin" /> : <RefreshIcon className="h-6 w-6" />}
119
</button>
120
</>
121
);
···
138
d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"
139
></path>
140
</svg>
141
-
);
···
1
+
import { useQueryClient } from "@tanstack/react-query";
2
import * as React from "react";
3
+
4
//import { useInView } from "react-intersection-observer";
5
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
6
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
import {
8
useInfiniteQueryFeedSkeleton,
9
+
// useQueryArbitrary,
10
+
// useQueryIdentity,
11
} from "~/utils/useQuery";
12
13
interface InfiniteCustomFeedProps {
···
38
isFetchingNextPage,
39
refetch,
40
isRefetching,
41
+
queryKey,
42
} = useInfiniteQueryFeedSkeleton({
43
feedUri: feedUri,
44
agent: agent ?? undefined,
···
46
pdsUrl: pdsUrl,
47
feedServiceDid: feedServiceDid,
48
});
49
+
const queryClient = useQueryClient();
50
+
51
52
const handleRefresh = () => {
53
+
queryClient.removeQueries({queryKey: queryKey});
54
+
//queryClient.invalidateQueries(["infinite-feed", feedUri] as const);
55
refetch();
56
};
57
58
+
const allPosts = React.useMemo(() => {
59
+
const flattenedPosts = data?.pages.flatMap((page) => page?.feed) ?? [];
60
+
61
+
const seenUris = new Set<string>();
62
+
63
+
return flattenedPosts.filter((item) => {
64
+
if (!item?.post) return false;
65
+
66
+
if (seenUris.has(item.post)) {
67
+
return false;
68
+
}
69
+
70
+
seenUris.add(item.post);
71
+
72
+
return true;
73
+
});
74
+
}, [data]);
75
+
76
//const { ref, inView } = useInView();
77
78
// React.useEffect(() => {
···
91
);
92
}
93
94
+
// const allPosts =
95
+
// data?.pages.flatMap((page) => {
96
+
// if (page) return page.feed;
97
+
// }) ?? [];
98
99
if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) {
100
return (
···
137
<button
138
onClick={handleRefresh}
139
disabled={isRefetching}
140
+
className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed"
141
aria-label="Refresh feed"
142
>
143
+
<RefreshIcon
144
+
className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`}
145
+
/>
146
</button>
147
</>
148
);
···
165
d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"
166
></path>
167
</svg>
168
+
);
+188
-58
src/components/Login.tsx
+188
-58
src/components/Login.tsx
···
1
// src/components/Login.tsx
2
-
import React, { useEffect, useState, useRef } from "react";
3
import { useAuth } from "~/providers/UnifiedAuthProvider";
4
-
import { Agent } from "@atproto/api";
5
6
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
7
-
export default function Login({ compact = false }: { compact?: boolean }) {
8
const { status, agent, logout } = useAuth();
9
10
// Loading state can be styled differently based on the prop
···
14
className={
15
compact
16
? "flex items-center justify-center p-1"
17
-
: "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4 flex justify-center items-center h-[280px]"
18
}
19
>
20
<span
···
33
// Large view
34
if (!compact) {
35
return (
36
-
<div className="p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4">
37
<div className="flex flex-col items-center justify-center text-center">
38
<p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100">
39
You are logged in!
···
41
<ProfileThing agent={agent} large />
42
<button
43
onClick={logout}
44
-
className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded px-6 py-2 font-semibold text-base transition-colors"
45
>
46
Log out
47
</button>
···
67
if (!compact) {
68
// Large view renders the form directly in the card
69
return (
70
-
<div className="p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4">
71
<UnifiedLoginForm />
72
</div>
73
);
74
}
75
76
// Compact view renders a button that toggles the form in a dropdown
77
-
return <CompactLoginButton />;
78
}
79
80
// --- 2. The Reusable, Self-Contained Login Form Component ---
···
83
84
return (
85
<div>
86
-
<div className="flex border-b border-gray-200 dark:border-gray-700 mb-4">
87
<TabButton
88
label="OAuth"
89
active={mode === "oauth"}
···
103
// --- 3. Helper components for layouts, forms, and UI ---
104
105
// A new component to contain the logic for the compact dropdown
106
-
const CompactLoginButton = () => {
107
const [showForm, setShowForm] = useState(false);
108
const formRef = useRef<HTMLDivElement>(null);
109
···
125
<div className="relative" ref={formRef}>
126
<button
127
onClick={() => setShowForm(!showForm)}
128
-
className="text-sm bg-gray-600 hover:bg-gray-700 text-white rounded px-3 py-1 font-medium transition-colors"
129
>
130
Log in
131
</button>
132
{showForm && (
133
-
<div className="absolute top-full right-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50">
134
<UnifiedLoginForm />
135
</div>
136
)}
···
138
);
139
};
140
141
-
const TabButton = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void; }) => (
142
<button
143
onClick={onClick}
144
-
className={`px-4 py-2 text-sm font-medium transition-colors ${
145
active
146
-
? "text-gray-600 dark:text-gray-200 border-b-2 border-gray-500"
147
-
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
148
}`}
149
>
150
{label}
···
169
};
170
return (
171
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
172
-
<p className="text-xs text-gray-500 dark:text-gray-400">Sign in with AT. Your password is never shared.</p>
173
-
<input type="text" placeholder="handle.bsky.social" value={handle} onChange={(e) => setHandle(e.target.value)} className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" />
174
-
<button type="submit" className="bg-gray-600 hover:bg-gray-700 text-white rounded px-4 py-2 font-medium text-sm transition-colors">Log in</button>
175
</form>
176
);
177
};
···
201
202
return (
203
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
204
-
<p className="text-xs text-red-500 dark:text-red-400">Warning: Less secure. Use an App Password.</p>
205
-
<input type="text" placeholder="handle.bsky.social" value={user} onChange={(e) => setUser(e.target.value)} className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" autoComplete="username" />
206
-
<input type="password" placeholder="App Password" value={password} onChange={(e) => setPassword(e.target.value)} className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" autoComplete="current-password" />
207
-
<input type="text" placeholder="PDS (e.g., bsky.social)" value={serviceURL} onChange={(e) => setServiceURL(e.target.value)} className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" />
208
{error && <p className="text-xs text-red-500">{error}</p>}
209
-
<button type="submit" className="bg-gray-600 hover:bg-gray-700 text-white rounded px-4 py-2 font-medium text-sm transition-colors">Log in</button>
210
</form>
211
);
212
};
213
214
// --- Profile Component (now supports a `large` prop for styling) ---
215
-
export const ProfileThing = ({ agent, large = false }: { agent: Agent | null; large?: boolean }) => {
216
-
const [profile, setProfile] = useState<any>(null);
217
218
-
useEffect(() => {
219
-
const fetchUser = async () => {
220
-
const did = (agent as any)?.session?.did ?? (agent as any)?.assertDid;
221
-
if (!did) return;
222
-
try {
223
-
const res = await agent!.getProfile({ actor: did });
224
-
setProfile(res.data);
225
-
} catch (e) { console.error("Failed to fetch profile", e); }
226
-
};
227
-
if (agent) fetchUser();
228
-
}, [agent]);
229
230
-
if (!profile) {
231
-
return ( // Skeleton loader
232
-
<div className={`flex items-center gap-2.5 animate-pulse ${large ? 'mb-1' : ''}`}>
233
-
<div className={`rounded-full bg-gray-300 dark:bg-gray-700 ${large ? 'w-10 h-10' : 'w-[30px] h-[30px]'}`} />
234
-
<div className="flex flex-col gap-2">
235
-
<div className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? 'h-4 w-28' : 'h-3 w-20'}`} />
236
-
<div className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? 'h-4 w-20' : 'h-3 w-16'}`} />
237
-
</div>
238
-
</div>
239
-
);
240
-
}
241
-
242
-
return (
243
-
<div className={`flex flex-row items-center gap-2.5 ${large ? 'mb-1' : ''}`}>
244
-
<img src={profile?.avatar} alt="avatar" className={`object-cover rounded-full ${large ? 'w-10 h-10' : 'w-[30px] h-[30px]'}`} />
245
-
<div className="flex flex-col items-start text-left">
246
-
<div className={`font-medium ${large ? 'text-gray-800 dark:text-gray-100 text-md' : 'text-gray-800 dark:text-gray-100 text-sm'}`}>{profile?.displayName}</div>
247
-
<div className={` ${large ? 'text-gray-500 dark:text-gray-400 text-sm' : 'text-gray-500 dark:text-gray-400 text-xs'}`}>@{profile?.handle}</div>
248
-
</div>
249
</div>
250
-
);
251
-
};
···
1
// src/components/Login.tsx
2
+
import AtpAgent, { Agent } from "@atproto/api";
3
+
import { useAtom } from "jotai";
4
+
import React, { useEffect, useRef, useState } from "react";
5
+
6
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
+
import { imgCDNAtom } from "~/utils/atoms";
8
+
import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery";
9
10
// --- 1. The Main Component (Orchestrator with `compact` prop) ---
11
+
export default function Login({
12
+
compact = false,
13
+
popup = false,
14
+
}: {
15
+
compact?: boolean;
16
+
popup?: boolean;
17
+
}) {
18
const { status, agent, logout } = useAuth();
19
20
// Loading state can be styled differently based on the prop
···
24
className={
25
compact
26
? "flex items-center justify-center p-1"
27
+
: "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-4 mx-4 flex justify-center items-center h-[280px]"
28
}
29
>
30
<span
···
43
// Large view
44
if (!compact) {
45
return (
46
+
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4">
47
<div className="flex flex-col items-center justify-center text-center">
48
<p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100">
49
You are logged in!
···
51
<ProfileThing agent={agent} large />
52
<button
53
onClick={logout}
54
+
className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded-full px-6 py-2 font-semibold text-base transition-colors"
55
>
56
Log out
57
</button>
···
77
if (!compact) {
78
// Large view renders the form directly in the card
79
return (
80
+
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4">
81
<UnifiedLoginForm />
82
</div>
83
);
84
}
85
86
// Compact view renders a button that toggles the form in a dropdown
87
+
return <CompactLoginButton popup={popup} />;
88
}
89
90
// --- 2. The Reusable, Self-Contained Login Form Component ---
···
93
94
return (
95
<div>
96
+
<div className="flex bg-gray-300 rounded-full dark:bg-gray-700 mb-4">
97
<TabButton
98
label="OAuth"
99
active={mode === "oauth"}
···
113
// --- 3. Helper components for layouts, forms, and UI ---
114
115
// A new component to contain the logic for the compact dropdown
116
+
const CompactLoginButton = ({ popup }: { popup?: boolean }) => {
117
const [showForm, setShowForm] = useState(false);
118
const formRef = useRef<HTMLDivElement>(null);
119
···
135
<div className="relative" ref={formRef}>
136
<button
137
onClick={() => setShowForm(!showForm)}
138
+
className="text-sm bg-gray-600 hover:bg-gray-700 text-white rounded-full px-3 py-1 font-medium transition-colors"
139
>
140
Log in
141
</button>
142
{showForm && (
143
+
<div
144
+
className={`absolute ${popup ? `bottom-[calc(100%)]` : `top-full`} right-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50`}
145
+
>
146
<UnifiedLoginForm />
147
</div>
148
)}
···
150
);
151
};
152
153
+
const TabButton = ({
154
+
label,
155
+
active,
156
+
onClick,
157
+
}: {
158
+
label: string;
159
+
active: boolean;
160
+
onClick: () => void;
161
+
}) => (
162
<button
163
onClick={onClick}
164
+
className={`px-4 py-2 text-sm font-medium transition-colors rounded-full flex-1 ${
165
active
166
+
? "text-gray-50 dark:text-gray-200 border-gray-500 bg-gray-400 dark:bg-gray-500"
167
+
: "text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200"
168
}`}
169
>
170
{label}
···
189
};
190
return (
191
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
192
+
<p className="text-xs text-gray-500 dark:text-gray-400">
193
+
Sign in with AT. Your password is never shared.
194
+
</p>
195
+
{/* <input
196
+
type="text"
197
+
placeholder="handle.bsky.social"
198
+
value={handle}
199
+
onChange={(e) => setHandle(e.target.value)}
200
+
className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
201
+
/> */}
202
+
<div className="flex flex-col gap-3">
203
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
204
+
<input
205
+
type="text"
206
+
placeholder=" "
207
+
value={handle}
208
+
onChange={(e) => setHandle(e.target.value)}
209
+
/>
210
+
<label>AT Handle</label>
211
+
</div>
212
+
<button
213
+
type="submit"
214
+
className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors"
215
+
>
216
+
Log in
217
+
</button>
218
+
</div>
219
</form>
220
);
221
};
···
245
246
return (
247
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
248
+
<p className="text-xs text-red-500 dark:text-red-400">
249
+
Warning: Less secure. Use an App Password.
250
+
</p>
251
+
{/* <input
252
+
type="text"
253
+
placeholder="handle.bsky.social"
254
+
value={user}
255
+
onChange={(e) => setUser(e.target.value)}
256
+
className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
257
+
autoComplete="username"
258
+
/>
259
+
<input
260
+
type="password"
261
+
placeholder="App Password"
262
+
value={password}
263
+
onChange={(e) => setPassword(e.target.value)}
264
+
className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
265
+
autoComplete="current-password"
266
+
/>
267
+
<input
268
+
type="text"
269
+
placeholder="PDS (e.g., bsky.social)"
270
+
value={serviceURL}
271
+
onChange={(e) => setServiceURL(e.target.value)}
272
+
className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
273
+
/> */}
274
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
275
+
<input
276
+
type="text"
277
+
placeholder=" "
278
+
value={user}
279
+
onChange={(e) => setUser(e.target.value)}
280
+
/>
281
+
<label>AT Handle</label>
282
+
</div>
283
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
284
+
<input
285
+
type="text"
286
+
placeholder=" "
287
+
value={password}
288
+
onChange={(e) => setPassword(e.target.value)}
289
+
/>
290
+
<label>App Password</label>
291
+
</div>
292
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
293
+
<input
294
+
type="text"
295
+
placeholder=" "
296
+
value={serviceURL}
297
+
onChange={(e) => setServiceURL(e.target.value)}
298
+
/>
299
+
<label>PDS</label>
300
+
</div>
301
{error && <p className="text-xs text-red-500">{error}</p>}
302
+
<button
303
+
type="submit"
304
+
className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors"
305
+
>
306
+
Log in
307
+
</button>
308
</form>
309
);
310
};
311
312
// --- Profile Component (now supports a `large` prop for styling) ---
313
+
export const ProfileThing = ({
314
+
agent,
315
+
large = false,
316
+
}: {
317
+
agent: Agent | null;
318
+
large?: boolean;
319
+
}) => {
320
+
const did = ((agent as AtpAgent)?.session?.did ??
321
+
(agent as AtpAgent)?.assertDid ??
322
+
agent?.did) as string | undefined;
323
+
const { data: identity } = useQueryIdentity(did);
324
+
const { data: profiledata } = useQueryProfile(
325
+
`at://${did}/app.bsky.actor.profile/self`
326
+
);
327
+
const profile = profiledata?.value;
328
329
+
const [imgcdn] = useAtom(imgCDNAtom)
330
+
331
+
function getAvatarUrl(p: typeof profile) {
332
+
const link = p?.avatar?.ref?.["$link"];
333
+
if (!link || !did) return null;
334
+
return `https://${imgcdn}/img/avatar/plain/${did}/${link}@jpeg`;
335
+
}
336
+
337
+
if (!profiledata) {
338
+
return (
339
+
// Skeleton loader
340
+
<div
341
+
className={`flex items-center gap-2.5 animate-pulse ${large ? "mb-1" : ""}`}
342
+
>
343
+
<div
344
+
className={`rounded-full bg-gray-300 dark:bg-gray-700 ${large ? "w-10 h-10" : "w-[30px] h-[30px]"}`}
345
+
/>
346
+
<div className="flex flex-col gap-2">
347
+
<div
348
+
className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? "h-4 w-28" : "h-3 w-20"}`}
349
+
/>
350
+
<div
351
+
className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? "h-4 w-20" : "h-3 w-16"}`}
352
+
/>
353
+
</div>
354
+
</div>
355
+
);
356
+
}
357
358
+
return (
359
+
<div
360
+
className={`flex flex-row items-center gap-2.5 ${large ? "mb-1" : ""}`}
361
+
>
362
+
<img
363
+
src={getAvatarUrl(profile) ?? undefined}
364
+
alt="avatar"
365
+
className={`object-cover rounded-full ${large ? "w-10 h-10" : "w-[30px] h-[30px]"}`}
366
+
/>
367
+
<div className="flex flex-col items-start text-left">
368
+
<div
369
+
className={`font-medium ${large ? "text-gray-800 dark:text-gray-100 text-md" : "text-gray-800 dark:text-gray-100 text-sm"}`}
370
+
>
371
+
{profile?.displayName}
372
+
</div>
373
+
<div
374
+
className={` ${large ? "text-gray-500 dark:text-gray-400 text-sm" : "text-gray-500 dark:text-gray-400 text-xs"}`}
375
+
>
376
+
@{identity?.handle}
377
</div>
378
+
</div>
379
+
</div>
380
+
);
381
+
};
+6
src/components/Star.tsx
+6
src/components/Star.tsx
···
···
1
+
import type { SVGProps } from 'react';
2
+
import React from 'react';
3
+
4
+
export function FluentEmojiHighContrastGlowingStar(props: SVGProps<SVGSVGElement>) {
5
+
return (<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} viewBox="0 0 32 32" {...props}><g fill="currentColor"><path d="m28.979 17.003l-3.108.214c-.834.06-1.178 1.079-.542 1.608l2.388 1.955c.521.428 1.314.204 1.523-.428l.709-2.127c.219-.632-.292-1.273-.97-1.222M21.75 2.691l-.72 2.9c-.2.78.66 1.41 1.34.98l2.54-1.58c.55-.34.58-1.14.05-1.52l-1.78-1.29a.912.912 0 0 0-1.43.51M6.43 4.995l2.53 1.58c.68.43 1.54-.19 1.35-.98l-.72-2.9a.92.92 0 0 0-1.43-.52l-1.78 1.29c-.53.4-.5 1.19.05 1.53M4.185 20.713l2.29-1.92c.62-.52.29-1.53-.51-1.58l-2.98-.21a.92.92 0 0 0-.94 1.2l.68 2.09c.2.62.97.84 1.46.42m13.61 7.292l-1.12-2.77c-.3-.75-1.36-.75-1.66 0l-1.12 2.77c-.24.6.2 1.26.85 1.26h2.2a.92.92 0 0 0 .85-1.26"></path><path d="m17.565 3.324l1.726 3.72c.326.694.967 1.18 1.717 1.29l4.056.624c1.835.278 2.575 2.53 1.293 3.859L23.268 16a2.28 2.28 0 0 0-.612 1.964l.71 4.374c.307 1.885-1.687 3.293-3.354 2.37l-3.405-1.894a2.25 2.25 0 0 0-2.21 0l-3.404 1.895c-1.668.922-3.661-.486-3.355-2.37l.71-4.375A2.28 2.28 0 0 0 7.736 16l-3.088-3.184c-1.293-1.34-.543-3.581 1.293-3.859l4.055-.625a2.3 2.3 0 0 0 1.717-1.29l1.727-3.719c.819-1.765 3.306-1.765 4.124 0"></path></g></svg>);
6
+
}
+456
-225
src/components/UniversalPostRenderer.tsx
+456
-225
src/components/UniversalPostRenderer.tsx
···
1
import { useNavigate } from "@tanstack/react-router";
2
import DOMPurify from "dompurify";
3
import { useAtom } from "jotai";
4
import * as React from "react";
5
import { type SVGProps } from "react";
6
-
import { createPortal } from "react-dom";
7
8
-
import { ProfilePostComponent } from "~/routes/profile.$did/post.$rkey";
9
-
import { likedPostsAtom } from "~/utils/atoms";
10
import { useHydratedEmbed } from "~/utils/useHydrated";
11
import {
12
useQueryConstellation,
13
useQueryIdentity,
14
useQueryPost,
15
useQueryProfile,
16
} from "~/utils/useQuery";
17
18
function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
···
35
ref?: React.Ref<HTMLDivElement>;
36
dataIndexPropPass?: number;
37
nopics?: boolean;
38
}
39
40
// export async function cachedGetRecord({
···
143
ref,
144
dataIndexPropPass,
145
nopics,
146
}: UniversalPostRendererATURILoaderProps) {
147
// /*mass comment*/ console.log("atUri", atUri);
148
//const { get, set } = usePersistentStore();
149
//const [record, setRecord] = React.useState<any>(null);
···
388
);
389
}, [links]);
390
391
// const navigateToProfile = (e: React.MouseEvent) => {
392
// e.stopPropagation();
393
// if (resolved?.did) {
···
403
}
404
405
return (
406
-
<UniversalPostRendererRawRecordShim
407
-
detailed={detailed}
408
-
postRecord={postQuery}
409
-
profileRecord={opProfile}
410
-
aturi={atUri}
411
-
resolved={resolved}
412
-
likesCount={likes}
413
-
repostsCount={reposts}
414
-
repliesCount={replies}
415
-
bottomReplyLine={bottomReplyLine}
416
-
topReplyLine={topReplyLine}
417
-
bottomBorder={bottomBorder}
418
-
feedviewpost={feedviewpost}
419
-
repostedby={repostedby}
420
-
style={style}
421
-
ref={ref}
422
-
dataIndexPropPass={dataIndexPropPass}
423
-
nopics={nopics}
424
-
/>
425
);
426
}
427
428
-
function getAvatarUrl(opProfile: any, did: string) {
429
const link = opProfile?.value?.avatar?.ref?.["$link"];
430
if (!link) return null;
431
-
return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`;
432
}
433
434
export function UniversalPostRendererRawRecordShim({
···
449
ref,
450
dataIndexPropPass,
451
nopics,
452
}: {
453
postRecord: any;
454
profileRecord: any;
···
467
ref?: React.Ref<HTMLDivElement>;
468
dataIndexPropPass?: number;
469
nopics?: boolean;
470
}) {
471
// /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`);
472
const navigate = useNavigate();
···
543
error: embedError,
544
} = useHydratedEmbed(postRecord?.value?.embed, resolved?.did);
545
546
const parsedaturi = new AtUri(aturi); //parseAtUri(aturi);
547
548
const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(
549
() => ({
550
$type: "app.bsky.feed.defs#postView",
551
uri: aturi,
552
cid: postRecord?.cid || "",
553
-
author: {
554
-
did: resolved?.did || "",
555
-
handle: resolved?.handle || "",
556
-
displayName: profileRecord?.value?.displayName || "",
557
-
avatar: getAvatarUrl(profileRecord, resolved?.did) || "",
558
-
viewer: undefined,
559
-
labels: profileRecord?.labels || undefined,
560
-
verification: undefined,
561
-
},
562
record: postRecord?.value || {},
563
embed: hydratedEmbed ?? undefined,
564
replyCount: repliesCount ?? 0,
···
575
postRecord?.cid,
576
postRecord?.value,
577
postRecord?.labels,
578
-
resolved?.did,
579
-
resolved?.handle,
580
-
profileRecord,
581
hydratedEmbed,
582
repliesCount,
583
repostsCount,
···
654
}
655
}}
656
post={fakepost}
657
salt={aturi}
658
bottomReplyLine={bottomReplyLine}
659
topReplyLine={topReplyLine}
···
665
ref={ref}
666
dataIndexPropPass={dataIndexPropPass}
667
nopics={nopics}
668
/>
669
</>
670
);
···
703
{...props}
704
>
705
<path
706
-
fill="oklch(0.704 0.05 28)"
707
d="M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12z"
708
></path>
709
</svg>
···
720
{...props}
721
>
722
<path
723
-
fill="oklch(0.704 0.05 28)"
724
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
725
></path>
726
</svg>
···
771
{...props}
772
>
773
<path
774
-
fill="oklch(0.704 0.05 28)"
775
d="m12.1 18.55l-.1.1l-.11-.1C7.14 14.24 4 11.39 4 8.5C4 6.5 5.5 5 7.5 5c1.54 0 3.04 1 3.57 2.36h1.86C13.46 6 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5c0 2.89-3.14 5.74-7.9 10.05M16.5 3c-1.74 0-3.41.81-4.5 2.08C10.91 3.81 9.24 3 7.5 3C4.42 3 2 5.41 2 8.5c0 3.77 3.4 6.86 8.55 11.53L12 21.35l1.45-1.32C18.6 15.36 22 12.27 22 8.5C22 5.41 19.58 3 16.5 3"
776
></path>
777
</svg>
···
788
{...props}
789
>
790
<path
791
-
fill="oklch(0.704 0.05 28)"
792
d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3a3 3 0 0 0-3-3a3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08"
793
></path>
794
</svg>
···
805
{...props}
806
>
807
<path
808
-
fill="oklch(0.704 0.05 28)"
809
d="M16 12a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"
810
></path>
811
</svg>
···
822
{...props}
823
>
824
<path
825
-
fill="oklch(0.704 0.05 28)"
826
d="M17.9 17.39c-.26-.8-1.01-1.39-1.9-1.39h-1v-3a1 1 0 0 0-1-1H8v-2h2a1 1 0 0 0 1-1V7h2a2 2 0 0 0 2-2v-.41a7.984 7.984 0 0 1 2.9 12.8M11 19.93c-3.95-.49-7-3.85-7-7.93c0-.62.08-1.22.21-1.79L9 15v1a2 2 0 0 0 2 2m1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"
827
></path>
828
</svg>
···
856
{...props}
857
>
858
<path
859
-
fill="oklch(0.704 0.05 28)"
860
d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11"
861
></path>
862
</svg>
···
910
{...props}
911
>
912
<path
913
-
fill="oklch(0.704 0.05 28)"
914
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
915
></path>
916
</svg>
···
927
{...props}
928
>
929
<path
930
-
fill="oklch(0.704 0.05 28)"
931
d="M6 5.75L10.25 10H7v6h6.5l2 2H7a2 2 0 0 1-2-2v-6H1.75zm12 12.5L13.75 14H17V8h-6.5l-2-2H17a2 2 0 0 1 2 2v6h3.25z"
932
></path>
933
</svg>
···
955
//import Masonry from "@mui/lab/Masonry";
956
import {
957
type $Typed,
958
AppBskyEmbedDefs,
959
AppBskyEmbedExternal,
960
AppBskyEmbedImages,
···
978
PostView,
979
//ThreadViewPost,
980
} from "@atproto/api/dist/client/types/app/bsky/feed/defs";
981
import { useEffect, useRef, useState } from "react";
982
import ReactPlayer from "react-player";
983
984
import defaultpfp from "~/../public/favicon.png";
985
import { useAuth } from "~/providers/UnifiedAuthProvider";
986
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
987
// import type {
988
// ViewRecord,
···
1090
1091
function UniversalPostRenderer({
1092
post,
1093
//setMainItem,
1094
//isMainItem,
1095
onPostClick,
···
1110
ref,
1111
dataIndexPropPass,
1112
nopics,
1113
}: {
1114
post: PostView;
1115
// optional for now because i havent ported every use to this yet
1116
// setMainItem?: React.Dispatch<
1117
// React.SetStateAction<AppBskyFeedDefs.FeedViewPost>
···
1133
ref?: React.Ref<HTMLDivElement>;
1134
dataIndexPropPass?: number;
1135
nopics?: boolean;
1136
}) {
1137
const parsed = new AtUri(post.uri);
1138
const navigate = useNavigate();
···
1143
const [hasLiked, setHasLiked] = useState<boolean>(
1144
post.uri in likedPosts || post.viewer?.like ? true : false
1145
);
1146
const { agent } = useAuth();
1147
const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like);
1148
const [retweetUri, setRetweetUri] = useState<string | undefined>(
···
1239
paddingLeft: isQuote ? 12 : 16,
1240
paddingRight: isQuote ? 12 : 16,
1241
//paddingTop: 16,
1242
-
paddingTop: isRepost ? 10 : isQuote ? 12 : 16,
1243
//paddingBottom: bottomReplyLine ? 0 : 16,
1244
paddingBottom: 0,
1245
fontFamily: "system-ui, sans-serif",
···
1248
// dont cursor: "pointer",
1249
borderBottomWidth: bottomBorder ? (isQuote ? 0 : 1) : 0,
1250
}}
1251
-
className="border-gray-300 dark:border-gray-600"
1252
>
1253
{isRepost && (
1254
<div
···
1280
//left: 16 + (42 / 2),
1281
width: 2,
1282
//height: "100%",
1283
-
height: isRepost ? "calc(16px + 1rem - 6px)" : 16 - 6,
1284
// background: theme.textSecondary,
1285
//opacity: 0.5,
1286
// no flex here
···
1288
className="bg-gray-500 dark:bg-gray-400"
1289
/>
1290
)}
1291
-
<div
1292
-
style={{
1293
-
position: "absolute",
1294
-
//top: isRepost ? "calc(16px + 1rem)" : 16,
1295
-
//left: 16,
1296
-
zIndex: 1,
1297
-
top: isRepost ? "calc(16px + 1rem)" : isQuote ? 12 : 16,
1298
-
left: isQuote ? 12 : 16,
1299
-
}}
1300
-
onClick={onProfileClick}
1301
-
>
1302
-
<img
1303
-
src={post.author.avatar || defaultpfp}
1304
-
alt="avatar"
1305
-
// transition={{
1306
-
// type: "spring",
1307
-
// stiffness: 260,
1308
-
// damping: 20,
1309
-
// }}
1310
-
style={{
1311
-
borderRadius: "50%",
1312
-
marginRight: 12,
1313
-
objectFit: "cover",
1314
-
//background: theme.border,
1315
-
//border: `1px solid ${theme.border}`,
1316
-
width: isQuote ? 16 : 42,
1317
-
height: isQuote ? 16 : 42,
1318
-
}}
1319
-
className="border border-gray-300 dark:border-gray-600 bg-gray-300 dark:bg-gray-600"
1320
-
/>
1321
-
</div>
1322
<div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}>
1323
<div
1324
style={{
···
1332
}}
1333
>
1334
{/* dummy for later use */}
1335
-
<div style={{ width: 42, height: 42 + 8, minHeight: 42 + 8 }} />
1336
{/* reply line !!!! bottomReplyLine */}
1337
{bottomReplyLine && (
1338
<div
···
1489
>
1490
{fedi ? (
1491
<>
1492
-
<span className="dangerousFediContent"
1493
dangerouslySetInnerHTML={{
1494
__html: DOMPurify.sanitize(fedi),
1495
}}
···
1514
navigate={navigate}
1515
postid={{ did: post.author.did, rkey: parsed.rkey }}
1516
nopics={nopics}
1517
/>
1518
) : null}
1519
{post.embed && depth > 0 && (
···
1521
hydrate embeds this deep but the connection here is implicit
1522
todo: idk make this a real part of the embed shim so its not implicit */
1523
<>
1524
-
<div className="border-gray-300 dark:border-gray-600 p-3 rounded-xl border italic text-gray-400 text-[14px]">
1525
(there is an embed here thats too deep to render)
1526
</div>
1527
</>
···
1544
borderBottomWidth: 1,
1545
marginBottom: 8,
1546
}} // important for height animation
1547
-
className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700"
1548
>
1549
{fullDateTimeFormat(post.indexedAt)}
1550
</div>
···
1563
}}
1564
className="text-gray-500 dark:text-gray-400"
1565
>
1566
-
<span style={btnstyle}>
1567
-
<MdiCommentOutline />
1568
-
{post.replyCount}
1569
-
</span>
1570
<HitSlopButton
1571
onClick={() => {
1572
-
repostOrUnrepostPost();
1573
}}
1574
style={{
1575
...btnstyle,
1576
-
...(hasRetweeted ? { color: "#5CEFAA" } : {}),
1577
}}
1578
>
1579
-
{hasRetweeted ? <MdiRepeatGreen /> : <MdiRepeat />}
1580
-
{(post.repostCount || 0) + (hasRetweeted ? 1 : 0)}
1581
</HitSlopButton>
1582
<HitSlopButton
1583
onClick={() => {
1584
likeOrUnlikePost();
···
1720
navigate,
1721
postid,
1722
nopics,
1723
}: {
1724
embed?: Embed;
1725
moderation?: ModerationDecision;
···
1730
navigate: (_: any) => void;
1731
postid?: { did: string; rkey: string };
1732
nopics?: boolean;
1733
}) {
1734
-
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
1735
if (
1736
AppBskyEmbedRecordWithMedia.isView(embed) &&
1737
AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
···
1767
navigate={navigate}
1768
postid={postid}
1769
nopics={nopics}
1770
/>
1771
{/* padding empty div of 8px height */}
1772
<div style={{ height: 12 }} />
···
1780
//boxShadow: theme.cardShadow,
1781
overflow: "hidden",
1782
}}
1783
-
className="shadow border border-gray-200 dark:border-gray-700"
1784
>
1785
<UniversalPostRenderer
1786
post={post}
···
1897
//boxShadow: theme.cardShadow,
1898
overflow: "hidden",
1899
}}
1900
-
className="shadow border border-gray-200 dark:border-gray-700"
1901
>
1902
<UniversalPostRenderer
1903
post={post}
···
1934
1935
// image embed
1936
// =
1937
-
if (AppBskyEmbedImages.isView(embed) && !nopics) {
1938
const { images } = embed;
1939
1940
const lightboxImages = images.map((img) => ({
1941
src: img.fullsize,
1942
alt: img.alt,
1943
}));
1944
1945
if (images.length > 0) {
1946
// const items = embed.images.map(img => ({
···
1970
//border: `1px solid ${theme.border}`,
1971
overflow: "hidden",
1972
}}
1973
-
className="border border-gray-200 dark:border-gray-700 bg-gray-200 dark:bg-gray-900"
1974
>
1975
-
{lightboxIndex !== null && (
1976
<Lightbox
1977
images={lightboxImages}
1978
index={lightboxIndex}
···
1980
onNavigate={(newIndex) => setLightboxIndex(newIndex)}
1981
post={postid}
1982
/>
1983
-
)}
1984
<img
1985
src={image.fullsize}
1986
alt={image.alt}
···
2011
overflow: "hidden",
2012
//border: `1px solid ${theme.border}`,
2013
}}
2014
-
className="border border-gray-200 dark:border-gray-700"
2015
>
2016
-
{lightboxIndex !== null && (
2017
<Lightbox
2018
images={lightboxImages}
2019
index={lightboxIndex}
···
2021
onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2022
post={postid}
2023
/>
2024
-
)}
2025
{images.map((img, i) => (
2026
<div
2027
key={i}
···
2061
//border: `1px solid ${theme.border}`,
2062
// height: 240, // fixed height for cropping
2063
}}
2064
-
className="border border-gray-200 dark:border-gray-700"
2065
>
2066
-
{lightboxIndex !== null && (
2067
<Lightbox
2068
images={lightboxImages}
2069
index={lightboxIndex}
···
2071
onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2072
post={postid}
2073
/>
2074
-
)}
2075
{/* Left: 1:1 */}
2076
<div
2077
style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }}
···
2146
//border: `1px solid ${theme.border}`,
2147
//aspectRatio: "3 / 2", // overall grid aspect
2148
}}
2149
-
className="border border-gray-200 dark:border-gray-700"
2150
>
2151
-
{lightboxIndex !== null && (
2152
<Lightbox
2153
images={lightboxImages}
2154
index={lightboxIndex}
···
2156
onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2157
post={postid}
2158
/>
2159
-
)}
2160
{images.map((img, i) => (
2161
<div
2162
key={i}
···
2247
return <div />;
2248
}
2249
2250
-
type LightboxProps = {
2251
-
images: { src: string; alt?: string }[];
2252
-
index: number;
2253
-
onClose: () => void;
2254
-
onNavigate?: (newIndex: number) => void;
2255
-
post?: { did: string; rkey: string };
2256
-
};
2257
-
export function Lightbox({
2258
-
images,
2259
-
index,
2260
-
onClose,
2261
-
onNavigate,
2262
-
post,
2263
-
}: LightboxProps) {
2264
-
const image = images[index];
2265
-
2266
-
useEffect(() => {
2267
-
function handleKey(e: KeyboardEvent) {
2268
-
if (e.key === "Escape") onClose();
2269
-
if (e.key === "ArrowRight" && onNavigate)
2270
-
onNavigate((index + 1) % images.length);
2271
-
if (e.key === "ArrowLeft" && onNavigate)
2272
-
onNavigate((index - 1 + images.length) % images.length);
2273
-
}
2274
-
window.addEventListener("keydown", handleKey);
2275
-
return () => window.removeEventListener("keydown", handleKey);
2276
-
}, [index, images.length, onClose, onNavigate]);
2277
-
2278
-
return createPortal(
2279
-
<>
2280
-
{post && (
2281
-
<div
2282
-
onClick={(e) => {
2283
-
e.stopPropagation();
2284
-
e.nativeEvent.stopImmediatePropagation();
2285
-
}}
2286
-
className="lightbox-sidebar overscroll-none disablegutter border-l dark:border-gray-700 border-gray-300 fixed z-50 flex top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white"
2287
-
>
2288
-
<ProfilePostComponent
2289
-
did={post.did}
2290
-
rkey={post.rkey}
2291
-
nopics={onClose}
2292
-
/>
2293
-
</div>
2294
-
)}
2295
-
<div
2296
-
className="lightbox fixed inset-0 z-50 flex items-center justify-center bg-black/80 w-screen lg:w-[calc(100vw-350px)] lg:max-w-[calc(100vw-350px)]"
2297
-
onClick={(e) => {
2298
-
e.stopPropagation();
2299
-
onClose();
2300
-
}}
2301
-
>
2302
-
<img
2303
-
src={image.src}
2304
-
alt={image.alt}
2305
-
className="max-h-[90%] max-w-[90%] object-contain rounded-lg shadow-lg"
2306
-
onClick={(e) => e.stopPropagation()}
2307
-
/>
2308
-
2309
-
{images.length > 1 && (
2310
-
<>
2311
-
<button
2312
-
onClick={(e) => {
2313
-
e.stopPropagation();
2314
-
onNavigate?.((index - 1 + images.length) % images.length);
2315
-
}}
2316
-
className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl h-8 w-8 rounded-full bg-gray-900 flex items-center justify-center"
2317
-
>
2318
-
<svg
2319
-
xmlns="http://www.w3.org/2000/svg"
2320
-
width={28}
2321
-
height={28}
2322
-
viewBox="0 0 24 24"
2323
-
>
2324
-
<g fill="none" fillRule="evenodd">
2325
-
<path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"></path>
2326
-
<path
2327
-
fill="currentColor"
2328
-
d="M8.293 12.707a1 1 0 0 1 0-1.414l5.657-5.657a1 1 0 1 1 1.414 1.414L10.414 12l4.95 4.95a1 1 0 0 1-1.414 1.414z"
2329
-
></path>
2330
-
</g>
2331
-
</svg>
2332
-
</button>
2333
-
<button
2334
-
onClick={(e) => {
2335
-
e.stopPropagation();
2336
-
onNavigate?.((index + 1) % images.length);
2337
-
}}
2338
-
className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl h-8 w-8 rounded-full bg-gray-900 flex items-center justify-center"
2339
-
>
2340
-
<svg
2341
-
xmlns="http://www.w3.org/2000/svg"
2342
-
width={28}
2343
-
height={28}
2344
-
viewBox="0 0 24 24"
2345
-
>
2346
-
<g fill="none" fillRule="evenodd">
2347
-
<path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"></path>
2348
-
<path
2349
-
fill="currentColor"
2350
-
d="M15.707 11.293a1 1 0 0 1 0 1.414l-5.657 5.657a1 1 0 1 1-1.414-1.414l4.95-4.95l-4.95-4.95a1 1 0 0 1 1.414-1.414z"
2351
-
></path>
2352
-
</g>
2353
-
</svg>
2354
-
</button>
2355
-
</>
2356
-
)}
2357
-
</div>
2358
-
</>,
2359
-
document.body
2360
-
);
2361
-
}
2362
-
2363
function getDomain(url: string) {
2364
try {
2365
const { hostname } = new URL(url);
···
2424
return { start, end, feature: f.features[0] };
2425
});
2426
}
2427
-
function renderTextWithFacets({
2428
text,
2429
facets,
2430
navigate,
···
2587
>
2588
<div
2589
style={containerStyle as React.CSSProperties}
2590
-
className="border border-gray-200 dark:border-gray-700"
2591
>
2592
{thumb && (
2593
<div
···
2601
marginBottom: 8,
2602
//borderBottom: `1px solid ${theme.border}`,
2603
}}
2604
-
className="border-b border-gray-200 dark:border-gray-700"
2605
>
2606
<img
2607
src={thumb}
···
2727
borderRadius: 12,
2728
//border: `1px solid ${theme.border}`,
2729
}}
2730
-
className="border border-gray-200 dark:border-gray-700"
2731
onClick={async (e) => {
2732
e.stopPropagation();
2733
setPlaying(true);
···
2768
100 / (aspect ? aspect.width / aspect.height : 16 / 9)
2769
}%`, // 16:9 = 56.25%, 4:3 = 75%
2770
}}
2771
-
className="border border-gray-200 dark:border-gray-700"
2772
>
2773
<ReactPlayer
2774
src={url}
···
1
import { useNavigate } from "@tanstack/react-router";
2
import DOMPurify from "dompurify";
3
import { useAtom } from "jotai";
4
+
import { DropdownMenu } from "radix-ui";
5
+
import { HoverCard } from "radix-ui";
6
import * as React from "react";
7
import { type SVGProps } from "react";
8
9
+
import {
10
+
composerAtom,
11
+
constellationURLAtom,
12
+
imgCDNAtom,
13
+
likedPostsAtom,
14
+
} from "~/utils/atoms";
15
import { useHydratedEmbed } from "~/utils/useHydrated";
16
import {
17
useQueryConstellation,
18
useQueryIdentity,
19
useQueryPost,
20
useQueryProfile,
21
+
yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks,
22
} from "~/utils/useQuery";
23
24
function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
···
41
ref?: React.Ref<HTMLDivElement>;
42
dataIndexPropPass?: number;
43
nopics?: boolean;
44
+
lightboxCallback?: (d: LightboxProps) => void;
45
+
maxReplies?: number;
46
+
isQuote?: boolean;
47
}
48
49
// export async function cachedGetRecord({
···
152
ref,
153
dataIndexPropPass,
154
nopics,
155
+
lightboxCallback,
156
+
maxReplies,
157
+
isQuote,
158
}: UniversalPostRendererATURILoaderProps) {
159
+
// todo remove this once tree rendering is implemented, use a prop like isTree
160
+
const TEMPLINEAR = true;
161
// /*mass comment*/ console.log("atUri", atUri);
162
//const { get, set } = usePersistentStore();
163
//const [record, setRecord] = React.useState<any>(null);
···
402
);
403
}, [links]);
404
405
+
// const { data: repliesData } = useQueryConstellation({
406
+
// method: "/links",
407
+
// target: atUri,
408
+
// collection: "app.bsky.feed.post",
409
+
// path: ".reply.parent.uri",
410
+
// });
411
+
412
+
const [constellationurl] = useAtom(constellationURLAtom);
413
+
414
+
const infinitequeryresults = useInfiniteQuery({
415
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
416
+
{
417
+
constellation: constellationurl,
418
+
method: "/links",
419
+
target: atUri,
420
+
collection: "app.bsky.feed.post",
421
+
path: ".reply.parent.uri",
422
+
}
423
+
),
424
+
enabled: !!atUri && !!maxReplies && !isQuote,
425
+
});
426
+
427
+
const {
428
+
data: repliesData,
429
+
// fetchNextPage,
430
+
// hasNextPage,
431
+
// isFetchingNextPage,
432
+
} = infinitequeryresults;
433
+
434
+
// auto-fetch all pages
435
+
useEffect(() => {
436
+
if (!maxReplies || isQuote || TEMPLINEAR) return;
437
+
if (
438
+
infinitequeryresults.hasNextPage &&
439
+
!infinitequeryresults.isFetchingNextPage
440
+
) {
441
+
console.log("Fetching the next page...");
442
+
infinitequeryresults.fetchNextPage();
443
+
}
444
+
}, [TEMPLINEAR, infinitequeryresults, isQuote, maxReplies]);
445
+
446
+
const replyAturis = repliesData
447
+
? repliesData.pages.flatMap((page) =>
448
+
page
449
+
? page.linking_records.map((record) => {
450
+
const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
451
+
return aturi;
452
+
})
453
+
: []
454
+
)
455
+
: [];
456
+
457
+
//const [oldestOpsReply, setOldestOpsReply] = useState<string | undefined>(undefined);
458
+
459
+
const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => {
460
+
if (isQuote || !replyAturis || replyAturis.length === 0 || !maxReplies)
461
+
return {
462
+
oldestOpsReply: undefined,
463
+
oldestOpsReplyElseNewestNonOpsReply: undefined,
464
+
};
465
+
466
+
const opdid = new AtUri(
467
+
//postQuery?.value.reply?.root.uri ?? postQuery?.uri ?? atUri
468
+
atUri
469
+
).host;
470
+
471
+
const opReplies = replyAturis.filter(
472
+
(aturi) => new AtUri(aturi).host === opdid
473
+
);
474
+
475
+
if (opReplies.length > 0) {
476
+
const opreply = opReplies[opReplies.length - 1];
477
+
//setOldestOpsReply(opreply);
478
+
return {
479
+
oldestOpsReply: opreply,
480
+
oldestOpsReplyElseNewestNonOpsReply: opreply,
481
+
};
482
+
} else {
483
+
return {
484
+
oldestOpsReply: undefined,
485
+
oldestOpsReplyElseNewestNonOpsReply: replyAturis[0],
486
+
};
487
+
}
488
+
})();
489
+
490
// const navigateToProfile = (e: React.MouseEvent) => {
491
// e.stopPropagation();
492
// if (resolved?.did) {
···
502
}
503
504
return (
505
+
<>
506
+
{/* <span>uprrs {maxReplies} {!!maxReplies&&!!oldestOpsReplyElseNewestNonOpsReply ? "true" : "false"}</span> */}
507
+
<UniversalPostRendererRawRecordShim
508
+
detailed={detailed}
509
+
postRecord={postQuery}
510
+
profileRecord={opProfile}
511
+
aturi={atUri}
512
+
resolved={resolved}
513
+
likesCount={likes}
514
+
repostsCount={reposts}
515
+
repliesCount={replies}
516
+
bottomReplyLine={
517
+
maxReplies && oldestOpsReplyElseNewestNonOpsReply
518
+
? true
519
+
: maxReplies && !oldestOpsReplyElseNewestNonOpsReply
520
+
? false
521
+
: (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine
522
+
}
523
+
topReplyLine={topReplyLine}
524
+
//bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
525
+
bottomBorder={
526
+
maxReplies && oldestOpsReplyElseNewestNonOpsReply
527
+
? false
528
+
: maxReplies === 0
529
+
? false
530
+
: bottomBorder
531
+
}
532
+
feedviewpost={feedviewpost}
533
+
repostedby={repostedby}
534
+
//style={{...style, background: oldestOpsReply === atUri ? "Red" : undefined}}
535
+
style={style}
536
+
ref={ref}
537
+
dataIndexPropPass={dataIndexPropPass}
538
+
nopics={nopics}
539
+
lightboxCallback={lightboxCallback}
540
+
maxReplies={maxReplies}
541
+
isQuote={isQuote}
542
+
/>
543
+
<>
544
+
{(maxReplies && maxReplies === 0 && replies && replies > 0) ? (
545
+
<>
546
+
{/* <div>hello</div> */}
547
+
<MoreReplies atUri={atUri} />
548
+
</>
549
+
) : (<></>)}
550
+
</>
551
+
{!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
552
+
<>
553
+
{/* <span>hello {maxReplies}</span> */}
554
+
<UniversalPostRendererATURILoader
555
+
//detailed={detailed}
556
+
atUri={oldestOpsReplyElseNewestNonOpsReply}
557
+
bottomReplyLine={(maxReplies ?? 0) > 0}
558
+
topReplyLine={
559
+
(!!(maxReplies && maxReplies - 1 === 0) &&
560
+
!!(replies && replies > 0)) ||
561
+
!!((maxReplies ?? 0) > 1)
562
+
}
563
+
bottomBorder={bottomBorder}
564
+
feedviewpost={feedviewpost}
565
+
repostedby={repostedby}
566
+
style={style}
567
+
ref={ref}
568
+
dataIndexPropPass={dataIndexPropPass}
569
+
nopics={nopics}
570
+
lightboxCallback={lightboxCallback}
571
+
maxReplies={
572
+
maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined
573
+
}
574
+
/>
575
+
</>
576
+
)}
577
+
</>
578
+
);
579
+
}
580
+
581
+
function MoreReplies({ atUri }: { atUri: string }) {
582
+
const navigate = useNavigate();
583
+
const aturio = new AtUri(atUri);
584
+
return (
585
+
<div
586
+
onClick={() =>
587
+
navigate({
588
+
to: "/profile/$did/post/$rkey",
589
+
params: { did: aturio.host, rkey: aturio.rkey },
590
+
})
591
+
}
592
+
className="border-b border-gray-300 dark:border-gray-800 flex flex-row px-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"
593
+
>
594
+
<div className="w-[42px] h-12 flex flex-col items-center justify-center">
595
+
<div
596
+
style={{
597
+
width: 2,
598
+
height: "100%",
599
+
backgroundImage:
600
+
"repeating-linear-gradient(to bottom, var(--color-gray-500) 0, var(--color-gray-500) 4px, transparent 4px, transparent 8px)",
601
+
opacity: 0.5,
602
+
}}
603
+
className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]"
604
+
//className="border-gray-400 dark:border-gray-500"
605
+
/>
606
+
</div>
607
+
608
+
<div className="flex items-center pl-3 text-sm text-gray-500 dark:text-gray-400 select-none">
609
+
More Replies
610
+
</div>
611
+
</div>
612
);
613
}
614
615
+
function getAvatarUrl(opProfile: any, did: string, cdn: string) {
616
const link = opProfile?.value?.avatar?.ref?.["$link"];
617
if (!link) return null;
618
+
return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`;
619
}
620
621
export function UniversalPostRendererRawRecordShim({
···
636
ref,
637
dataIndexPropPass,
638
nopics,
639
+
lightboxCallback,
640
+
maxReplies,
641
+
isQuote,
642
}: {
643
postRecord: any;
644
profileRecord: any;
···
657
ref?: React.Ref<HTMLDivElement>;
658
dataIndexPropPass?: number;
659
nopics?: boolean;
660
+
lightboxCallback?: (d: LightboxProps) => void;
661
+
maxReplies?: number;
662
+
isQuote?: boolean;
663
}) {
664
// /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`);
665
const navigate = useNavigate();
···
736
error: embedError,
737
} = useHydratedEmbed(postRecord?.value?.embed, resolved?.did);
738
739
+
const [imgcdn] = useAtom(imgCDNAtom);
740
+
741
const parsedaturi = new AtUri(aturi); //parseAtUri(aturi);
742
743
+
const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>(
744
+
() => ({
745
+
did: resolved?.did || "",
746
+
handle: resolved?.handle || "",
747
+
displayName: profileRecord?.value?.displayName || "",
748
+
avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "",
749
+
viewer: undefined,
750
+
labels: profileRecord?.labels || undefined,
751
+
verification: undefined,
752
+
}),
753
+
[imgcdn, profileRecord, resolved?.did, resolved?.handle]
754
+
);
755
+
756
+
const fakeprofileviewdetailed =
757
+
React.useMemo<AppBskyActorDefs.ProfileViewDetailed>(
758
+
() => ({
759
+
...fakeprofileviewbasic,
760
+
$type: "app.bsky.actor.defs#profileViewDetailed",
761
+
description: profileRecord?.value?.description || undefined,
762
+
}),
763
+
[fakeprofileviewbasic, profileRecord?.value?.description]
764
+
);
765
+
766
const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(
767
() => ({
768
$type: "app.bsky.feed.defs#postView",
769
uri: aturi,
770
cid: postRecord?.cid || "",
771
+
author: fakeprofileviewbasic,
772
record: postRecord?.value || {},
773
embed: hydratedEmbed ?? undefined,
774
replyCount: repliesCount ?? 0,
···
785
postRecord?.cid,
786
postRecord?.value,
787
postRecord?.labels,
788
+
fakeprofileviewbasic,
789
hydratedEmbed,
790
repliesCount,
791
repostsCount,
···
862
}
863
}}
864
post={fakepost}
865
+
uprrrsauthor={fakeprofileviewdetailed}
866
salt={aturi}
867
bottomReplyLine={bottomReplyLine}
868
topReplyLine={topReplyLine}
···
874
ref={ref}
875
dataIndexPropPass={dataIndexPropPass}
876
nopics={nopics}
877
+
lightboxCallback={lightboxCallback}
878
+
maxReplies={maxReplies}
879
+
isQuote={isQuote}
880
/>
881
</>
882
);
···
915
{...props}
916
>
917
<path
918
+
fill="var(--color-gray-400)"
919
d="M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12z"
920
></path>
921
</svg>
···
932
{...props}
933
>
934
<path
935
+
fill="var(--color-gray-400)"
936
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
937
></path>
938
</svg>
···
983
{...props}
984
>
985
<path
986
+
fill="var(--color-gray-400)"
987
d="m12.1 18.55l-.1.1l-.11-.1C7.14 14.24 4 11.39 4 8.5C4 6.5 5.5 5 7.5 5c1.54 0 3.04 1 3.57 2.36h1.86C13.46 6 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5c0 2.89-3.14 5.74-7.9 10.05M16.5 3c-1.74 0-3.41.81-4.5 2.08C10.91 3.81 9.24 3 7.5 3C4.42 3 2 5.41 2 8.5c0 3.77 3.4 6.86 8.55 11.53L12 21.35l1.45-1.32C18.6 15.36 22 12.27 22 8.5C22 5.41 19.58 3 16.5 3"
988
></path>
989
</svg>
···
1000
{...props}
1001
>
1002
<path
1003
+
fill="var(--color-gray-400)"
1004
d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3a3 3 0 0 0-3-3a3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08"
1005
></path>
1006
</svg>
···
1017
{...props}
1018
>
1019
<path
1020
+
fill="var(--color-gray-400)"
1021
d="M16 12a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"
1022
></path>
1023
</svg>
···
1034
{...props}
1035
>
1036
<path
1037
+
fill="var(--color-gray-400)"
1038
d="M17.9 17.39c-.26-.8-1.01-1.39-1.9-1.39h-1v-3a1 1 0 0 0-1-1H8v-2h2a1 1 0 0 0 1-1V7h2a2 2 0 0 0 2-2v-.41a7.984 7.984 0 0 1 2.9 12.8M11 19.93c-3.95-.49-7-3.85-7-7.93c0-.62.08-1.22.21-1.79L9 15v1a2 2 0 0 0 2 2m1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"
1039
></path>
1040
</svg>
···
1068
{...props}
1069
>
1070
<path
1071
+
fill="var(--color-gray-400)"
1072
d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11"
1073
></path>
1074
</svg>
···
1122
{...props}
1123
>
1124
<path
1125
+
fill="var(--color-gray-400)"
1126
d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
1127
></path>
1128
</svg>
···
1139
{...props}
1140
>
1141
<path
1142
+
fill="var(--color-gray-400)"
1143
d="M6 5.75L10.25 10H7v6h6.5l2 2H7a2 2 0 0 1-2-2v-6H1.75zm12 12.5L13.75 14H17V8h-6.5l-2-2H17a2 2 0 0 1 2 2v6h3.25z"
1144
></path>
1145
</svg>
···
1167
//import Masonry from "@mui/lab/Masonry";
1168
import {
1169
type $Typed,
1170
+
AppBskyActorDefs,
1171
AppBskyEmbedDefs,
1172
AppBskyEmbedExternal,
1173
AppBskyEmbedImages,
···
1191
PostView,
1192
//ThreadViewPost,
1193
} from "@atproto/api/dist/client/types/app/bsky/feed/defs";
1194
+
import { useInfiniteQuery } from "@tanstack/react-query";
1195
import { useEffect, useRef, useState } from "react";
1196
import ReactPlayer from "react-player";
1197
1198
import defaultpfp from "~/../public/favicon.png";
1199
import { useAuth } from "~/providers/UnifiedAuthProvider";
1200
+
import { FollowButton, Mutual } from "~/routes/profile.$did";
1201
+
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1202
// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1203
// import type {
1204
// ViewRecord,
···
1306
1307
function UniversalPostRenderer({
1308
post,
1309
+
uprrrsauthor,
1310
//setMainItem,
1311
//isMainItem,
1312
onPostClick,
···
1327
ref,
1328
dataIndexPropPass,
1329
nopics,
1330
+
lightboxCallback,
1331
+
maxReplies,
1332
}: {
1333
post: PostView;
1334
+
uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed;
1335
// optional for now because i havent ported every use to this yet
1336
// setMainItem?: React.Dispatch<
1337
// React.SetStateAction<AppBskyFeedDefs.FeedViewPost>
···
1353
ref?: React.Ref<HTMLDivElement>;
1354
dataIndexPropPass?: number;
1355
nopics?: boolean;
1356
+
lightboxCallback?: (d: LightboxProps) => void;
1357
+
maxReplies?: number;
1358
}) {
1359
const parsed = new AtUri(post.uri);
1360
const navigate = useNavigate();
···
1365
const [hasLiked, setHasLiked] = useState<boolean>(
1366
post.uri in likedPosts || post.viewer?.like ? true : false
1367
);
1368
+
const [, setComposerPost] = useAtom(composerAtom);
1369
const { agent } = useAuth();
1370
const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like);
1371
const [retweetUri, setRetweetUri] = useState<string | undefined>(
···
1462
paddingLeft: isQuote ? 12 : 16,
1463
paddingRight: isQuote ? 12 : 16,
1464
//paddingTop: 16,
1465
+
paddingTop: isRepost ? 10 : isQuote ? 12 : topReplyLine ? 8 : 16,
1466
//paddingBottom: bottomReplyLine ? 0 : 16,
1467
paddingBottom: 0,
1468
fontFamily: "system-ui, sans-serif",
···
1471
// dont cursor: "pointer",
1472
borderBottomWidth: bottomBorder ? (isQuote ? 0 : 1) : 0,
1473
}}
1474
+
className="border-gray-300 dark:border-gray-800"
1475
>
1476
{isRepost && (
1477
<div
···
1503
//left: 16 + (42 / 2),
1504
width: 2,
1505
//height: "100%",
1506
+
height: isRepost
1507
+
? "calc(16px + 1rem - 6px)"
1508
+
: topReplyLine
1509
+
? 8 - 6
1510
+
: 16 - 6,
1511
// background: theme.textSecondary,
1512
//opacity: 0.5,
1513
// no flex here
···
1515
className="bg-gray-500 dark:bg-gray-400"
1516
/>
1517
)}
1518
+
<HoverCard.Root>
1519
+
<HoverCard.Trigger asChild>
1520
+
<div
1521
+
className={`absolute`}
1522
+
style={{
1523
+
top: isRepost
1524
+
? "calc(16px + 1rem)"
1525
+
: isQuote
1526
+
? 12
1527
+
: topReplyLine
1528
+
? 8
1529
+
: 16,
1530
+
left: isQuote ? 12 : 16,
1531
+
}}
1532
+
onClick={onProfileClick}
1533
+
>
1534
+
<img
1535
+
src={post.author.avatar || defaultpfp}
1536
+
alt="avatar"
1537
+
className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
1538
+
style={{
1539
+
width: isQuote ? 16 : 42,
1540
+
height: isQuote ? 16 : 42,
1541
+
}}
1542
+
/>
1543
+
</div>
1544
+
</HoverCard.Trigger>
1545
+
<HoverCard.Portal>
1546
+
<HoverCard.Content
1547
+
className="rounded-md p-4 w-72 bg-gray-50 dark:bg-gray-900 shadow-lg border border-gray-300 dark:border-gray-800 animate-slide-fade z-50"
1548
+
side={"bottom"}
1549
+
sideOffset={5}
1550
+
onClick={onProfileClick}
1551
+
>
1552
+
<div className="flex flex-col gap-2">
1553
+
<div className="flex flex-row">
1554
+
<img
1555
+
src={post.author.avatar || defaultpfp}
1556
+
alt="avatar"
1557
+
className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1558
+
/>
1559
+
<div className=" flex-1 flex flex-row align-middle justify-end">
1560
+
<FollowButton targetdidorhandle={post.author.did} />
1561
+
</div>
1562
+
</div>
1563
+
<div className="flex flex-col gap-3">
1564
+
<div>
1565
+
<div className="text-gray-900 dark:text-gray-100 font-medium text-md">
1566
+
{post.author.displayName || post.author.handle}{" "}
1567
+
</div>
1568
+
<div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1569
+
<Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "}
1570
+
</div>
1571
+
</div>
1572
+
{uprrrsauthor?.description && (
1573
+
<div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3">
1574
+
{uprrrsauthor.description}
1575
+
</div>
1576
+
)}
1577
+
{/* <div className="flex gap-4">
1578
+
<div className="flex gap-1">
1579
+
<div className="font-medium text-gray-900 dark:text-gray-100">
1580
+
0
1581
+
</div>
1582
+
<div className="text-gray-500 dark:text-gray-400">
1583
+
Following
1584
+
</div>
1585
+
</div>
1586
+
<div className="flex gap-1">
1587
+
<div className="font-medium text-gray-900 dark:text-gray-100">
1588
+
2,900
1589
+
</div>
1590
+
<div className="text-gray-500 dark:text-gray-400">
1591
+
Followers
1592
+
</div>
1593
+
</div>
1594
+
</div> */}
1595
+
</div>
1596
+
</div>
1597
+
1598
+
{/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */}
1599
+
</HoverCard.Content>
1600
+
</HoverCard.Portal>
1601
+
</HoverCard.Root>
1602
+
1603
<div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}>
1604
<div
1605
style={{
···
1613
}}
1614
>
1615
{/* dummy for later use */}
1616
+
<div style={{ width: 42, height: 42 + 6, minHeight: 42 + 6 }} />
1617
{/* reply line !!!! bottomReplyLine */}
1618
{bottomReplyLine && (
1619
<div
···
1770
>
1771
{fedi ? (
1772
<>
1773
+
<span
1774
+
className="dangerousFediContent"
1775
dangerouslySetInnerHTML={{
1776
__html: DOMPurify.sanitize(fedi),
1777
}}
···
1796
navigate={navigate}
1797
postid={{ did: post.author.did, rkey: parsed.rkey }}
1798
nopics={nopics}
1799
+
lightboxCallback={lightboxCallback}
1800
/>
1801
) : null}
1802
{post.embed && depth > 0 && (
···
1804
hydrate embeds this deep but the connection here is implicit
1805
todo: idk make this a real part of the embed shim so its not implicit */
1806
<>
1807
+
<div className="border-gray-300 dark:border-gray-800 p-3 rounded-xl border italic text-gray-400 text-[14px]">
1808
(there is an embed here thats too deep to render)
1809
</div>
1810
</>
···
1827
borderBottomWidth: 1,
1828
marginBottom: 8,
1829
}} // important for height animation
1830
+
className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-800 was7"
1831
>
1832
{fullDateTimeFormat(post.indexedAt)}
1833
</div>
···
1846
}}
1847
className="text-gray-500 dark:text-gray-400"
1848
>
1849
<HitSlopButton
1850
onClick={() => {
1851
+
setComposerPost({ kind: "reply", parent: post.uri });
1852
}}
1853
style={{
1854
...btnstyle,
1855
}}
1856
>
1857
+
<MdiCommentOutline />
1858
+
{post.replyCount}
1859
</HitSlopButton>
1860
+
<DropdownMenu.Root modal={false}>
1861
+
<DropdownMenu.Trigger asChild>
1862
+
<div
1863
+
style={{
1864
+
...btnstyle,
1865
+
...(hasRetweeted ? { color: "#5CEFAA" } : {}),
1866
+
}}
1867
+
aria-label="Repost or quote post"
1868
+
>
1869
+
{hasRetweeted ? <MdiRepeatGreen /> : <MdiRepeat />}
1870
+
{post.repostCount ?? 0}
1871
+
</div>
1872
+
</DropdownMenu.Trigger>
1873
+
1874
+
<DropdownMenu.Portal>
1875
+
<DropdownMenu.Content
1876
+
align="start"
1877
+
sideOffset={5}
1878
+
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-32 z-50 overflow-hidden"
1879
+
>
1880
+
<DropdownMenu.Item
1881
+
onSelect={repostOrUnrepostPost}
1882
+
className="px-3 py-2 text-sm flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700"
1883
+
>
1884
+
<MdiRepeat
1885
+
className={hasRetweeted ? "text-green-400" : ""}
1886
+
/>
1887
+
<span>{hasRetweeted ? "Undo Repost" : "Repost"}</span>
1888
+
</DropdownMenu.Item>
1889
+
1890
+
<DropdownMenu.Item
1891
+
onSelect={() => {
1892
+
setComposerPost({
1893
+
kind: "quote",
1894
+
subject: post.uri,
1895
+
});
1896
+
}}
1897
+
className="px-3 py-2 text-sm flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700"
1898
+
>
1899
+
{/* You might want a specific quote icon here */}
1900
+
<MdiCommentOutline />
1901
+
<span>Quote</span>
1902
+
</DropdownMenu.Item>
1903
+
</DropdownMenu.Content>
1904
+
</DropdownMenu.Portal>
1905
+
</DropdownMenu.Root>
1906
<HitSlopButton
1907
onClick={() => {
1908
likeOrUnlikePost();
···
2044
navigate,
2045
postid,
2046
nopics,
2047
+
lightboxCallback,
2048
}: {
2049
embed?: Embed;
2050
moderation?: ModerationDecision;
···
2055
navigate: (_: any) => void;
2056
postid?: { did: string; rkey: string };
2057
nopics?: boolean;
2058
+
lightboxCallback?: (d: LightboxProps) => void;
2059
}) {
2060
+
//const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
2061
+
function setLightboxIndex(number: number) {
2062
+
navigate({
2063
+
to: "/profile/$did/post/$rkey/image/$i",
2064
+
params: {
2065
+
did: postid?.did,
2066
+
rkey: postid?.rkey,
2067
+
i: number.toString(),
2068
+
},
2069
+
});
2070
+
}
2071
if (
2072
AppBskyEmbedRecordWithMedia.isView(embed) &&
2073
AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
···
2103
navigate={navigate}
2104
postid={postid}
2105
nopics={nopics}
2106
+
lightboxCallback={lightboxCallback}
2107
/>
2108
{/* padding empty div of 8px height */}
2109
<div style={{ height: 12 }} />
···
2117
//boxShadow: theme.cardShadow,
2118
overflow: "hidden",
2119
}}
2120
+
className="shadow border border-gray-200 dark:border-gray-800 was7"
2121
>
2122
<UniversalPostRenderer
2123
post={post}
···
2234
//boxShadow: theme.cardShadow,
2235
overflow: "hidden",
2236
}}
2237
+
className="shadow border border-gray-200 dark:border-gray-800 was7"
2238
>
2239
<UniversalPostRenderer
2240
post={post}
···
2271
2272
// image embed
2273
// =
2274
+
if (AppBskyEmbedImages.isView(embed)) {
2275
const { images } = embed;
2276
2277
const lightboxImages = images.map((img) => ({
2278
src: img.fullsize,
2279
alt: img.alt,
2280
}));
2281
+
console.log("rendering images");
2282
+
if (lightboxCallback) {
2283
+
lightboxCallback({ images: lightboxImages });
2284
+
console.log("rendering images");
2285
+
}
2286
+
2287
+
if (nopics) return;
2288
2289
if (images.length > 0) {
2290
// const items = embed.images.map(img => ({
···
2314
//border: `1px solid ${theme.border}`,
2315
overflow: "hidden",
2316
}}
2317
+
className="border border-gray-200 dark:border-gray-800 was7 bg-gray-200 dark:bg-gray-900"
2318
>
2319
+
{/* {lightboxIndex !== null && (
2320
<Lightbox
2321
images={lightboxImages}
2322
index={lightboxIndex}
···
2324
onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2325
post={postid}
2326
/>
2327
+
)} */}
2328
<img
2329
src={image.fullsize}
2330
alt={image.alt}
···
2355
overflow: "hidden",
2356
//border: `1px solid ${theme.border}`,
2357
}}
2358
+
className="border border-gray-200 dark:border-gray-800 was7"
2359
>
2360
+
{/* {lightboxIndex !== null && (
2361
<Lightbox
2362
images={lightboxImages}
2363
index={lightboxIndex}
···
2365
onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2366
post={postid}
2367
/>
2368
+
)} */}
2369
{images.map((img, i) => (
2370
<div
2371
key={i}
···
2405
//border: `1px solid ${theme.border}`,
2406
// height: 240, // fixed height for cropping
2407
}}
2408
+
className="border border-gray-200 dark:border-gray-800 was7"
2409
>
2410
+
{/* {lightboxIndex !== null && (
2411
<Lightbox
2412
images={lightboxImages}
2413
index={lightboxIndex}
···
2415
onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2416
post={postid}
2417
/>
2418
+
)} */}
2419
{/* Left: 1:1 */}
2420
<div
2421
style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }}
···
2490
//border: `1px solid ${theme.border}`,
2491
//aspectRatio: "3 / 2", // overall grid aspect
2492
}}
2493
+
className="border border-gray-200 dark:border-gray-800 was7"
2494
>
2495
+
{/* {lightboxIndex !== null && (
2496
<Lightbox
2497
images={lightboxImages}
2498
index={lightboxIndex}
···
2500
onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2501
post={postid}
2502
/>
2503
+
)} */}
2504
{images.map((img, i) => (
2505
<div
2506
key={i}
···
2591
return <div />;
2592
}
2593
2594
function getDomain(url: string) {
2595
try {
2596
const { hostname } = new URL(url);
···
2655
return { start, end, feature: f.features[0] };
2656
});
2657
}
2658
+
export function renderTextWithFacets({
2659
text,
2660
facets,
2661
navigate,
···
2818
>
2819
<div
2820
style={containerStyle as React.CSSProperties}
2821
+
className="border border-gray-200 dark:border-gray-800 was7"
2822
>
2823
{thumb && (
2824
<div
···
2832
marginBottom: 8,
2833
//borderBottom: `1px solid ${theme.border}`,
2834
}}
2835
+
className="border-b border-gray-200 dark:border-gray-800 was7"
2836
>
2837
<img
2838
src={thumb}
···
2958
borderRadius: 12,
2959
//border: `1px solid ${theme.border}`,
2960
}}
2961
+
className="border border-gray-200 dark:border-gray-800 was7"
2962
onClick={async (e) => {
2963
e.stopPropagation();
2964
setPlaying(true);
···
2999
100 / (aspect ? aspect.width / aspect.height : 16 / 9)
3000
}%`, // 16:9 = 56.25%, 4:3 = 75%
3001
}}
3002
+
className="border border-gray-200 dark:border-gray-800 was7"
3003
>
3004
<ReactPlayer
3005
src={url}
+54
-9
src/main.tsx
+54
-9
src/main.tsx
···
1
import "~/styles/app.css";
2
3
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
4
-
import { QueryClient, QueryClientProvider, } from "@tanstack/react-query";
5
-
import {
6
-
persistQueryClient,
7
-
} from "@tanstack/react-query-persist-client";
8
-
import { createRouter,RouterProvider } from "@tanstack/react-router";
9
//import { StrictMode } from "react";
10
import ReactDOM from "react-dom/client";
11
12
import reportWebVitals from "./reportWebVitals.ts";
13
// Import the generated route tree
14
import { routeTree } from "./routeTree.gen";
15
16
17
const queryClient = new QueryClient({
18
defaultOptions: {
···
28
persistQueryClient({
29
queryClient,
30
persister: localStoragePersister,
31
-
})
32
33
// Create a new router instance
34
const router = createRouter({
···
54
root.render(
55
// double queries annoys me
56
// <StrictMode>
57
-
<QueryClientProvider client={queryClient}>
58
-
<RouterProvider router={router} />
59
-
</QueryClientProvider>
60
// </StrictMode>
61
);
62
}
···
65
// to log results (for example: reportWebVitals(// /*mass comment*/ console.log))
66
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
67
reportWebVitals();
···
1
import "~/styles/app.css";
2
3
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
4
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+
import { persistQueryClient } from "@tanstack/react-query-persist-client";
6
+
import { createRouter, RouterProvider } from "@tanstack/react-router";
7
+
import { useSetAtom } from "jotai";
8
+
import { useEffect } from "react";
9
//import { StrictMode } from "react";
10
import ReactDOM from "react-dom/client";
11
12
import reportWebVitals from "./reportWebVitals.ts";
13
// Import the generated route tree
14
import { routeTree } from "./routeTree.gen";
15
+
import { isAtTopAtom } from "./utils/atoms.ts";
16
17
+
//initAtomToCssVar(hueAtom, "--tw-gray-hue")
18
19
const queryClient = new QueryClient({
20
defaultOptions: {
···
30
persistQueryClient({
31
queryClient,
32
persister: localStoragePersister,
33
+
});
34
35
// Create a new router instance
36
const router = createRouter({
···
56
root.render(
57
// double queries annoys me
58
// <StrictMode>
59
+
<QueryClientProvider client={queryClient}>
60
+
<ScrollTopWatcher />
61
+
<RouterProvider router={router} />
62
+
</QueryClientProvider>
63
// </StrictMode>
64
);
65
}
···
68
// to log results (for example: reportWebVitals(// /*mass comment*/ console.log))
69
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
70
reportWebVitals();
71
+
72
+
export default function ScrollTopWatcher() {
73
+
const setIsAtTop = useSetAtom(isAtTopAtom);
74
+
useEffect(() => {
75
+
const meta = document.querySelector('meta[name="theme-color"]');
76
+
let lastAtTop = window.scrollY === 0;
77
+
let timeoutId: number | undefined;
78
+
79
+
const setVars = (atTop: boolean) => {
80
+
const root = document.documentElement;
81
+
root.style.setProperty("--is-top", atTop ? "1" : "0");
82
+
83
+
const bg = getComputedStyle(root).getPropertyValue("--header-bg").trim();
84
+
if (meta && bg) meta.setAttribute("content", bg);
85
+
setIsAtTop(atTop);
86
+
};
87
+
88
+
const check = () => {
89
+
const atTop = window.scrollY === 0;
90
+
if (atTop !== lastAtTop) {
91
+
lastAtTop = atTop;
92
+
setVars(atTop);
93
+
}
94
+
};
95
+
96
+
const handleScroll = () => {
97
+
if (timeoutId) clearTimeout(timeoutId);
98
+
timeoutId = window.setTimeout(check, 2);
99
+
};
100
+
101
+
// initialize
102
+
setVars(lastAtTop);
103
+
window.addEventListener("scroll", handleScroll, { passive: true });
104
+
105
+
return () => {
106
+
window.removeEventListener("scroll", handleScroll);
107
+
if (timeoutId) clearTimeout(timeoutId);
108
+
};
109
+
}, []);
110
+
111
+
return null;
112
+
}
+36
-5
src/routeTree.gen.ts
+36
-5
src/routeTree.gen.ts
···
21
import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b'
22
import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a'
23
import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey'
24
25
const SettingsRoute = SettingsRouteImport.update({
26
id: '/settings',
···
83
path: '/profile/$did/post/$rkey',
84
getParentRoute: () => rootRouteImport,
85
} as any)
86
87
export interface FileRoutesByFullPath {
88
'/': typeof IndexRoute
···
94
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
95
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
96
'/profile/$did': typeof ProfileDidIndexRoute
97
-
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRoute
98
}
99
export interface FileRoutesByTo {
100
'/': typeof IndexRoute
···
106
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
107
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
108
'/profile/$did': typeof ProfileDidIndexRoute
109
-
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRoute
110
}
111
export interface FileRoutesById {
112
__root__: typeof rootRouteImport
···
121
'/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
122
'/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
123
'/profile/$did/': typeof ProfileDidIndexRoute
124
-
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRoute
125
}
126
export interface FileRouteTypes {
127
fileRoutesByFullPath: FileRoutesByFullPath
···
136
| '/route-b'
137
| '/profile/$did'
138
| '/profile/$did/post/$rkey'
139
fileRoutesByTo: FileRoutesByTo
140
to:
141
| '/'
···
148
| '/route-b'
149
| '/profile/$did'
150
| '/profile/$did/post/$rkey'
151
id:
152
| '__root__'
153
| '/'
···
162
| '/_pathlessLayout/_nested-layout/route-b'
163
| '/profile/$did/'
164
| '/profile/$did/post/$rkey'
165
fileRoutesById: FileRoutesById
166
}
167
export interface RootRouteChildren {
···
173
SettingsRoute: typeof SettingsRoute
174
CallbackIndexRoute: typeof CallbackIndexRoute
175
ProfileDidIndexRoute: typeof ProfileDidIndexRoute
176
-
ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRoute
177
}
178
179
declare module '@tanstack/react-router' {
···
262
preLoaderRoute: typeof ProfileDidPostRkeyRouteImport
263
parentRoute: typeof rootRouteImport
264
}
265
}
266
}
267
···
295
PathlessLayoutRouteChildren,
296
)
297
298
const rootRouteChildren: RootRouteChildren = {
299
IndexRoute: IndexRoute,
300
PathlessLayoutRoute: PathlessLayoutRouteWithChildren,
···
304
SettingsRoute: SettingsRoute,
305
CallbackIndexRoute: CallbackIndexRoute,
306
ProfileDidIndexRoute: ProfileDidIndexRoute,
307
-
ProfileDidPostRkeyRoute: ProfileDidPostRkeyRoute,
308
}
309
export const routeTree = rootRouteImport
310
._addFileChildren(rootRouteChildren)
···
21
import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b'
22
import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a'
23
import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey'
24
+
import { Route as ProfileDidPostRkeyImageIRouteImport } from './routes/profile.$did/post.$rkey.image.$i'
25
26
const SettingsRoute = SettingsRouteImport.update({
27
id: '/settings',
···
84
path: '/profile/$did/post/$rkey',
85
getParentRoute: () => rootRouteImport,
86
} as any)
87
+
const ProfileDidPostRkeyImageIRoute =
88
+
ProfileDidPostRkeyImageIRouteImport.update({
89
+
id: '/image/$i',
90
+
path: '/image/$i',
91
+
getParentRoute: () => ProfileDidPostRkeyRoute,
92
+
} as any)
93
94
export interface FileRoutesByFullPath {
95
'/': typeof IndexRoute
···
101
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
102
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
103
'/profile/$did': typeof ProfileDidIndexRoute
104
+
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
105
+
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
106
}
107
export interface FileRoutesByTo {
108
'/': typeof IndexRoute
···
114
'/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
115
'/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
116
'/profile/$did': typeof ProfileDidIndexRoute
117
+
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
118
+
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
119
}
120
export interface FileRoutesById {
121
__root__: typeof rootRouteImport
···
130
'/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute
131
'/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute
132
'/profile/$did/': typeof ProfileDidIndexRoute
133
+
'/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren
134
+
'/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute
135
}
136
export interface FileRouteTypes {
137
fileRoutesByFullPath: FileRoutesByFullPath
···
146
| '/route-b'
147
| '/profile/$did'
148
| '/profile/$did/post/$rkey'
149
+
| '/profile/$did/post/$rkey/image/$i'
150
fileRoutesByTo: FileRoutesByTo
151
to:
152
| '/'
···
159
| '/route-b'
160
| '/profile/$did'
161
| '/profile/$did/post/$rkey'
162
+
| '/profile/$did/post/$rkey/image/$i'
163
id:
164
| '__root__'
165
| '/'
···
174
| '/_pathlessLayout/_nested-layout/route-b'
175
| '/profile/$did/'
176
| '/profile/$did/post/$rkey'
177
+
| '/profile/$did/post/$rkey/image/$i'
178
fileRoutesById: FileRoutesById
179
}
180
export interface RootRouteChildren {
···
186
SettingsRoute: typeof SettingsRoute
187
CallbackIndexRoute: typeof CallbackIndexRoute
188
ProfileDidIndexRoute: typeof ProfileDidIndexRoute
189
+
ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren
190
}
191
192
declare module '@tanstack/react-router' {
···
275
preLoaderRoute: typeof ProfileDidPostRkeyRouteImport
276
parentRoute: typeof rootRouteImport
277
}
278
+
'/profile/$did/post/$rkey/image/$i': {
279
+
id: '/profile/$did/post/$rkey/image/$i'
280
+
path: '/image/$i'
281
+
fullPath: '/profile/$did/post/$rkey/image/$i'
282
+
preLoaderRoute: typeof ProfileDidPostRkeyImageIRouteImport
283
+
parentRoute: typeof ProfileDidPostRkeyRoute
284
+
}
285
}
286
}
287
···
315
PathlessLayoutRouteChildren,
316
)
317
318
+
interface ProfileDidPostRkeyRouteChildren {
319
+
ProfileDidPostRkeyImageIRoute: typeof ProfileDidPostRkeyImageIRoute
320
+
}
321
+
322
+
const ProfileDidPostRkeyRouteChildren: ProfileDidPostRkeyRouteChildren = {
323
+
ProfileDidPostRkeyImageIRoute: ProfileDidPostRkeyImageIRoute,
324
+
}
325
+
326
+
const ProfileDidPostRkeyRouteWithChildren =
327
+
ProfileDidPostRkeyRoute._addFileChildren(ProfileDidPostRkeyRouteChildren)
328
+
329
const rootRouteChildren: RootRouteChildren = {
330
IndexRoute: IndexRoute,
331
PathlessLayoutRoute: PathlessLayoutRouteWithChildren,
···
335
SettingsRoute: SettingsRoute,
336
CallbackIndexRoute: CallbackIndexRoute,
337
ProfileDidIndexRoute: ProfileDidIndexRoute,
338
+
ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren,
339
}
340
export const routeTree = rootRouteImport
341
._addFileChildren(rootRouteChildren)
+544
-199
src/routes/__root.tsx
+544
-199
src/routes/__root.tsx
···
5
import type { QueryClient } from "@tanstack/react-query";
6
import {
7
createRootRouteWithContext,
8
-
Link,
9
// Outlet,
10
Scripts,
11
useLocation,
12
useNavigate,
13
} from "@tanstack/react-router";
14
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
15
-
import { useState } from "react";
16
import * as React from "react";
17
import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive";
18
19
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
20
import Login from "~/components/Login";
21
import { NotFound } from "~/components/NotFound";
22
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
23
import { seo } from "~/utils/seo";
24
25
export const Route = createRootRouteWithContext<{
···
85
}
86
87
function RootDocument({ children }: { children: React.ReactNode }) {
88
const location = useLocation();
89
const navigate = useNavigate();
90
const { agent } = useAuth();
···
95
agent &&
96
(location.pathname === `/profile/${agent?.did}` ||
97
location.pathname === `/profile/${encodeURIComponent(agent?.did ?? "")}`);
98
99
-
const [postOpen, setPostOpen] = useState(false);
100
-
const [postText, setPostText] = useState("");
101
-
const [posting, setPosting] = useState(false);
102
-
const [postSuccess, setPostSuccess] = useState(false);
103
-
const [postError, setPostError] = useState<string | null>(null);
104
105
-
async function handlePost() {
106
-
if (!agent) return;
107
-
setPosting(true);
108
-
setPostError(null);
109
-
try {
110
-
await agent.com.atproto.repo.createRecord({
111
-
collection: "app.bsky.feed.post",
112
-
repo: agent.assertDid,
113
-
record: {
114
-
$type: "app.bsky.feed.post",
115
-
text: postText,
116
-
createdAt: new Date().toISOString(),
117
-
},
118
-
});
119
-
setPostSuccess(true);
120
-
setPostText("");
121
-
setTimeout(() => {
122
-
setPostSuccess(false);
123
-
setPostOpen(false);
124
-
}, 1500);
125
-
} catch (e: any) {
126
-
setPostError(e?.message || "Failed to post");
127
-
} finally {
128
-
setPosting(false);
129
-
}
130
-
}
131
132
return (
133
<>
134
-
{postOpen && (
135
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
136
-
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-md relative">
137
-
<button
138
-
className="absolute top-2 right-2 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
139
-
onClick={() => !posting && setPostOpen(false)}
140
-
disabled={posting}
141
-
aria-label="Close"
142
-
>
143
-
ร
144
-
</button>
145
-
<h2 className="text-lg font-bold mb-2">Create Post</h2>
146
-
{postSuccess ? (
147
-
<div className="flex flex-col items-center justify-center py-8">
148
-
<span className="text-green-500 text-4xl mb-2">โ</span>
149
-
<span className="text-green-600">Posted!</span>
150
-
</div>
151
-
) : (
152
-
<>
153
-
<textarea
154
-
className="w-full border rounded p-2 mb-2 dark:bg-gray-800 dark:border-gray-700"
155
-
rows={4}
156
-
placeholder="What's on your mind?"
157
-
value={postText}
158
-
onChange={(e) => setPostText(e.target.value)}
159
-
disabled={posting}
160
-
autoFocus
161
-
/>
162
-
{postError && (
163
-
<div className="text-red-500 text-sm mb-2">{postError}</div>
164
-
)}
165
-
<button
166
-
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50"
167
-
onClick={handlePost}
168
-
disabled={posting || !postText.trim()}
169
-
>
170
-
{posting ? "Posting..." : "Post"}
171
-
</button>
172
-
</>
173
-
)}
174
-
</div>
175
-
</div>
176
-
)}
177
178
<div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950">
179
-
<nav className="hidden lg:flex h-screen w-[250px] flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start">
180
<div className="flex items-center gap-3 mb-4">
181
-
<img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" />
182
<span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100">
183
Red Dwarf{" "}
184
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
186
</span> */}
187
</span>
188
</div>
189
-
<Link
190
to="/"
191
className={
192
`py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-900 text-xl flex items-center gap-3 ` +
···
260
<IconMaterialSymbolsAccountCircleOutline width={28} height={28} />
261
) : (
262
<IconMaterialSymbolsAccountCircle width={28} height={28} />
263
-
)
264
-
}
265
<span>Profile</span>
266
</button>
267
<Link
···
276
<IconMaterialSymbolsSettings width={28} height={28} />
277
)}
278
<span>Settings</span>
279
-
</Link>
280
-
<button
281
className="mt-4 w-full flex items-center justify-center gap-3 py-3 px-0 mb-3 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 text-xl font-bold rounded-full transition-colors shadow"
282
onClick={() => setPostOpen(true)}
283
type="button"
···
288
className="text-gray-600 dark:text-gray-400"
289
/>
290
<span>Post</span>
291
-
</button>
292
<div className="flex-1"></div>
293
<a
294
href="https://tangled.sh/@whey.party/red-dwarf"
···
319
</div>
320
</nav>
321
322
-
<button
323
-
className="lg:hidden fixed bottom-20 right-6 z-50 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-blue-600 dark:text-blue-400 rounded-full shadow-lg w-16 h-16 flex items-center justify-center border-4 border-white dark:border-gray-950 transition-all"
324
-
style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }}
325
-
onClick={() => setPostOpen(true)}
326
-
type="button"
327
-
aria-label="Create Post"
328
-
>
329
-
<IconMdiPencilOutline
330
-
width={24}
331
-
height={24}
332
-
className="text-gray-600 dark:text-gray-400"
333
/>
334
-
</button>
335
336
-
<main className="w-full max-w-[600px] lg:border-x border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-950 pb-16 lg:pb-0">
337
-
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-950">
338
-
<div className="flex items-center gap-2">
339
-
<img
340
-
src="/redstar.png"
341
-
alt="Red Dwarf Logo"
342
-
className="w-6 h-6"
343
-
/>
344
-
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">
345
-
Red Dwarf{" "}
346
-
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
347
-
lite
348
-
</span> */}
349
-
</span>
350
-
</div>
351
-
<div className="flex items-center gap-2">
352
-
<Login compact={true} />
353
-
</div>
354
</div>
355
356
{children}
357
</main>
358
359
<aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col">
360
<Login />
361
362
<div className="flex-1"></div>
363
<p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4">
364
-
Red Dwarf is a bluesky client that uses Constellation and direct PDS
365
-
queries. Skylite would be a self-hosted bluesky "instance". Stay
366
-
tuned for the release of Skylite.
367
</p>
368
</aside>
369
</div>
370
371
-
<nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-950 border-t border-gray-200 dark:border-gray-700 z-40">
372
-
<div className="flex justify-around items-center py-2">
373
-
<Link
374
-
to="/"
375
-
className={`flex flex-col items-center py-2 px-3 rounded-lg transition-colors flex-1 ${
376
-
isHome
377
-
? "text-gray-900 dark:text-gray-100"
378
-
: "text-gray-600 dark:text-gray-400"
379
-
}`}
380
-
>
381
-
{!isHome ? (
382
-
<IconMaterialSymbolsHomeOutline width={24} height={24} />
383
-
) : (
384
-
<IconMaterialSymbolsHome width={24} height={24} />
385
-
)}
386
-
<span className="text-xs mt-1">Home</span>
387
-
</Link>
388
-
<Link
389
-
to="/search"
390
-
className={`flex flex-col items-center py-2 px-3 rounded-lg transition-colors flex-1 ${
391
-
location.pathname.startsWith("/search")
392
-
? "text-gray-900 dark:text-gray-100"
393
-
: "text-gray-600 dark:text-gray-400"
394
-
}`}
395
-
>
396
-
{!location.pathname.startsWith("/search") ? (
397
-
<IconMaterialSymbolsSearch width={24} height={24} />
398
-
) : (
399
-
<IconMaterialSymbolsSearch width={24} height={24} />
400
-
)}
401
-
<span className="text-xs mt-1">Search</span>
402
-
</Link>
403
-
<Link
404
-
to="/notifications"
405
-
className={`flex flex-col items-center py-2 px-3 rounded-lg transition-colors flex-1 ${
406
-
isNotifications
407
-
? "text-gray-900 dark:text-gray-100"
408
-
: "text-gray-600 dark:text-gray-400"
409
-
}`}
410
-
>
411
-
{!isNotifications ? (
412
-
<IconMaterialSymbolsNotificationsOutline width={24} height={24} />
413
-
) : (
414
-
<IconMaterialSymbolsNotifications width={24} height={24} />
415
-
)}
416
-
<span className="text-xs mt-1">Notifications</span>
417
-
</Link>
418
-
<button
419
-
className={`flex flex-col items-center py-2 px-3 rounded-lg transition-colors flex-1 ${
420
-
isProfile
421
-
? "text-gray-900 dark:text-gray-100"
422
-
: "text-gray-600 dark:text-gray-400"
423
-
}`}
424
-
onClick={() => {
425
-
if (authed && agent && agent.assertDid) {
426
-
//window.location.href = `/profile/${agent.assertDid}`;
427
navigate({
428
-
to: "/profile/$did",
429
-
params: { did: agent.assertDid },
430
-
});
431
}
432
-
}}
433
-
type="button"
434
-
>
435
-
<IconMaterialSymbolsAccountCircleOutline width={24} height={24} />
436
-
<span className="text-xs mt-1">Profile</span>
437
-
</button>
438
-
<Link
439
-
to="/settings"
440
-
className={`flex flex-col items-center py-2 px-3 rounded-lg transition-colors flex-1 ${
441
-
location.pathname.startsWith("/settings")
442
-
? "text-gray-900 dark:text-gray-100"
443
-
: "text-gray-600 dark:text-gray-400"
444
-
}`}
445
-
>
446
-
{!location.pathname.startsWith("/settings") ? (
447
-
<IconMaterialSymbolsSettingsOutline width={24} height={24} />
448
-
) : (
449
-
<IconMaterialSymbolsSettings width={24} height={24} />
450
-
)}
451
-
<span className="text-xs mt-1">Settings</span>
452
-
</Link>
453
</div>
454
-
</nav>
455
456
-
<TanStackRouterDevtools position="bottom-right" />
457
<Scripts />
458
</>
459
);
460
}
···
5
import type { QueryClient } from "@tanstack/react-query";
6
import {
7
createRootRouteWithContext,
8
+
// Link,
9
// Outlet,
10
Scripts,
11
useLocation,
12
useNavigate,
13
} from "@tanstack/react-router";
14
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
15
+
import { useAtom } from "jotai";
16
import * as React from "react";
17
import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive";
18
19
+
import { Composer } from "~/components/Composer";
20
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
21
+
import { Import } from "~/components/Import";
22
import Login from "~/components/Login";
23
import { NotFound } from "~/components/NotFound";
24
+
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
25
import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider";
26
+
import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms";
27
import { seo } from "~/utils/seo";
28
29
export const Route = createRootRouteWithContext<{
···
89
}
90
91
function RootDocument({ children }: { children: React.ReactNode }) {
92
+
useAtomCssVar(hueAtom, "--tw-gray-hue");
93
const location = useLocation();
94
const navigate = useNavigate();
95
const { agent } = useAuth();
···
100
agent &&
101
(location.pathname === `/profile/${agent?.did}` ||
102
location.pathname === `/profile/${encodeURIComponent(agent?.did ?? "")}`);
103
+
const isSettings = location.pathname.startsWith("/settings");
104
+
const isSearch = location.pathname.startsWith("/search");
105
+
const isFeeds = location.pathname.startsWith("/feeds");
106
107
+
const locationEnum:
108
+
| "feeds"
109
+
| "search"
110
+
| "settings"
111
+
| "notifications"
112
+
| "profile"
113
+
| "home" = isFeeds
114
+
? "feeds"
115
+
: isSearch
116
+
? "search"
117
+
: isSettings
118
+
? "settings"
119
+
: isNotifications
120
+
? "notifications"
121
+
: isProfile
122
+
? "profile"
123
+
: "home";
124
125
+
const [, setComposerPost] = useAtom(composerAtom);
126
127
return (
128
<>
129
+
<Composer />
130
131
<div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950">
132
+
<nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start">
133
<div className="flex items-center gap-3 mb-4">
134
+
<FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
135
<span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100">
136
Red Dwarf{" "}
137
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
···
139
</span> */}
140
</span>
141
</div>
142
+
<MaterialNavItem
143
+
InactiveIcon={
144
+
<IconMaterialSymbolsHomeOutline className="w-6 h-6" />
145
+
}
146
+
ActiveIcon={<IconMaterialSymbolsHome className="w-6 h-6" />}
147
+
active={locationEnum === "home"}
148
+
onClickCallbback={() =>
149
+
navigate({
150
+
to: "/",
151
+
//params: { did: agent.assertDid },
152
+
})
153
+
}
154
+
text="Home"
155
+
/>
156
+
157
+
<MaterialNavItem
158
+
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
159
+
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
160
+
active={locationEnum === "search"}
161
+
onClickCallbback={() =>
162
+
navigate({
163
+
to: "/search",
164
+
//params: { did: agent.assertDid },
165
+
})
166
+
}
167
+
text="Explore"
168
+
/>
169
+
<MaterialNavItem
170
+
InactiveIcon={
171
+
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
172
+
}
173
+
ActiveIcon={
174
+
<IconMaterialSymbolsNotifications className="w-6 h-6" />
175
+
}
176
+
active={locationEnum === "notifications"}
177
+
onClickCallbback={() =>
178
+
navigate({
179
+
to: "/notifications",
180
+
//params: { did: agent.assertDid },
181
+
})
182
+
}
183
+
text="Notifications"
184
+
/>
185
+
<MaterialNavItem
186
+
InactiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
187
+
ActiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
188
+
active={locationEnum === "feeds"}
189
+
onClickCallbback={() =>
190
+
navigate({
191
+
to: "/feeds",
192
+
//params: { did: agent.assertDid },
193
+
})
194
+
}
195
+
text="Feeds"
196
+
/>
197
+
<MaterialNavItem
198
+
InactiveIcon={
199
+
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
200
+
}
201
+
ActiveIcon={
202
+
<IconMaterialSymbolsAccountCircle className="w-6 h-6" />
203
+
}
204
+
active={locationEnum === "profile"}
205
+
onClickCallbback={() => {
206
+
if (authed && agent && agent.assertDid) {
207
+
//window.location.href = `/profile/${agent.assertDid}`;
208
+
navigate({
209
+
to: "/profile/$did",
210
+
params: { did: agent.assertDid },
211
+
});
212
+
}
213
+
}}
214
+
text="Profile"
215
+
/>
216
+
<MaterialNavItem
217
+
InactiveIcon={
218
+
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
219
+
}
220
+
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
221
+
active={locationEnum === "settings"}
222
+
onClickCallbback={() =>
223
+
navigate({
224
+
to: "/settings",
225
+
//params: { did: agent.assertDid },
226
+
})
227
+
}
228
+
text="Settings"
229
+
/>
230
+
<div className="flex flex-row items-center justify-center mt-3">
231
+
<MaterialPillButton
232
+
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
233
+
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
234
+
//active={true}
235
+
onClickCallbback={() => setComposerPost({ kind: 'root' })}
236
+
text="Post"
237
+
/>
238
+
</div>
239
+
{/* <Link
240
to="/"
241
className={
242
`py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-900 text-xl flex items-center gap-3 ` +
···
310
<IconMaterialSymbolsAccountCircleOutline width={28} height={28} />
311
) : (
312
<IconMaterialSymbolsAccountCircle width={28} height={28} />
313
+
)}
314
<span>Profile</span>
315
</button>
316
<Link
···
325
<IconMaterialSymbolsSettings width={28} height={28} />
326
)}
327
<span>Settings</span>
328
+
</Link> */}
329
+
{/* <button
330
className="mt-4 w-full flex items-center justify-center gap-3 py-3 px-0 mb-3 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 text-xl font-bold rounded-full transition-colors shadow"
331
onClick={() => setPostOpen(true)}
332
type="button"
···
337
className="text-gray-600 dark:text-gray-400"
338
/>
339
<span>Post</span>
340
+
</button> */}
341
<div className="flex-1"></div>
342
<a
343
href="https://tangled.sh/@whey.party/red-dwarf"
···
368
</div>
369
</nav>
370
371
+
<nav className="hidden sm:flex items-center lg:hidden h-screen flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start">
372
+
<div className="flex items-center gap-3 mb-4">
373
+
<FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
374
+
</div>
375
+
<MaterialNavItem
376
+
small
377
+
InactiveIcon={
378
+
<IconMaterialSymbolsHomeOutline className="w-6 h-6" />
379
+
}
380
+
ActiveIcon={<IconMaterialSymbolsHome className="w-6 h-6" />}
381
+
active={locationEnum === "home"}
382
+
onClickCallbback={() =>
383
+
navigate({
384
+
to: "/",
385
+
//params: { did: agent.assertDid },
386
+
})
387
+
}
388
+
text="Home"
389
/>
390
391
+
<MaterialNavItem
392
+
small
393
+
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
394
+
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
395
+
active={locationEnum === "search"}
396
+
onClickCallbback={() =>
397
+
navigate({
398
+
to: "/search",
399
+
//params: { did: agent.assertDid },
400
+
})
401
+
}
402
+
text="Explore"
403
+
/>
404
+
<MaterialNavItem
405
+
small
406
+
InactiveIcon={
407
+
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
408
+
}
409
+
ActiveIcon={
410
+
<IconMaterialSymbolsNotifications className="w-6 h-6" />
411
+
}
412
+
active={locationEnum === "notifications"}
413
+
onClickCallbback={() =>
414
+
navigate({
415
+
to: "/notifications",
416
+
//params: { did: agent.assertDid },
417
+
})
418
+
}
419
+
text="Notifications"
420
+
/>
421
+
<MaterialNavItem
422
+
small
423
+
InactiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
424
+
ActiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />}
425
+
active={locationEnum === "feeds"}
426
+
onClickCallbback={() =>
427
+
navigate({
428
+
to: "/feeds",
429
+
//params: { did: agent.assertDid },
430
+
})
431
+
}
432
+
text="Feeds"
433
+
/>
434
+
<MaterialNavItem
435
+
small
436
+
InactiveIcon={
437
+
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
438
+
}
439
+
ActiveIcon={
440
+
<IconMaterialSymbolsAccountCircle className="w-6 h-6" />
441
+
}
442
+
active={locationEnum === "profile"}
443
+
onClickCallbback={() => {
444
+
if (authed && agent && agent.assertDid) {
445
+
//window.location.href = `/profile/${agent.assertDid}`;
446
+
navigate({
447
+
to: "/profile/$did",
448
+
params: { did: agent.assertDid },
449
+
});
450
+
}
451
+
}}
452
+
text="Profile"
453
+
/>
454
+
<MaterialNavItem
455
+
small
456
+
InactiveIcon={
457
+
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
458
+
}
459
+
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
460
+
active={locationEnum === "settings"}
461
+
onClickCallbback={() =>
462
+
navigate({
463
+
to: "/settings",
464
+
//params: { did: agent.assertDid },
465
+
})
466
+
}
467
+
text="Settings"
468
+
/>
469
+
<div className="flex flex-row items-center justify-center mt-3">
470
+
<MaterialPillButton
471
+
small
472
+
InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
473
+
ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />}
474
+
//active={true}
475
+
onClickCallbback={() => setComposerPost({ kind: 'root' })}
476
+
text="Post"
477
+
/>
478
</div>
479
+
</nav>
480
481
+
{agent?.did && (
482
+
<button
483
+
className="lg:hidden fixed bottom-22 right-4 z-50 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 rounded-2xl w-14 h-14 flex items-center justify-center transition-all"
484
+
style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }}
485
+
onClick={() => setComposerPost({ kind: 'root' })}
486
+
type="button"
487
+
aria-label="Create Post"
488
+
>
489
+
<IconMdiPencilOutline
490
+
width={24}
491
+
height={24}
492
+
className="text-gray-600 dark:text-gray-400"
493
+
/>
494
+
</button>
495
+
)}
496
+
497
+
<main className="w-full max-w-[600px] sm:border-x border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 pb-16 lg:pb-0 overflow-x-clip">
498
{children}
499
</main>
500
501
<aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col">
502
+
<div className="px-4 pt-4"><Import /></div>
503
<Login />
504
505
<div className="flex-1"></div>
506
<p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4">
507
+
Red Dwarf is a Bluesky client that does not rely on any Bluesky API App Servers. Instead, it uses Microcosm to fetch records directly from each users' PDS (via Slingshot) and connect them using backlinks (via Constellation)
508
</p>
509
</aside>
510
</div>
511
512
+
{agent?.did ? (
513
+
<nav className="sm:hidden fixed bottom-0 left-0 right-0 bg-gray-50 dark:bg-gray-900 border-0 shadow border-gray-200 dark:border-gray-700 z-40">
514
+
<div className="flex justify-around items-center p-2">
515
+
<MaterialNavItem
516
+
small
517
+
InactiveIcon={
518
+
<IconMaterialSymbolsHomeOutline className="w-6 h-6" />
519
+
}
520
+
ActiveIcon={<IconMaterialSymbolsHome className="w-6 h-6" />}
521
+
active={locationEnum === "home"}
522
+
onClickCallbback={() =>
523
+
navigate({
524
+
to: "/",
525
+
//params: { did: agent.assertDid },
526
+
})
527
+
}
528
+
text="Home"
529
+
/>
530
+
{/* <Link
531
+
to="/"
532
+
className={`flex flex-col items-center py-2 px-3 rounded-lg transition-colors flex-1 ${
533
+
isHome
534
+
? "text-gray-900 dark:text-gray-100"
535
+
: "text-gray-600 dark:text-gray-400"
536
+
}`}
537
+
>
538
+
{!isHome ? (
539
+
<IconMaterialSymbolsHomeOutline width={24} height={24} />
540
+
) : (
541
+
<IconMaterialSymbolsHome width={24} height={24} />
542
+
)}
543
+
<span className="text-xs mt-1">Home</span>
544
+
</Link> */}
545
+
<MaterialNavItem
546
+
small
547
+
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
548
+
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
549
+
active={locationEnum === "search"}
550
+
onClickCallbback={() =>
551
+
navigate({
552
+
to: "/search",
553
+
//params: { did: agent.assertDid },
554
+
})
555
+
}
556
+
text="Explore"
557
+
/>
558
+
{/* <Link
559
+
to="/search"
560
+
className={`flex flex-col items-center py-2 px-3 rounded-lg transition-colors flex-1 ${
561
+
location.pathname.startsWith("/search")
562
+
? "text-gray-900 dark:text-gray-100"
563
+
: "text-gray-600 dark:text-gray-400"
564
+
}`}
565
+
>
566
+
{!location.pathname.startsWith("/search") ? (
567
+
<IconMaterialSymbolsSearch width={24} height={24} />
568
+
) : (
569
+
<IconMaterialSymbolsSearch width={24} height={24} />
570
+
)}
571
+
<span className="text-xs mt-1">Search</span>
572
+
</Link> */}
573
+
<MaterialNavItem
574
+
small
575
+
InactiveIcon={
576
+
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
577
+
}
578
+
ActiveIcon={
579
+
<IconMaterialSymbolsNotifications className="w-6 h-6" />
580
+
}
581
+
active={locationEnum === "notifications"}
582
+
onClickCallbback={() =>
583
+
navigate({
584
+
to: "/notifications",
585
+
//params: { did: agent.assertDid },
586
+
})
587
+
}
588
+
text="Notifications"
589
+
/>
590
+
{/* <Link
591
+
to="/notifications"
592
+
className={`flex flex-col items-center py-2 px-3 rounded-lg transition-colors flex-1 ${
593
+
isNotifications
594
+
? "text-gray-900 dark:text-gray-100"
595
+
: "text-gray-600 dark:text-gray-400"
596
+
}`}
597
+
>
598
+
{!isNotifications ? (
599
+
<IconMaterialSymbolsNotificationsOutline
600
+
width={24}
601
+
height={24}
602
+
/>
603
+
) : (
604
+
<IconMaterialSymbolsNotifications width={24} height={24} />
605
+
)}
606
+
<span className="text-xs mt-1">Notifications</span>
607
+
</Link> */}
608
+
<MaterialNavItem
609
+
small
610
+
InactiveIcon={
611
+
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
612
+
}
613
+
ActiveIcon={
614
+
<IconMaterialSymbolsAccountCircle className="w-6 h-6" />
615
+
}
616
+
active={locationEnum === "profile"}
617
+
onClickCallbback={() => {
618
+
if (authed && agent && agent.assertDid) {
619
+
//window.location.href = `/profile/${agent.assertDid}`;
620
+
navigate({
621
+
to: "/profile/$did",
622
+
params: { did: agent.assertDid },
623
+
});
624
+
}
625
+
}}
626
+
text="Profile"
627
+
/>
628
+
{/* <button
629
+
className={`flex flex-col items-center py-2 px-3 rounded-lg transition-colors flex-1 ${
630
+
isProfile
631
+
? "text-gray-900 dark:text-gray-100"
632
+
: "text-gray-600 dark:text-gray-400"
633
+
}`}
634
+
onClick={() => {
635
+
if (authed && agent && agent.assertDid) {
636
+
//window.location.href = `/profile/${agent.assertDid}`;
637
+
navigate({
638
+
to: "/profile/$did",
639
+
params: { did: agent.assertDid },
640
+
});
641
+
}
642
+
}}
643
+
type="button"
644
+
>
645
+
<IconMaterialSymbolsAccountCircleOutline width={24} height={24} />
646
+
<span className="text-xs mt-1">Profile</span>
647
+
</button> */}
648
+
<MaterialNavItem
649
+
small
650
+
InactiveIcon={
651
+
<IconMaterialSymbolsSettingsOutline className="w-6 h-6" />
652
+
}
653
+
ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />}
654
+
active={locationEnum === "settings"}
655
+
onClickCallbback={() =>
656
navigate({
657
+
to: "/settings",
658
+
//params: { did: agent.assertDid },
659
+
})
660
}
661
+
text="Settings"
662
+
/>
663
+
{/* <Link
664
+
to="/settings"
665
+
className={`flex flex-col items-center py-2 px-3 rounded-lg transition-colors flex-1 ${
666
+
location.pathname.startsWith("/settings")
667
+
? "text-gray-900 dark:text-gray-100"
668
+
: "text-gray-600 dark:text-gray-400"
669
+
}`}
670
+
>
671
+
{!location.pathname.startsWith("/settings") ? (
672
+
<IconMaterialSymbolsSettingsOutline width={24} height={24} />
673
+
) : (
674
+
<IconMaterialSymbolsSettings width={24} height={24} />
675
+
)}
676
+
<span className="text-xs mt-1">Settings</span>
677
+
</Link> */}
678
+
</div>
679
+
</nav>
680
+
) : (
681
+
<div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10">
682
+
<div className="flex items-center gap-2">
683
+
<FluentEmojiHighContrastGlowingStar className="h-6 w-6" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} />
684
+
<span className="font-bold text-lg text-gray-900 dark:text-gray-100">
685
+
Red Dwarf{" "}
686
+
{/* <span className="text-gray-500 dark:text-gray-400 text-sm">
687
+
lite
688
+
</span> */}
689
+
</span>
690
+
</div>
691
+
<div className="flex items-center gap-2">
692
+
<Login compact={true} popup={true} />
693
+
</div>
694
</div>
695
+
)}
696
697
+
<TanStackRouterDevtools position="bottom-left" />
698
<Scripts />
699
</>
700
);
701
}
702
+
703
+
function MaterialNavItem({
704
+
InactiveIcon,
705
+
ActiveIcon,
706
+
text,
707
+
active,
708
+
onClickCallbback,
709
+
small,
710
+
}: {
711
+
InactiveIcon: React.ReactElement;
712
+
ActiveIcon: React.ReactElement;
713
+
text: string;
714
+
active: boolean;
715
+
onClickCallbback: () => void;
716
+
small?: boolean | string;
717
+
}) {
718
+
if (small)
719
+
return (
720
+
<button
721
+
className={`flex flex-col items-center rounded-lg transition-colors ${small} gap-1 ${
722
+
active
723
+
? "text-gray-900 dark:text-gray-100"
724
+
: "text-gray-600 dark:text-gray-400"
725
+
}`}
726
+
onClick={() => {
727
+
onClickCallbback();
728
+
}}
729
+
>
730
+
<div
731
+
className={`px-4 py-1 rounded-full flex items-center justify-center ${active ? " bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 hover:dark:bg-gray-700" : "hover:bg-gray-50 hover:dark:bg-gray-900"}`}
732
+
>
733
+
{active ? ActiveIcon : InactiveIcon}
734
+
</div>
735
+
<span
736
+
className={`text-[12.8px] text-roboto ${active ? "font-medium" : ""}`}
737
+
>
738
+
{text}
739
+
</span>
740
+
</button>
741
+
);
742
+
743
+
return (
744
+
<button
745
+
className={`flex flex-row h-12 min-h-12 max-h-12 px-4 py-0.5 w-full items-center rounded-full transition-colors flex-1 gap-1 ${
746
+
active
747
+
? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-800 bg-gray-200 hover:dark:bg-gray-700"
748
+
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-900"
749
+
}`}
750
+
onClick={() => {
751
+
onClickCallbback();
752
+
}}
753
+
>
754
+
<div className={`mr-4 ${active ? " " : " "}`}>
755
+
{active ? ActiveIcon : InactiveIcon}
756
+
</div>
757
+
<span
758
+
className={`text-[17px] text-roboto ${active ? "font-medium" : ""}`}
759
+
>
760
+
{text}
761
+
</span>
762
+
</button>
763
+
);
764
+
}
765
+
766
+
function MaterialPillButton({
767
+
InactiveIcon,
768
+
ActiveIcon,
769
+
text,
770
+
//active,
771
+
onClickCallbback,
772
+
small,
773
+
}: {
774
+
InactiveIcon: React.ReactElement;
775
+
ActiveIcon: React.ReactElement;
776
+
text: string;
777
+
//active: boolean;
778
+
onClickCallbback: () => void;
779
+
small?: boolean | string;
780
+
}) {
781
+
const active = false;
782
+
return (
783
+
<button
784
+
className={`flex border border-gray-400 dark:border-gray-400 flex-row h-12 min-h-12 max-h-12 ${small ? "p-3 w-12" : "px-4 py-0.5"} items-center rounded-full transition-colors gap-1 ${
785
+
active
786
+
? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600"
787
+
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800"
788
+
}`}
789
+
onClick={() => {
790
+
onClickCallbback();
791
+
}}
792
+
>
793
+
<div className={`${!small && "mr-2"} ${active ? " " : " "}`}>
794
+
{active ? ActiveIcon : InactiveIcon}
795
+
</div>
796
+
{!small && (
797
+
<span
798
+
className={`text-[17px] text-roboto ${active ? "font-medium" : ""}`}
799
+
>
800
+
{text}
801
+
</span>
802
+
)}
803
+
</button>
804
+
);
805
+
}
+28
-13
src/routes/index.tsx
+28
-13
src/routes/index.tsx
···
3
import * as React from "react";
4
import { useEffect, useLayoutEffect } from "react";
5
6
import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed";
7
import { useAuth } from "~/providers/UnifiedAuthProvider";
8
import {
9
agentAtom,
10
authedAtom,
11
feedScrollPositionsAtom,
12
selectedFeedUriAtom,
13
store,
14
} from "~/utils/atoms";
···
349
authed && agent && identity?.pds && feedServiceDid;
350
const isReadyForUnauthedFeed = !authed && selectedFeed;
351
352
return (
353
<div
354
className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`}
355
>
356
-
<div className="flex items-center gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin">
357
-
{savedFeeds.length > 0 ? (
358
-
savedFeeds.map((item: any, idx: number) => {
359
const label = item.value.split("/").pop() || item.value;
360
const isActive = selectedFeed === item.value;
361
return (
···
363
key={item.value || idx}
364
className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${
365
isActive
366
-
? "bg-gray-500 text-white"
367
-
: item.pinned
368
-
? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
369
-
: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
370
}`}
371
onClick={() => setSelectedFeed(item.value)}
372
title={item.value}
373
>
374
{label}
375
{item.pinned && (
376
-
<span className="ml-1 text-xs text-gray-700 dark:text-gray-200">
377
โ
378
</span>
379
)}
380
</button>
381
);
382
-
})
383
-
) : (
384
-
<span className="text-xl font-bold ml-2">Home</span>
385
-
)}
386
-
</div>
387
{/* {isFeedLoading && <div className="p-4 text-gray-500">Loading...</div>}
388
{feedError && <div className="p-4 text-red-500">{feedError.message}</div>}
389
{!isFeedLoading && !feedError && feed.length === 0 && (
···
404
405
{isReadyForAuthedFeed || isReadyForUnauthedFeed ? (
406
<InfiniteCustomFeed
407
feedUri={selectedFeed!}
408
pdsUrl={identity?.pds}
409
feedServiceDid={feedServiceDid}
···
3
import * as React from "react";
4
import { useEffect, useLayoutEffect } from "react";
5
6
+
import { Header } from "~/components/Header";
7
import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed";
8
import { useAuth } from "~/providers/UnifiedAuthProvider";
9
import {
10
agentAtom,
11
authedAtom,
12
feedScrollPositionsAtom,
13
+
isAtTopAtom,
14
selectedFeedUriAtom,
15
store,
16
} from "~/utils/atoms";
···
351
authed && agent && identity?.pds && feedServiceDid;
352
const isReadyForUnauthedFeed = !authed && selectedFeed;
353
354
+
355
+
const [isAtTop] = useAtom(isAtTopAtom);
356
+
357
return (
358
<div
359
className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`}
360
>
361
+
{savedFeeds.length > 0 ? (
362
+
<div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}>
363
+
{savedFeeds.map((item: any, idx: number) => {
364
const label = item.value.split("/").pop() || item.value;
365
const isActive = selectedFeed === item.value;
366
return (
···
368
key={item.value || idx}
369
className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${
370
isActive
371
+
? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600"
372
+
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800"
373
+
// ? "bg-gray-500 text-white"
374
+
// : item.pinned
375
+
// ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
376
+
// : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
377
}`}
378
onClick={() => setSelectedFeed(item.value)}
379
title={item.value}
380
>
381
{label}
382
{item.pinned && (
383
+
<span
384
+
className={`ml-1 text-xs ${
385
+
isActive
386
+
? "text-gray-900 dark:text-gray-100"
387
+
: "text-gray-600 dark:text-gray-400"
388
+
}`}
389
+
>
390
โ
391
</span>
392
)}
393
</button>
394
);
395
+
})}
396
+
</div>
397
+
) : (
398
+
// <span className="text-xl font-bold ml-2">Home</span>
399
+
<Header title="Home" />
400
+
)}
401
{/* {isFeedLoading && <div className="p-4 text-gray-500">Loading...</div>}
402
{feedError && <div className="p-4 text-red-500">{feedError.message}</div>}
403
{!isFeedLoading && !feedError && feed.length === 0 && (
···
418
419
{isReadyForAuthedFeed || isReadyForUnauthedFeed ? (
420
<InfiniteCustomFeed
421
+
key={selectedFeed!}
422
feedUri={selectedFeed!}
423
pdsUrl={identity?.pds}
424
feedServiceDid={feedServiceDid}
+7
-3
src/routes/notifications.tsx
+7
-3
src/routes/notifications.tsx
···
1
import { createFileRoute } from "@tanstack/react-router";
2
import React, { useEffect, useRef,useState } from "react";
3
4
import { useAuth } from "~/providers/UnifiedAuthProvider";
5
6
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
7
···
70
}
71
}
72
73
useEffect(() => {
74
if (!did) return;
75
setLoading(true);
76
setError(null);
77
const urls = [
78
-
`https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`,
79
-
`https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`,
80
-
`https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`,
81
];
82
let ignore = false;
83
Promise.all(
···
1
import { createFileRoute } from "@tanstack/react-router";
2
+
import { useAtom } from "jotai";
3
import React, { useEffect, useRef,useState } from "react";
4
5
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
+
import { constellationURLAtom } from "~/utils/atoms";
7
8
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
9
···
72
}
73
}
74
75
+
const [constellationURL] = useAtom(constellationURLAtom)
76
+
77
useEffect(() => {
78
if (!did) return;
79
setLoading(true);
80
setError(null);
81
const urls = [
82
+
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`,
83
+
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`,
84
+
`https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`,
85
];
86
let ignore = false;
87
Promise.all(
+207
-58
src/routes/profile.$did/index.tsx
+207
-58
src/routes/profile.$did/index.tsx
···
1
import { useQueryClient } from "@tanstack/react-query";
2
-
import { createFileRoute, Link } from "@tanstack/react-router";
3
-
import React from "react";
4
5
-
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
6
import { useAuth } from "~/providers/UnifiedAuthProvider";
7
-
import { toggleFollow, useGetFollowState } from "~/utils/followState";
8
import {
9
-
useInfiniteQueryAuthorFeed,
10
useQueryIdentity,
11
useQueryProfile,
12
} from "~/utils/useQuery";
···
18
function ProfileComponent() {
19
// booo bad this is not always the did it might be a handle, use identity.did instead
20
const { did } = Route.useParams();
21
const queryClient = useQueryClient();
22
-
const { agent } = useAuth();
23
const {
24
data: identity,
25
isLoading: isIdentityLoading,
26
error: identityError,
27
} = useQueryIdentity(did);
28
29
-
const followRecords = useGetFollowState({
30
-
target: identity?.did || did,
31
-
user: agent?.did,
32
-
});
33
-
34
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
35
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
36
-
const pdsUrl = identity?.pds;
37
38
const profileUri = resolvedDid
39
? `at://${resolvedDid}/app.bsky.actor.profile/self`
···
41
const { data: profileRecord } = useQueryProfile(profileUri);
42
const profile = profileRecord?.value;
43
44
const {
45
data: postsData,
46
fetchNextPage,
47
hasNextPage,
48
isFetchingNextPage,
49
isLoading: arePostsLoading,
50
-
} = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl);
51
52
React.useEffect(() => {
53
if (postsData) {
54
postsData.pages.forEach((page) => {
55
-
page.records.forEach((record) => {
56
if (!queryClient.getQueryData(["post", record.uri])) {
57
queryClient.setQueryData(["post", record.uri], record);
58
}
···
62
}, [postsData, queryClient]);
63
64
const posts = React.useMemo(
65
-
() => postsData?.pages.flatMap((page) => page.records) ?? [],
66
[postsData]
67
);
68
69
function getAvatarUrl(p: typeof profile) {
70
const link = p?.avatar?.ref?.["$link"];
71
if (!link || !resolvedDid) return null;
72
-
return `https://cdn.bsky.app/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
73
}
74
function getBannerUrl(p: typeof profile) {
75
const link = p?.banner?.ref?.["$link"];
76
if (!link || !resolvedDid) return null;
77
-
return `https://cdn.bsky.app/img/banner/plain/${resolvedDid}/${link}@jpeg`;
78
}
79
80
const displayName =
···
104
105
return (
106
<>
107
-
<div className="flex gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700">
108
<Link
109
to=".."
110
className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
···
121
โ
122
</Link>
123
<span className="text-xl font-bold ml-2">Profile</span>
124
-
</div>
125
126
{/* Profile Header */}
127
<div className="w-full max-w-2xl mx-auto overflow-hidden relative bg-gray-100 dark:bg-gray-900">
···
151
also delay the backfill to be on demand because it would be pretty intense
152
also save it persistently
153
*/}
154
-
{identity?.did !== agent?.did ? (
155
-
<>
156
-
{!(followRecords?.length && followRecords?.length > 0) ? (
157
-
<button
158
-
onClick={() =>
159
-
toggleFollow({
160
-
agent: agent || undefined,
161
-
targetDid: identity?.did,
162
-
followRecords: followRecords,
163
-
queryClient: queryClient,
164
-
})
165
-
}
166
-
className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"
167
-
>
168
-
Follow
169
-
</button>
170
-
) : (
171
-
<button
172
-
onClick={() =>
173
-
toggleFollow({
174
-
agent: agent || undefined,
175
-
targetDid: identity?.did,
176
-
followRecords: followRecords,
177
-
queryClient: queryClient,
178
-
})
179
-
}
180
-
className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"
181
-
>
182
-
Unfollow
183
-
</button>
184
-
)}
185
-
</>
186
-
) : (
187
-
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
188
-
Edit Profile
189
-
</button>
190
-
)}
191
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
192
... {/* todo: icon */}
193
</button>
···
196
{/* Info Card */}
197
<div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100">
198
<div className="font-bold text-2xl">{displayName}</div>
199
-
<div className="text-gray-500 dark:text-gray-400 text-base mb-3">
200
{handle}
201
</div>
202
{description && (
203
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
204
-
{description}
205
</div>
206
)}
207
</div>
···
244
</>
245
);
246
}
···
1
+
import { RichText } from "@atproto/api";
2
import { useQueryClient } from "@tanstack/react-query";
3
+
import { createFileRoute, useNavigate } from "@tanstack/react-router";
4
+
import { useAtom } from "jotai";
5
+
import React, { type ReactNode, useEffect, useState } from "react";
6
7
+
import { Header } from "~/components/Header";
8
+
import {
9
+
renderTextWithFacets,
10
+
UniversalPostRendererATURILoader,
11
+
} from "~/components/UniversalPostRenderer";
12
import { useAuth } from "~/providers/UnifiedAuthProvider";
13
+
import { aturiListServiceAtom, imgCDNAtom } from "~/utils/atoms";
14
import {
15
+
toggleFollow,
16
+
useGetFollowState,
17
+
useGetOneToOneState,
18
+
} from "~/utils/followState";
19
+
import {
20
+
useInfiniteQueryAturiList,
21
useQueryIdentity,
22
useQueryProfile,
23
} from "~/utils/useQuery";
···
29
function ProfileComponent() {
30
// booo bad this is not always the did it might be a handle, use identity.did instead
31
const { did } = Route.useParams();
32
+
//const navigate = useNavigate();
33
const queryClient = useQueryClient();
34
const {
35
data: identity,
36
isLoading: isIdentityLoading,
37
error: identityError,
38
} = useQueryIdentity(did);
39
40
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
41
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
42
+
//const pdsUrl = identity?.pds;
43
44
const profileUri = resolvedDid
45
? `at://${resolvedDid}/app.bsky.actor.profile/self`
···
47
const { data: profileRecord } = useQueryProfile(profileUri);
48
const profile = profileRecord?.value;
49
50
+
const [aturilistservice] = useAtom(aturiListServiceAtom);
51
+
52
const {
53
data: postsData,
54
fetchNextPage,
55
hasNextPage,
56
isFetchingNextPage,
57
isLoading: arePostsLoading,
58
+
} = useInfiniteQueryAturiList({
59
+
aturilistservice: aturilistservice,
60
+
did: resolvedDid,
61
+
collection: "app.bsky.feed.post",
62
+
reverse: true
63
+
});
64
65
React.useEffect(() => {
66
if (postsData) {
67
postsData.pages.forEach((page) => {
68
+
page.forEach((record) => {
69
if (!queryClient.getQueryData(["post", record.uri])) {
70
queryClient.setQueryData(["post", record.uri], record);
71
}
···
75
}, [postsData, queryClient]);
76
77
const posts = React.useMemo(
78
+
() => postsData?.pages.flatMap((page) => page) ?? [],
79
[postsData]
80
);
81
82
+
const [imgcdn] = useAtom(imgCDNAtom);
83
+
84
function getAvatarUrl(p: typeof profile) {
85
const link = p?.avatar?.ref?.["$link"];
86
if (!link || !resolvedDid) return null;
87
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
88
}
89
function getBannerUrl(p: typeof profile) {
90
const link = p?.banner?.ref?.["$link"];
91
if (!link || !resolvedDid) return null;
92
+
return `https://${imgcdn}/img/banner/plain/${resolvedDid}/${link}@jpeg`;
93
}
94
95
const displayName =
···
119
120
return (
121
<>
122
+
<Header
123
+
title={`Profile`}
124
+
backButtonCallback={() => {
125
+
if (window.history.length > 1) {
126
+
window.history.back();
127
+
} else {
128
+
window.location.assign("/");
129
+
}
130
+
}}
131
+
/>
132
+
{/* <div className="flex gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700">
133
<Link
134
to=".."
135
className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
···
146
โ
147
</Link>
148
<span className="text-xl font-bold ml-2">Profile</span>
149
+
</div> */}
150
151
{/* Profile Header */}
152
<div className="w-full max-w-2xl mx-auto overflow-hidden relative bg-gray-100 dark:bg-gray-900">
···
176
also delay the backfill to be on demand because it would be pretty intense
177
also save it persistently
178
*/}
179
+
<FollowButton targetdidorhandle={did} />
180
<button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]">
181
... {/* todo: icon */}
182
</button>
···
185
{/* Info Card */}
186
<div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100">
187
<div className="font-bold text-2xl">{displayName}</div>
188
+
<div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1">
189
+
<Mutual targetdidorhandle={did} />
190
{handle}
191
</div>
192
{description && (
193
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
194
+
{/* {description} */}
195
+
<RichTextRenderer key={did} description={description} />
196
</div>
197
)}
198
</div>
···
235
</>
236
);
237
}
238
+
239
+
export function FollowButton({
240
+
targetdidorhandle,
241
+
}: {
242
+
targetdidorhandle: string;
243
+
}) {
244
+
const { agent } = useAuth();
245
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
246
+
const queryClient = useQueryClient();
247
+
248
+
const followRecords = useGetFollowState({
249
+
target: identity?.did ?? targetdidorhandle,
250
+
user: agent?.did,
251
+
});
252
+
253
+
return (
254
+
<>
255
+
{identity?.did !== agent?.did ? (
256
+
<>
257
+
{!(followRecords?.length && followRecords?.length > 0) ? (
258
+
<button
259
+
onClick={(e) => {
260
+
e.stopPropagation();
261
+
toggleFollow({
262
+
agent: agent || undefined,
263
+
targetDid: identity?.did,
264
+
followRecords: followRecords,
265
+
queryClient: queryClient,
266
+
});
267
+
}}
268
+
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
269
+
>
270
+
Follow
271
+
</button>
272
+
) : (
273
+
<button
274
+
onClick={(e) => {
275
+
e.stopPropagation();
276
+
toggleFollow({
277
+
agent: agent || undefined,
278
+
targetDid: identity?.did,
279
+
followRecords: followRecords,
280
+
queryClient: queryClient,
281
+
});
282
+
}}
283
+
className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"
284
+
>
285
+
Unfollow
286
+
</button>
287
+
)}
288
+
</>
289
+
) : (
290
+
<button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]">
291
+
Edit Profile
292
+
</button>
293
+
)}
294
+
</>
295
+
);
296
+
}
297
+
298
+
export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
299
+
const { agent } = useAuth();
300
+
const { data: identity } = useQueryIdentity(targetdidorhandle);
301
+
302
+
const theyFollowYouRes = useGetOneToOneState(
303
+
agent?.did
304
+
? {
305
+
target: agent?.did,
306
+
user: identity?.did ?? targetdidorhandle,
307
+
collection: "app.bsky.graph.follow",
308
+
path: ".subject",
309
+
}
310
+
: undefined
311
+
);
312
+
313
+
const youFollowThemRes = useGetFollowState({
314
+
target: identity?.did ?? targetdidorhandle,
315
+
user: agent?.did,
316
+
});
317
+
318
+
const theyFollowYou: boolean =
319
+
!!theyFollowYouRes?.length && theyFollowYouRes.length > 0;
320
+
const youFollowThem: boolean =
321
+
!!youFollowThemRes?.length && youFollowThemRes.length > 0;
322
+
323
+
return (
324
+
<>
325
+
{/* if not self */}
326
+
{identity?.did !== agent?.did ? (
327
+
<>
328
+
{theyFollowYou ? (
329
+
<>
330
+
{youFollowThem ? (
331
+
<div className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center">
332
+
mutuals
333
+
</div>
334
+
) : (
335
+
<div className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center">
336
+
follows you
337
+
</div>
338
+
)}
339
+
</>
340
+
) : (
341
+
<></>
342
+
)}
343
+
</>
344
+
) : (
345
+
// lmao can someone be mutuals with themselves ??
346
+
<></>
347
+
)}
348
+
</>
349
+
);
350
+
}
351
+
352
+
export function RichTextRenderer({ description }: { description: string }) {
353
+
const [richDescription, setRichDescription] = useState<string | ReactNode[]>(
354
+
description
355
+
);
356
+
const { agent } = useAuth();
357
+
const navigate = useNavigate();
358
+
359
+
useEffect(() => {
360
+
let mounted = true;
361
+
362
+
// setRichDescription(description);
363
+
364
+
async function processRichText() {
365
+
try {
366
+
if (!agent?.did) return;
367
+
const rt = new RichText({ text: description });
368
+
await rt.detectFacets(agent);
369
+
370
+
if (!mounted) return;
371
+
372
+
if (rt.facets) {
373
+
setRichDescription(
374
+
renderTextWithFacets({ text: rt.text, facets: rt.facets, navigate })
375
+
);
376
+
} else {
377
+
setRichDescription(rt.text);
378
+
}
379
+
} catch (error) {
380
+
console.error("Failed to detect facets:", error);
381
+
if (mounted) {
382
+
setRichDescription(description);
383
+
}
384
+
}
385
+
}
386
+
387
+
processRichText();
388
+
389
+
return () => {
390
+
mounted = false;
391
+
};
392
+
}, [description, agent, navigate]);
393
+
394
+
return <>{richDescription}</>;
395
+
}
+165
src/routes/profile.$did/post.$rkey.image.$i.tsx
+165
src/routes/profile.$did/post.$rkey.image.$i.tsx
···
···
1
+
import {
2
+
createFileRoute,
3
+
useNavigate,
4
+
type UseNavigateResult,
5
+
} from "@tanstack/react-router";
6
+
import { useEffect, useState } from "react";
7
+
import { createPortal } from "react-dom";
8
+
9
+
import { ProfilePostComponent } from "./post.$rkey";
10
+
11
+
export const Route = createFileRoute("/profile/$did/post/$rkey/image/$i")({
12
+
component: Lightbox,
13
+
});
14
+
15
+
export type LightboxProps = {
16
+
images: { src: string; alt?: string }[];
17
+
};
18
+
19
+
function nextprev({
20
+
index,
21
+
images,
22
+
navigate,
23
+
did,
24
+
rkey,
25
+
prev,
26
+
}: {
27
+
index?: number;
28
+
images?: LightboxProps["images"];
29
+
navigate: UseNavigateResult<string>;
30
+
did: string;
31
+
rkey: string;
32
+
prev?: boolean;
33
+
}) {
34
+
const len = images?.length ?? 0;
35
+
if (len === 0) return;
36
+
37
+
const nextIndex = ((index ?? 0) + (prev ? -1 : 1) + len) % len;
38
+
39
+
navigate({
40
+
to: "/profile/$did/post/$rkey/image/$i",
41
+
params: {
42
+
did,
43
+
rkey,
44
+
i: nextIndex.toString(),
45
+
},
46
+
replace: true,
47
+
});
48
+
}
49
+
50
+
export function Lightbox() {
51
+
console.log("hey the $i route is loaded w!!!");
52
+
const { did, rkey, i } = Route.useParams();
53
+
const [images, setImages] = useState<LightboxProps["images"] | undefined>(
54
+
undefined
55
+
);
56
+
const index = Number(i);
57
+
const navigate = useNavigate();
58
+
const post = true;
59
+
const image = images?.[index] ?? undefined;
60
+
61
+
function lightboxCallback(d: LightboxProps) {
62
+
console.log("callback actually called!");
63
+
setImages(d.images);
64
+
}
65
+
66
+
useEffect(() => {
67
+
function handleKey(e: KeyboardEvent) {
68
+
if (e.key === "Escape") window.history.back();
69
+
if (e.key === "ArrowRight")
70
+
nextprev({ index, images, navigate, did, rkey });
71
+
//onNavigate((index + 1) % images.length);
72
+
if (e.key === "ArrowLeft")
73
+
nextprev({ index, images, navigate, did, rkey, prev: true });
74
+
//onNavigate((index - 1 + images.length) % images.length);
75
+
}
76
+
window.addEventListener("keydown", handleKey);
77
+
return () => window.removeEventListener("keydown", handleKey);
78
+
}, [index, navigate, did, rkey, images]);
79
+
80
+
return createPortal(
81
+
<>
82
+
{post && (
83
+
<div
84
+
onClick={(e) => {
85
+
e.stopPropagation();
86
+
e.nativeEvent.stopImmediatePropagation();
87
+
}}
88
+
className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white"
89
+
>
90
+
<ProfilePostComponent
91
+
key={`/profile/${did}/post/${rkey}`}
92
+
did={did}
93
+
rkey={rkey}
94
+
nopics
95
+
lightboxCallback={lightboxCallback}
96
+
/>
97
+
</div>
98
+
)}
99
+
<div
100
+
className="lightbox fixed inset-0 z-50 flex items-center justify-center bg-black/80 w-screen lg:w-[calc(100vw-350px)] lg:max-w-[calc(100vw-350px)]"
101
+
onClick={(e) => {
102
+
e.stopPropagation();
103
+
window.history.back();
104
+
}}
105
+
>
106
+
<img
107
+
src={image?.src}
108
+
alt={image?.alt}
109
+
className="max-h-[90%] max-w-[90%] object-contain rounded-lg shadow-lg"
110
+
onClick={(e) => e.stopPropagation()}
111
+
/>
112
+
113
+
{(images?.length ?? 0) > 1 && (
114
+
<>
115
+
<button
116
+
onClick={(e) => {
117
+
e.stopPropagation();
118
+
nextprev({ index, images, navigate, did, rkey, prev: true });
119
+
}}
120
+
className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl h-8 w-8 rounded-full bg-gray-900 flex items-center justify-center"
121
+
>
122
+
<svg
123
+
xmlns="http://www.w3.org/2000/svg"
124
+
width={28}
125
+
height={28}
126
+
viewBox="0 0 24 24"
127
+
>
128
+
<g fill="none" fillRule="evenodd">
129
+
<path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"></path>
130
+
<path
131
+
fill="currentColor"
132
+
d="M8.293 12.707a1 1 0 0 1 0-1.414l5.657-5.657a1 1 0 1 1 1.414 1.414L10.414 12l4.95 4.95a1 1 0 0 1-1.414 1.414z"
133
+
></path>
134
+
</g>
135
+
</svg>
136
+
</button>
137
+
<button
138
+
onClick={(e) => {
139
+
e.stopPropagation();
140
+
nextprev({ index, images, navigate, did, rkey });
141
+
}}
142
+
className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl h-8 w-8 rounded-full bg-gray-900 flex items-center justify-center"
143
+
>
144
+
<svg
145
+
xmlns="http://www.w3.org/2000/svg"
146
+
width={28}
147
+
height={28}
148
+
viewBox="0 0 24 24"
149
+
>
150
+
<g fill="none" fillRule="evenodd">
151
+
<path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"></path>
152
+
<path
153
+
fill="currentColor"
154
+
d="M15.707 11.293a1 1 0 0 1 0 1.414l-5.657 5.657a1 1 0 1 1-1.414-1.414l4.95-4.95l-4.95-4.95a1 1 0 0 1 1.414-1.414z"
155
+
></path>
156
+
</g>
157
+
</svg>
158
+
</button>
159
+
</>
160
+
)}
161
+
</div>
162
+
</>,
163
+
document.body
164
+
);
165
+
}
+229
-80
src/routes/profile.$did/post.$rkey.tsx
+229
-80
src/routes/profile.$did/post.$rkey.tsx
···
1
-
import { useQueryClient } from "@tanstack/react-query";
2
-
import { createFileRoute } from "@tanstack/react-router";
3
import React, { useLayoutEffect } from "react";
4
5
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
6
//import { usePersistentStore } from '~/providers/PersistentStoreProvider';
7
import {
8
constructPostQuery,
9
useQueryConstellation,
10
useQueryIdentity,
11
useQueryPost,
12
} from "~/utils/useQuery";
13
14
//const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
15
···
36
did,
37
rkey,
38
nopics,
39
}: {
40
did: string;
41
rkey: string;
42
-
nopics?: () => void;
43
}) {
44
//const { get, set } = usePersistentStore();
45
const queryClient = useQueryClient();
···
187
() =>
188
resolvedDid
189
? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}`
190
-
: "",
191
[resolvedDid, rkey]
192
);
193
194
const { data: mainPost } = useQueryPost(atUri);
195
196
-
const { data: repliesData } = useQueryConstellation({
197
-
method: "/links",
198
target: atUri,
199
-
collection: "app.bsky.feed.post",
200
-
path: ".reply.parent.uri",
201
});
202
-
const replies = repliesData?.linking_records.slice(0, 50) ?? [];
203
204
const [parents, setParents] = React.useState<any[]>([]);
205
const [parentsLoading, setParentsLoading] = React.useState(false);
206
207
const mainPostRef = React.useRef<HTMLDivElement>(null);
208
-
const userHasScrolled = React.useRef(false);
209
210
-
const scrollAnchor = React.useRef<{ top: number } | null>(null);
211
212
-
React.useEffect(() => {
213
-
const onScroll = () => {
214
-
if (window.scrollY > 50) {
215
-
userHasScrolled.current = true;
216
217
-
window.removeEventListener("scroll", onScroll);
218
}
219
-
};
220
-
221
-
if (!userHasScrolled.current) {
222
-
window.addEventListener("scroll", onScroll, { passive: true });
223
}
224
-
return () => window.removeEventListener("scroll", onScroll);
225
-
}, []);
226
227
-
useLayoutEffect(() => {
228
-
if (parentsLoading && mainPostRef.current && !userHasScrolled.current) {
229
-
scrollAnchor.current = {
230
-
top: mainPostRef.current.getBoundingClientRect().top,
231
-
};
232
}
233
-
}, [parentsLoading]);
234
235
-
useLayoutEffect(() => {
236
-
if (
237
-
scrollAnchor.current &&
238
-
mainPostRef.current &&
239
-
!userHasScrolled.current
240
-
) {
241
-
const newTop = mainPostRef.current.getBoundingClientRect().top;
242
-
const topDiff = newTop - scrollAnchor.current.top;
243
-
if (topDiff > 0) {
244
-
window.scrollBy(0, topDiff);
245
-
}
246
-
scrollAnchor.current = null;
247
}
248
-
}, [parents]);
249
250
React.useEffect(() => {
251
if (!mainPost?.value?.reply?.parent?.uri) {
···
264
while (currentParentUri && safetyCounter < MAX_PARENTS) {
265
try {
266
const parentPost = await queryClient.fetchQuery(
267
-
constructPostQuery(currentParentUri)
268
);
269
if (!parentPost) break;
270
parentChain.push(parentPost);
···
296
297
return (
298
<>
299
-
<div className="flex items-center gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700">
300
-
{!nopics ? (
301
-
<button
302
-
//to=".."
303
-
className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
304
-
onClick={(e) => {
305
-
e.preventDefault();
306
-
if (window.history.length > 1) {
307
-
window.history.back();
308
-
} else {
309
-
window.location.assign("/");
310
-
}
311
-
}}
312
-
aria-label="Go back"
313
-
>
314
-
โ
315
-
</button>
316
-
) : (
317
-
<button
318
-
//to=".."
319
-
className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
320
-
onClick={(e) => {
321
-
e.preventDefault();
322
-
nopics();
323
-
}}
324
-
aria-label="Go back"
325
-
>
326
-
โ
327
-
</button>
328
-
)}
329
-
<span className="text-xl font-bold ml-2">Post</span>
330
-
</div>
331
332
{parentsLoading && (
333
<div className="text-center text-gray-500 dark:text-gray-400 flex flex-row">
···
359
detailed={true}
360
topReplyLine={parentsLoading || parents.length > 0}
361
nopics={!!nopics}
362
/>
363
</div>
364
<div
···
366
maxWidth: 600,
367
//margin: "0px auto 0",
368
padding: 0,
369
-
minHeight: "100dvh",
370
}}
371
>
372
<div
···
380
Replies
381
</div>
382
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
383
-
{replies.length > 0 &&
384
-
replies.map((reply) => {
385
-
const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`;
386
return (
387
<UniversalPostRendererATURILoader
388
-
key={replyAtUri}
389
-
atUri={replyAtUri}
390
/>
391
);
392
})}
393
</div>
394
</div>
395
</>
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
+
import { createFileRoute, Outlet } from "@tanstack/react-router";
4
+
import { useAtom } from "jotai";
5
import React, { useLayoutEffect } from "react";
6
7
+
import { Header } from "~/components/Header";
8
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
9
+
import { constellationURLAtom, slingshotURLAtom } from "~/utils/atoms";
10
//import { usePersistentStore } from '~/providers/PersistentStoreProvider';
11
import {
12
constructPostQuery,
13
+
type linksAllResponse,
14
+
type linksRecordsResponse,
15
useQueryConstellation,
16
useQueryIdentity,
17
useQueryPost,
18
+
yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks,
19
} from "~/utils/useQuery";
20
+
21
+
import type { LightboxProps } from "./post.$rkey.image.$i";
22
23
//const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
24
···
45
did,
46
rkey,
47
nopics,
48
+
lightboxCallback,
49
}: {
50
did: string;
51
rkey: string;
52
+
nopics?: boolean;
53
+
lightboxCallback?: (d: LightboxProps) => void;
54
}) {
55
//const { get, set } = usePersistentStore();
56
const queryClient = useQueryClient();
···
198
() =>
199
resolvedDid
200
? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}`
201
+
: undefined,
202
[resolvedDid, rkey]
203
);
204
205
const { data: mainPost } = useQueryPost(atUri);
206
207
+
console.log("atUri",atUri)
208
+
209
+
const opdid = React.useMemo(
210
+
() =>
211
+
atUri
212
+
? new AtUri(atUri).host
213
+
: undefined,
214
+
[atUri]
215
+
);
216
+
217
+
// @ts-expect-error i hate overloads
218
+
const { data: links } = useQueryConstellation(atUri?{
219
+
method: "/links/all",
220
target: atUri,
221
+
} : {
222
+
method: "undefined",
223
+
target: ""
224
+
})as { data: linksAllResponse | undefined };
225
+
226
+
//const [likes, setLikes] = React.useState<number | null>(null);
227
+
//const [reposts, setReposts] = React.useState<number | null>(null);
228
+
const [replyCount, setReplyCount] = React.useState<number | null>(null);
229
+
230
+
React.useEffect(() => {
231
+
// /*mass comment*/ console.log(JSON.stringify(links, null, 2));
232
+
// setLikes(
233
+
// links
234
+
// ? links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0
235
+
// : null
236
+
// );
237
+
// setReposts(
238
+
// links
239
+
// ? links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0
240
+
// : null
241
+
// );
242
+
setReplyCount(
243
+
links
244
+
? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]
245
+
?.records || 0
246
+
: null
247
+
);
248
+
}, [links]);
249
+
250
+
const { data: opreplies } = useQueryConstellation(
251
+
!!opdid && replyCount && replyCount >= 25
252
+
? {
253
+
method: "/links",
254
+
target: atUri,
255
+
// @ts-expect-error overloading sucks so much
256
+
collection: "app.bsky.feed.post",
257
+
path: ".reply.parent.uri",
258
+
//cursor?: string;
259
+
dids: [opdid],
260
+
}
261
+
: {
262
+
method: "undefined",
263
+
target: "",
264
+
}
265
+
) as { data: linksRecordsResponse | undefined };
266
+
267
+
const opReplyAturis =
268
+
opreplies?.linking_records.map(
269
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`,
270
+
) ?? [];
271
+
272
+
273
+
// const { data: repliesData } = useQueryConstellation({
274
+
// method: "/links",
275
+
// target: atUri,
276
+
// collection: "app.bsky.feed.post",
277
+
// path: ".reply.parent.uri",
278
+
// });
279
+
// const replies = repliesData?.linking_records.slice(0, 50) ?? [];
280
+
const [constellationurl] = useAtom(constellationURLAtom)
281
+
282
+
const infinitequeryresults = useInfiniteQuery({
283
+
...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
284
+
{
285
+
constellation: constellationurl,
286
+
method: "/links",
287
+
target: atUri,
288
+
collection: "app.bsky.feed.post",
289
+
path: ".reply.parent.uri",
290
+
}
291
+
),
292
+
enabled: !!atUri,
293
});
294
+
295
+
const {
296
+
data: infiniteRepliesData,
297
+
fetchNextPage,
298
+
hasNextPage,
299
+
isFetchingNextPage,
300
+
} = infinitequeryresults;
301
+
302
+
// // auto-fetch all pages
303
+
// useEffect(() => {
304
+
// if (
305
+
// infinitequeryresults.hasNextPage &&
306
+
// !infinitequeryresults.isFetchingNextPage
307
+
// ) {
308
+
// console.log("Fetching the next page...");
309
+
// infinitequeryresults.fetchNextPage();
310
+
// }
311
+
// }, [infinitequeryresults]);
312
+
313
+
// const replyAturis = repliesData
314
+
// ? repliesData.pages.flatMap((page) =>
315
+
// page
316
+
// ? page.linking_records.map((record) => {
317
+
// const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
318
+
// return aturi;
319
+
// })
320
+
// : []
321
+
// )
322
+
// : [];
323
+
324
+
const replyAturis = React.useMemo(() => {
325
+
// Get all replies from the standard infinite query
326
+
const allReplies =
327
+
infiniteRepliesData?.pages.flatMap(
328
+
(page) =>
329
+
page?.linking_records.map(
330
+
(r) => `at://${r.did}/${r.collection}/${r.rkey}`,
331
+
) ?? [],
332
+
) ?? [];
333
+
334
+
if (replyCount && (replyCount < 25)) {
335
+
// If count is low, just use the standard list and find the oldest OP reply to move to the top
336
+
const opdidFromUri = atUri ? new AtUri(atUri).host : undefined;
337
+
const oldestOpsIndex = allReplies.findIndex(
338
+
(aturi) => new AtUri(aturi).host === opdidFromUri,
339
+
);
340
+
if (oldestOpsIndex > 0) {
341
+
const [oldestOpsReply] = allReplies.splice(oldestOpsIndex, 1);
342
+
allReplies.unshift(oldestOpsReply);
343
+
}
344
+
return allReplies;
345
+
} else {
346
+
// If count is high, prioritize OP replies from the special query
347
+
// and filter them out from the main list to avoid duplication.
348
+
const opReplySet = new Set(opReplyAturis);
349
+
const otherReplies = allReplies.filter((uri) => !opReplySet.has(uri));
350
+
return [...opReplyAturis, ...otherReplies];
351
+
}
352
+
}, [infiniteRepliesData, opReplyAturis, replyCount, atUri]);
353
+
354
+
// Find oldest OP reply
355
+
const oldestOpsIndex = replyAturis.findIndex(
356
+
(aturi) => new AtUri(aturi).host === opdid
357
+
);
358
+
359
+
// Reorder: move oldest OP reply to the front
360
+
if (oldestOpsIndex > 0) {
361
+
const [oldestOpsReply] = replyAturis.splice(oldestOpsIndex, 1);
362
+
replyAturis.unshift(oldestOpsReply);
363
+
}
364
365
const [parents, setParents] = React.useState<any[]>([]);
366
const [parentsLoading, setParentsLoading] = React.useState(false);
367
368
const mainPostRef = React.useRef<HTMLDivElement>(null);
369
+
const hasPerformedInitialLayout = React.useRef(false);
370
+
371
+
const [layoutReady, setLayoutReady] = React.useState(false);
372
+
373
+
useLayoutEffect(() => {
374
+
if (parents.length > 0 && !layoutReady && mainPostRef.current) {
375
+
const mainPostElement = mainPostRef.current;
376
+
377
+
if (window.scrollY === 0 && !hasPerformedInitialLayout.current) {
378
+
const elementTop = mainPostElement.getBoundingClientRect().top;
379
+
const headerOffset = 70;
380
381
+
const targetScrollY = elementTop - headerOffset;
382
383
+
window.scrollBy(0, targetScrollY);
384
385
+
hasPerformedInitialLayout.current = true;
386
}
387
+
388
+
// todo idk what to do with this
389
+
// eslint-disable-next-line react-hooks/set-state-in-effect
390
+
setLayoutReady(true);
391
}
392
+
}, [parents, layoutReady]);
393
394
+
395
+
const [slingshoturl] = useAtom(slingshotURLAtom)
396
+
397
+
React.useEffect(() => {
398
+
if (parentsLoading) {
399
+
setLayoutReady(false);
400
}
401
402
+
if (!mainPost?.value?.reply?.parent?.uri && !parentsLoading) {
403
+
setLayoutReady(true);
404
+
hasPerformedInitialLayout.current = true;
405
}
406
+
}, [parentsLoading, mainPost]);
407
408
React.useEffect(() => {
409
if (!mainPost?.value?.reply?.parent?.uri) {
···
422
while (currentParentUri && safetyCounter < MAX_PARENTS) {
423
try {
424
const parentPost = await queryClient.fetchQuery(
425
+
constructPostQuery(currentParentUri, slingshoturl)
426
);
427
if (!parentPost) break;
428
parentChain.push(parentPost);
···
454
455
return (
456
<>
457
+
<Outlet />
458
+
<Header
459
+
title={`Post`}
460
+
backButtonCallback={() => {
461
+
if (window.history.length > 1) {
462
+
window.history.back();
463
+
} else {
464
+
window.location.assign("/");
465
+
}
466
+
}}
467
+
/>
468
469
{parentsLoading && (
470
<div className="text-center text-gray-500 dark:text-gray-400 flex flex-row">
···
496
detailed={true}
497
topReplyLine={parentsLoading || parents.length > 0}
498
nopics={!!nopics}
499
+
lightboxCallback={lightboxCallback}
500
/>
501
</div>
502
<div
···
504
maxWidth: 600,
505
//margin: "0px auto 0",
506
padding: 0,
507
+
minHeight: "80dvh",
508
+
paddingBottom: "20dvh",
509
}}
510
>
511
<div
···
519
Replies
520
</div>
521
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
522
+
{replyAturis.length > 0 &&
523
+
replyAturis.map((reply) => {
524
+
//const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`;
525
return (
526
<UniversalPostRendererATURILoader
527
+
key={reply}
528
+
atUri={reply}
529
+
maxReplies={4}
530
/>
531
);
532
})}
533
+
{hasNextPage && (
534
+
<button
535
+
onClick={() => fetchNextPage()}
536
+
disabled={isFetchingNextPage}
537
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50"
538
+
>
539
+
{isFetchingNextPage ? "Loading..." : "Load More"}
540
+
</button>
541
+
)}
542
</div>
543
</div>
544
</>
+50
-1
src/routes/search.tsx
+50
-1
src/routes/search.tsx
···
1
import { createFileRoute } from "@tanstack/react-router";
2
3
+
import { Header } from "~/components/Header";
4
+
import { Import } from "~/components/Import";
5
+
6
export const Route = createFileRoute("/search")({
7
component: Search,
8
});
9
10
export function Search() {
11
+
return (
12
+
<>
13
+
<Header
14
+
title="Explore"
15
+
backButtonCallback={() => {
16
+
if (window.history.length > 1) {
17
+
window.history.back();
18
+
} else {
19
+
window.location.assign("/");
20
+
}
21
+
}}
22
+
/>
23
+
<div className=" flex flex-col items-center mt-4 mx-4 gap-4">
24
+
<Import />
25
+
<div className="flex flex-col">
26
+
<p className="text-gray-600 dark:text-gray-400">
27
+
Sorry we dont have search. But instead, you can load some of these
28
+
types of content into Red Dwarf:
29
+
</p>
30
+
<ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400">
31
+
<li>
32
+
Bluesky URLs from supported clients (like{" "}
33
+
<code className="text-sm">bsky.app</code> or{" "}
34
+
<code className="text-sm">deer.social</code>).
35
+
</li>
36
+
<li>
37
+
AT-URIs (e.g.,{" "}
38
+
<code className="text-sm">at://did:example/collection/item</code>
39
+
).
40
+
</li>
41
+
<li>
42
+
Plain handles (like{" "}
43
+
<code className="text-sm">@username.bsky.social</code>).
44
+
</li>
45
+
<li>
46
+
Direct DIDs (Decentralized Identifiers, starting with{" "}
47
+
<code className="text-sm">did:</code>).
48
+
</li>
49
+
</ul>
50
+
<p className="mt-2 text-gray-600 dark:text-gray-400">
51
+
Simply paste one of these into the import field above and press
52
+
Enter to load the content.
53
+
</p>
54
+
</div>
55
+
</div>
56
+
</>
57
+
);
58
}
+189
-1
src/routes/settings.tsx
+189
-1
src/routes/settings.tsx
···
1
import { createFileRoute } from "@tanstack/react-router";
2
+
import { useAtom } from "jotai";
3
+
import { Slider } from "radix-ui";
4
+
5
+
import { Header } from "~/components/Header";
6
+
import Login from "~/components/Login";
7
+
import {
8
+
aturiListServiceAtom,
9
+
constellationURLAtom,
10
+
defaultaturilistservice,
11
+
defaultconstellationURL,
12
+
defaulthue,
13
+
defaultImgCDN,
14
+
defaultslingshotURL,
15
+
defaultVideoCDN,
16
+
hueAtom,
17
+
imgCDNAtom,
18
+
slingshotURLAtom,
19
+
videoCDNAtom,
20
+
} from "~/utils/atoms";
21
22
export const Route = createFileRoute("/settings")({
23
component: Settings,
24
});
25
26
export function Settings() {
27
+
return (
28
+
<>
29
+
<Header
30
+
title="Settings"
31
+
backButtonCallback={() => {
32
+
if (window.history.length > 1) {
33
+
window.history.back();
34
+
} else {
35
+
window.location.assign("/");
36
+
}
37
+
}}
38
+
/>
39
+
<div className="lg:hidden">
40
+
<Login />
41
+
</div>
42
+
<div className="h-4" />
43
+
<TextInputSetting
44
+
atom={constellationURLAtom}
45
+
title={"Constellation"}
46
+
description={
47
+
"Customize the Constellation instance to be used by Red Dwarf"
48
+
}
49
+
init={defaultconstellationURL}
50
+
/>
51
+
<TextInputSetting
52
+
atom={slingshotURLAtom}
53
+
title={"Slingshot"}
54
+
description={"Customize the Slingshot instance to be used by Red Dwarf"}
55
+
init={defaultslingshotURL}
56
+
/>
57
+
<TextInputSetting
58
+
atom={aturiListServiceAtom}
59
+
title={"AtUriListService"}
60
+
description={"Customize the AtUriListService instance to be used by Red Dwarf"}
61
+
init={defaultaturilistservice}
62
+
/>
63
+
<TextInputSetting
64
+
atom={imgCDNAtom}
65
+
title={"Image CDN"}
66
+
description={
67
+
"Customize the Constellation instance to be used by Red Dwarf"
68
+
}
69
+
init={defaultImgCDN}
70
+
/>
71
+
<TextInputSetting
72
+
atom={videoCDNAtom}
73
+
title={"Video CDN"}
74
+
description={"Customize the Slingshot instance to be used by Red Dwarf"}
75
+
init={defaultVideoCDN}
76
+
/>
77
+
78
+
<Hue />
79
+
<p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm">
80
+
please restart/refresh the app if changes arent applying correctly
81
+
</p>
82
+
</>
83
+
);
84
}
85
+
function Hue() {
86
+
const [hue, setHue] = useAtom(hueAtom);
87
+
return (
88
+
<div className="flex flex-col px-4 mt-4 ">
89
+
<span className="z-10">Hue</span>
90
+
<div className="flex flex-row items-center gap-4">
91
+
<SliderComponent
92
+
atom={hueAtom}
93
+
max={360}
94
+
/>
95
+
<button
96
+
onClick={() => setHue(defaulthue ?? 28)}
97
+
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
98
+
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
99
+
>
100
+
Reset
101
+
</button>
102
+
</div>
103
+
</div>
104
+
);
105
+
}
106
+
107
+
export function TextInputSetting({
108
+
atom,
109
+
title,
110
+
description,
111
+
init,
112
+
}: {
113
+
atom: typeof constellationURLAtom;
114
+
title?: string;
115
+
description?: string;
116
+
init?: string;
117
+
}) {
118
+
const [value, setValue] = useAtom(atom);
119
+
return (
120
+
<div className="flex flex-col gap-2 px-4 py-2">
121
+
{/* <div>
122
+
{title && (
123
+
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
124
+
{title}
125
+
</h3>
126
+
)}
127
+
{description && (
128
+
<p className="text-sm text-gray-500 dark:text-gray-400">
129
+
{description}
130
+
</p>
131
+
)}
132
+
</div> */}
133
+
134
+
<div className="flex flex-row gap-2 items-center">
135
+
<div className="m3input-field m3input-label m3input-border size-md flex-1">
136
+
<input
137
+
type="text"
138
+
placeholder=" "
139
+
value={value}
140
+
onChange={(e) => setValue(e.target.value)}
141
+
/>
142
+
<label>{title}</label>
143
+
</div>
144
+
{/* <input
145
+
type="text"
146
+
value={value}
147
+
onChange={(e) => setValue(e.target.value)}
148
+
className="flex-1 px-3 py-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700
149
+
text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400
150
+
focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600"
151
+
placeholder="Enter value..."
152
+
/> */}
153
+
<button
154
+
onClick={() => setValue(init ?? "")}
155
+
className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800
156
+
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
157
+
>
158
+
Reset
159
+
</button>
160
+
</div>
161
+
</div>
162
+
);
163
+
}
164
+
165
+
166
+
interface SliderProps {
167
+
atom: typeof hueAtom;
168
+
min?: number;
169
+
max?: number;
170
+
step?: number;
171
+
}
172
+
173
+
export const SliderComponent: React.FC<SliderProps> = ({
174
+
atom,
175
+
min = 0,
176
+
max = 100,
177
+
step = 1,
178
+
}) => {
179
+
180
+
const [value, setValue] = useAtom(atom)
181
+
182
+
return (
183
+
<Slider.Root
184
+
className="relative flex items-center w-full h-4"
185
+
value={[value]}
186
+
min={min}
187
+
max={max}
188
+
step={step}
189
+
onValueChange={(v: number[]) => setValue(v[0])}
190
+
>
191
+
<Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full">
192
+
<Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" />
193
+
</Slider.Track>
194
+
<Slider.Thumb className="shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" />
195
+
</Slider.Root>
196
+
);
197
+
};
+160
-11
src/styles/app.css
+160
-11
src/styles/app.css
···
1
@import "tailwindcss";
2
3
/* @theme {
···
14
--color-gray-950: oklch(0.129 0.050 222.000);
15
} */
16
17
@theme {
18
-
--color-gray-50: oklch(0.984 0.012 28);
19
-
--color-gray-100: oklch(0.968 0.017 28);
20
-
--color-gray-200: oklch(0.929 0.025 28);
21
-
--color-gray-300: oklch(0.869 0.035 28);
22
-
--color-gray-400: oklch(0.704 0.05 28);
23
-
--color-gray-500: oklch(0.554 0.06 28);
24
-
--color-gray-600: oklch(0.446 0.058 28);
25
-
--color-gray-700: oklch(0.372 0.058 28);
26
-
--color-gray-800: oklch(0.279 0.055 28);
27
-
--color-gray-900: oklch(0.208 0.055 28);
28
-
--color-gray-950: oklch(0.129 0.055 28);
29
}
30
31
@layer base {
···
78
color: rgb(29, 122, 242);
79
word-break: break-all;
80
}
81
}
···
1
+
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Roboto:ital,wght@0,100..900;1,100..900&family=Spectral+SC:wght@500&display=swap');
2
@import "tailwindcss";
3
4
/* @theme {
···
15
--color-gray-950: oklch(0.129 0.050 222.000);
16
} */
17
18
+
:root {
19
+
--safe-hue: var(--tw-gray-hue, 28)
20
+
}
21
+
22
@theme {
23
+
--color-gray-50: oklch(0.984 0.012 var(--safe-hue));
24
+
--color-gray-100: oklch(0.968 0.017 var(--safe-hue));
25
+
--color-gray-200: oklch(0.929 0.025 var(--safe-hue));
26
+
--color-gray-300: oklch(0.869 0.035 var(--safe-hue));
27
+
--color-gray-400: oklch(0.704 0.05 var(--safe-hue));
28
+
--color-gray-500: oklch(0.554 0.06 var(--safe-hue));
29
+
--color-gray-600: oklch(0.446 0.058 var(--safe-hue));
30
+
--color-gray-700: oklch(0.372 0.058 var(--safe-hue));
31
+
--color-gray-800: oklch(0.279 0.055 var(--safe-hue));
32
+
--color-gray-900: oklch(0.208 0.055 var(--safe-hue));
33
+
--color-gray-950: oklch(0.129 0.055 var(--safe-hue));
34
}
35
36
@layer base {
···
83
color: rgb(29, 122, 242);
84
word-break: break-all;
85
}
86
+
}
87
+
88
+
.font-inter {
89
+
font-family: "Inter", sans-serif;
90
+
}
91
+
.font-roboto {
92
+
font-family: "Roboto", sans-serif;
93
+
}
94
+
95
+
:root {
96
+
--header-bg-light: color-mix(in srgb, var(--color-white) calc(var(--is-top) * 100%), var(--color-gray-50));
97
+
--header-bg-dark: color-mix(in srgb, var(--color-gray-950) calc(var(--is-top) * 100%), var(--color-gray-900));
98
+
}
99
+
100
+
:root {
101
+
--header-bg: var(--header-bg-light);
102
+
}
103
+
@media (prefers-color-scheme: dark) {
104
+
:root {
105
+
--header-bg: var(--header-bg-dark);
106
+
}
107
+
}
108
+
109
+
:root {
110
+
--shadow-opacity: calc(1 - var(--is-top));
111
+
--tw-shadow-header: 0 2px 8px hsl(0 0% 0% / calc(var(--shadow-opacity) * 0.15));
112
+
}
113
+
114
+
115
+
/* m3 input */
116
+
:root {
117
+
--m3input-radius: 6px;
118
+
--m3input-border-width: .0625rem;
119
+
--m3input-font-size: 16px;
120
+
--m3input-transition: 150ms cubic-bezier(.2, .8, .2, 1);
121
+
/* light theme */
122
+
--m3input-bg: var(--color-gray-50);
123
+
--m3input-border-color: var(--color-gray-400);
124
+
--m3input-label-color: var(--color-gray-500);
125
+
--m3input-text-color: var(--color-gray-900);
126
+
--m3input-focus-color: var(--color-gray-600);
127
+
}
128
+
129
+
@media (prefers-color-scheme: dark) {
130
+
:root {
131
+
--m3input-bg: var(--color-gray-950);
132
+
--m3input-border-color: var(--color-gray-700);
133
+
--m3input-label-color: var(--color-gray-400);
134
+
--m3input-text-color: var(--color-gray-50);
135
+
--m3input-focus-color: var(--color-gray-400);
136
+
}
137
+
}
138
+
139
+
/* reset page *//*
140
+
html,
141
+
body {
142
+
background: var(--m3input-bg);
143
+
margin: 0;
144
+
padding: 1rem;
145
+
color: var(--m3input-text-color);
146
+
font-family: system-ui, sans-serif;
147
+
font-size: var(--m3input-font-size);
148
+
}*/
149
+
150
+
/* base wrapper */
151
+
.m3input-field.m3input-label.m3input-border {
152
+
position: relative;
153
+
display: inline-block;
154
+
width: 100%;
155
+
/*max-width: 400px;*/
156
+
}
157
+
158
+
/* size variants */
159
+
.m3input-field.size-sm {
160
+
--m3input-h: 40px;
161
+
}
162
+
163
+
.m3input-field.size-md {
164
+
--m3input-h: 48px;
165
+
}
166
+
167
+
.m3input-field.size-lg {
168
+
--m3input-h: 56px;
169
+
}
170
+
171
+
.m3input-field.size-xl {
172
+
--m3input-h: 64px;
173
+
}
174
+
175
+
.m3input-field.m3input-label.m3input-border:not(.size-sm):not(.size-md):not(.size-lg):not(.size-xl) {
176
+
--m3input-h: 48px;
177
+
}
178
+
179
+
/* outlined input */
180
+
.m3input-field.m3input-label.m3input-border input {
181
+
width: 100%;
182
+
height: var(--m3input-h);
183
+
border: var(--m3input-border-width) solid var(--m3input-border-color);
184
+
border-radius: var(--m3input-radius);
185
+
background: var(--m3input-bg);
186
+
color: var(--m3input-text-color);
187
+
font-size: var(--m3input-font-size);
188
+
padding: 0 12px;
189
+
box-sizing: border-box;
190
+
outline: none;
191
+
transition: border-color var(--m3input-transition), box-shadow var(--m3input-transition);
192
+
}
193
+
194
+
/* focus ring */
195
+
.m3input-field.m3input-label.m3input-border input:focus {
196
+
border-color: var(--m3input-focus-color);
197
+
/*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/
198
+
}
199
+
200
+
/* label */
201
+
.m3input-field.m3input-label.m3input-border label {
202
+
position: absolute;
203
+
left: 12px;
204
+
top: 50%;
205
+
transform: translateY(-50%);
206
+
background: var(--m3input-bg);
207
+
padding: 0 .25em;
208
+
color: var(--m3input-label-color);
209
+
pointer-events: none;
210
+
transition: all var(--m3input-transition);
211
+
}
212
+
213
+
/* float on focus or when filled */
214
+
.m3input-field.m3input-label.m3input-border input:focus+label,
215
+
.m3input-field.m3input-label.m3input-border input:not(:placeholder-shown)+label {
216
+
top: 0;
217
+
transform: translateY(-50%) scale(.78);
218
+
left: 0;
219
+
color: var(--m3input-focus-color);
220
+
}
221
+
222
+
/* placeholder trick */
223
+
.m3input-field.m3input-label.m3input-border input::placeholder {
224
+
color: transparent;
225
+
}
226
+
227
+
/* radix i love you but like cmon man */
228
+
body[data-scroll-locked]{
229
+
margin-left: var(--removed-body-scroll-bar-size) !important;
230
}
+64
-6
src/utils/atoms.ts
+64
-6
src/utils/atoms.ts
···
1
import type Agent from "@atproto/api";
2
-
import { atom, createStore } from "jotai";
3
-
import { atomWithStorage } from 'jotai/utils';
4
5
export const store = createStore();
6
7
export const selectedFeedUriAtom = atomWithStorage<string | null>(
8
-
'selectedFeedUri',
9
null
10
);
11
12
//export const feedScrollPositionsAtom = atom<Record<string, number>>({});
13
14
export const feedScrollPositionsAtom = atomWithStorage<Record<string, number>>(
15
-
'feedscrollpositions',
16
{}
17
);
18
19
export const likedPostsAtom = atomWithStorage<Record<string, string>>(
20
-
'likedPosts',
21
{}
22
);
23
24
-
export const agentAtom = atom<Agent|null>(null);
25
export const authedAtom = atom<boolean>(false);
···
1
import type Agent from "@atproto/api";
2
+
import { atom, createStore, useAtomValue } from "jotai";
3
+
import { atomWithStorage } from "jotai/utils";
4
+
import { useEffect } from "react";
5
6
export const store = createStore();
7
8
export const selectedFeedUriAtom = atomWithStorage<string | null>(
9
+
"selectedFeedUri",
10
null
11
);
12
13
//export const feedScrollPositionsAtom = atom<Record<string, number>>({});
14
15
export const feedScrollPositionsAtom = atomWithStorage<Record<string, number>>(
16
+
"feedscrollpositions",
17
{}
18
);
19
20
export const likedPostsAtom = atomWithStorage<Record<string, string>>(
21
+
"likedPosts",
22
{}
23
);
24
25
+
export const defaultconstellationURL = "constellation.microcosm.blue";
26
+
export const constellationURLAtom = atomWithStorage<string>(
27
+
"constellationURL",
28
+
defaultconstellationURL
29
+
);
30
+
export const defaultslingshotURL = "slingshot.microcosm.blue";
31
+
export const slingshotURLAtom = atomWithStorage<string>(
32
+
"slingshotURL",
33
+
defaultslingshotURL
34
+
);
35
+
export const defaultaturilistservice = "aturilistservice.reddwarf.app";
36
+
export const aturiListServiceAtom = atomWithStorage<string>(
37
+
"aturilistservice",
38
+
defaultaturilistservice
39
+
);
40
+
export const defaultImgCDN = "cdn.bsky.app";
41
+
export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN);
42
+
export const defaultVideoCDN = "video.bsky.app";
43
+
export const videoCDNAtom = atomWithStorage<string>(
44
+
"videocdnurl",
45
+
defaultVideoCDN
46
+
);
47
+
48
+
export const defaulthue = 28;
49
+
export const hueAtom = atomWithStorage<number>("hue", defaulthue);
50
+
51
+
export const isAtTopAtom = atom<boolean>(true);
52
+
53
+
type ComposerState =
54
+
| { kind: "closed" }
55
+
| { kind: "root" }
56
+
| { kind: "reply"; parent: string }
57
+
| { kind: "quote"; subject: string };
58
+
export const composerAtom = atom<ComposerState>({ kind: "closed" });
59
+
60
+
export const agentAtom = atom<Agent | null>(null);
61
export const authedAtom = atom<boolean>(false);
62
+
63
+
export function useAtomCssVar(atom: typeof hueAtom, cssVar: string) {
64
+
const value = useAtomValue(atom);
65
+
66
+
useEffect(() => {
67
+
document.documentElement.style.setProperty(cssVar, value.toString());
68
+
}, [value, cssVar]);
69
+
70
+
useEffect(() => {
71
+
document.documentElement.style.setProperty(cssVar, value.toString());
72
+
}, []);
73
+
}
74
+
75
+
hueAtom.onMount = (setAtom) => {
76
+
const stored = localStorage.getItem("hue");
77
+
if (stored != null) setAtom(Number(stored));
78
+
};
79
+
// export function initAtomToCssVar(atom: typeof hueAtom, cssVar: string) {
80
+
// const initial = store.get(atom);
81
+
// console.log("atom get ", initial);
82
+
// document.documentElement.style.setProperty(cssVar, initial.toString());
83
+
// }
+37
-3
src/utils/followState.ts
+37
-3
src/utils/followState.ts
···
1
-
import { AtUri, type Agent } from "@atproto/api";
2
-
import { useQueryConstellation, type linksRecordsResponse } from "./useQuery";
3
import type { QueryClient } from "@tanstack/react-query";
4
-
import { TID } from "@atproto/common-web";
5
6
export function useGetFollowState({
7
target,
···
127
};
128
});
129
}
···
1
+
import { type Agent,AtUri } from "@atproto/api";
2
+
import { TID } from "@atproto/common-web";
3
import type { QueryClient } from "@tanstack/react-query";
4
+
5
+
import { type linksRecordsResponse,useQueryConstellation } from "./useQuery";
6
7
export function useGetFollowState({
8
target,
···
128
};
129
});
130
}
131
+
132
+
133
+
134
+
export function useGetOneToOneState(params?: {
135
+
target: string;
136
+
user: string;
137
+
collection: string;
138
+
path: string;
139
+
}): string[] | undefined {
140
+
const { data: arbitrarydata } = useQueryConstellation(
141
+
params && params.user
142
+
? {
143
+
method: "/links",
144
+
target: params.target,
145
+
// @ts-expect-error overloading sucks so much
146
+
collection: params.collection,
147
+
path: params.path,
148
+
dids: [params.user],
149
+
}
150
+
: { method: "undefined", target: "whatever" }
151
+
// overloading sucks so much
152
+
) as { data: linksRecordsResponse | undefined };
153
+
if (!params || !params.user) return undefined;
154
+
const data = arbitrarydata?.linking_records.slice(0, 50) ?? [];
155
+
156
+
if (data.length > 0) {
157
+
return data.map((linksRecord) => {
158
+
return `at://${linksRecord.did}/${linksRecord.collection}/${linksRecord.rkey}`;
159
+
});
160
+
}
161
+
162
+
return undefined;
163
+
}
+53
-23
src/utils/useHydrated.ts
+53
-23
src/utils/useHydrated.ts
···
9
AppBskyFeedPost,
10
AtUri,
11
} from "@atproto/api";
12
import { useMemo } from "react";
13
14
-
import { useQueryIdentity,useQueryPost, useQueryProfile } from "./useQuery";
15
16
-
type QueryResultData<T extends (...args: any) => any> = ReturnType<T> extends
17
-
| { data: infer D }
18
-
| undefined
19
-
? D
20
-
: never;
21
22
function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
23
return obj as $Typed<T>;
···
26
export function hydrateEmbedImages(
27
embed: AppBskyEmbedImages.Main,
28
did: string,
29
): $Typed<AppBskyEmbedImages.View> {
30
return asTyped({
31
$type: "app.bsky.embed.images#view" as const,
···
34
const link = img.image.ref?.["$link"];
35
if (!link) return null;
36
return {
37
-
thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${link}@jpeg`,
38
-
fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${link}@jpeg`,
39
alt: img.alt || "",
40
aspectRatio: img.aspectRatio,
41
};
···
47
export function hydrateEmbedExternal(
48
embed: AppBskyEmbedExternal.Main,
49
did: string,
50
): $Typed<AppBskyEmbedExternal.View> {
51
return asTyped({
52
$type: "app.bsky.embed.external#view" as const,
···
55
title: embed.external.title,
56
description: embed.external.description,
57
thumb: embed.external.thumb?.ref?.$link
58
-
? `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg`
59
: undefined,
60
},
61
});
···
64
export function hydrateEmbedVideo(
65
embed: AppBskyEmbedVideo.Main,
66
did: string,
67
): $Typed<AppBskyEmbedVideo.View> {
68
const videoLink = embed.video.ref.$link;
69
return asTyped({
70
$type: "app.bsky.embed.video#view" as const,
71
-
playlist: `https://video.bsky.app/watch/${did}/${videoLink}/playlist.m3u8`,
72
-
thumbnail: `https://video.bsky.app/watch/${did}/${videoLink}/thumbnail.jpg`,
73
aspectRatio: embed.aspectRatio,
74
cid: videoLink,
75
});
···
80
quotedPost: QueryResultData<typeof useQueryPost>,
81
quotedProfile: QueryResultData<typeof useQueryProfile>,
82
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
83
): $Typed<AppBskyEmbedRecord.View> | undefined {
84
if (!quotedPost || !quotedProfile || !quotedIdentity) {
85
return undefined;
···
91
handle: quotedIdentity.handle,
92
displayName: quotedProfile.value.displayName ?? quotedIdentity.handle,
93
avatar: quotedProfile.value.avatar?.ref?.$link
94
-
? `https://cdn.bsky.app/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg`
95
: undefined,
96
viewer: {},
97
labels: [],
···
122
quotedPost: QueryResultData<typeof useQueryPost>,
123
quotedProfile: QueryResultData<typeof useQueryProfile>,
124
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
125
): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined {
126
const hydratedRecord = hydrateEmbedRecord(
127
embed.record,
128
quotedPost,
129
quotedProfile,
130
quotedIdentity,
131
);
132
133
if (!hydratedRecord) return undefined;
···
148
149
export function useHydratedEmbed(
150
embed: AppBskyFeedPost.Record["embed"],
151
-
postAuthorDid: string | undefined,
152
) {
153
const recordInfo = useMemo(() => {
154
if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
···
181
error: profileError,
182
} = useQueryProfile(profileUri);
183
184
const queryidentityresult = useQueryIdentity(quotedAuthorDid);
185
186
const hydratedEmbed: HydratedEmbedView | undefined = (() => {
187
if (!embed || !postAuthorDid) return undefined;
188
189
-
if (isRecordType && (!usequerypostresults?.data || !quotedProfile || !queryidentityresult?.data)) {
190
return undefined;
191
}
192
193
try {
194
if (AppBskyEmbedImages.isMain(embed)) {
195
-
return hydrateEmbedImages(embed, postAuthorDid);
196
} else if (AppBskyEmbedExternal.isMain(embed)) {
197
-
return hydrateEmbedExternal(embed, postAuthorDid);
198
} else if (AppBskyEmbedVideo.isMain(embed)) {
199
-
return hydrateEmbedVideo(embed, postAuthorDid);
200
} else if (AppBskyEmbedRecord.isMain(embed)) {
201
return hydrateEmbedRecord(
202
embed,
203
usequerypostresults?.data,
204
quotedProfile,
205
queryidentityresult?.data,
206
);
207
} else if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
208
let hydratedMedia:
···
212
| undefined;
213
214
if (AppBskyEmbedImages.isMain(embed.media)) {
215
-
hydratedMedia = hydrateEmbedImages(embed.media, postAuthorDid);
216
} else if (AppBskyEmbedExternal.isMain(embed.media)) {
217
-
hydratedMedia = hydrateEmbedExternal(embed.media, postAuthorDid);
218
} else if (AppBskyEmbedVideo.isMain(embed.media)) {
219
-
hydratedMedia = hydrateEmbedVideo(embed.media, postAuthorDid);
220
}
221
222
if (hydratedMedia) {
···
226
usequerypostresults?.data,
227
quotedProfile,
228
queryidentityresult?.data,
229
);
230
}
231
}
···
236
})();
237
238
const isLoading = isRecordType
239
-
? usequerypostresults?.isLoading || isLoadingProfile || queryidentityresult?.isLoading
240
: false;
241
242
-
const error = usequerypostresults?.error || profileError || queryidentityresult?.error;
243
244
return { data: hydratedEmbed, isLoading, error };
245
-
}
···
9
AppBskyFeedPost,
10
AtUri,
11
} from "@atproto/api";
12
+
import { useAtom } from "jotai";
13
import { useMemo } from "react";
14
15
+
import { imgCDNAtom, videoCDNAtom } from "./atoms";
16
+
import { useQueryIdentity, useQueryPost, useQueryProfile } from "./useQuery";
17
18
+
type QueryResultData<T extends (...args: any) => any> =
19
+
ReturnType<T> extends { data: infer D } | undefined ? D : never;
20
21
function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
22
return obj as $Typed<T>;
···
25
export function hydrateEmbedImages(
26
embed: AppBskyEmbedImages.Main,
27
did: string,
28
+
cdn: string
29
): $Typed<AppBskyEmbedImages.View> {
30
return asTyped({
31
$type: "app.bsky.embed.images#view" as const,
···
34
const link = img.image.ref?.["$link"];
35
if (!link) return null;
36
return {
37
+
thumb: `https://${cdn}/img/feed_thumbnail/plain/${did}/${link}@jpeg`,
38
+
fullsize: `https://${cdn}/img/feed_fullsize/plain/${did}/${link}@jpeg`,
39
alt: img.alt || "",
40
aspectRatio: img.aspectRatio,
41
};
···
47
export function hydrateEmbedExternal(
48
embed: AppBskyEmbedExternal.Main,
49
did: string,
50
+
cdn: string
51
): $Typed<AppBskyEmbedExternal.View> {
52
return asTyped({
53
$type: "app.bsky.embed.external#view" as const,
···
56
title: embed.external.title,
57
description: embed.external.description,
58
thumb: embed.external.thumb?.ref?.$link
59
+
? `https://${cdn}/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg`
60
: undefined,
61
},
62
});
···
65
export function hydrateEmbedVideo(
66
embed: AppBskyEmbedVideo.Main,
67
did: string,
68
+
videocdn: string
69
): $Typed<AppBskyEmbedVideo.View> {
70
const videoLink = embed.video.ref.$link;
71
return asTyped({
72
$type: "app.bsky.embed.video#view" as const,
73
+
playlist: `https://${videocdn}/watch/${did}/${videoLink}/playlist.m3u8`,
74
+
thumbnail: `https://${videocdn}/watch/${did}/${videoLink}/thumbnail.jpg`,
75
aspectRatio: embed.aspectRatio,
76
cid: videoLink,
77
});
···
82
quotedPost: QueryResultData<typeof useQueryPost>,
83
quotedProfile: QueryResultData<typeof useQueryProfile>,
84
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
85
+
cdn: string
86
): $Typed<AppBskyEmbedRecord.View> | undefined {
87
if (!quotedPost || !quotedProfile || !quotedIdentity) {
88
return undefined;
···
94
handle: quotedIdentity.handle,
95
displayName: quotedProfile.value.displayName ?? quotedIdentity.handle,
96
avatar: quotedProfile.value.avatar?.ref?.$link
97
+
? `https://${cdn}/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg`
98
: undefined,
99
viewer: {},
100
labels: [],
···
125
quotedPost: QueryResultData<typeof useQueryPost>,
126
quotedProfile: QueryResultData<typeof useQueryProfile>,
127
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
128
+
cdn: string
129
): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined {
130
const hydratedRecord = hydrateEmbedRecord(
131
embed.record,
132
quotedPost,
133
quotedProfile,
134
quotedIdentity,
135
+
cdn
136
);
137
138
if (!hydratedRecord) return undefined;
···
153
154
export function useHydratedEmbed(
155
embed: AppBskyFeedPost.Record["embed"],
156
+
postAuthorDid: string | undefined
157
) {
158
const recordInfo = useMemo(() => {
159
if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
···
186
error: profileError,
187
} = useQueryProfile(profileUri);
188
189
+
const [imgcdn] = useAtom(imgCDNAtom);
190
+
const [videocdn] = useAtom(videoCDNAtom);
191
+
192
const queryidentityresult = useQueryIdentity(quotedAuthorDid);
193
194
const hydratedEmbed: HydratedEmbedView | undefined = (() => {
195
if (!embed || !postAuthorDid) return undefined;
196
197
+
if (
198
+
isRecordType &&
199
+
(!usequerypostresults?.data ||
200
+
!quotedProfile ||
201
+
!queryidentityresult?.data)
202
+
) {
203
return undefined;
204
}
205
206
try {
207
if (AppBskyEmbedImages.isMain(embed)) {
208
+
return hydrateEmbedImages(embed, postAuthorDid, imgcdn);
209
} else if (AppBskyEmbedExternal.isMain(embed)) {
210
+
return hydrateEmbedExternal(embed, postAuthorDid, imgcdn);
211
} else if (AppBskyEmbedVideo.isMain(embed)) {
212
+
return hydrateEmbedVideo(embed, postAuthorDid, videocdn);
213
} else if (AppBskyEmbedRecord.isMain(embed)) {
214
return hydrateEmbedRecord(
215
embed,
216
usequerypostresults?.data,
217
quotedProfile,
218
queryidentityresult?.data,
219
+
imgcdn
220
);
221
} else if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
222
let hydratedMedia:
···
226
| undefined;
227
228
if (AppBskyEmbedImages.isMain(embed.media)) {
229
+
hydratedMedia = hydrateEmbedImages(
230
+
embed.media,
231
+
postAuthorDid,
232
+
imgcdn
233
+
);
234
} else if (AppBskyEmbedExternal.isMain(embed.media)) {
235
+
hydratedMedia = hydrateEmbedExternal(
236
+
embed.media,
237
+
postAuthorDid,
238
+
imgcdn
239
+
);
240
} else if (AppBskyEmbedVideo.isMain(embed.media)) {
241
+
hydratedMedia = hydrateEmbedVideo(
242
+
embed.media,
243
+
postAuthorDid,
244
+
videocdn
245
+
);
246
}
247
248
if (hydratedMedia) {
···
252
usequerypostresults?.data,
253
quotedProfile,
254
queryidentityresult?.data,
255
+
imgcdn
256
);
257
}
258
}
···
263
})();
264
265
const isLoading = isRecordType
266
+
? usequerypostresults?.isLoading ||
267
+
isLoadingProfile ||
268
+
queryidentityresult?.isLoading
269
: false;
270
271
+
const error =
272
+
usequerypostresults?.error || profileError || queryidentityresult?.error;
273
274
return { data: hydratedEmbed, isLoading, error };
275
+
}
+161
-17
src/utils/useQuery.ts
+161
-17
src/utils/useQuery.ts
···
1
import * as ATPAPI from "@atproto/api";
2
import {
3
type QueryFunctionContext,
4
queryOptions,
5
useInfiniteQuery,
6
useQuery,
7
type UseQueryResult} from "@tanstack/react-query";
8
9
-
export function constructIdentityQuery(didorhandle?: string) {
10
return queryOptions({
11
queryKey: ["identity", didorhandle],
12
queryFn: async () => {
13
if (!didorhandle) return undefined as undefined
14
const res = await fetch(
15
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
16
);
17
if (!res.ok) throw new Error("Failed to fetch post");
18
try {
···
54
Error
55
>
56
export function useQueryIdentity(didorhandle?: string) {
57
-
return useQuery(constructIdentityQuery(didorhandle));
58
}
59
60
-
export function constructPostQuery(uri?: string) {
61
return queryOptions({
62
queryKey: ["post", uri],
63
queryFn: async () => {
64
if (!uri) return undefined as undefined
65
const res = await fetch(
66
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
67
);
68
let data: any;
69
try {
···
117
Error
118
>
119
export function useQueryPost(uri?: string) {
120
-
return useQuery(constructPostQuery(uri));
121
}
122
123
-
export function constructProfileQuery(uri?: string) {
124
return queryOptions({
125
queryKey: ["profile", uri],
126
queryFn: async () => {
127
if (!uri) return undefined as undefined
128
const res = await fetch(
129
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
130
);
131
let data: any;
132
try {
···
180
Error
181
>
182
export function useQueryProfile(uri?: string) {
183
-
return useQuery(constructProfileQuery(uri));
184
}
185
186
// export function constructConstellationQuery(
···
216
// target: string
217
// ): QueryOptions<linksAllResponse, Error>;
218
export function constructConstellationQuery(query?:{
219
method:
220
| "/links"
221
| "/links/distinct-dids"
···
249
const cursor = query.cursor
250
const dids = query?.dids
251
const res = await fetch(
252
-
`https://constellation.microcosm.blue${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`
253
);
254
if (!res.ok) throw new Error("Failed to fetch post");
255
try {
···
338
>
339
| undefined {
340
//if (!query) return;
341
return useQuery(
342
-
constructConstellationQuery(query)
343
);
344
}
345
···
361
type linksCountResponse = {
362
total: string;
363
};
364
-
type linksAllResponse = {
365
links: Record<
366
string,
367
Record<
···
444
445
446
447
-
export function constructArbitraryQuery(uri?: string) {
448
return queryOptions({
449
queryKey: ["arbitrary", uri],
450
queryFn: async () => {
451
if (!uri) return undefined as undefined
452
const res = await fetch(
453
-
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
454
);
455
let data: any;
456
try {
···
503
Error
504
>;
505
export function useQueryArbitrary(uri?: string) {
506
-
return useQuery(constructArbitraryQuery(uri));
507
}
508
509
export function constructFallbackNothingQuery(){
···
555
});
556
}
557
558
type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
559
560
export function constructInfiniteFeedSkeletonQuery(options: {
···
605
}) {
606
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
607
608
-
return useInfiniteQuery({
609
queryKey,
610
queryFn,
611
initialPageParam: undefined as never,
···
613
staleTime: Infinity,
614
refetchOnWindowFocus: false,
615
enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
616
-
});
617
}
···
1
import * as ATPAPI from "@atproto/api";
2
import {
3
+
infiniteQueryOptions,
4
type QueryFunctionContext,
5
queryOptions,
6
useInfiniteQuery,
7
useQuery,
8
type UseQueryResult} from "@tanstack/react-query";
9
+
import { useAtom } from "jotai";
10
11
+
import { constellationURLAtom, slingshotURLAtom } from "./atoms";
12
+
13
+
export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) {
14
return queryOptions({
15
queryKey: ["identity", didorhandle],
16
queryFn: async () => {
17
if (!didorhandle) return undefined as undefined
18
const res = await fetch(
19
+
`https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
20
);
21
if (!res.ok) throw new Error("Failed to fetch post");
22
try {
···
58
Error
59
>
60
export function useQueryIdentity(didorhandle?: string) {
61
+
const [slingshoturl] = useAtom(slingshotURLAtom)
62
+
return useQuery(constructIdentityQuery(didorhandle, slingshoturl));
63
}
64
65
+
export function constructPostQuery(uri?: string, slingshoturl?: string) {
66
return queryOptions({
67
queryKey: ["post", uri],
68
queryFn: async () => {
69
if (!uri) return undefined as undefined
70
const res = await fetch(
71
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
72
);
73
let data: any;
74
try {
···
122
Error
123
>
124
export function useQueryPost(uri?: string) {
125
+
const [slingshoturl] = useAtom(slingshotURLAtom)
126
+
return useQuery(constructPostQuery(uri, slingshoturl));
127
}
128
129
+
export function constructProfileQuery(uri?: string, slingshoturl?: string) {
130
return queryOptions({
131
queryKey: ["profile", uri],
132
queryFn: async () => {
133
if (!uri) return undefined as undefined
134
const res = await fetch(
135
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
136
);
137
let data: any;
138
try {
···
186
Error
187
>
188
export function useQueryProfile(uri?: string) {
189
+
const [slingshoturl] = useAtom(slingshotURLAtom)
190
+
return useQuery(constructProfileQuery(uri, slingshoturl));
191
}
192
193
// export function constructConstellationQuery(
···
223
// target: string
224
// ): QueryOptions<linksAllResponse, Error>;
225
export function constructConstellationQuery(query?:{
226
+
constellation: string,
227
method:
228
| "/links"
229
| "/links/distinct-dids"
···
257
const cursor = query.cursor
258
const dids = query?.dids
259
const res = await fetch(
260
+
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`
261
);
262
if (!res.ok) throw new Error("Failed to fetch post");
263
try {
···
346
>
347
| undefined {
348
//if (!query) return;
349
+
const [constellationurl] = useAtom(constellationURLAtom)
350
return useQuery(
351
+
constructConstellationQuery(query && {constellation: constellationurl, ...query})
352
);
353
}
354
···
370
type linksCountResponse = {
371
total: string;
372
};
373
+
export type linksAllResponse = {
374
links: Record<
375
string,
376
Record<
···
453
454
455
456
+
export function constructArbitraryQuery(uri?: string, slingshoturl?: string) {
457
return queryOptions({
458
queryKey: ["arbitrary", uri],
459
queryFn: async () => {
460
if (!uri) return undefined as undefined
461
const res = await fetch(
462
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
463
);
464
let data: any;
465
try {
···
512
Error
513
>;
514
export function useQueryArbitrary(uri?: string) {
515
+
const [slingshoturl] = useAtom(slingshotURLAtom)
516
+
return useQuery(constructArbitraryQuery(uri, slingshoturl));
517
}
518
519
export function constructFallbackNothingQuery(){
···
565
});
566
}
567
568
+
export const ATURI_PAGE_LIMIT = 100;
569
+
570
+
export interface AturiDirectoryAturisItem {
571
+
uri: string;
572
+
cid: string;
573
+
rkey: string;
574
+
}
575
+
576
+
export type AturiDirectoryAturis = AturiDirectoryAturisItem[];
577
+
578
+
export function constructAturiListQuery(aturilistservice: string, did: string, collection: string, reverse?: boolean) {
579
+
return queryOptions({
580
+
// A unique key for this query, including all parameters that affect the data.
581
+
queryKey: ["aturiList", did, collection, { reverse }],
582
+
583
+
// The function that fetches the data.
584
+
queryFn: async ({ pageParam }: QueryFunctionContext) => {
585
+
const cursor = pageParam as string | undefined;
586
+
587
+
// Use URLSearchParams for safe and clean URL construction.
588
+
const params = new URLSearchParams({
589
+
did,
590
+
collection,
591
+
});
592
+
593
+
if (cursor) {
594
+
params.set("cursor", cursor);
595
+
}
596
+
597
+
// Add the reverse parameter if it's true
598
+
if (reverse) {
599
+
params.set("reverse", "true");
600
+
}
601
+
602
+
const url = `https://${aturilistservice}/aturis?${params.toString()}`;
603
+
604
+
const res = await fetch(url);
605
+
if (!res.ok) {
606
+
// You can add more specific error handling here
607
+
throw new Error(`Failed to fetch AT-URI list for ${did}`);
608
+
}
609
+
610
+
return res.json() as Promise<AturiDirectoryAturis>;
611
+
},
612
+
});
613
+
}
614
+
615
+
export function useInfiniteQueryAturiList({aturilistservice, did, collection, reverse}:{aturilistservice: string, did: string | undefined, collection: string | undefined, reverse?: boolean}) {
616
+
// We only enable the query if both `did` and `collection` are provided.
617
+
const isEnabled = !!did && !!collection;
618
+
619
+
const { queryKey, queryFn } = constructAturiListQuery(aturilistservice, did!, collection!, reverse);
620
+
621
+
return useInfiniteQuery({
622
+
queryKey,
623
+
queryFn,
624
+
initialPageParam: undefined as never, // ???? what is this shit
625
+
626
+
// @ts-expect-error i wouldve used as null | undefined, anyways
627
+
getNextPageParam: (lastPage: AturiDirectoryAturis) => {
628
+
// If the last page returned no records, we're at the end.
629
+
if (!lastPage || lastPage.length === 0) {
630
+
return undefined;
631
+
}
632
+
633
+
// If the number of records is less than our page limit, it must be the last page.
634
+
if (lastPage.length < ATURI_PAGE_LIMIT) {
635
+
return undefined;
636
+
}
637
+
638
+
// The cursor for the next page is the `rkey` of the last item we received.
639
+
const lastItem = lastPage[lastPage.length - 1];
640
+
return lastItem.rkey;
641
+
},
642
+
643
+
enabled: isEnabled,
644
+
});
645
+
}
646
+
647
+
648
type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
649
650
export function constructInfiniteFeedSkeletonQuery(options: {
···
695
}) {
696
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
697
698
+
return {...useInfiniteQuery({
699
queryKey,
700
queryFn,
701
initialPageParam: undefined as never,
···
703
staleTime: Infinity,
704
refetchOnWindowFocus: false,
705
enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
706
+
}), queryKey: queryKey};
707
+
}
708
+
709
+
710
+
export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
711
+
constellation: string,
712
+
method: '/links'
713
+
target?: string
714
+
collection: string
715
+
path: string
716
+
}) {
717
+
console.log(
718
+
'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
719
+
query,
720
+
)
721
+
722
+
return infiniteQueryOptions({
723
+
enabled: !!query?.target,
724
+
queryKey: [
725
+
'reddwarf_constellation',
726
+
query?.method,
727
+
query?.target,
728
+
query?.collection,
729
+
query?.path,
730
+
] as const,
731
+
732
+
queryFn: async ({pageParam}: {pageParam?: string}) => {
733
+
if (!query || !query?.target) return undefined
734
+
735
+
const method = query.method
736
+
const target = query.target
737
+
const collection = query.collection
738
+
const path = query.path
739
+
const cursor = pageParam
740
+
741
+
const res = await fetch(
742
+
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${
743
+
collection ? `&collection=${encodeURIComponent(collection)}` : ''
744
+
}${path ? `&path=${encodeURIComponent(path)}` : ''}${
745
+
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
746
+
}`,
747
+
)
748
+
749
+
if (!res.ok) throw new Error('Failed to fetch')
750
+
751
+
return (await res.json()) as linksRecordsResponse
752
+
},
753
+
754
+
getNextPageParam: lastPage => {
755
+
return (lastPage as any)?.cursor ?? undefined
756
+
},
757
+
initialPageParam: undefined,
758
+
staleTime: 5 * 60 * 1000,
759
+
gcTime: 5 * 60 * 1000,
760
+
})
761
}
+3
-2
vite.config.ts
+3
-2
vite.config.ts
···
10
11
import { generateMetadataPlugin } from "./oauthdev.mts";
12
13
-
const PROD_URL = "https://reddwarf.whey.party"
14
const DEV_URL = "https://local3768forumtest.whey.party"
15
16
function shp(url: string): string {
···
39
IconsResolver({
40
prefix: 'Icon',
41
extension: 'jsx',
42
}),
43
],
44
dts: 'src/auto-imports.d.ts',
45
}),
46
Icons({
47
-
autoInstall: true,
48
compiler: 'jsx',
49
jsx: 'react'
50
}),
···
10
11
import { generateMetadataPlugin } from "./oauthdev.mts";
12
13
+
const PROD_URL = "https://reddwarf.app"
14
const DEV_URL = "https://local3768forumtest.whey.party"
15
16
function shp(url: string): string {
···
39
IconsResolver({
40
prefix: 'Icon',
41
extension: 'jsx',
42
+
enabledCollections: ['mdi','material-symbols'],
43
}),
44
],
45
dts: 'src/auto-imports.d.ts',
46
}),
47
Icons({
48
+
//autoInstall: true,
49
compiler: 'jsx',
50
jsx: 'react'
51
}),