atmo.rsvp
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #22 from flo-bit/feat/spaces

Feat/spaces

authored by

Florian and committed by
GitHub
edaf97dc ecae675a

+6794 -1885
+3
.gitignore
··· 25 25 # Vite 26 26 vite.config.js.timestamp-* 27 27 vite.config.ts.timestamp-* 28 + 29 + # Tunnel-generated (only meaningful while tunnel is running) 30 + static/.well-known/did.json
+20 -1
lexicons-generated/community/lexicon/calendar/event/getRecord.json lexicons-generated/rsvp/atmo/event/getRecord.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "community.lexicon.calendar.event.getRecord", 3 + "id": "rsvp.atmo.event.getRecord", 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", ··· 19 19 "profiles": { 20 20 "type": "boolean", 21 21 "description": "Include profile + identity info keyed by DID" 22 + }, 23 + "spaceUri": { 24 + "type": "string", 25 + "format": "at-uri", 26 + "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)." 27 + }, 28 + "inviteToken": { 29 + "type": "string", 30 + "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." 22 31 }, 23 32 "hydrateRsvps": { 24 33 "type": "integer", ··· 65 74 "time_us": { 66 75 "type": "integer" 67 76 }, 77 + "space": { 78 + "type": "string", 79 + "format": "at-uri", 80 + "description": "Present when the record was read from a permissioned space; its value is the space URI." 81 + }, 68 82 "rsvpsCount": { 69 83 "type": "integer", 70 84 "description": "Total rsvps count" ··· 130 144 }, 131 145 "time_us": { 132 146 "type": "integer" 147 + }, 148 + "space": { 149 + "type": "string", 150 + "format": "at-uri", 151 + "description": "Present when the record was read from a permissioned space." 133 152 } 134 153 } 135 154 },
+30 -1
lexicons-generated/community/lexicon/calendar/event/listRecords.json lexicons-generated/rsvp/atmo/event/listRecords.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "community.lexicon.calendar.event.listRecords", 3 + "id": "rsvp.atmo.event.listRecords", 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", ··· 25 25 "profiles": { 26 26 "type": "boolean", 27 27 "description": "Include profile + identity info keyed by DID" 28 + }, 29 + "spaceUri": { 30 + "type": "string", 31 + "format": "at-uri", 32 + "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." 33 + }, 34 + "byUser": { 35 + "type": "string", 36 + "format": "did", 37 + "description": "Only used with spaceUri — filter to records authored by this DID." 38 + }, 39 + "inviteToken": { 40 + "type": "string", 41 + "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." 28 42 }, 29 43 "search": { 30 44 "type": "string", ··· 70 84 "type": "string", 71 85 "description": "Filter by description" 72 86 }, 87 + "preferencesShowInDiscovery": { 88 + "type": "string", 89 + "description": "Filter by preferences.showInDiscovery" 90 + }, 73 91 "rsvpsCountMin": { 74 92 "type": "integer", 75 93 "description": "Minimum total rsvps count" ··· 102 120 "startsAt", 103 121 "createdAt", 104 122 "description", 123 + "preferencesShowInDiscovery", 105 124 "rsvpsCount", 106 125 "rsvpsInterestedCount", 107 126 "rsvpsGoingCount", ··· 183 202 "time_us": { 184 203 "type": "integer" 185 204 }, 205 + "space": { 206 + "type": "string", 207 + "format": "at-uri", 208 + "description": "Present when the record was read from a permissioned space; its value is the space URI." 209 + }, 186 210 "rsvpsCount": { 187 211 "type": "integer", 188 212 "description": "Total rsvps count" ··· 239 263 }, 240 264 "time_us": { 241 265 "type": "integer" 266 + }, 267 + "space": { 268 + "type": "string", 269 + "format": "at-uri", 270 + "description": "Present when the record was read from a permissioned space." 242 271 } 243 272 } 244 273 },
+20 -1
lexicons-generated/community/lexicon/calendar/rsvp/getRecord.json lexicons-generated/rsvp/atmo/rsvp/getRecord.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "community.lexicon.calendar.rsvp.getRecord", 3 + "id": "rsvp.atmo.rsvp.getRecord", 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", ··· 19 19 "profiles": { 20 20 "type": "boolean", 21 21 "description": "Include profile + identity info keyed by DID" 22 + }, 23 + "spaceUri": { 24 + "type": "string", 25 + "format": "at-uri", 26 + "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)." 27 + }, 28 + "inviteToken": { 29 + "type": "string", 30 + "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." 22 31 }, 23 32 "hydrateEvent": { 24 33 "type": "boolean", ··· 63 72 "time_us": { 64 73 "type": "integer" 65 74 }, 75 + "space": { 76 + "type": "string", 77 + "format": "at-uri", 78 + "description": "Present when the record was read from a permissioned space; its value is the space URI." 79 + }, 66 80 "event": { 67 81 "type": "ref", 68 82 "ref": "#refEventRecord" ··· 112 126 }, 113 127 "time_us": { 114 128 "type": "integer" 129 + }, 130 + "space": { 131 + "type": "string", 132 + "format": "at-uri", 133 + "description": "Present when the record was read from a permissioned space." 115 134 } 116 135 } 117 136 },
+25 -1
lexicons-generated/community/lexicon/calendar/rsvp/listRecords.json lexicons-generated/rsvp/atmo/rsvp/listRecords.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "community.lexicon.calendar.rsvp.listRecords", 3 + "id": "rsvp.atmo.rsvp.listRecords", 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", ··· 25 25 "profiles": { 26 26 "type": "boolean", 27 27 "description": "Include profile + identity info keyed by DID" 28 + }, 29 + "spaceUri": { 30 + "type": "string", 31 + "format": "at-uri", 32 + "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." 33 + }, 34 + "byUser": { 35 + "type": "string", 36 + "format": "did", 37 + "description": "Only used with spaceUri — filter to records authored by this DID." 38 + }, 39 + "inviteToken": { 40 + "type": "string", 41 + "description": "Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied." 28 42 }, 29 43 "status": { 30 44 "type": "string", ··· 120 134 "time_us": { 121 135 "type": "integer" 122 136 }, 137 + "space": { 138 + "type": "string", 139 + "format": "at-uri", 140 + "description": "Present when the record was read from a permissioned space; its value is the space URI." 141 + }, 123 142 "event": { 124 143 "type": "ref", 125 144 "ref": "#refEventRecord" ··· 160 179 }, 161 180 "time_us": { 162 181 "type": "integer" 182 + }, 183 + "space": { 184 + "type": "string", 185 + "format": "at-uri", 186 + "description": "Present when the record was read from a permissioned space." 163 187 } 164 188 } 165 189 },
+14 -3
lexicons-generated/rsvp/atmo/getProfile.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", 7 - "description": "Get a user's profile by DID or handle", 7 + "description": "Get a user's profiles by DID or handle", 8 8 "parameters": { 9 9 "type": "params", 10 10 "required": [ ··· 21 21 "output": { 22 22 "encoding": "application/json", 23 23 "schema": { 24 - "type": "ref", 25 - "ref": "#profileEntry" 24 + "type": "object", 25 + "required": [ 26 + "profiles" 27 + ], 28 + "properties": { 29 + "profiles": { 30 + "type": "array", 31 + "items": { 32 + "type": "ref", 33 + "ref": "#profileEntry" 34 + } 35 + } 36 + } 26 37 } 27 38 } 28 39 },
+44
lexicons-generated/rsvp/atmo/permissionSet.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.permissionSet", 4 + "defs": { 5 + "main": { 6 + "type": "permission-set", 7 + "title": "Atmo Events", 8 + "description": "Manage your private events and rsvps.", 9 + "permissions": [ 10 + { 11 + "type": "permission", 12 + "resource": "rpc", 13 + "aud": "*", 14 + "lxm": [ 15 + "rsvp.atmo.event.getRecord", 16 + "rsvp.atmo.event.listRecords", 17 + "rsvp.atmo.getCursor", 18 + "rsvp.atmo.getOverview", 19 + "rsvp.atmo.getProfile", 20 + "rsvp.atmo.notifyOfUpdate", 21 + "rsvp.atmo.rsvp.getRecord", 22 + "rsvp.atmo.rsvp.listRecords", 23 + "rsvp.atmo.space.addMember", 24 + "rsvp.atmo.space.createSpace", 25 + "rsvp.atmo.space.deleteRecord", 26 + "rsvp.atmo.space.getRecord", 27 + "rsvp.atmo.space.getSpace", 28 + "rsvp.atmo.space.invite.create", 29 + "rsvp.atmo.space.invite.list", 30 + "rsvp.atmo.space.invite.redeem", 31 + "rsvp.atmo.space.invite.revoke", 32 + "rsvp.atmo.space.leaveSpace", 33 + "rsvp.atmo.space.listMembers", 34 + "rsvp.atmo.space.listRecords", 35 + "rsvp.atmo.space.listSpaces", 36 + "rsvp.atmo.space.putRecord", 37 + "rsvp.atmo.space.removeMember", 38 + "rsvp.atmo.space.whoami" 39 + ] 40 + } 41 + ] 42 + } 43 + } 44 + }
+60
lexicons-generated/rsvp/atmo/space/addMember.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.addMember", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Add a member to a space. Caller must be the space owner.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "spaceUri", 14 + "did" 15 + ], 16 + "properties": { 17 + "spaceUri": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "did": { 22 + "type": "string", 23 + "format": "did" 24 + }, 25 + "perms": { 26 + "type": "string", 27 + "knownValues": [ 28 + "read", 29 + "write" 30 + ], 31 + "default": "write" 32 + } 33 + } 34 + } 35 + }, 36 + "output": { 37 + "encoding": "application/json", 38 + "schema": { 39 + "type": "object", 40 + "required": [ 41 + "ok" 42 + ], 43 + "properties": { 44 + "ok": { 45 + "type": "boolean" 46 + } 47 + } 48 + } 49 + }, 50 + "errors": [ 51 + { 52 + "name": "NotFound" 53 + }, 54 + { 55 + "name": "Forbidden" 56 + } 57 + ] 58 + } 59 + } 60 + }
+59
lexicons-generated/rsvp/atmo/space/createSpace.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.createSpace", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a new space owned by the JWT issuer. The caller is added as an owner-perm member.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "properties": { 13 + "type": { 14 + "type": "string", 15 + "format": "nsid", 16 + "description": "Space type NSID. Defaults to the service's configured type." 17 + }, 18 + "key": { 19 + "type": "string", 20 + "description": "Space key. Auto-generated (TID) if omitted." 21 + }, 22 + "memberListRef": { 23 + "type": "string", 24 + "format": "at-uri" 25 + }, 26 + "appPolicyRef": { 27 + "type": "string", 28 + "format": "at-uri" 29 + }, 30 + "appPolicy": { 31 + "type": "ref", 32 + "ref": "rsvp.atmo.space.defs#appPolicy" 33 + } 34 + } 35 + } 36 + }, 37 + "output": { 38 + "encoding": "application/json", 39 + "schema": { 40 + "type": "object", 41 + "required": [ 42 + "space" 43 + ], 44 + "properties": { 45 + "space": { 46 + "type": "ref", 47 + "ref": "rsvp.atmo.space.defs#spaceView" 48 + } 49 + } 50 + } 51 + }, 52 + "errors": [ 53 + { 54 + "name": "AlreadyExists" 55 + } 56 + ] 57 + } 58 + } 59 + }
+202
lexicons-generated/rsvp/atmo/space/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.defs", 4 + "description": "Shared types for permissioned-space XRPC methods.", 5 + "defs": { 6 + "spaceView": { 7 + "type": "object", 8 + "required": [ 9 + "uri", 10 + "ownerDid", 11 + "type", 12 + "key", 13 + "serviceDid", 14 + "createdAt" 15 + ], 16 + "properties": { 17 + "uri": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "ownerDid": { 22 + "type": "string", 23 + "format": "did" 24 + }, 25 + "type": { 26 + "type": "string", 27 + "format": "nsid" 28 + }, 29 + "key": { 30 + "type": "string" 31 + }, 32 + "serviceDid": { 33 + "type": "string" 34 + }, 35 + "memberListRef": { 36 + "type": "string", 37 + "format": "at-uri" 38 + }, 39 + "appPolicyRef": { 40 + "type": "string", 41 + "format": "at-uri" 42 + }, 43 + "createdAt": { 44 + "type": "integer" 45 + }, 46 + "appPolicy": { 47 + "type": "ref", 48 + "ref": "#appPolicy", 49 + "description": "Owner-only" 50 + } 51 + } 52 + }, 53 + "memberView": { 54 + "type": "object", 55 + "required": [ 56 + "did", 57 + "perms", 58 + "addedAt" 59 + ], 60 + "properties": { 61 + "did": { 62 + "type": "string", 63 + "format": "did" 64 + }, 65 + "perms": { 66 + "type": "string", 67 + "knownValues": [ 68 + "read", 69 + "write" 70 + ], 71 + "description": "'write' implies 'read'. Space owner is always implicit write." 72 + }, 73 + "addedAt": { 74 + "type": "integer" 75 + }, 76 + "addedBy": { 77 + "type": "string", 78 + "format": "did" 79 + } 80 + } 81 + }, 82 + "recordView": { 83 + "type": "object", 84 + "required": [ 85 + "spaceUri", 86 + "collection", 87 + "authorDid", 88 + "rkey", 89 + "record", 90 + "createdAt" 91 + ], 92 + "properties": { 93 + "spaceUri": { 94 + "type": "string", 95 + "format": "at-uri" 96 + }, 97 + "collection": { 98 + "type": "string", 99 + "format": "nsid" 100 + }, 101 + "authorDid": { 102 + "type": "string", 103 + "format": "did" 104 + }, 105 + "rkey": { 106 + "type": "string" 107 + }, 108 + "cid": { 109 + "type": "string", 110 + "format": "cid" 111 + }, 112 + "record": { 113 + "type": "unknown" 114 + }, 115 + "createdAt": { 116 + "type": "integer" 117 + } 118 + } 119 + }, 120 + "appPolicy": { 121 + "type": "object", 122 + "required": [ 123 + "mode", 124 + "apps" 125 + ], 126 + "properties": { 127 + "mode": { 128 + "type": "string", 129 + "knownValues": [ 130 + "allow", 131 + "deny" 132 + ], 133 + "description": "'allow' = default-allow with apps[] as denylist; 'deny' = default-deny with apps[] as allowlist." 134 + }, 135 + "apps": { 136 + "type": "array", 137 + "items": { 138 + "type": "string" 139 + } 140 + } 141 + } 142 + }, 143 + "inviteView": { 144 + "type": "object", 145 + "required": [ 146 + "tokenHash", 147 + "spaceUri", 148 + "kind", 149 + "perms", 150 + "usedCount", 151 + "createdBy", 152 + "createdAt" 153 + ], 154 + "properties": { 155 + "tokenHash": { 156 + "type": "string" 157 + }, 158 + "spaceUri": { 159 + "type": "string", 160 + "format": "at-uri" 161 + }, 162 + "kind": { 163 + "type": "string", 164 + "knownValues": [ 165 + "join", 166 + "read", 167 + "read-join" 168 + ] 169 + }, 170 + "perms": { 171 + "type": "string", 172 + "knownValues": [ 173 + "read", 174 + "write" 175 + ] 176 + }, 177 + "expiresAt": { 178 + "type": "integer" 179 + }, 180 + "maxUses": { 181 + "type": "integer" 182 + }, 183 + "usedCount": { 184 + "type": "integer" 185 + }, 186 + "createdBy": { 187 + "type": "string", 188 + "format": "did" 189 + }, 190 + "createdAt": { 191 + "type": "integer" 192 + }, 193 + "revokedAt": { 194 + "type": "integer" 195 + }, 196 + "note": { 197 + "type": "string" 198 + } 199 + } 200 + } 201 + } 202 + }
+56
lexicons-generated/rsvp/atmo/space/deleteRecord.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.deleteRecord", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a record from a space. Callers can delete their own records; the space owner can delete any record (via a separate admin path).", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "spaceUri", 14 + "collection", 15 + "rkey" 16 + ], 17 + "properties": { 18 + "spaceUri": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "collection": { 23 + "type": "string", 24 + "format": "nsid" 25 + }, 26 + "rkey": { 27 + "type": "string" 28 + } 29 + } 30 + } 31 + }, 32 + "output": { 33 + "encoding": "application/json", 34 + "schema": { 35 + "type": "object", 36 + "required": [ 37 + "ok" 38 + ], 39 + "properties": { 40 + "ok": { 41 + "type": "boolean" 42 + } 43 + } 44 + } 45 + }, 46 + "errors": [ 47 + { 48 + "name": "NotFound" 49 + }, 50 + { 51 + "name": "Forbidden" 52 + } 53 + ] 54 + } 55 + } 56 + }
+63
lexicons-generated/rsvp/atmo/space/getRecord.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.getRecord", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a single record from a space.", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "spaceUri", 12 + "collection", 13 + "author", 14 + "rkey" 15 + ], 16 + "properties": { 17 + "spaceUri": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "collection": { 22 + "type": "string", 23 + "format": "nsid" 24 + }, 25 + "author": { 26 + "type": "string", 27 + "format": "did" 28 + }, 29 + "rkey": { 30 + "type": "string" 31 + }, 32 + "inviteToken": { 33 + "type": "string", 34 + "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." 35 + } 36 + } 37 + }, 38 + "output": { 39 + "encoding": "application/json", 40 + "schema": { 41 + "type": "object", 42 + "required": [ 43 + "record" 44 + ], 45 + "properties": { 46 + "record": { 47 + "type": "ref", 48 + "ref": "rsvp.atmo.space.defs#recordView" 49 + } 50 + } 51 + } 52 + }, 53 + "errors": [ 54 + { 55 + "name": "NotFound" 56 + }, 57 + { 58 + "name": "Forbidden" 59 + } 60 + ] 61 + } 62 + } 63 + }
+49
lexicons-generated/rsvp/atmo/space/getSpace.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.getSpace", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get metadata for a single space. Caller must be a member, the owner, or hold a read-grant invite token.", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "uri" 12 + ], 13 + "properties": { 14 + "uri": { 15 + "type": "string", 16 + "format": "at-uri" 17 + }, 18 + "inviteToken": { 19 + "type": "string", 20 + "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/json", 26 + "schema": { 27 + "type": "object", 28 + "required": [ 29 + "space" 30 + ], 31 + "properties": { 32 + "space": { 33 + "type": "ref", 34 + "ref": "rsvp.atmo.space.defs#spaceView" 35 + } 36 + } 37 + } 38 + }, 39 + "errors": [ 40 + { 41 + "name": "NotFound" 42 + }, 43 + { 44 + "name": "Forbidden" 45 + } 46 + ] 47 + } 48 + } 49 + }
+84
lexicons-generated/rsvp/atmo/space/invite/create.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.invite.create", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create an invite for a space. Caller must be the space owner. Returns the raw token once; only the hash is stored.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "spaceUri" 14 + ], 15 + "properties": { 16 + "spaceUri": { 17 + "type": "string", 18 + "format": "at-uri" 19 + }, 20 + "kind": { 21 + "type": "string", 22 + "knownValues": [ 23 + "join", 24 + "read", 25 + "read-join" 26 + ], 27 + "default": "join", 28 + "description": "join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." 29 + }, 30 + "perms": { 31 + "type": "string", 32 + "knownValues": [ 33 + "read", 34 + "write" 35 + ], 36 + "default": "write" 37 + }, 38 + "expiresAt": { 39 + "type": "integer", 40 + "description": "Unix ms timestamp. Omit for no expiry." 41 + }, 42 + "maxUses": { 43 + "type": "integer", 44 + "minimum": 1, 45 + "description": "Caps join redemptions only — read-token reads are unlimited. Omit for unlimited joins." 46 + }, 47 + "note": { 48 + "type": "string", 49 + "maxLength": 500 50 + } 51 + } 52 + } 53 + }, 54 + "output": { 55 + "encoding": "application/json", 56 + "schema": { 57 + "type": "object", 58 + "required": [ 59 + "token", 60 + "invite" 61 + ], 62 + "properties": { 63 + "token": { 64 + "type": "string", 65 + "description": "Raw token. Shown once — cannot be retrieved later." 66 + }, 67 + "invite": { 68 + "type": "ref", 69 + "ref": "rsvp.atmo.space.defs#inviteView" 70 + } 71 + } 72 + } 73 + }, 74 + "errors": [ 75 + { 76 + "name": "NotFound" 77 + }, 78 + { 79 + "name": "Forbidden" 80 + } 81 + ] 82 + } 83 + } 84 + }
+52
lexicons-generated/rsvp/atmo/space/invite/list.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.invite.list", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List invites for a space. Owner only.", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "spaceUri" 12 + ], 13 + "properties": { 14 + "spaceUri": { 15 + "type": "string", 16 + "format": "at-uri" 17 + }, 18 + "includeRevoked": { 19 + "type": "boolean", 20 + "default": false 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/json", 26 + "schema": { 27 + "type": "object", 28 + "required": [ 29 + "invites" 30 + ], 31 + "properties": { 32 + "invites": { 33 + "type": "array", 34 + "items": { 35 + "type": "ref", 36 + "ref": "rsvp.atmo.space.defs#inviteView" 37 + } 38 + } 39 + } 40 + } 41 + }, 42 + "errors": [ 43 + { 44 + "name": "NotFound" 45 + }, 46 + { 47 + "name": "Forbidden" 48 + } 49 + ] 50 + } 51 + } 52 + }
+52
lexicons-generated/rsvp/atmo/space/invite/redeem.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.invite.redeem", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Redeem an invite token. The JWT issuer becomes a member of the space with the invite's perms.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "token" 14 + ], 15 + "properties": { 16 + "token": { 17 + "type": "string" 18 + } 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": [ 27 + "spaceUri", 28 + "perms" 29 + ], 30 + "properties": { 31 + "spaceUri": { 32 + "type": "string", 33 + "format": "at-uri" 34 + }, 35 + "perms": { 36 + "type": "string", 37 + "knownValues": [ 38 + "read", 39 + "write" 40 + ] 41 + } 42 + } 43 + } 44 + }, 45 + "errors": [ 46 + { 47 + "name": "InvalidInvite" 48 + } 49 + ] 50 + } 51 + } 52 + }
+51
lexicons-generated/rsvp/atmo/space/invite/revoke.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.invite.revoke", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Revoke an invite. Owner only. Invites are identified by their tokenHash (visible in list output).", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "spaceUri", 14 + "tokenHash" 15 + ], 16 + "properties": { 17 + "spaceUri": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "tokenHash": { 22 + "type": "string" 23 + } 24 + } 25 + } 26 + }, 27 + "output": { 28 + "encoding": "application/json", 29 + "schema": { 30 + "type": "object", 31 + "required": [ 32 + "ok" 33 + ], 34 + "properties": { 35 + "ok": { 36 + "type": "boolean" 37 + } 38 + } 39 + } 40 + }, 41 + "errors": [ 42 + { 43 + "name": "NotFound" 44 + }, 45 + { 46 + "name": "Forbidden" 47 + } 48 + ] 49 + } 50 + } 51 + }
+48
lexicons-generated/rsvp/atmo/space/leaveSpace.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.leaveSpace", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Remove the caller from a space's member list. The owner cannot leave — they must delete the space instead.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "spaceUri" 14 + ], 15 + "properties": { 16 + "spaceUri": { 17 + "type": "string", 18 + "format": "at-uri" 19 + } 20 + } 21 + } 22 + }, 23 + "output": { 24 + "encoding": "application/json", 25 + "schema": { 26 + "type": "object", 27 + "required": [ 28 + "ok" 29 + ], 30 + "properties": { 31 + "ok": { 32 + "type": "boolean" 33 + } 34 + } 35 + } 36 + }, 37 + "errors": [ 38 + { 39 + "name": "NotFound" 40 + }, 41 + { 42 + "name": "InvalidRequest", 43 + "description": "Raised if the caller is the space owner." 44 + } 45 + ] 46 + } 47 + } 48 + }
+48
lexicons-generated/rsvp/atmo/space/listMembers.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.listMembers", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List members of a space. Caller must be a member or the owner.", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "spaceUri" 12 + ], 13 + "properties": { 14 + "spaceUri": { 15 + "type": "string", 16 + "format": "at-uri" 17 + } 18 + } 19 + }, 20 + "output": { 21 + "encoding": "application/json", 22 + "schema": { 23 + "type": "object", 24 + "required": [ 25 + "members" 26 + ], 27 + "properties": { 28 + "members": { 29 + "type": "array", 30 + "items": { 31 + "type": "ref", 32 + "ref": "rsvp.atmo.space.defs#memberView" 33 + } 34 + } 35 + } 36 + } 37 + }, 38 + "errors": [ 39 + { 40 + "name": "NotFound" 41 + }, 42 + { 43 + "name": "Forbidden" 44 + } 45 + ] 46 + } 47 + } 48 + }
+74
lexicons-generated/rsvp/atmo/space/listRecords.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.listRecords", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List records of a given collection within a space. Access is governed by the space's collection policy.", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "spaceUri", 12 + "collection" 13 + ], 14 + "properties": { 15 + "spaceUri": { 16 + "type": "string", 17 + "format": "at-uri" 18 + }, 19 + "collection": { 20 + "type": "string", 21 + "format": "nsid" 22 + }, 23 + "byUser": { 24 + "type": "string", 25 + "format": "did", 26 + "description": "Only return records authored by this DID." 27 + }, 28 + "cursor": { 29 + "type": "string" 30 + }, 31 + "limit": { 32 + "type": "integer", 33 + "minimum": 1, 34 + "maximum": 200, 35 + "default": 50 36 + }, 37 + "inviteToken": { 38 + "type": "string", 39 + "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." 40 + } 41 + } 42 + }, 43 + "output": { 44 + "encoding": "application/json", 45 + "schema": { 46 + "type": "object", 47 + "required": [ 48 + "records" 49 + ], 50 + "properties": { 51 + "records": { 52 + "type": "array", 53 + "items": { 54 + "type": "ref", 55 + "ref": "rsvp.atmo.space.defs#recordView" 56 + } 57 + }, 58 + "cursor": { 59 + "type": "string" 60 + } 61 + } 62 + } 63 + }, 64 + "errors": [ 65 + { 66 + "name": "NotFound" 67 + }, 68 + { 69 + "name": "Forbidden" 70 + } 71 + ] 72 + } 73 + } 74 + }
+57
lexicons-generated/rsvp/atmo/space/listSpaces.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.listSpaces", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List spaces the caller has access to. Default scope is 'member' (spaces the caller is a member of, including owned); 'owner' lists only spaces the caller owns.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "scope": { 12 + "type": "string", 13 + "knownValues": [ 14 + "member", 15 + "owner" 16 + ], 17 + "default": "member" 18 + }, 19 + "type": { 20 + "type": "string", 21 + "format": "nsid" 22 + }, 23 + "cursor": { 24 + "type": "string" 25 + }, 26 + "limit": { 27 + "type": "integer", 28 + "minimum": 1, 29 + "maximum": 200, 30 + "default": 50 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": [ 39 + "spaces" 40 + ], 41 + "properties": { 42 + "spaces": { 43 + "type": "array", 44 + "items": { 45 + "type": "ref", 46 + "ref": "rsvp.atmo.space.defs#spaceView" 47 + } 48 + }, 49 + "cursor": { 50 + "type": "string" 51 + } 52 + } 53 + } 54 + } 55 + } 56 + } 57 + }
+68
lexicons-generated/rsvp/atmo/space/putRecord.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.putRecord", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Write a record into a space. The author is always the JWT issuer. If rkey is omitted, a TID is generated.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "spaceUri", 14 + "collection", 15 + "record" 16 + ], 17 + "properties": { 18 + "spaceUri": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "collection": { 23 + "type": "string", 24 + "format": "nsid" 25 + }, 26 + "rkey": { 27 + "type": "string" 28 + }, 29 + "record": { 30 + "type": "unknown" 31 + } 32 + } 33 + } 34 + }, 35 + "output": { 36 + "encoding": "application/json", 37 + "schema": { 38 + "type": "object", 39 + "required": [ 40 + "rkey", 41 + "authorDid", 42 + "createdAt" 43 + ], 44 + "properties": { 45 + "rkey": { 46 + "type": "string" 47 + }, 48 + "authorDid": { 49 + "type": "string", 50 + "format": "did" 51 + }, 52 + "createdAt": { 53 + "type": "integer" 54 + } 55 + } 56 + } 57 + }, 58 + "errors": [ 59 + { 60 + "name": "NotFound" 61 + }, 62 + { 63 + "name": "Forbidden" 64 + } 65 + ] 66 + } 67 + } 68 + }
+55
lexicons-generated/rsvp/atmo/space/removeMember.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.removeMember", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Remove a member from a space. Owner only. Cannot remove the owner.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "spaceUri", 14 + "did" 15 + ], 16 + "properties": { 17 + "spaceUri": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "did": { 22 + "type": "string", 23 + "format": "did" 24 + } 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "application/json", 30 + "schema": { 31 + "type": "object", 32 + "required": [ 33 + "ok" 34 + ], 35 + "properties": { 36 + "ok": { 37 + "type": "boolean" 38 + } 39 + } 40 + } 41 + }, 42 + "errors": [ 43 + { 44 + "name": "NotFound" 45 + }, 46 + { 47 + "name": "Forbidden" 48 + }, 49 + { 50 + "name": "InvalidRequest" 51 + } 52 + ] 53 + } 54 + } 55 + }
+53
lexicons-generated/rsvp/atmo/space/whoami.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "rsvp.atmo.space.whoami", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Report the caller's relationship to a space: whether they are the owner, a member, and at what permission level. Useful for clients to avoid a listMembers roundtrip.", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "spaceUri" 12 + ], 13 + "properties": { 14 + "spaceUri": { 15 + "type": "string", 16 + "format": "at-uri" 17 + } 18 + } 19 + }, 20 + "output": { 21 + "encoding": "application/json", 22 + "schema": { 23 + "type": "object", 24 + "required": [ 25 + "isOwner", 26 + "isMember" 27 + ], 28 + "properties": { 29 + "isOwner": { 30 + "type": "boolean" 31 + }, 32 + "isMember": { 33 + "type": "boolean" 34 + }, 35 + "perms": { 36 + "type": "string", 37 + "knownValues": [ 38 + "read", 39 + "write" 40 + ], 41 + "description": "Present only when the caller is a member or the owner." 42 + } 43 + } 44 + } 45 + }, 46 + "errors": [ 47 + { 48 + "name": "NotFound" 49 + } 50 + ] 51 + } 52 + } 53 + }
+2 -7
package.json
··· 21 21 "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts", 22 22 "tunnel": "npx tsx src/lib/atproto/scripts/tunnel.ts", 23 23 "lexicons": "lex-cli pull && lex-cli generate", 24 - "vods": "tsx scripts/vod-processing/pipeline.ts", 25 - "vods:fetch": "tsx scripts/vod-processing/fetch-events.ts", 26 - "vods:download": "tsx scripts/vod-processing/download-audio.ts", 27 - "vods:transcribe": "tsx scripts/vod-processing/transcribe.ts", 28 - "vods:summarize": "tsx scripts/vod-processing/summarize.ts", 29 - "vods:vtt": "tsx scripts/vod-processing/generate-vtt.ts" 24 + "publish-lexicons": "tsx --env-file=.env scripts/publish-lexicons.ts" 30 25 }, 31 26 "devDependencies": { 32 27 "@atcute/atproto": "^3.1.10", ··· 66 61 "dependencies": { 67 62 "@atcute/bluesky-richtext-parser": "^2.1.1", 68 63 "@atcute/jetstream": "^1.1.2", 69 - "@atmo-dev/contrail": "^0.0.6", 64 + "@atmo-dev/contrail": "^0.1.1", 70 65 "@ethercorps/sveltekit-og": "^4.2.1", 71 66 "@foxui/colors": "^0.8.5", 72 67 "@foxui/core": "^0.9.1",
+26 -10
pnpm-lock.yaml
··· 15 15 specifier: ^1.1.2 16 16 version: 1.1.2 17 17 '@atmo-dev/contrail': 18 - specifier: ^0.0.6 19 - version: 0.0.6(@atcute/identity@1.1.4) 18 + specifier: ^0.1.1 19 + version: 0.1.1 20 20 '@ethercorps/sveltekit-og': 21 21 specifier: ^4.2.1 22 22 version: 4.2.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.0)(vite@8.0.3(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.0)(typescript@6.0.2)(vite@8.0.3(@types/node@25.0.10)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))) ··· 267 267 '@atcute/varint@2.0.0': 268 268 resolution: {integrity: sha512-CEY/oVK/nVpL4e5y3sdenLETDL6/Xu5xsE/0TupK+f0Yv8jcD60t2gD8SHROWSvUwYLdkjczLCSA7YrtnjCzWw==} 269 269 270 - '@atmo-dev/contrail@0.0.6': 271 - resolution: {integrity: sha512-0cJ6Ftg2DJKp6MX1RCGHR7tHT8k7ZwWwJqjvdIfKN+ciVqsv5i0BvEBxs+MQ/0xF4wfddGYFxrAJRetLkzWU9w==} 270 + '@atcute/xrpc-server@0.1.12': 271 + resolution: {integrity: sha512-70KIerQlljp5+s6t0u6YNN9klEboQUZa2hhoi/hmXIO1cIKEORettTMctnyjfcCJaSfAuj42dxPu51GTZBlm8w==} 272 + 273 + '@atmo-dev/contrail@0.1.1': 274 + resolution: {integrity: sha512-7BdLYjHHGC2EKlT/p3KszJAE6Pv1FPPuLVG+eYRpXSTF6iwrk/FVA0Dep7lTowbLlo5mQBpbEpo2CrQtUZAPfQ==} 272 275 peerDependencies: 273 276 pg: ^8.0.0 274 277 peerDependenciesMeta: ··· 1994 1997 hls.js@1.6.15: 1995 1998 resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==} 1996 1999 1997 - hono@4.12.9: 1998 - resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} 2000 + hono@4.12.14: 2001 + resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} 1999 2002 engines: {node: '>=16.9.0'} 2000 2003 2001 2004 htmlparser2@10.1.0: ··· 3135 3138 3136 3139 '@atcute/varint@2.0.0': {} 3137 3140 3138 - '@atmo-dev/contrail@0.0.6(@atcute/identity@1.1.4)': 3141 + '@atcute/xrpc-server@0.1.12': 3142 + dependencies: 3143 + '@atcute/cbor': 2.3.2 3144 + '@atcute/crypto': 2.4.1 3145 + '@atcute/identity': 1.1.4 3146 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) 3147 + '@atcute/lexicons': 1.2.9 3148 + '@atcute/multibase': 1.2.0 3149 + '@atcute/uint8array': 1.1.1 3150 + '@badrap/valita': 0.4.6 3151 + nanoid: 5.1.7 3152 + 3153 + '@atmo-dev/contrail@0.1.1': 3139 3154 dependencies: 3140 3155 '@atcute/atproto': 3.1.10 3141 3156 '@atcute/client': 4.2.1 3157 + '@atcute/identity': 1.1.4 3142 3158 '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) 3143 3159 '@atcute/jetstream': 1.1.2 3144 3160 '@atcute/lexicons': 1.2.9 3145 - hono: 4.12.9 3161 + '@atcute/xrpc-server': 0.1.12 3162 + hono: 4.12.14 3146 3163 transitivePeerDependencies: 3147 - - '@atcute/identity' 3148 3164 - react 3149 3165 3150 3166 '@badrap/valita@0.4.6': {} ··· 4766 4782 4767 4783 hls.js@1.6.15: {} 4768 4784 4769 - hono@4.12.9: {} 4785 + hono@4.12.14: {} 4770 4786 4771 4787 htmlparser2@10.1.0: 4772 4788 dependencies:
+40
scripts/publish-lexicons.ts
··· 1 + /** 2 + * Publish atmo-events' generated lexicons to the specified account's PDS. 3 + * 4 + * Usage: 5 + * LEXICON_ACCOUNT_IDENTIFIER=you.bsky.social \ 6 + * LEXICON_ACCOUNT_PASSWORD=xxxx-xxxx-xxxx-xxxx \ 7 + * pnpm publish-lexicons 8 + * 9 + * pnpm publish-lexicons <identifier> <app-password> 10 + */ 11 + 12 + import { join, dirname } from 'node:path'; 13 + import { fileURLToPath } from 'node:url'; 14 + import { publishLexicons } from '@atmo-dev/contrail/publish'; 15 + 16 + const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); 17 + 18 + async function main(): Promise<void> { 19 + const identifier = process.argv[2] ?? process.env.LEXICON_ACCOUNT_IDENTIFIER; 20 + const password = process.argv[3] ?? process.env.LEXICON_ACCOUNT_PASSWORD; 21 + 22 + if (!identifier || !password) { 23 + console.error( 24 + 'Usage: pnpm publish-lexicons <handle-or-did> <app-password>\n' + 25 + ' (or set LEXICON_ACCOUNT_IDENTIFIER and LEXICON_ACCOUNT_PASSWORD env vars)\n' 26 + ); 27 + process.exit(1); 28 + } 29 + 30 + await publishLexicons({ 31 + generatedDir: join(ROOT, 'lexicons-generated'), 32 + identifier, 33 + password 34 + }); 35 + } 36 + 37 + main().catch((err) => { 38 + console.error(err); 39 + process.exit(1); 40 + });
+21 -4
src/lexicon-types/index.ts
··· 1 1 export * as AppBskyActorProfile from "./types/app/bsky/actor/profile.js"; 2 2 export * as CommunityLexiconCalendarEvent from "./types/community/lexicon/calendar/event.js"; 3 - export * as CommunityLexiconCalendarEventGetRecord from "./types/community/lexicon/calendar/event/getRecord.js"; 4 - export * as CommunityLexiconCalendarEventListRecords from "./types/community/lexicon/calendar/event/listRecords.js"; 5 3 export * as CommunityLexiconCalendarRsvp from "./types/community/lexicon/calendar/rsvp.js"; 6 - export * as CommunityLexiconCalendarRsvpGetRecord from "./types/community/lexicon/calendar/rsvp/getRecord.js"; 7 - export * as CommunityLexiconCalendarRsvpListRecords from "./types/community/lexicon/calendar/rsvp/listRecords.js"; 8 4 export * as CommunityLexiconLocationAddress from "./types/community/lexicon/location/address.js"; 9 5 export * as CommunityLexiconLocationFsq from "./types/community/lexicon/location/fsq.js"; 10 6 export * as CommunityLexiconLocationGeo from "./types/community/lexicon/location/geo.js"; 11 7 export * as CommunityLexiconLocationHthree from "./types/community/lexicon/location/hthree.js"; 8 + export * as RsvpAtmoEventGetRecord from "./types/rsvp/atmo/event/getRecord.js"; 9 + export * as RsvpAtmoEventListRecords from "./types/rsvp/atmo/event/listRecords.js"; 12 10 export * as RsvpAtmoGetCursor from "./types/rsvp/atmo/getCursor.js"; 13 11 export * as RsvpAtmoGetOverview from "./types/rsvp/atmo/getOverview.js"; 14 12 export * as RsvpAtmoGetProfile from "./types/rsvp/atmo/getProfile.js"; 15 13 export * as RsvpAtmoNotifyOfUpdate from "./types/rsvp/atmo/notifyOfUpdate.js"; 14 + export * as RsvpAtmoRsvpGetRecord from "./types/rsvp/atmo/rsvp/getRecord.js"; 15 + export * as RsvpAtmoRsvpListRecords from "./types/rsvp/atmo/rsvp/listRecords.js"; 16 + export * as RsvpAtmoSpaceAddMember from "./types/rsvp/atmo/space/addMember.js"; 17 + export * as RsvpAtmoSpaceCreateSpace from "./types/rsvp/atmo/space/createSpace.js"; 18 + export * as RsvpAtmoSpaceDefs from "./types/rsvp/atmo/space/defs.js"; 19 + export * as RsvpAtmoSpaceDeleteRecord from "./types/rsvp/atmo/space/deleteRecord.js"; 20 + export * as RsvpAtmoSpaceGetRecord from "./types/rsvp/atmo/space/getRecord.js"; 21 + export * as RsvpAtmoSpaceGetSpace from "./types/rsvp/atmo/space/getSpace.js"; 22 + export * as RsvpAtmoSpaceInviteCreate from "./types/rsvp/atmo/space/invite/create.js"; 23 + export * as RsvpAtmoSpaceInviteList from "./types/rsvp/atmo/space/invite/list.js"; 24 + export * as RsvpAtmoSpaceInviteRedeem from "./types/rsvp/atmo/space/invite/redeem.js"; 25 + export * as RsvpAtmoSpaceInviteRevoke from "./types/rsvp/atmo/space/invite/revoke.js"; 26 + export * as RsvpAtmoSpaceLeaveSpace from "./types/rsvp/atmo/space/leaveSpace.js"; 27 + export * as RsvpAtmoSpaceListMembers from "./types/rsvp/atmo/space/listMembers.js"; 28 + export * as RsvpAtmoSpaceListRecords from "./types/rsvp/atmo/space/listRecords.js"; 29 + export * as RsvpAtmoSpaceListSpaces from "./types/rsvp/atmo/space/listSpaces.js"; 30 + export * as RsvpAtmoSpacePutRecord from "./types/rsvp/atmo/space/putRecord.js"; 31 + export * as RsvpAtmoSpaceRemoveMember from "./types/rsvp/atmo/space/removeMember.js"; 32 + export * as RsvpAtmoSpaceWhoami from "./types/rsvp/atmo/space/whoami.js";
+242
src/lexicon-types/types/rsvp/atmo/event/getRecord.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; 5 + import * as ComAtprotoRepoStrongRef from "@atcute/atproto/types/repo/strongRef"; 6 + import * as CommunityLexiconCalendarEvent from "../../../community/lexicon/calendar/event.js"; 7 + import * as CommunityLexiconCalendarRsvp from "../../../community/lexicon/calendar/rsvp.js"; 8 + 9 + const _appBskyActorProfileSchema = /*#__PURE__*/ v.object({ 10 + $type: /*#__PURE__*/ v.optional( 11 + /*#__PURE__*/ v.literal("rsvp.atmo.event.getRecord#appBskyActorProfile"), 12 + ), 13 + /** 14 + * Small image to be displayed next to posts from account. AKA, 'profile picture' 15 + * @accept image/png, image/jpeg 16 + * @maxSize 1000000 17 + */ 18 + avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.blob()), 19 + /** 20 + * Larger horizontal image to display behind profile view. 21 + * @accept image/png, image/jpeg 22 + * @maxSize 1000000 23 + */ 24 + banner: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.blob()), 25 + createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 26 + /** 27 + * Free-form profile description text. 28 + * @maxLength 2560 29 + * @maxGraphemes 256 30 + */ 31 + description: /*#__PURE__*/ v.optional( 32 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 33 + /*#__PURE__*/ v.stringLength(0, 2560), 34 + /*#__PURE__*/ v.stringGraphemes(0, 256), 35 + ]), 36 + ), 37 + /** 38 + * @maxLength 640 39 + * @maxGraphemes 64 40 + */ 41 + displayName: /*#__PURE__*/ v.optional( 42 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 43 + /*#__PURE__*/ v.stringLength(0, 640), 44 + /*#__PURE__*/ v.stringGraphemes(0, 64), 45 + ]), 46 + ), 47 + get joinedViaStarterPack() { 48 + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); 49 + }, 50 + /** 51 + * Self-label values, specific to the Bluesky application, on the overall account. 52 + */ 53 + get labels() { 54 + return /*#__PURE__*/ v.optional( 55 + /*#__PURE__*/ v.variant([ComAtprotoLabelDefs.selfLabelsSchema]), 56 + ); 57 + }, 58 + get pinnedPost() { 59 + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); 60 + }, 61 + /** 62 + * Free-form pronouns text. 63 + * @maxLength 200 64 + * @maxGraphemes 20 65 + */ 66 + pronouns: /*#__PURE__*/ v.optional( 67 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 68 + /*#__PURE__*/ v.stringLength(0, 200), 69 + /*#__PURE__*/ v.stringGraphemes(0, 20), 70 + ]), 71 + ), 72 + website: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), 73 + }); 74 + const _hydrateRsvpsSchema = /*#__PURE__*/ v.object({ 75 + $type: /*#__PURE__*/ v.optional( 76 + /*#__PURE__*/ v.literal("rsvp.atmo.event.getRecord#hydrateRsvps"), 77 + ), 78 + get going() { 79 + return /*#__PURE__*/ v.optional( 80 + /*#__PURE__*/ v.array(hydrateRsvpsRecordSchema), 81 + ); 82 + }, 83 + get interested() { 84 + return /*#__PURE__*/ v.optional( 85 + /*#__PURE__*/ v.array(hydrateRsvpsRecordSchema), 86 + ); 87 + }, 88 + get notgoing() { 89 + return /*#__PURE__*/ v.optional( 90 + /*#__PURE__*/ v.array(hydrateRsvpsRecordSchema), 91 + ); 92 + }, 93 + get other() { 94 + return /*#__PURE__*/ v.optional( 95 + /*#__PURE__*/ v.array(hydrateRsvpsRecordSchema), 96 + ); 97 + }, 98 + }); 99 + const _hydrateRsvpsRecordSchema = /*#__PURE__*/ v.object({ 100 + $type: /*#__PURE__*/ v.optional( 101 + /*#__PURE__*/ v.literal("rsvp.atmo.event.getRecord#hydrateRsvpsRecord"), 102 + ), 103 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 104 + collection: /*#__PURE__*/ v.nsidString(), 105 + did: /*#__PURE__*/ v.didString(), 106 + get record() { 107 + return /*#__PURE__*/ v.optional(CommunityLexiconCalendarRsvp.mainSchema); 108 + }, 109 + rkey: /*#__PURE__*/ v.string(), 110 + /** 111 + * Present when the record was read from a permissioned space. 112 + */ 113 + space: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 114 + time_us: /*#__PURE__*/ v.integer(), 115 + uri: /*#__PURE__*/ v.resourceUriString(), 116 + }); 117 + const _mainSchema = /*#__PURE__*/ v.query("rsvp.atmo.event.getRecord", { 118 + params: /*#__PURE__*/ v.object({ 119 + /** 120 + * Number of rsvps records to embed 121 + * @minimum 1 122 + * @maximum 50 123 + */ 124 + hydrateRsvps: /*#__PURE__*/ v.optional( 125 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 126 + /*#__PURE__*/ v.integerRange(1, 50), 127 + ]), 128 + ), 129 + /** 130 + * Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied. 131 + */ 132 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 133 + /** 134 + * Include profile + identity info keyed by DID 135 + */ 136 + profiles: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), 137 + /** 138 + * If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token). 139 + */ 140 + spaceUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 141 + /** 142 + * AT URI of the record 143 + */ 144 + uri: /*#__PURE__*/ v.resourceUriString(), 145 + }), 146 + output: { 147 + type: "lex", 148 + schema: /*#__PURE__*/ v.object({ 149 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 150 + collection: /*#__PURE__*/ v.nsidString(), 151 + did: /*#__PURE__*/ v.didString(), 152 + get profiles() { 153 + return /*#__PURE__*/ v.optional( 154 + /*#__PURE__*/ v.array(profileEntrySchema), 155 + ); 156 + }, 157 + get record() { 158 + return /*#__PURE__*/ v.optional( 159 + CommunityLexiconCalendarEvent.mainSchema, 160 + ); 161 + }, 162 + rkey: /*#__PURE__*/ v.string(), 163 + get rsvps() { 164 + return /*#__PURE__*/ v.optional(hydrateRsvpsSchema); 165 + }, 166 + /** 167 + * Total rsvps count 168 + */ 169 + rsvpsCount: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 170 + /** 171 + * rsvps count where status = going 172 + */ 173 + rsvpsGoingCount: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 174 + /** 175 + * rsvps count where status = interested 176 + */ 177 + rsvpsInterestedCount: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 178 + /** 179 + * rsvps count where status = notgoing 180 + */ 181 + rsvpsNotgoingCount: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 182 + /** 183 + * Present when the record was read from a permissioned space; its value is the space URI. 184 + */ 185 + space: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 186 + time_us: /*#__PURE__*/ v.integer(), 187 + uri: /*#__PURE__*/ v.resourceUriString(), 188 + }), 189 + }, 190 + }); 191 + const _profileEntrySchema = /*#__PURE__*/ v.object({ 192 + $type: /*#__PURE__*/ v.optional( 193 + /*#__PURE__*/ v.literal("rsvp.atmo.event.getRecord#profileEntry"), 194 + ), 195 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 196 + collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), 197 + did: /*#__PURE__*/ v.didString(), 198 + handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 199 + get record() { 200 + return /*#__PURE__*/ v.optional(appBskyActorProfileSchema); 201 + }, 202 + rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 203 + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 204 + }); 205 + 206 + type appBskyActorProfile$schematype = typeof _appBskyActorProfileSchema; 207 + type hydrateRsvps$schematype = typeof _hydrateRsvpsSchema; 208 + type hydrateRsvpsRecord$schematype = typeof _hydrateRsvpsRecordSchema; 209 + type main$schematype = typeof _mainSchema; 210 + type profileEntry$schematype = typeof _profileEntrySchema; 211 + 212 + export interface appBskyActorProfileSchema extends appBskyActorProfile$schematype {} 213 + export interface hydrateRsvpsSchema extends hydrateRsvps$schematype {} 214 + export interface hydrateRsvpsRecordSchema extends hydrateRsvpsRecord$schematype {} 215 + export interface mainSchema extends main$schematype {} 216 + export interface profileEntrySchema extends profileEntry$schematype {} 217 + 218 + export const appBskyActorProfileSchema = 219 + _appBskyActorProfileSchema as appBskyActorProfileSchema; 220 + export const hydrateRsvpsSchema = _hydrateRsvpsSchema as hydrateRsvpsSchema; 221 + export const hydrateRsvpsRecordSchema = 222 + _hydrateRsvpsRecordSchema as hydrateRsvpsRecordSchema; 223 + export const mainSchema = _mainSchema as mainSchema; 224 + export const profileEntrySchema = _profileEntrySchema as profileEntrySchema; 225 + 226 + export interface AppBskyActorProfile extends v.InferInput< 227 + typeof appBskyActorProfileSchema 228 + > {} 229 + export interface HydrateRsvps extends v.InferInput<typeof hydrateRsvpsSchema> {} 230 + export interface HydrateRsvpsRecord extends v.InferInput< 231 + typeof hydrateRsvpsRecordSchema 232 + > {} 233 + export interface ProfileEntry extends v.InferInput<typeof profileEntrySchema> {} 234 + 235 + export interface $params extends v.InferInput<mainSchema["params"]> {} 236 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 237 + 238 + declare module "@atcute/lexicons/ambient" { 239 + interface XRPCQueries { 240 + "rsvp.atmo.event.getRecord": mainSchema; 241 + } 242 + }
+363
src/lexicon-types/types/rsvp/atmo/event/listRecords.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; 5 + import * as ComAtprotoRepoStrongRef from "@atcute/atproto/types/repo/strongRef"; 6 + import * as CommunityLexiconCalendarEvent from "../../../community/lexicon/calendar/event.js"; 7 + import * as CommunityLexiconCalendarRsvp from "../../../community/lexicon/calendar/rsvp.js"; 8 + 9 + const _appBskyActorProfileSchema = /*#__PURE__*/ v.object({ 10 + $type: /*#__PURE__*/ v.optional( 11 + /*#__PURE__*/ v.literal("rsvp.atmo.event.listRecords#appBskyActorProfile"), 12 + ), 13 + /** 14 + * Small image to be displayed next to posts from account. AKA, 'profile picture' 15 + * @accept image/png, image/jpeg 16 + * @maxSize 1000000 17 + */ 18 + avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.blob()), 19 + /** 20 + * Larger horizontal image to display behind profile view. 21 + * @accept image/png, image/jpeg 22 + * @maxSize 1000000 23 + */ 24 + banner: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.blob()), 25 + createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 26 + /** 27 + * Free-form profile description text. 28 + * @maxLength 2560 29 + * @maxGraphemes 256 30 + */ 31 + description: /*#__PURE__*/ v.optional( 32 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 33 + /*#__PURE__*/ v.stringLength(0, 2560), 34 + /*#__PURE__*/ v.stringGraphemes(0, 256), 35 + ]), 36 + ), 37 + /** 38 + * @maxLength 640 39 + * @maxGraphemes 64 40 + */ 41 + displayName: /*#__PURE__*/ v.optional( 42 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 43 + /*#__PURE__*/ v.stringLength(0, 640), 44 + /*#__PURE__*/ v.stringGraphemes(0, 64), 45 + ]), 46 + ), 47 + get joinedViaStarterPack() { 48 + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); 49 + }, 50 + /** 51 + * Self-label values, specific to the Bluesky application, on the overall account. 52 + */ 53 + get labels() { 54 + return /*#__PURE__*/ v.optional( 55 + /*#__PURE__*/ v.variant([ComAtprotoLabelDefs.selfLabelsSchema]), 56 + ); 57 + }, 58 + get pinnedPost() { 59 + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); 60 + }, 61 + /** 62 + * Free-form pronouns text. 63 + * @maxLength 200 64 + * @maxGraphemes 20 65 + */ 66 + pronouns: /*#__PURE__*/ v.optional( 67 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 68 + /*#__PURE__*/ v.stringLength(0, 200), 69 + /*#__PURE__*/ v.stringGraphemes(0, 20), 70 + ]), 71 + ), 72 + website: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), 73 + }); 74 + const _hydrateRsvpsSchema = /*#__PURE__*/ v.object({ 75 + $type: /*#__PURE__*/ v.optional( 76 + /*#__PURE__*/ v.literal("rsvp.atmo.event.listRecords#hydrateRsvps"), 77 + ), 78 + get going() { 79 + return /*#__PURE__*/ v.optional( 80 + /*#__PURE__*/ v.array(hydrateRsvpsRecordSchema), 81 + ); 82 + }, 83 + get interested() { 84 + return /*#__PURE__*/ v.optional( 85 + /*#__PURE__*/ v.array(hydrateRsvpsRecordSchema), 86 + ); 87 + }, 88 + get notgoing() { 89 + return /*#__PURE__*/ v.optional( 90 + /*#__PURE__*/ v.array(hydrateRsvpsRecordSchema), 91 + ); 92 + }, 93 + get other() { 94 + return /*#__PURE__*/ v.optional( 95 + /*#__PURE__*/ v.array(hydrateRsvpsRecordSchema), 96 + ); 97 + }, 98 + }); 99 + const _hydrateRsvpsRecordSchema = /*#__PURE__*/ v.object({ 100 + $type: /*#__PURE__*/ v.optional( 101 + /*#__PURE__*/ v.literal("rsvp.atmo.event.listRecords#hydrateRsvpsRecord"), 102 + ), 103 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 104 + collection: /*#__PURE__*/ v.nsidString(), 105 + did: /*#__PURE__*/ v.didString(), 106 + get record() { 107 + return /*#__PURE__*/ v.optional(CommunityLexiconCalendarRsvp.mainSchema); 108 + }, 109 + rkey: /*#__PURE__*/ v.string(), 110 + /** 111 + * Present when the record was read from a permissioned space. 112 + */ 113 + space: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 114 + time_us: /*#__PURE__*/ v.integer(), 115 + uri: /*#__PURE__*/ v.resourceUriString(), 116 + }); 117 + const _mainSchema = /*#__PURE__*/ v.query("rsvp.atmo.event.listRecords", { 118 + params: /*#__PURE__*/ v.object({ 119 + /** 120 + * Filter by DID or handle (triggers on-demand backfill) 121 + */ 122 + actor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.actorIdentifierString()), 123 + /** 124 + * Only used with spaceUri — filter to records authored by this DID. 125 + */ 126 + byUser: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()), 127 + /** 128 + * Maximum value for createdAt 129 + */ 130 + createdAtMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 131 + /** 132 + * Minimum value for createdAt 133 + */ 134 + createdAtMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 135 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 136 + /** 137 + * Filter by description 138 + */ 139 + description: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 140 + /** 141 + * Maximum value for endsAt 142 + */ 143 + endsAtMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 144 + /** 145 + * Minimum value for endsAt 146 + */ 147 + endsAtMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 148 + /** 149 + * Number of rsvps records to embed per record 150 + * @minimum 1 151 + * @maximum 50 152 + */ 153 + hydrateRsvps: /*#__PURE__*/ v.optional( 154 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 155 + /*#__PURE__*/ v.integerRange(1, 50), 156 + ]), 157 + ), 158 + /** 159 + * Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied. 160 + */ 161 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 162 + /** 163 + * @minimum 1 164 + * @maximum 200 165 + * @default 50 166 + */ 167 + limit: /*#__PURE__*/ v.optional( 168 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 169 + /*#__PURE__*/ v.integerRange(1, 200), 170 + ]), 171 + 50, 172 + ), 173 + /** 174 + * Filter by mode 175 + */ 176 + mode: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 177 + /** 178 + * Filter by name 179 + */ 180 + name: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 181 + /** 182 + * Sort direction (default: desc for dates/numbers/counts, asc for strings) 183 + */ 184 + order: /*#__PURE__*/ v.optional( 185 + /*#__PURE__*/ v.string<"asc" | "desc" | (string & {})>(), 186 + ), 187 + /** 188 + * Filter by preferences.showInDiscovery 189 + */ 190 + preferencesShowInDiscovery: /*#__PURE__*/ v.optional( 191 + /*#__PURE__*/ v.string(), 192 + ), 193 + /** 194 + * Include profile + identity info keyed by DID 195 + */ 196 + profiles: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), 197 + /** 198 + * Minimum total rsvps count 199 + */ 200 + rsvpsCountMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 201 + /** 202 + * Minimum rsvps count where status = going 203 + */ 204 + rsvpsGoingCountMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 205 + /** 206 + * Minimum rsvps count where status = interested 207 + */ 208 + rsvpsInterestedCountMin: /*#__PURE__*/ v.optional( 209 + /*#__PURE__*/ v.integer(), 210 + ), 211 + /** 212 + * Minimum rsvps count where status = notgoing 213 + */ 214 + rsvpsNotgoingCountMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 215 + /** 216 + * Full-text search across: mode, name, status, description 217 + */ 218 + search: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 219 + /** 220 + * Field to sort by (default: time_us) 221 + */ 222 + sort: /*#__PURE__*/ v.optional( 223 + /*#__PURE__*/ v.string< 224 + | "createdAt" 225 + | "description" 226 + | "endsAt" 227 + | "mode" 228 + | "name" 229 + | "preferencesShowInDiscovery" 230 + | "rsvpsCount" 231 + | "rsvpsGoingCount" 232 + | "rsvpsInterestedCount" 233 + | "rsvpsNotgoingCount" 234 + | "startsAt" 235 + | "status" 236 + | (string & {}) 237 + >(), 238 + ), 239 + /** 240 + * If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token). 241 + */ 242 + spaceUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 243 + /** 244 + * Maximum value for startsAt 245 + */ 246 + startsAtMax: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 247 + /** 248 + * Minimum value for startsAt 249 + */ 250 + startsAtMin: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 251 + /** 252 + * Filter by status 253 + */ 254 + status: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 255 + }), 256 + output: { 257 + type: "lex", 258 + schema: /*#__PURE__*/ v.object({ 259 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 260 + get profiles() { 261 + return /*#__PURE__*/ v.optional( 262 + /*#__PURE__*/ v.array(profileEntrySchema), 263 + ); 264 + }, 265 + get records() { 266 + return /*#__PURE__*/ v.array(recordSchema); 267 + }, 268 + }), 269 + }, 270 + }); 271 + const _profileEntrySchema = /*#__PURE__*/ v.object({ 272 + $type: /*#__PURE__*/ v.optional( 273 + /*#__PURE__*/ v.literal("rsvp.atmo.event.listRecords#profileEntry"), 274 + ), 275 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 276 + collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), 277 + did: /*#__PURE__*/ v.didString(), 278 + handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 279 + get record() { 280 + return /*#__PURE__*/ v.optional(appBskyActorProfileSchema); 281 + }, 282 + rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 283 + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 284 + }); 285 + const _recordSchema = /*#__PURE__*/ v.object({ 286 + $type: /*#__PURE__*/ v.optional( 287 + /*#__PURE__*/ v.literal("rsvp.atmo.event.listRecords#record"), 288 + ), 289 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 290 + collection: /*#__PURE__*/ v.nsidString(), 291 + did: /*#__PURE__*/ v.didString(), 292 + get record() { 293 + return /*#__PURE__*/ v.optional(CommunityLexiconCalendarEvent.mainSchema); 294 + }, 295 + rkey: /*#__PURE__*/ v.string(), 296 + get rsvps() { 297 + return /*#__PURE__*/ v.optional(hydrateRsvpsSchema); 298 + }, 299 + /** 300 + * Total rsvps count 301 + */ 302 + rsvpsCount: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 303 + /** 304 + * rsvps count where status = going 305 + */ 306 + rsvpsGoingCount: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 307 + /** 308 + * rsvps count where status = interested 309 + */ 310 + rsvpsInterestedCount: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 311 + /** 312 + * rsvps count where status = notgoing 313 + */ 314 + rsvpsNotgoingCount: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 315 + /** 316 + * Present when the record was read from a permissioned space; its value is the space URI. 317 + */ 318 + space: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 319 + time_us: /*#__PURE__*/ v.integer(), 320 + uri: /*#__PURE__*/ v.resourceUriString(), 321 + }); 322 + 323 + type appBskyActorProfile$schematype = typeof _appBskyActorProfileSchema; 324 + type hydrateRsvps$schematype = typeof _hydrateRsvpsSchema; 325 + type hydrateRsvpsRecord$schematype = typeof _hydrateRsvpsRecordSchema; 326 + type main$schematype = typeof _mainSchema; 327 + type profileEntry$schematype = typeof _profileEntrySchema; 328 + type record$schematype = typeof _recordSchema; 329 + 330 + export interface appBskyActorProfileSchema extends appBskyActorProfile$schematype {} 331 + export interface hydrateRsvpsSchema extends hydrateRsvps$schematype {} 332 + export interface hydrateRsvpsRecordSchema extends hydrateRsvpsRecord$schematype {} 333 + export interface mainSchema extends main$schematype {} 334 + export interface profileEntrySchema extends profileEntry$schematype {} 335 + export interface recordSchema extends record$schematype {} 336 + 337 + export const appBskyActorProfileSchema = 338 + _appBskyActorProfileSchema as appBskyActorProfileSchema; 339 + export const hydrateRsvpsSchema = _hydrateRsvpsSchema as hydrateRsvpsSchema; 340 + export const hydrateRsvpsRecordSchema = 341 + _hydrateRsvpsRecordSchema as hydrateRsvpsRecordSchema; 342 + export const mainSchema = _mainSchema as mainSchema; 343 + export const profileEntrySchema = _profileEntrySchema as profileEntrySchema; 344 + export const recordSchema = _recordSchema as recordSchema; 345 + 346 + export interface AppBskyActorProfile extends v.InferInput< 347 + typeof appBskyActorProfileSchema 348 + > {} 349 + export interface HydrateRsvps extends v.InferInput<typeof hydrateRsvpsSchema> {} 350 + export interface HydrateRsvpsRecord extends v.InferInput< 351 + typeof hydrateRsvpsRecordSchema 352 + > {} 353 + export interface ProfileEntry extends v.InferInput<typeof profileEntrySchema> {} 354 + export interface Record extends v.InferInput<typeof recordSchema> {} 355 + 356 + export interface $params extends v.InferInput<mainSchema["params"]> {} 357 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 358 + 359 + declare module "@atcute/lexicons/ambient" { 360 + interface XRPCQueries { 361 + "rsvp.atmo.event.listRecords": mainSchema; 362 + } 363 + }
+6 -4
src/lexicon-types/types/rsvp/atmo/getProfile.ts
··· 78 78 }), 79 79 output: { 80 80 type: "lex", 81 - get schema() { 82 - return profileEntrySchema; 83 - }, 81 + schema: /*#__PURE__*/ v.object({ 82 + get profiles() { 83 + return /*#__PURE__*/ v.array(profileEntrySchema); 84 + }, 85 + }), 84 86 }, 85 87 }); 86 88 const _profileEntrySchema = /*#__PURE__*/ v.object({ ··· 117 119 export interface ProfileEntry extends v.InferInput<typeof profileEntrySchema> {} 118 120 119 121 export interface $params extends v.InferInput<mainSchema["params"]> {} 120 - export type $output = v.InferXRPCBodyInput<mainSchema["output"]>; 122 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 121 123 122 124 declare module "@atcute/lexicons/ambient" { 123 125 interface XRPCQueries {
+191
src/lexicon-types/types/rsvp/atmo/rsvp/getRecord.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; 5 + import * as ComAtprotoRepoStrongRef from "@atcute/atproto/types/repo/strongRef"; 6 + import * as CommunityLexiconCalendarEvent from "../../../community/lexicon/calendar/event.js"; 7 + import * as CommunityLexiconCalendarRsvp from "../../../community/lexicon/calendar/rsvp.js"; 8 + 9 + const _appBskyActorProfileSchema = /*#__PURE__*/ v.object({ 10 + $type: /*#__PURE__*/ v.optional( 11 + /*#__PURE__*/ v.literal("rsvp.atmo.rsvp.getRecord#appBskyActorProfile"), 12 + ), 13 + /** 14 + * Small image to be displayed next to posts from account. AKA, 'profile picture' 15 + * @accept image/png, image/jpeg 16 + * @maxSize 1000000 17 + */ 18 + avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.blob()), 19 + /** 20 + * Larger horizontal image to display behind profile view. 21 + * @accept image/png, image/jpeg 22 + * @maxSize 1000000 23 + */ 24 + banner: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.blob()), 25 + createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 26 + /** 27 + * Free-form profile description text. 28 + * @maxLength 2560 29 + * @maxGraphemes 256 30 + */ 31 + description: /*#__PURE__*/ v.optional( 32 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 33 + /*#__PURE__*/ v.stringLength(0, 2560), 34 + /*#__PURE__*/ v.stringGraphemes(0, 256), 35 + ]), 36 + ), 37 + /** 38 + * @maxLength 640 39 + * @maxGraphemes 64 40 + */ 41 + displayName: /*#__PURE__*/ v.optional( 42 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 43 + /*#__PURE__*/ v.stringLength(0, 640), 44 + /*#__PURE__*/ v.stringGraphemes(0, 64), 45 + ]), 46 + ), 47 + get joinedViaStarterPack() { 48 + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); 49 + }, 50 + /** 51 + * Self-label values, specific to the Bluesky application, on the overall account. 52 + */ 53 + get labels() { 54 + return /*#__PURE__*/ v.optional( 55 + /*#__PURE__*/ v.variant([ComAtprotoLabelDefs.selfLabelsSchema]), 56 + ); 57 + }, 58 + get pinnedPost() { 59 + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); 60 + }, 61 + /** 62 + * Free-form pronouns text. 63 + * @maxLength 200 64 + * @maxGraphemes 20 65 + */ 66 + pronouns: /*#__PURE__*/ v.optional( 67 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 68 + /*#__PURE__*/ v.stringLength(0, 200), 69 + /*#__PURE__*/ v.stringGraphemes(0, 20), 70 + ]), 71 + ), 72 + website: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), 73 + }); 74 + const _mainSchema = /*#__PURE__*/ v.query("rsvp.atmo.rsvp.getRecord", { 75 + params: /*#__PURE__*/ v.object({ 76 + /** 77 + * Embed the referenced event record 78 + */ 79 + hydrateEvent: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), 80 + /** 81 + * Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied. 82 + */ 83 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 84 + /** 85 + * Include profile + identity info keyed by DID 86 + */ 87 + profiles: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), 88 + /** 89 + * If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token). 90 + */ 91 + spaceUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 92 + /** 93 + * AT URI of the record 94 + */ 95 + uri: /*#__PURE__*/ v.resourceUriString(), 96 + }), 97 + output: { 98 + type: "lex", 99 + schema: /*#__PURE__*/ v.object({ 100 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 101 + collection: /*#__PURE__*/ v.nsidString(), 102 + did: /*#__PURE__*/ v.didString(), 103 + get event() { 104 + return /*#__PURE__*/ v.optional(refEventRecordSchema); 105 + }, 106 + get profiles() { 107 + return /*#__PURE__*/ v.optional( 108 + /*#__PURE__*/ v.array(profileEntrySchema), 109 + ); 110 + }, 111 + get record() { 112 + return /*#__PURE__*/ v.optional( 113 + CommunityLexiconCalendarRsvp.mainSchema, 114 + ); 115 + }, 116 + rkey: /*#__PURE__*/ v.string(), 117 + /** 118 + * Present when the record was read from a permissioned space; its value is the space URI. 119 + */ 120 + space: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 121 + time_us: /*#__PURE__*/ v.integer(), 122 + uri: /*#__PURE__*/ v.resourceUriString(), 123 + }), 124 + }, 125 + }); 126 + const _profileEntrySchema = /*#__PURE__*/ v.object({ 127 + $type: /*#__PURE__*/ v.optional( 128 + /*#__PURE__*/ v.literal("rsvp.atmo.rsvp.getRecord#profileEntry"), 129 + ), 130 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 131 + collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), 132 + did: /*#__PURE__*/ v.didString(), 133 + handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 134 + get record() { 135 + return /*#__PURE__*/ v.optional(appBskyActorProfileSchema); 136 + }, 137 + rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 138 + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 139 + }); 140 + const _refEventRecordSchema = /*#__PURE__*/ v.object({ 141 + $type: /*#__PURE__*/ v.optional( 142 + /*#__PURE__*/ v.literal("rsvp.atmo.rsvp.getRecord#refEventRecord"), 143 + ), 144 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 145 + collection: /*#__PURE__*/ v.nsidString(), 146 + did: /*#__PURE__*/ v.didString(), 147 + get record() { 148 + return /*#__PURE__*/ v.optional(CommunityLexiconCalendarEvent.mainSchema); 149 + }, 150 + rkey: /*#__PURE__*/ v.string(), 151 + /** 152 + * Present when the record was read from a permissioned space. 153 + */ 154 + space: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 155 + time_us: /*#__PURE__*/ v.integer(), 156 + uri: /*#__PURE__*/ v.resourceUriString(), 157 + }); 158 + 159 + type appBskyActorProfile$schematype = typeof _appBskyActorProfileSchema; 160 + type main$schematype = typeof _mainSchema; 161 + type profileEntry$schematype = typeof _profileEntrySchema; 162 + type refEventRecord$schematype = typeof _refEventRecordSchema; 163 + 164 + export interface appBskyActorProfileSchema extends appBskyActorProfile$schematype {} 165 + export interface mainSchema extends main$schematype {} 166 + export interface profileEntrySchema extends profileEntry$schematype {} 167 + export interface refEventRecordSchema extends refEventRecord$schematype {} 168 + 169 + export const appBskyActorProfileSchema = 170 + _appBskyActorProfileSchema as appBskyActorProfileSchema; 171 + export const mainSchema = _mainSchema as mainSchema; 172 + export const profileEntrySchema = _profileEntrySchema as profileEntrySchema; 173 + export const refEventRecordSchema = 174 + _refEventRecordSchema as refEventRecordSchema; 175 + 176 + export interface AppBskyActorProfile extends v.InferInput< 177 + typeof appBskyActorProfileSchema 178 + > {} 179 + export interface ProfileEntry extends v.InferInput<typeof profileEntrySchema> {} 180 + export interface RefEventRecord extends v.InferInput< 181 + typeof refEventRecordSchema 182 + > {} 183 + 184 + export interface $params extends v.InferInput<mainSchema["params"]> {} 185 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 186 + 187 + declare module "@atcute/lexicons/ambient" { 188 + interface XRPCQueries { 189 + "rsvp.atmo.rsvp.getRecord": mainSchema; 190 + } 191 + }
+238
src/lexicon-types/types/rsvp/atmo/rsvp/listRecords.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; 5 + import * as ComAtprotoRepoStrongRef from "@atcute/atproto/types/repo/strongRef"; 6 + import * as CommunityLexiconCalendarEvent from "../../../community/lexicon/calendar/event.js"; 7 + import * as CommunityLexiconCalendarRsvp from "../../../community/lexicon/calendar/rsvp.js"; 8 + 9 + const _appBskyActorProfileSchema = /*#__PURE__*/ v.object({ 10 + $type: /*#__PURE__*/ v.optional( 11 + /*#__PURE__*/ v.literal("rsvp.atmo.rsvp.listRecords#appBskyActorProfile"), 12 + ), 13 + /** 14 + * Small image to be displayed next to posts from account. AKA, 'profile picture' 15 + * @accept image/png, image/jpeg 16 + * @maxSize 1000000 17 + */ 18 + avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.blob()), 19 + /** 20 + * Larger horizontal image to display behind profile view. 21 + * @accept image/png, image/jpeg 22 + * @maxSize 1000000 23 + */ 24 + banner: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.blob()), 25 + createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 26 + /** 27 + * Free-form profile description text. 28 + * @maxLength 2560 29 + * @maxGraphemes 256 30 + */ 31 + description: /*#__PURE__*/ v.optional( 32 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 33 + /*#__PURE__*/ v.stringLength(0, 2560), 34 + /*#__PURE__*/ v.stringGraphemes(0, 256), 35 + ]), 36 + ), 37 + /** 38 + * @maxLength 640 39 + * @maxGraphemes 64 40 + */ 41 + displayName: /*#__PURE__*/ v.optional( 42 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 43 + /*#__PURE__*/ v.stringLength(0, 640), 44 + /*#__PURE__*/ v.stringGraphemes(0, 64), 45 + ]), 46 + ), 47 + get joinedViaStarterPack() { 48 + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); 49 + }, 50 + /** 51 + * Self-label values, specific to the Bluesky application, on the overall account. 52 + */ 53 + get labels() { 54 + return /*#__PURE__*/ v.optional( 55 + /*#__PURE__*/ v.variant([ComAtprotoLabelDefs.selfLabelsSchema]), 56 + ); 57 + }, 58 + get pinnedPost() { 59 + return /*#__PURE__*/ v.optional(ComAtprotoRepoStrongRef.mainSchema); 60 + }, 61 + /** 62 + * Free-form pronouns text. 63 + * @maxLength 200 64 + * @maxGraphemes 20 65 + */ 66 + pronouns: /*#__PURE__*/ v.optional( 67 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 68 + /*#__PURE__*/ v.stringLength(0, 200), 69 + /*#__PURE__*/ v.stringGraphemes(0, 20), 70 + ]), 71 + ), 72 + website: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), 73 + }); 74 + const _mainSchema = /*#__PURE__*/ v.query("rsvp.atmo.rsvp.listRecords", { 75 + params: /*#__PURE__*/ v.object({ 76 + /** 77 + * Filter by DID or handle (triggers on-demand backfill) 78 + */ 79 + actor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.actorIdentifierString()), 80 + /** 81 + * Only used with spaceUri — filter to records authored by this DID. 82 + */ 83 + byUser: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()), 84 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 85 + /** 86 + * Embed the referenced event record 87 + */ 88 + hydrateEvent: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), 89 + /** 90 + * Read-grant invite token for anonymous bearer access. Replaces JWT auth when supplied. 91 + */ 92 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 93 + /** 94 + * @minimum 1 95 + * @maximum 200 96 + * @default 50 97 + */ 98 + limit: /*#__PURE__*/ v.optional( 99 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 100 + /*#__PURE__*/ v.integerRange(1, 200), 101 + ]), 102 + 50, 103 + ), 104 + /** 105 + * Sort direction (default: desc for dates/numbers/counts, asc for strings) 106 + */ 107 + order: /*#__PURE__*/ v.optional( 108 + /*#__PURE__*/ v.string<"asc" | "desc" | (string & {})>(), 109 + ), 110 + /** 111 + * Include profile + identity info keyed by DID 112 + */ 113 + profiles: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), 114 + /** 115 + * Field to sort by (default: time_us) 116 + */ 117 + sort: /*#__PURE__*/ v.optional( 118 + /*#__PURE__*/ v.string<"status" | "subjectUri" | (string & {})>(), 119 + ), 120 + /** 121 + * If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token). 122 + */ 123 + spaceUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 124 + /** 125 + * Filter by status 126 + */ 127 + status: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 128 + /** 129 + * Filter by subject.uri 130 + */ 131 + subjectUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 132 + }), 133 + output: { 134 + type: "lex", 135 + schema: /*#__PURE__*/ v.object({ 136 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 137 + get profiles() { 138 + return /*#__PURE__*/ v.optional( 139 + /*#__PURE__*/ v.array(profileEntrySchema), 140 + ); 141 + }, 142 + get records() { 143 + return /*#__PURE__*/ v.array(recordSchema); 144 + }, 145 + }), 146 + }, 147 + }); 148 + const _profileEntrySchema = /*#__PURE__*/ v.object({ 149 + $type: /*#__PURE__*/ v.optional( 150 + /*#__PURE__*/ v.literal("rsvp.atmo.rsvp.listRecords#profileEntry"), 151 + ), 152 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 153 + collection: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), 154 + did: /*#__PURE__*/ v.didString(), 155 + handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 156 + get record() { 157 + return /*#__PURE__*/ v.optional(appBskyActorProfileSchema); 158 + }, 159 + rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 160 + uri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 161 + }); 162 + const _recordSchema = /*#__PURE__*/ v.object({ 163 + $type: /*#__PURE__*/ v.optional( 164 + /*#__PURE__*/ v.literal("rsvp.atmo.rsvp.listRecords#record"), 165 + ), 166 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 167 + collection: /*#__PURE__*/ v.nsidString(), 168 + did: /*#__PURE__*/ v.didString(), 169 + get event() { 170 + return /*#__PURE__*/ v.optional(refEventRecordSchema); 171 + }, 172 + get record() { 173 + return /*#__PURE__*/ v.optional(CommunityLexiconCalendarRsvp.mainSchema); 174 + }, 175 + rkey: /*#__PURE__*/ v.string(), 176 + /** 177 + * Present when the record was read from a permissioned space; its value is the space URI. 178 + */ 179 + space: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 180 + time_us: /*#__PURE__*/ v.integer(), 181 + uri: /*#__PURE__*/ v.resourceUriString(), 182 + }); 183 + const _refEventRecordSchema = /*#__PURE__*/ v.object({ 184 + $type: /*#__PURE__*/ v.optional( 185 + /*#__PURE__*/ v.literal("rsvp.atmo.rsvp.listRecords#refEventRecord"), 186 + ), 187 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 188 + collection: /*#__PURE__*/ v.nsidString(), 189 + did: /*#__PURE__*/ v.didString(), 190 + get record() { 191 + return /*#__PURE__*/ v.optional(CommunityLexiconCalendarEvent.mainSchema); 192 + }, 193 + rkey: /*#__PURE__*/ v.string(), 194 + /** 195 + * Present when the record was read from a permissioned space. 196 + */ 197 + space: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 198 + time_us: /*#__PURE__*/ v.integer(), 199 + uri: /*#__PURE__*/ v.resourceUriString(), 200 + }); 201 + 202 + type appBskyActorProfile$schematype = typeof _appBskyActorProfileSchema; 203 + type main$schematype = typeof _mainSchema; 204 + type profileEntry$schematype = typeof _profileEntrySchema; 205 + type record$schematype = typeof _recordSchema; 206 + type refEventRecord$schematype = typeof _refEventRecordSchema; 207 + 208 + export interface appBskyActorProfileSchema extends appBskyActorProfile$schematype {} 209 + export interface mainSchema extends main$schematype {} 210 + export interface profileEntrySchema extends profileEntry$schematype {} 211 + export interface recordSchema extends record$schematype {} 212 + export interface refEventRecordSchema extends refEventRecord$schematype {} 213 + 214 + export const appBskyActorProfileSchema = 215 + _appBskyActorProfileSchema as appBskyActorProfileSchema; 216 + export const mainSchema = _mainSchema as mainSchema; 217 + export const profileEntrySchema = _profileEntrySchema as profileEntrySchema; 218 + export const recordSchema = _recordSchema as recordSchema; 219 + export const refEventRecordSchema = 220 + _refEventRecordSchema as refEventRecordSchema; 221 + 222 + export interface AppBskyActorProfile extends v.InferInput< 223 + typeof appBskyActorProfileSchema 224 + > {} 225 + export interface ProfileEntry extends v.InferInput<typeof profileEntrySchema> {} 226 + export interface Record extends v.InferInput<typeof recordSchema> {} 227 + export interface RefEventRecord extends v.InferInput< 228 + typeof refEventRecordSchema 229 + > {} 230 + 231 + export interface $params extends v.InferInput<mainSchema["params"]> {} 232 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 233 + 234 + declare module "@atcute/lexicons/ambient" { 235 + interface XRPCQueries { 236 + "rsvp.atmo.rsvp.listRecords": mainSchema; 237 + } 238 + }
+43
src/lexicon-types/types/rsvp/atmo/space/addMember.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure("rsvp.atmo.space.addMember", { 6 + params: null, 7 + input: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + did: /*#__PURE__*/ v.didString(), 11 + /** 12 + * @default "write" 13 + */ 14 + perms: /*#__PURE__*/ v.optional( 15 + /*#__PURE__*/ v.string<"read" | "write" | (string & {})>(), 16 + "write", 17 + ), 18 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 19 + }), 20 + }, 21 + output: { 22 + type: "lex", 23 + schema: /*#__PURE__*/ v.object({ 24 + ok: /*#__PURE__*/ v.boolean(), 25 + }), 26 + }, 27 + }); 28 + 29 + type main$schematype = typeof _mainSchema; 30 + 31 + export interface mainSchema extends main$schematype {} 32 + 33 + export const mainSchema = _mainSchema as mainSchema; 34 + 35 + export interface $params {} 36 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 37 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 38 + 39 + declare module "@atcute/lexicons/ambient" { 40 + interface XRPCProcedures { 41 + "rsvp.atmo.space.addMember": mainSchema; 42 + } 43 + }
+54
src/lexicon-types/types/rsvp/atmo/space/createSpace.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as RsvpAtmoSpaceDefs from "./defs.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.procedure("rsvp.atmo.space.createSpace", { 7 + params: null, 8 + input: { 9 + type: "lex", 10 + schema: /*#__PURE__*/ v.object({ 11 + get appPolicy() { 12 + return /*#__PURE__*/ v.optional(RsvpAtmoSpaceDefs.appPolicySchema); 13 + }, 14 + appPolicyRef: /*#__PURE__*/ v.optional( 15 + /*#__PURE__*/ v.resourceUriString(), 16 + ), 17 + /** 18 + * Space key. Auto-generated (TID) if omitted. 19 + */ 20 + key: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 21 + memberListRef: /*#__PURE__*/ v.optional( 22 + /*#__PURE__*/ v.resourceUriString(), 23 + ), 24 + /** 25 + * Space type NSID. Defaults to the service's configured type. 26 + */ 27 + type: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), 28 + }), 29 + }, 30 + output: { 31 + type: "lex", 32 + schema: /*#__PURE__*/ v.object({ 33 + get space() { 34 + return RsvpAtmoSpaceDefs.spaceViewSchema; 35 + }, 36 + }), 37 + }, 38 + }); 39 + 40 + type main$schematype = typeof _mainSchema; 41 + 42 + export interface mainSchema extends main$schematype {} 43 + 44 + export const mainSchema = _mainSchema as mainSchema; 45 + 46 + export interface $params {} 47 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 48 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 49 + 50 + declare module "@atcute/lexicons/ambient" { 51 + interface XRPCProcedures { 52 + "rsvp.atmo.space.createSpace": mainSchema; 53 + } 54 + }
+96
src/lexicon-types/types/rsvp/atmo/space/defs.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + 4 + const _appPolicySchema = /*#__PURE__*/ v.object({ 5 + $type: /*#__PURE__*/ v.optional( 6 + /*#__PURE__*/ v.literal("rsvp.atmo.space.defs#appPolicy"), 7 + ), 8 + apps: /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), 9 + /** 10 + * 'allow' = default-allow with apps[] as denylist; 'deny' = default-deny with apps[] as allowlist. 11 + */ 12 + mode: /*#__PURE__*/ v.string<"allow" | "deny" | (string & {})>(), 13 + }); 14 + const _inviteViewSchema = /*#__PURE__*/ v.object({ 15 + $type: /*#__PURE__*/ v.optional( 16 + /*#__PURE__*/ v.literal("rsvp.atmo.space.defs#inviteView"), 17 + ), 18 + createdAt: /*#__PURE__*/ v.integer(), 19 + createdBy: /*#__PURE__*/ v.didString(), 20 + expiresAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 21 + kind: /*#__PURE__*/ v.string<"join" | "read" | "read-join" | (string & {})>(), 22 + maxUses: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 23 + note: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 24 + perms: /*#__PURE__*/ v.string<"read" | "write" | (string & {})>(), 25 + revokedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 26 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 27 + tokenHash: /*#__PURE__*/ v.string(), 28 + usedCount: /*#__PURE__*/ v.integer(), 29 + }); 30 + const _memberViewSchema = /*#__PURE__*/ v.object({ 31 + $type: /*#__PURE__*/ v.optional( 32 + /*#__PURE__*/ v.literal("rsvp.atmo.space.defs#memberView"), 33 + ), 34 + addedAt: /*#__PURE__*/ v.integer(), 35 + addedBy: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()), 36 + did: /*#__PURE__*/ v.didString(), 37 + /** 38 + * 'write' implies 'read'. Space owner is always implicit write. 39 + */ 40 + perms: /*#__PURE__*/ v.string<"read" | "write" | (string & {})>(), 41 + }); 42 + const _recordViewSchema = /*#__PURE__*/ v.object({ 43 + $type: /*#__PURE__*/ v.optional( 44 + /*#__PURE__*/ v.literal("rsvp.atmo.space.defs#recordView"), 45 + ), 46 + authorDid: /*#__PURE__*/ v.didString(), 47 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), 48 + collection: /*#__PURE__*/ v.nsidString(), 49 + createdAt: /*#__PURE__*/ v.integer(), 50 + record: /*#__PURE__*/ v.unknown(), 51 + rkey: /*#__PURE__*/ v.string(), 52 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 53 + }); 54 + const _spaceViewSchema = /*#__PURE__*/ v.object({ 55 + $type: /*#__PURE__*/ v.optional( 56 + /*#__PURE__*/ v.literal("rsvp.atmo.space.defs#spaceView"), 57 + ), 58 + /** 59 + * Owner-only 60 + */ 61 + get appPolicy() { 62 + return /*#__PURE__*/ v.optional(appPolicySchema); 63 + }, 64 + appPolicyRef: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 65 + createdAt: /*#__PURE__*/ v.integer(), 66 + key: /*#__PURE__*/ v.string(), 67 + memberListRef: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 68 + ownerDid: /*#__PURE__*/ v.didString(), 69 + serviceDid: /*#__PURE__*/ v.string(), 70 + type: /*#__PURE__*/ v.nsidString(), 71 + uri: /*#__PURE__*/ v.resourceUriString(), 72 + }); 73 + 74 + type appPolicy$schematype = typeof _appPolicySchema; 75 + type inviteView$schematype = typeof _inviteViewSchema; 76 + type memberView$schematype = typeof _memberViewSchema; 77 + type recordView$schematype = typeof _recordViewSchema; 78 + type spaceView$schematype = typeof _spaceViewSchema; 79 + 80 + export interface appPolicySchema extends appPolicy$schematype {} 81 + export interface inviteViewSchema extends inviteView$schematype {} 82 + export interface memberViewSchema extends memberView$schematype {} 83 + export interface recordViewSchema extends recordView$schematype {} 84 + export interface spaceViewSchema extends spaceView$schematype {} 85 + 86 + export const appPolicySchema = _appPolicySchema as appPolicySchema; 87 + export const inviteViewSchema = _inviteViewSchema as inviteViewSchema; 88 + export const memberViewSchema = _memberViewSchema as memberViewSchema; 89 + export const recordViewSchema = _recordViewSchema as recordViewSchema; 90 + export const spaceViewSchema = _spaceViewSchema as spaceViewSchema; 91 + 92 + export interface AppPolicy extends v.InferInput<typeof appPolicySchema> {} 93 + export interface InviteView extends v.InferInput<typeof inviteViewSchema> {} 94 + export interface MemberView extends v.InferInput<typeof memberViewSchema> {} 95 + export interface RecordView extends v.InferInput<typeof recordViewSchema> {} 96 + export interface SpaceView extends v.InferInput<typeof spaceViewSchema> {}
+37
src/lexicon-types/types/rsvp/atmo/space/deleteRecord.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure("rsvp.atmo.space.deleteRecord", { 6 + params: null, 7 + input: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + collection: /*#__PURE__*/ v.nsidString(), 11 + rkey: /*#__PURE__*/ v.string(), 12 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 13 + }), 14 + }, 15 + output: { 16 + type: "lex", 17 + schema: /*#__PURE__*/ v.object({ 18 + ok: /*#__PURE__*/ v.boolean(), 19 + }), 20 + }, 21 + }); 22 + 23 + type main$schematype = typeof _mainSchema; 24 + 25 + export interface mainSchema extends main$schematype {} 26 + 27 + export const mainSchema = _mainSchema as mainSchema; 28 + 29 + export interface $params {} 30 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 31 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 32 + 33 + declare module "@atcute/lexicons/ambient" { 34 + interface XRPCProcedures { 35 + "rsvp.atmo.space.deleteRecord": mainSchema; 36 + } 37 + }
+40
src/lexicon-types/types/rsvp/atmo/space/getRecord.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as RsvpAtmoSpaceDefs from "./defs.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.query("rsvp.atmo.space.getRecord", { 7 + params: /*#__PURE__*/ v.object({ 8 + author: /*#__PURE__*/ v.didString(), 9 + collection: /*#__PURE__*/ v.nsidString(), 10 + /** 11 + * Read-grant invite token. When supplied, replaces JWT auth for this read. 12 + */ 13 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 14 + rkey: /*#__PURE__*/ v.string(), 15 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 16 + }), 17 + output: { 18 + type: "lex", 19 + schema: /*#__PURE__*/ v.object({ 20 + get record() { 21 + return RsvpAtmoSpaceDefs.recordViewSchema; 22 + }, 23 + }), 24 + }, 25 + }); 26 + 27 + type main$schematype = typeof _mainSchema; 28 + 29 + export interface mainSchema extends main$schematype {} 30 + 31 + export const mainSchema = _mainSchema as mainSchema; 32 + 33 + export interface $params extends v.InferInput<mainSchema["params"]> {} 34 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 35 + 36 + declare module "@atcute/lexicons/ambient" { 37 + interface XRPCQueries { 38 + "rsvp.atmo.space.getRecord": mainSchema; 39 + } 40 + }
+37
src/lexicon-types/types/rsvp/atmo/space/getSpace.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as RsvpAtmoSpaceDefs from "./defs.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.query("rsvp.atmo.space.getSpace", { 7 + params: /*#__PURE__*/ v.object({ 8 + /** 9 + * Read-grant invite token. When supplied, replaces JWT auth for this read. 10 + */ 11 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 12 + uri: /*#__PURE__*/ v.resourceUriString(), 13 + }), 14 + output: { 15 + type: "lex", 16 + schema: /*#__PURE__*/ v.object({ 17 + get space() { 18 + return RsvpAtmoSpaceDefs.spaceViewSchema; 19 + }, 20 + }), 21 + }, 22 + }); 23 + 24 + type main$schematype = typeof _mainSchema; 25 + 26 + export interface mainSchema extends main$schematype {} 27 + 28 + export const mainSchema = _mainSchema as mainSchema; 29 + 30 + export interface $params extends v.InferInput<mainSchema["params"]> {} 31 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 32 + 33 + declare module "@atcute/lexicons/ambient" { 34 + interface XRPCQueries { 35 + "rsvp.atmo.space.getSpace": mainSchema; 36 + } 37 + }
+78
src/lexicon-types/types/rsvp/atmo/space/invite/create.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as RsvpAtmoSpaceDefs from "../defs.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.procedure("rsvp.atmo.space.invite.create", { 7 + params: null, 8 + input: { 9 + type: "lex", 10 + schema: /*#__PURE__*/ v.object({ 11 + /** 12 + * Unix ms timestamp. Omit for no expiry. 13 + */ 14 + expiresAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()), 15 + /** 16 + * join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join. 17 + * @default "join" 18 + */ 19 + kind: /*#__PURE__*/ v.optional( 20 + /*#__PURE__*/ v.string<"join" | "read" | "read-join" | (string & {})>(), 21 + "join", 22 + ), 23 + /** 24 + * Caps join redemptions only — read-token reads are unlimited. Omit for unlimited joins. 25 + * @minimum 1 26 + */ 27 + maxUses: /*#__PURE__*/ v.optional( 28 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 29 + /*#__PURE__*/ v.integerRange(1), 30 + ]), 31 + ), 32 + /** 33 + * @maxLength 500 34 + */ 35 + note: /*#__PURE__*/ v.optional( 36 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 37 + /*#__PURE__*/ v.stringLength(0, 500), 38 + ]), 39 + ), 40 + /** 41 + * @default "write" 42 + */ 43 + perms: /*#__PURE__*/ v.optional( 44 + /*#__PURE__*/ v.string<"read" | "write" | (string & {})>(), 45 + "write", 46 + ), 47 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 48 + }), 49 + }, 50 + output: { 51 + type: "lex", 52 + schema: /*#__PURE__*/ v.object({ 53 + get invite() { 54 + return RsvpAtmoSpaceDefs.inviteViewSchema; 55 + }, 56 + /** 57 + * Raw token. Shown once — cannot be retrieved later. 58 + */ 59 + token: /*#__PURE__*/ v.string(), 60 + }), 61 + }, 62 + }); 63 + 64 + type main$schematype = typeof _mainSchema; 65 + 66 + export interface mainSchema extends main$schematype {} 67 + 68 + export const mainSchema = _mainSchema as mainSchema; 69 + 70 + export interface $params {} 71 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 72 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 73 + 74 + declare module "@atcute/lexicons/ambient" { 75 + interface XRPCProcedures { 76 + "rsvp.atmo.space.invite.create": mainSchema; 77 + } 78 + }
+37
src/lexicon-types/types/rsvp/atmo/space/invite/list.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as RsvpAtmoSpaceDefs from "../defs.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.query("rsvp.atmo.space.invite.list", { 7 + params: /*#__PURE__*/ v.object({ 8 + /** 9 + * @default false 10 + */ 11 + includeRevoked: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean(), false), 12 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 13 + }), 14 + output: { 15 + type: "lex", 16 + schema: /*#__PURE__*/ v.object({ 17 + get invites() { 18 + return /*#__PURE__*/ v.array(RsvpAtmoSpaceDefs.inviteViewSchema); 19 + }, 20 + }), 21 + }, 22 + }); 23 + 24 + type main$schematype = typeof _mainSchema; 25 + 26 + export interface mainSchema extends main$schematype {} 27 + 28 + export const mainSchema = _mainSchema as mainSchema; 29 + 30 + export interface $params extends v.InferInput<mainSchema["params"]> {} 31 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 32 + 33 + declare module "@atcute/lexicons/ambient" { 34 + interface XRPCQueries { 35 + "rsvp.atmo.space.invite.list": mainSchema; 36 + } 37 + }
+36
src/lexicon-types/types/rsvp/atmo/space/invite/redeem.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure("rsvp.atmo.space.invite.redeem", { 6 + params: null, 7 + input: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + token: /*#__PURE__*/ v.string(), 11 + }), 12 + }, 13 + output: { 14 + type: "lex", 15 + schema: /*#__PURE__*/ v.object({ 16 + perms: /*#__PURE__*/ v.string<"read" | "write" | (string & {})>(), 17 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 18 + }), 19 + }, 20 + }); 21 + 22 + type main$schematype = typeof _mainSchema; 23 + 24 + export interface mainSchema extends main$schematype {} 25 + 26 + export const mainSchema = _mainSchema as mainSchema; 27 + 28 + export interface $params {} 29 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 30 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 31 + 32 + declare module "@atcute/lexicons/ambient" { 33 + interface XRPCProcedures { 34 + "rsvp.atmo.space.invite.redeem": mainSchema; 35 + } 36 + }
+36
src/lexicon-types/types/rsvp/atmo/space/invite/revoke.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure("rsvp.atmo.space.invite.revoke", { 6 + params: null, 7 + input: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 11 + tokenHash: /*#__PURE__*/ v.string(), 12 + }), 13 + }, 14 + output: { 15 + type: "lex", 16 + schema: /*#__PURE__*/ v.object({ 17 + ok: /*#__PURE__*/ v.boolean(), 18 + }), 19 + }, 20 + }); 21 + 22 + type main$schematype = typeof _mainSchema; 23 + 24 + export interface mainSchema extends main$schematype {} 25 + 26 + export const mainSchema = _mainSchema as mainSchema; 27 + 28 + export interface $params {} 29 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 30 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 31 + 32 + declare module "@atcute/lexicons/ambient" { 33 + interface XRPCProcedures { 34 + "rsvp.atmo.space.invite.revoke": mainSchema; 35 + } 36 + }
+35
src/lexicon-types/types/rsvp/atmo/space/leaveSpace.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure("rsvp.atmo.space.leaveSpace", { 6 + params: null, 7 + input: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 11 + }), 12 + }, 13 + output: { 14 + type: "lex", 15 + schema: /*#__PURE__*/ v.object({ 16 + ok: /*#__PURE__*/ v.boolean(), 17 + }), 18 + }, 19 + }); 20 + 21 + type main$schematype = typeof _mainSchema; 22 + 23 + export interface mainSchema extends main$schematype {} 24 + 25 + export const mainSchema = _mainSchema as mainSchema; 26 + 27 + export interface $params {} 28 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 29 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 30 + 31 + declare module "@atcute/lexicons/ambient" { 32 + interface XRPCProcedures { 33 + "rsvp.atmo.space.leaveSpace": mainSchema; 34 + } 35 + }
+33
src/lexicon-types/types/rsvp/atmo/space/listMembers.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as RsvpAtmoSpaceDefs from "./defs.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.query("rsvp.atmo.space.listMembers", { 7 + params: /*#__PURE__*/ v.object({ 8 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 9 + }), 10 + output: { 11 + type: "lex", 12 + schema: /*#__PURE__*/ v.object({ 13 + get members() { 14 + return /*#__PURE__*/ v.array(RsvpAtmoSpaceDefs.memberViewSchema); 15 + }, 16 + }), 17 + }, 18 + }); 19 + 20 + type main$schematype = typeof _mainSchema; 21 + 22 + export interface mainSchema extends main$schematype {} 23 + 24 + export const mainSchema = _mainSchema as mainSchema; 25 + 26 + export interface $params extends v.InferInput<mainSchema["params"]> {} 27 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 28 + 29 + declare module "@atcute/lexicons/ambient" { 30 + interface XRPCQueries { 31 + "rsvp.atmo.space.listMembers": mainSchema; 32 + } 33 + }
+55
src/lexicon-types/types/rsvp/atmo/space/listRecords.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as RsvpAtmoSpaceDefs from "./defs.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.query("rsvp.atmo.space.listRecords", { 7 + params: /*#__PURE__*/ v.object({ 8 + /** 9 + * Only return records authored by this DID. 10 + */ 11 + byUser: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()), 12 + collection: /*#__PURE__*/ v.nsidString(), 13 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 14 + /** 15 + * Read-grant invite token. When supplied, replaces JWT auth for this read. 16 + */ 17 + inviteToken: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 18 + /** 19 + * @minimum 1 20 + * @maximum 200 21 + * @default 50 22 + */ 23 + limit: /*#__PURE__*/ v.optional( 24 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 25 + /*#__PURE__*/ v.integerRange(1, 200), 26 + ]), 27 + 50, 28 + ), 29 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 30 + }), 31 + output: { 32 + type: "lex", 33 + schema: /*#__PURE__*/ v.object({ 34 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 35 + get records() { 36 + return /*#__PURE__*/ v.array(RsvpAtmoSpaceDefs.recordViewSchema); 37 + }, 38 + }), 39 + }, 40 + }); 41 + 42 + type main$schematype = typeof _mainSchema; 43 + 44 + export interface mainSchema extends main$schematype {} 45 + 46 + export const mainSchema = _mainSchema as mainSchema; 47 + 48 + export interface $params extends v.InferInput<mainSchema["params"]> {} 49 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 50 + 51 + declare module "@atcute/lexicons/ambient" { 52 + interface XRPCQueries { 53 + "rsvp.atmo.space.listRecords": mainSchema; 54 + } 55 + }
+53
src/lexicon-types/types/rsvp/atmo/space/listSpaces.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as RsvpAtmoSpaceDefs from "./defs.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.query("rsvp.atmo.space.listSpaces", { 7 + params: /*#__PURE__*/ v.object({ 8 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 9 + /** 10 + * @minimum 1 11 + * @maximum 200 12 + * @default 50 13 + */ 14 + limit: /*#__PURE__*/ v.optional( 15 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 16 + /*#__PURE__*/ v.integerRange(1, 200), 17 + ]), 18 + 50, 19 + ), 20 + /** 21 + * @default "member" 22 + */ 23 + scope: /*#__PURE__*/ v.optional( 24 + /*#__PURE__*/ v.string<"member" | "owner" | (string & {})>(), 25 + "member", 26 + ), 27 + type: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.nsidString()), 28 + }), 29 + output: { 30 + type: "lex", 31 + schema: /*#__PURE__*/ v.object({ 32 + cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 33 + get spaces() { 34 + return /*#__PURE__*/ v.array(RsvpAtmoSpaceDefs.spaceViewSchema); 35 + }, 36 + }), 37 + }, 38 + }); 39 + 40 + type main$schematype = typeof _mainSchema; 41 + 42 + export interface mainSchema extends main$schematype {} 43 + 44 + export const mainSchema = _mainSchema as mainSchema; 45 + 46 + export interface $params extends v.InferInput<mainSchema["params"]> {} 47 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 48 + 49 + declare module "@atcute/lexicons/ambient" { 50 + interface XRPCQueries { 51 + "rsvp.atmo.space.listSpaces": mainSchema; 52 + } 53 + }
+40
src/lexicon-types/types/rsvp/atmo/space/putRecord.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure("rsvp.atmo.space.putRecord", { 6 + params: null, 7 + input: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + collection: /*#__PURE__*/ v.nsidString(), 11 + record: /*#__PURE__*/ v.unknown(), 12 + rkey: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 13 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 14 + }), 15 + }, 16 + output: { 17 + type: "lex", 18 + schema: /*#__PURE__*/ v.object({ 19 + authorDid: /*#__PURE__*/ v.didString(), 20 + createdAt: /*#__PURE__*/ v.integer(), 21 + rkey: /*#__PURE__*/ v.string(), 22 + }), 23 + }, 24 + }); 25 + 26 + type main$schematype = typeof _mainSchema; 27 + 28 + export interface mainSchema extends main$schematype {} 29 + 30 + export const mainSchema = _mainSchema as mainSchema; 31 + 32 + export interface $params {} 33 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 34 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 35 + 36 + declare module "@atcute/lexicons/ambient" { 37 + interface XRPCProcedures { 38 + "rsvp.atmo.space.putRecord": mainSchema; 39 + } 40 + }
+36
src/lexicon-types/types/rsvp/atmo/space/removeMember.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure("rsvp.atmo.space.removeMember", { 6 + params: null, 7 + input: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + did: /*#__PURE__*/ v.didString(), 11 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 12 + }), 13 + }, 14 + output: { 15 + type: "lex", 16 + schema: /*#__PURE__*/ v.object({ 17 + ok: /*#__PURE__*/ v.boolean(), 18 + }), 19 + }, 20 + }); 21 + 22 + type main$schematype = typeof _mainSchema; 23 + 24 + export interface mainSchema extends main$schematype {} 25 + 26 + export const mainSchema = _mainSchema as mainSchema; 27 + 28 + export interface $params {} 29 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 30 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 31 + 32 + declare module "@atcute/lexicons/ambient" { 33 + interface XRPCProcedures { 34 + "rsvp.atmo.space.removeMember": mainSchema; 35 + } 36 + }
+42
src/lexicon-types/types/rsvp/atmo/space/transferOwnership.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as RsvpAtmoSpaceDefs from "./defs.js"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.procedure( 7 + "rsvp.atmo.space.transferOwnership", 8 + { 9 + params: null, 10 + input: { 11 + type: "lex", 12 + schema: /*#__PURE__*/ v.object({ 13 + newOwnerDid: /*#__PURE__*/ v.didString(), 14 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 15 + }), 16 + }, 17 + output: { 18 + type: "lex", 19 + schema: /*#__PURE__*/ v.object({ 20 + get space() { 21 + return RsvpAtmoSpaceDefs.spaceViewSchema; 22 + }, 23 + }), 24 + }, 25 + }, 26 + ); 27 + 28 + type main$schematype = typeof _mainSchema; 29 + 30 + export interface mainSchema extends main$schematype {} 31 + 32 + export const mainSchema = _mainSchema as mainSchema; 33 + 34 + export interface $params {} 35 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 36 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 37 + 38 + declare module "@atcute/lexicons/ambient" { 39 + interface XRPCProcedures { 40 + "rsvp.atmo.space.transferOwnership": mainSchema; 41 + } 42 + }
+37
src/lexicon-types/types/rsvp/atmo/space/whoami.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.query("rsvp.atmo.space.whoami", { 6 + params: /*#__PURE__*/ v.object({ 7 + spaceUri: /*#__PURE__*/ v.resourceUriString(), 8 + }), 9 + output: { 10 + type: "lex", 11 + schema: /*#__PURE__*/ v.object({ 12 + isMember: /*#__PURE__*/ v.boolean(), 13 + isOwner: /*#__PURE__*/ v.boolean(), 14 + /** 15 + * Present only when the caller is a member or the owner. 16 + */ 17 + perms: /*#__PURE__*/ v.optional( 18 + /*#__PURE__*/ v.string<"read" | "write" | (string & {})>(), 19 + ), 20 + }), 21 + }, 22 + }); 23 + 24 + type main$schematype = typeof _mainSchema; 25 + 26 + export interface mainSchema extends main$schematype {} 27 + 28 + export const mainSchema = _mainSchema as mainSchema; 29 + 30 + export interface $params extends v.InferInput<mainSchema["params"]> {} 31 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 32 + 33 + declare module "@atcute/lexicons/ambient" { 34 + interface XRPCQueries { 35 + "rsvp.atmo.space.whoami": mainSchema; 36 + } 37 + }
+59 -2
src/lib/atproto/scripts/tunnel.ts
··· 1 - import { readFileSync, writeFileSync } from 'node:fs'; 2 - import { resolve } from 'node:path'; 1 + import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; 2 + import { resolve, dirname } from 'node:path'; 3 3 import { spawn } from 'node:child_process'; 4 4 import { DEV_PORT } from '../port'; 5 5 6 6 const cwd = process.cwd(); 7 7 const envPath = resolve(cwd, '.env'); 8 8 const vitePath = resolve(cwd, 'vite.config.ts'); 9 + const didDocPath = resolve(cwd, 'static/.well-known/did.json'); 10 + const generatedServicePath = resolve(cwd, 'src/lib/spaces/tunnel-service.generated.ts'); 11 + 12 + /** Service fragment identifier used by our DID doc and service DID. */ 13 + const SERVICE_FRAGMENT = 'event_space'; 14 + const SERVICE_TYPE = 'AtmoSpaceService'; 9 15 10 16 let tunnelUrl: string | null = null; 11 17 let statusBarActive = false; ··· 133 139 writeFileSync(vitePath, vite); 134 140 } 135 141 142 + // ── did doc + generated service file ───────────────────────────── 143 + 144 + function writeDidDoc(hostname: string, tunnelUrl: string): void { 145 + const did = `did:web:${hostname}`; 146 + const doc = { 147 + '@context': ['https://www.w3.org/ns/did/v1'], 148 + id: did, 149 + service: [ 150 + { 151 + id: `#${SERVICE_FRAGMENT}`, 152 + type: SERVICE_TYPE, 153 + serviceEndpoint: tunnelUrl 154 + } 155 + ] 156 + }; 157 + mkdirSync(dirname(didDocPath), { recursive: true }); 158 + writeFileSync(didDocPath, JSON.stringify(doc, null, 2) + '\n'); 159 + } 160 + 161 + function writeGeneratedService(hostname: string, tunnelUrl: string): void { 162 + // NOTE: plain DID, no fragment. PDSes reject fragments in getServiceAuth's `aud` param, 163 + // and the library's middleware does strict string equality. The fragment is only used 164 + // by PDSes to look up service entries in the DID doc for Atproto-Proxy routing — that 165 + // concern is separate from JWT audience validation. 166 + const did = `did:web:${hostname}`; 167 + const body = 168 + `/** Auto-generated by \`pnpm tunnel\`. Do not edit by hand.\n` + 169 + ` * When the tunnel is running, this file is rewritten with the tunnel's\n` + 170 + ` * service DID + URL; when the tunnel stops, it is reset to null values. */\n\n` + 171 + `export const SERVICE_DID: string | null = ${JSON.stringify(did)};\n` + 172 + `export const SERVICE_URL: string | null = ${JSON.stringify(tunnelUrl)};\n`; 173 + mkdirSync(dirname(generatedServicePath), { recursive: true }); 174 + writeFileSync(generatedServicePath, body); 175 + } 176 + 177 + function resetGeneratedService(): void { 178 + const body = 179 + `/** Auto-generated by \`pnpm tunnel\`. Do not edit by hand.\n` + 180 + ` * When the tunnel is running, this file is rewritten with the tunnel's\n` + 181 + ` * service DID + URL; when the tunnel stops, it is reset to null values. */\n\n` + 182 + `export const SERVICE_DID: string | null = null;\n` + 183 + `export const SERVICE_URL: string | null = null;\n`; 184 + writeFileSync(generatedServicePath, body); 185 + } 186 + 136 187 // ── cleanup ────────────────────────────────────────────────────── 137 188 138 189 function cleanup(): void { ··· 143 194 console.log(' Cleared OAUTH_PUBLIC_URL from .env'); 144 195 clearViteAllowedHosts(); 145 196 console.log(' Cleared allowedHosts from vite.config.ts'); 197 + resetGeneratedService(); 198 + console.log(' Reset src/lib/spaces/tunnel-service.generated.ts'); 146 199 } 147 200 } 148 201 ··· 163 216 164 217 setEnvVar('OAUTH_PUBLIC_URL', tunnelUrl); 165 218 setViteAllowedHosts(hostname); 219 + writeDidDoc(hostname, tunnelUrl); 220 + writeGeneratedService(hostname, tunnelUrl); 166 221 167 222 writeLog(`\n Set OAUTH_PUBLIC_URL=${tunnelUrl}\n`); 168 223 writeLog(` Set vite allowedHosts to [${hostname}]\n`); 224 + writeLog(` Wrote static/.well-known/did.json (did:web:${hostname}#${SERVICE_FRAGMENT})\n`); 225 + writeLog(` Wrote src/lib/spaces/tunnel-service.generated.ts\n`); 169 226 writeLog(` Tunnel is ready! Restart your dev server to pick up the new URL.\n\n`); 170 227 171 228 setupScrollRegion();
+11 -3
src/lib/atproto/settings.ts
··· 1 1 import { dev } from '$app/environment'; 2 2 import { scope } from '@atcute/oauth-node-client'; 3 3 4 - // writable collections 4 + // writable collections — declared as a standalone scope because their NSIDs 5 + // (`community.lexicon.*`) sit outside our namespace, so they can't go in 6 + // `rsvp.atmo.permissionSet` (permission sets can only reference NSIDs in their 7 + // own namespace). 5 8 export const collections = [ 6 9 'community.lexicon.calendar.event', 7 10 'community.lexicon.calendar.rsvp' ··· 9 12 10 13 export type AllowedCollection = (typeof collections)[number]; 11 14 12 - // OAuth scope — add scope.blob(), scope.rpc(), etc. as needed 15 + // OAuth scopes. `include:rsvp.atmo.permissionSet?aud=*` bundles every rpc method 16 + // the deployment exposes; `aud=*` lets the same consent cover dev (tunnel DID) 17 + // and prod (published DID) without re-consenting. Repo writes and blob uploads 18 + // live as standalone scopes since they reference NSIDs (or resource kinds) 19 + // outside the `rsvp.atmo` namespace. 13 20 export const scopes = [ 14 21 'atproto', 15 22 scope.repo({ collection: [...collections] }), 16 - scope.blob({ accept: ['image/*'] }) 23 + scope.blob({ accept: ['image/*'] }), 24 + 'include:rsvp.atmo.permissionSet' 17 25 ]; 18 26 19 27 // set to false to disable signup
+19 -4
src/lib/components/EventCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { getCDNImageBlobUrl } from '$lib/atproto'; 3 - import { isEventOngoing, type FlatEventRecord } from '$lib/contrail'; 3 + import { eventUrl, isEventOngoing, type FlatEventRecord } from '$lib/contrail'; 4 4 import Avatar from 'svelte-boring-avatars'; 5 5 6 6 let { ··· 63 63 </script> 64 64 65 65 <a 66 - href="/p/{actor || event.did}/e/{event.rkey}" 66 + href={eventUrl(event, actor)} 67 67 class="group grid grid-cols-[4rem_1fr] gap-3 transition-colors sm:grid-cols-[5rem_1fr] sm:gap-4" 68 68 > 69 69 <div class="w-full"> ··· 99 99 {/if} 100 100 </p> 101 101 <h3 102 - class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mt-0.5 line-clamp-2 text-sm leading-snug font-semibold transition-colors sm:text-base" 102 + class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mt-0.5 line-clamp-2 flex items-center gap-1.5 text-sm leading-snug font-semibold transition-colors sm:text-base" 103 103 > 104 - {event.name} 104 + {#if event.space} 105 + <svg 106 + viewBox="0 0 24 24" 107 + fill="none" 108 + stroke="currentColor" 109 + stroke-width="2" 110 + stroke-linecap="round" 111 + stroke-linejoin="round" 112 + class="text-base-500 dark:text-base-400 mt-0.5 size-3.5 shrink-0" 113 + aria-label="Private event" 114 + > 115 + <rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> 116 + <path d="M7 11V7a5 5 0 0 1 10 0v4" /> 117 + </svg> 118 + {/if} 119 + <span>{event.name}</span> 105 120 </h3> 106 121 {#if location || mode} 107 122 <p class="text-base-500 dark:text-base-400 mt-1 text-xs">
+189 -1082
src/lib/components/EventEditor.svelte
··· 1 1 <script lang="ts"> 2 2 import { user } from '$lib/atproto/auth.svelte'; 3 3 import { atProtoLoginModalState } from '$lib/components/LoginModal.svelte'; 4 - import { uploadBlob, putRecord, deleteRecord, resolveHandle } from '$lib/atproto/methods'; 4 + import { putRecord, deleteRecord } from '$lib/atproto/methods'; 5 5 import { getCDNImageBlobUrl } from '$lib/atproto'; 6 6 import { notifyContrailOfUpdate } from '$lib/contrail'; 7 - import { compressImage } from '$lib/atproto/image-helper'; 8 - import { validateLink } from '$lib/cal/helper'; 9 - import * as TID from '@atcute/tid'; 10 7 import { 11 8 Avatar as FoxAvatar, 12 9 Button, 13 - PopoverRoot, 14 - PopoverTrigger, 15 - PopoverContent, 16 10 ToggleGroup, 17 - ToggleGroupItem, 18 - Input, 19 - Checkbox 11 + ToggleGroupItem 20 12 } from '@foxui/core'; 21 13 import { goto } from '$app/navigation'; 22 - import { tokenize, type Token } from '@atcute/bluesky-richtext-parser'; 23 - import type { Handle } from '@atcute/lexicons'; 24 14 import { onMount } from 'svelte'; 25 - import { browser } from '$app/environment'; 26 - import { putImage, getImage, deleteImage } from '$lib/components/image-store'; 27 - import { Modal } from '@foxui/core'; 15 + import { browser, dev } from '$app/environment'; 16 + import { getImage, deleteImage } from '$lib/components/image-store'; 28 17 import { PlainTextEditor } from '@foxui/text'; 29 - import Avatar from 'svelte-boring-avatars'; 30 18 import DateTimePicker from '$lib/components/DateTimePicker.svelte'; 31 19 import TimezonePicker from '$lib/components/TimezonePicker.svelte'; 32 20 import { parseDateTime } from '@internationalized/date'; 33 - import { datetimeLocalToISO, isoToDatetimeLocalInTz } from '$lib/date-format'; 34 - import ThumbnailPresets from '$lib/components/ThumbnailPresets.svelte'; 35 - import { designs } from '$lib/components/thumbnails/designs'; 21 + import { isoToDatetimeLocalInTz } from '$lib/date-format'; 36 22 import type { FlatEventRecord } from '$lib/contrail'; 37 - import ThemePicker from '$lib/components/ThemePicker.svelte'; 38 23 import ThemeApply from '$lib/components/ThemeApply.svelte'; 39 24 import ThemeBackground from '$lib/components/ThemeBackground.svelte'; 40 - import { defaultTheme, themeBackgrounds, type EventTheme } from '$lib/theme'; 25 + import { defaultTheme, type EventTheme } from '$lib/theme'; 26 + 27 + import type { Readable } from 'svelte/store'; 28 + import { get } from 'svelte/store'; 29 + import type { Editor } from '@tiptap/core'; 30 + 31 + import ThumbnailSection from './editor/ThumbnailSection.svelte'; 32 + import LocationSection from './editor/LocationSection.svelte'; 33 + import LinksSection from './editor/LinksSection.svelte'; 34 + import ThemeSection from './editor/ThemeSection.svelte'; 35 + import RecurringModal from './editor/RecurringModal.svelte'; 36 + import { 37 + stripModePrefix, 38 + type EventDraft, 39 + type EventLocation, 40 + type EventMode, 41 + type Visibility 42 + } from './editor/types'; 43 + import { clearDraft, migrateLegacyDraft, readDraft, writeDraft } from './editor/draft'; 44 + import { buildEventRecord, buildThumbnailMedia, renderPresetThumbnail } from './editor/save'; 41 45 42 46 let { 43 47 eventData = null, 44 48 actorDid, 45 - rkey 49 + rkey, 50 + privateMode = false 46 51 }: { 47 52 eventData: FlatEventRecord | null; 48 53 actorDid: string; 49 54 rkey: string; 55 + /** If true, save writes into a permissioned space instead of the user's public PDS. */ 56 + privateMode?: boolean; 50 57 } = $props(); 51 58 52 59 let isNew = $derived(eventData === null); 53 - let DRAFT_KEY = $derived(`blento-event-edit-${rkey}`); 54 - 55 - type EventMode = 'inperson' | 'virtual' | 'hybrid'; 56 - 57 - interface EventLocation { 58 - street?: string; 59 - locality?: string; 60 - region?: string; 61 - country?: string; 62 - } 63 - 64 - interface EventDraft { 65 - name: string; 66 - description: string; 67 - startsAt: string; 68 - endsAt: string; 69 - timezone?: string; 70 - theme?: EventTheme; 71 - links: Array<{ uri: string; name: string }>; 72 - mode?: EventMode; 73 - thumbnailKey?: string; 74 - thumbnailChanged?: boolean; 75 - location?: EventLocation | null; 76 - locationChanged?: boolean; 77 - } 78 60 79 61 let thumbnailKey: string | null = $state(null); 80 62 let thumbnailChanged = $state(false); ··· 86 68 let endsAt = $state(''); 87 69 let timezone = $state(Intl.DateTimeFormat().resolvedOptions().timeZone); 88 70 let mode: EventMode = $state('inperson'); 71 + // svelte-ignore state_referenced_locally 72 + let visibility: Visibility = $state(privateMode && dev ? 'private' : 'public'); 89 73 let eventTheme: EventTheme = $state({ ...defaultTheme }); 90 - let showThemeModal = $state(false); 91 74 let thumbnailFile: File | null = $state(null); 92 75 let thumbnailPreview: string | null = $state(null); 93 76 let selectedPreset: { design: string; seed: number } | null = $state(null); 94 - let presetPreviewCanvas: HTMLCanvasElement | undefined = $state(undefined); 95 - let showThumbnailModal = $state(false); 96 77 let submitting = $state(false); 97 78 let error: string | null = $state(null); 98 - import type { Readable } from 'svelte/store'; 99 - import { get } from 'svelte/store'; 100 - import type { Editor } from '@tiptap/core'; 101 79 let titleEditor: Readable<Editor> | undefined = $state(undefined); 102 80 103 81 let location: EventLocation | null = $state(null); 104 82 let locationChanged = $state(false); 105 - let showLocationModal = $state(false); 106 - let locationSearch = $state(''); 107 - let locationSearching = $state(false); 108 - let locationError = $state(''); 109 - let locationResult: { displayName: string; location: EventLocation } | null = $state(null); 110 83 111 84 let links: Array<{ uri: string; name: string }> = $state([]); 112 - let showLinkPopup = $state(false); 113 - let newLinkUri = $state(''); 114 - let newLinkName = $state(''); 115 - let linkError = $state(''); 116 85 117 86 let draftLoaded = $state(false); 118 87 119 88 let showRecurringModal = $state(false); 120 - let recurringInterval = $state(1); 121 - let recurringUnit: 'days' | 'weeks' | 'months' | 'years' = $state('weeks'); 122 - let recurringCount = $state(4); 123 - let recurringNumberInTitle = $state(false); 124 - let recurringCreating = $state(false); 125 - let recurringError: string | null = $state(null); 126 - let recurringCreated = $state(0); 127 - 128 - let titleNumberMatch = $derived(name.match(/#?(\d+)\s*$/)); 129 - let detectedStartNumber = $derived(titleNumberMatch ? parseInt(titleNumberMatch[1]) : null); 130 - 131 - $effect(() => { 132 - if (detectedStartNumber !== null) { 133 - recurringNumberInTitle = true; 134 - } 135 - }); 136 - 137 - function stripModePrefix(modeStr: string): EventMode { 138 - const stripped = modeStr.replace('community.lexicon.calendar.event#', ''); 139 - if (stripped === 'virtual' || stripped === 'hybrid' || stripped === 'inperson') return stripped; 140 - return 'inperson'; 141 - } 142 89 143 90 function populateLocationFromEventData() { 144 91 if (!eventData) return; ··· 186 133 startsAt = eventData.startsAt ? isoToDatetimeLocalInTz(eventData.startsAt, timezone) : ''; 187 134 endsAt = eventData.endsAt ? isoToDatetimeLocalInTz(eventData.endsAt, timezone) : ''; 188 135 mode = eventData.mode ? stripModePrefix(eventData.mode) : 'inperson'; 136 + const prefs = (eventData as unknown as { preferences?: { showInDiscovery?: boolean } }) 137 + .preferences; 138 + if (privateMode && dev) visibility = 'private'; 139 + else if (prefs && prefs.showInDiscovery === false) visibility = 'unlisted'; 140 + else visibility = 'public'; 189 141 links = eventData.uris ? eventData.uris.map((l) => ({ uri: l.uri, name: l.name || '' })) : []; 190 142 if (eventData.theme) eventTheme = { ...eventData.theme }; 191 143 populateLocationFromEventData(); ··· 193 145 } 194 146 195 147 onMount(async () => { 196 - // Migrate old creation draft if this is a new event 197 - if (isNew) { 198 - const oldDraft = localStorage.getItem('blento-event-draft'); 199 - if (oldDraft && !localStorage.getItem(DRAFT_KEY)) { 200 - localStorage.setItem(DRAFT_KEY, oldDraft); 201 - localStorage.removeItem('blento-event-draft'); 202 - } 203 - } 148 + if (isNew) migrateLegacyDraft(rkey); 204 149 205 - const saved = localStorage.getItem(DRAFT_KEY); 206 - if (saved) { 207 - try { 208 - const draft: EventDraft = JSON.parse(saved); 209 - name = draft.name || ''; 210 - description = draft.description || ''; 211 - startsAt = draft.startsAt || ''; 212 - endsAt = draft.endsAt || ''; 213 - if (draft.timezone) timezone = draft.timezone; 214 - if (draft.theme) eventTheme = draft.theme; 215 - links = draft.links || []; 216 - mode = draft.mode || 'inperson'; 217 - locationChanged = draft.locationChanged || false; 218 - if (draft.locationChanged) { 219 - location = draft.location || null; 220 - } else if (!isNew) { 221 - // For edits without location changes, load from event data 222 - populateLocationFromEventData(); 223 - } 224 - thumbnailChanged = draft.thumbnailChanged || false; 150 + const draft = readDraft(rkey); 151 + if (draft) { 152 + name = draft.name || ''; 153 + description = draft.description || ''; 154 + startsAt = draft.startsAt || ''; 155 + endsAt = draft.endsAt || ''; 156 + if (draft.timezone) timezone = draft.timezone; 157 + if (draft.theme) eventTheme = draft.theme; 158 + links = draft.links || []; 159 + mode = draft.mode || 'inperson'; 160 + if (draft.visibility && (draft.visibility !== 'private' || dev)) 161 + visibility = draft.visibility; 162 + else if (privateMode && dev) visibility = 'private'; 163 + locationChanged = draft.locationChanged || false; 164 + if (draft.locationChanged) { 165 + location = draft.location || null; 166 + } else if (!isNew) { 167 + populateLocationFromEventData(); 168 + } 169 + thumbnailChanged = draft.thumbnailChanged || false; 225 170 226 - if (draft.thumbnailKey) { 227 - const img = await getImage(draft.thumbnailKey); 228 - if (img) { 229 - thumbnailKey = draft.thumbnailKey; 230 - thumbnailFile = new File([img.blob], img.name, { type: img.blob.type }); 231 - thumbnailPreview = URL.createObjectURL(img.blob); 232 - thumbnailChanged = true; 233 - } 234 - } else if (!thumbnailChanged && !isNew) { 235 - // No new thumbnail in draft, show existing one from event data 236 - populateThumbnailFromEventData(); 171 + if (draft.thumbnailKey) { 172 + const img = await getImage(draft.thumbnailKey); 173 + if (img) { 174 + thumbnailKey = draft.thumbnailKey; 175 + thumbnailFile = new File([img.blob], img.name, { type: img.blob.type }); 176 + thumbnailPreview = URL.createObjectURL(img.blob); 177 + thumbnailChanged = true; 237 178 } 238 - } catch { 239 - localStorage.removeItem(DRAFT_KEY); 240 - if (!isNew) populateFromEventData(); 179 + } else if (!thumbnailChanged && !isNew) { 180 + populateThumbnailFromEventData(); 241 181 } 242 182 } else if (!isNew) { 243 183 populateFromEventData(); ··· 261 201 theme: eventTheme, 262 202 links, 263 203 mode, 204 + visibility, 264 205 thumbnailChanged, 265 206 locationChanged 266 207 }; 267 208 if (locationChanged) draft.location = location; 268 209 if (thumbnailKey) draft.thumbnailKey = thumbnailKey; 269 - localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); 210 + writeDraft(rkey, draft); 270 211 }, 500); 271 212 } 272 213 ··· 280 221 timezone, 281 222 JSON.stringify(eventTheme), 282 223 mode, 224 + visibility, 283 225 JSON.stringify(links), 284 - JSON.stringify(location) 226 + JSON.stringify(location), 227 + thumbnailKey, 228 + thumbnailChanged, 229 + locationChanged 285 230 ]; 286 231 saveDraft(); 287 232 }); 288 233 289 - async function searchLocation() { 290 - const q = locationSearch.trim(); 291 - if (!q) return; 292 - locationError = ''; 293 - locationSearching = true; 294 - locationResult = null; 295 - 296 - try { 297 - const response = await fetch('/api/geocoding?q=' + encodeURIComponent(q)); 298 - if (!response.ok) throw new Error('response not ok'); 299 - const data: Record<string, unknown> = await response.json(); 300 - if (!data || data.error) throw new Error('no results'); 301 - 302 - const addr = (data.address || {}) as Record<string, string>; 303 - const road = addr.road || ''; 304 - const houseNumber = addr.house_number || ''; 305 - const street = road ? (houseNumber ? `${road} ${houseNumber}` : road) : ''; 306 - const locality = 307 - addr.city || addr.town || addr.village || addr.municipality || addr.hamlet || ''; 308 - const region = addr.state || addr.county || ''; 309 - const country = addr.country || ''; 310 - 311 - locationResult = { 312 - displayName: (data.display_name as string) || q, 313 - location: { 314 - ...(street && { street }), 315 - ...(locality && { locality }), 316 - ...(region && { region }), 317 - ...(country && { country }) 318 - } 319 - }; 320 - } catch { 321 - locationError = "Couldn't find that location."; 322 - } finally { 323 - locationSearching = false; 324 - } 325 - } 326 - 327 - function confirmLocation() { 328 - if (locationResult) { 329 - location = locationResult.location; 330 - locationChanged = true; 331 - } 332 - showLocationModal = false; 333 - locationSearch = ''; 334 - locationResult = null; 335 - locationError = ''; 336 - } 337 - 338 - function removeLocation() { 339 - location = null; 340 - locationChanged = true; 341 - } 342 - 343 - function getLocationDisplayString(loc: EventLocation): string { 344 - return [loc.street, loc.locality, loc.region, loc.country].filter(Boolean).join(', '); 345 - } 346 - 347 - function addLink() { 348 - const raw = newLinkUri.trim(); 349 - if (!raw) return; 350 - const uri = validateLink(raw); 351 - if (!uri) { 352 - linkError = 'Please enter a valid URL'; 353 - return; 354 - } 355 - links.push({ uri, name: newLinkName.trim() }); 356 - newLinkUri = ''; 357 - newLinkName = ''; 358 - linkError = ''; 359 - showLinkPopup = false; 360 - } 361 - 362 - function removeLink(index: number) { 363 - links.splice(index, 1); 364 - } 365 - 366 - let fileInput: HTMLInputElement | undefined = $state(); 367 - 368 234 let hostName = $derived(user.profile?.displayName || user.profile?.handle || user.did || ''); 369 235 370 - async function setThumbnail(file: File) { 371 - thumbnailFile = file; 372 - thumbnailChanged = true; 373 - selectedPreset = null; 374 - if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 375 - thumbnailPreview = URL.createObjectURL(file); 376 - 377 - if (thumbnailKey) await deleteImage(thumbnailKey); 378 - thumbnailKey = crypto.randomUUID(); 379 - await putImage(thumbnailKey, file, file.name); 380 - saveDraft(); 381 - } 382 - 383 - async function onFileChange(e: Event) { 384 - const input = e.target as HTMLInputElement; 385 - const file = input.files?.[0]; 386 - if (!file) return; 387 - setThumbnail(file); 388 - } 389 - 390 - let isDragOver = $state(false); 391 - 392 - function onDragOver(e: DragEvent) { 393 - e.preventDefault(); 394 - isDragOver = true; 395 - } 396 - 397 - function onDragLeave(e: DragEvent) { 398 - e.preventDefault(); 399 - isDragOver = false; 400 - } 401 - 402 - function onDrop(e: DragEvent) { 403 - e.preventDefault(); 404 - isDragOver = false; 405 - const file = e.dataTransfer?.files?.[0]; 406 - if (file?.type.startsWith('image/')) { 407 - setThumbnail(file); 408 - } 409 - } 410 - 411 - function removeThumbnail() { 412 - thumbnailFile = null; 413 - thumbnailChanged = true; 414 - selectedPreset = null; 415 - if (thumbnailPreview) { 416 - URL.revokeObjectURL(thumbnailPreview); 417 - thumbnailPreview = null; 418 - } 419 - if (thumbnailKey) { 420 - deleteImage(thumbnailKey); 421 - thumbnailKey = null; 422 - } 423 - if (fileInput) fileInput.value = ''; 424 - saveDraft(); 425 - } 426 - 427 236 let thumbnailDateStr = $derived.by(() => { 428 237 if (!startsAt) return ''; 429 238 const d = new Date(startsAt); ··· 431 240 return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); 432 241 }); 433 242 434 - // Render preset preview canvas 435 - $effect(() => { 436 - if (selectedPreset && presetPreviewCanvas && designs[selectedPreset.design]) { 437 - const ctx = presetPreviewCanvas.getContext('2d'); 438 - if (!ctx) return; 439 - presetPreviewCanvas.width = 800; 440 - presetPreviewCanvas.height = 800; 441 - designs[selectedPreset.design](ctx, 800, 800, name || 'Event', thumbnailDateStr, selectedPreset.seed); 442 - } 443 - }); 444 - 445 243 // Trim a CalendarDateTime.toString() ("YYYY-MM-DDTHH:mm:ss[.sss]") down to 446 244 // the "YYYY-MM-DDTHH:mm" shape that <input type="datetime-local"> expects. 447 245 function cdtToDatetimeLocal(s: string): string { ··· 466 264 } 467 265 }); 468 266 469 - async function tokensToFacets(tokens: Token[]): Promise<Record<string, unknown>[]> { 470 - const encoder = new TextEncoder(); 471 - const facets: Record<string, unknown>[] = []; 472 - let byteOffset = 0; 473 - 474 - for (const token of tokens) { 475 - const tokenBytes = encoder.encode(token.raw); 476 - const byteStart = byteOffset; 477 - const byteEnd = byteOffset + tokenBytes.length; 478 - 479 - if (token.type === 'mention') { 480 - try { 481 - const did = await resolveHandle({ handle: token.handle as Handle }); 482 - if (did) { 483 - facets.push({ 484 - index: { byteStart, byteEnd }, 485 - features: [{ $type: 'app.bsky.richtext.facet#mention', did }] 486 - }); 487 - } 488 - } catch { 489 - // skip unresolvable mentions 490 - } 491 - } else if (token.type === 'autolink') { 492 - facets.push({ 493 - index: { byteStart, byteEnd }, 494 - features: [{ $type: 'app.bsky.richtext.facet#link', uri: token.url }] 495 - }); 496 - } else if (token.type === 'topic') { 497 - facets.push({ 498 - index: { byteStart, byteEnd }, 499 - features: [{ $type: 'app.bsky.richtext.facet#tag', tag: token.name }] 500 - }); 501 - } 502 - 503 - byteOffset = byteEnd; 504 - } 505 - 506 - return facets; 507 - } 508 - 509 267 async function handleSubmit() { 510 268 error = null; 511 269 512 - if (!name.trim()) { 513 - error = 'Name is required.'; 514 - return; 515 - } 516 - if (!startsAt) { 517 - error = 'Start date is required.'; 518 - return; 519 - } 520 - if (!endsAt) { 521 - error = 'End date is required.'; 522 - return; 523 - } 524 - if (!user.isLoggedIn || !user.did) { 525 - error = 'You must be logged in.'; 526 - return; 527 - } 270 + if (!name.trim()) return void (error = 'Name is required.'); 271 + if (!startsAt) return void (error = 'Start date is required.'); 272 + if (!endsAt) return void (error = 'End date is required.'); 273 + if (!user.isLoggedIn || !user.did) return void (error = 'You must be logged in.'); 528 274 529 275 submitting = true; 530 276 531 277 try { 532 278 // Generate thumbnail from preset if selected and no custom upload 533 - if (selectedPreset && !thumbnailFile && designs[selectedPreset.design]) { 534 - const canvas = document.createElement('canvas'); 535 - canvas.width = 800; 536 - canvas.height = 800; 537 - const ctx = canvas.getContext('2d')!; 538 - designs[selectedPreset.design](ctx, 800, 800, name.trim() || 'Event', thumbnailDateStr, selectedPreset.seed); 539 - const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png')); 540 - if (blob) { 541 - thumbnailFile = new File([blob], 'thumbnail.png', { type: 'image/png' }); 279 + if (selectedPreset && !thumbnailFile) { 280 + const rendered = await renderPresetThumbnail({ 281 + design: selectedPreset.design, 282 + seed: selectedPreset.seed, 283 + name, 284 + dateStr: thumbnailDateStr 285 + }); 286 + if (rendered) { 287 + thumbnailFile = rendered; 542 288 thumbnailChanged = true; 543 289 } 544 290 } 545 291 546 - let media: Array<Record<string, unknown>> | undefined; 547 - 548 - // Start with existing media, excluding thumbnail role 549 292 const existingMedia = (eventData?.media ?? []) as Array<Record<string, unknown>>; 293 + const media = await buildThumbnailMedia({ 294 + isNew, 295 + thumbnailChanged, 296 + thumbnailFile, 297 + existingMedia 298 + }); 550 299 551 - if (isNew || thumbnailChanged) { 552 - if (thumbnailFile) { 553 - const compressed = await compressImage(thumbnailFile); 554 - const result = await uploadBlob({ blob: compressed.blob }); 555 - if (result) { 556 - const { aspectRatio: _ar, ...blobRef } = result as Record<string, unknown> & { aspectRatio?: unknown }; 557 - // Keep all non-thumbnail media, add new thumbnail 558 - media = [ 559 - ...existingMedia.filter((m) => m.role !== 'thumbnail'), 560 - { 561 - role: 'thumbnail', 562 - content: blobRef, 563 - aspect_ratio: { 564 - width: compressed.aspectRatio.width, 565 - height: compressed.aspectRatio.height 566 - } 567 - } 568 - ]; 569 - } 570 - } else { 571 - // Thumbnail removed — keep all non-thumbnail media 572 - const remaining = existingMedia.filter((m) => m.role !== 'thumbnail'); 573 - if (remaining.length > 0) media = remaining; 574 - } 575 - } else { 576 - // Thumbnail not changed — keep all original media 577 - if (existingMedia.length > 0) { 578 - media = existingMedia; 579 - } 580 - } 581 - 582 - const createdAt = isNew 583 - ? new Date().toISOString() 584 - : eventData?.createdAt || new Date().toISOString(); 585 - 586 - // Spread original record to preserve unspecced fields (e.g. additionalData) 587 - const record: Record<string, unknown> = { 588 - ...(eventData ? { ...eventData } : {}), 589 - $type: 'community.lexicon.calendar.event', 590 - createdWith: 'https://atmo.rsvp', 591 - name: name.trim(), 592 - mode: `community.lexicon.calendar.event#${mode}`, 593 - status: 'community.lexicon.calendar.event#scheduled', 594 - startsAt: datetimeLocalToISO(startsAt, timezone), 300 + const record = await buildEventRecord({ 301 + eventData, 302 + isNew, 303 + name, 304 + description, 305 + startsAt, 306 + endsAt, 595 307 timezone, 596 - createdAt, 597 - theme: eventTheme 598 - }; 599 - // Remove flattened fields that aren't part of the actual record 600 - delete record.cid; 601 - delete record.did; 602 - delete record.rkey; 603 - delete record.uri; 604 - delete record.rsvps; 605 - delete record.rsvpsCount; 606 - delete record.rsvpsGoingCount; 607 - delete record.rsvpsInterestedCount; 608 - delete record.rsvpsNotgoingCount; 308 + mode, 309 + visibility, 310 + theme: eventTheme, 311 + links, 312 + location, 313 + locationChanged, 314 + media 315 + }); 609 316 610 - const trimmedDescription = description.trim(); 611 - if (trimmedDescription) { 612 - record.description = trimmedDescription; 613 - const tokens = tokenize(trimmedDescription); 614 - const facets = await tokensToFacets(tokens); 615 - if (facets.length > 0) { 616 - record.facets = facets; 617 - } 618 - } 619 - if (endsAt) { 620 - record.endsAt = datetimeLocalToISO(endsAt, timezone); 621 - } 622 - if (media) { 623 - record.media = media; 624 - } 625 - if (links.length > 0) { 626 - record.uris = links; 627 - } 628 - if (isNew || locationChanged) { 629 - if (location) { 630 - record.locations = [ 631 - { 632 - $type: 'community.lexicon.location.address', 633 - ...location 634 - } 635 - ]; 636 - } 637 - // If changed/new but no location, locations stays undefined (removed/absent) 638 - } else if (eventData?.locations && eventData.locations.length > 0) { 639 - record.locations = eventData.locations; 317 + if (visibility === 'private') { 318 + const { createPrivateEvent } = await import('$lib/spaces/server/spaces.remote'); 319 + const { spaceUri, rkey: eventRkey } = await createPrivateEvent({ key: rkey, record }); 320 + clearDraft(rkey); 321 + if (thumbnailKey) deleteImage(thumbnailKey); 322 + const spaceKey = spaceUri.split('/').pop(); 323 + const handle = 324 + user.profile?.handle && user.profile.handle !== 'handle.invalid' 325 + ? user.profile.handle 326 + : user.did; 327 + goto(`/p/${handle}/e/${eventRkey}/s/${spaceKey}?created=true`); 328 + return; 640 329 } 641 330 642 331 const response = await putRecord({ ··· 648 337 if (response.ok) { 649 338 const eventUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 650 339 await notifyContrailOfUpdate(eventUri); 651 - localStorage.removeItem(DRAFT_KEY); 340 + clearDraft(rkey); 652 341 if (thumbnailKey) deleteImage(thumbnailKey); 653 342 const handle = 654 343 user.profile?.handle && user.profile.handle !== 'handle.invalid' ··· 678 367 }); 679 368 const eventUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 680 369 await notifyContrailOfUpdate(eventUri); 681 - localStorage.removeItem(DRAFT_KEY); 370 + clearDraft(rkey); 682 371 if (thumbnailKey) deleteImage(thumbnailKey); 683 372 const handle = 684 373 user.profile?.handle && user.profile.handle !== 'handle.invalid' ··· 693 382 showDeleteConfirm = false; 694 383 } 695 384 } 696 - 697 - async function handleCreateRecurring() { 698 - if (!name.trim() || !startsAt || !user.isLoggedIn || !user.did) return; 699 - 700 - recurringCreating = true; 701 - recurringError = null; 702 - recurringCreated = 0; 703 - 704 - try { 705 - // Recurring instances advance by wall-clock duration (e.g. "every week 706 - // at 10am"), so operate on CalendarDateTime — not absolute instants — 707 - // to preserve the wall time across DST transitions. 708 - const baseStart = parseDateTime(startsAt); 709 - const baseEnd = endsAt ? parseDateTime(endsAt) : null; 710 - const durationMs = baseEnd 711 - ? baseEnd.toDate(timezone).getTime() - baseStart.toDate(timezone).getTime() 712 - : 0; 713 - const baseName = recurringNumberInTitle && titleNumberMatch 714 - ? name.replace(/#?\d+\s*$/, '').trimEnd() 715 - : name.trim(); 716 - const startNum = detectedStartNumber ?? 1; 717 - const hasHash = titleNumberMatch ? titleNumberMatch[0].includes('#') : false; 718 - 719 - // Generate thumbnail from preset if selected and no custom upload 720 - if (selectedPreset && !thumbnailFile && designs[selectedPreset.design]) { 721 - const canvas = document.createElement('canvas'); 722 - canvas.width = 800; 723 - canvas.height = 800; 724 - const ctx = canvas.getContext('2d')!; 725 - designs[selectedPreset.design](ctx, 800, 800, name.trim() || 'Event', thumbnailDateStr, selectedPreset.seed); 726 - const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png')); 727 - if (blob) { 728 - thumbnailFile = new File([blob], 'thumbnail.png', { type: 'image/png' }); 729 - thumbnailChanged = true; 730 - } 731 - } 732 - 733 - // Build the same record shape as handleSubmit 734 - let media: Array<Record<string, unknown>> | undefined; 735 - const existingMedia = (eventData?.media ?? []) as Array<Record<string, unknown>>; 736 - 737 - if (isNew || thumbnailChanged) { 738 - if (thumbnailFile) { 739 - const compressed = await compressImage(thumbnailFile); 740 - const result = await uploadBlob({ blob: compressed.blob }); 741 - if (result) { 742 - const { aspectRatio: _ar, ...blobRef } = result as Record<string, unknown> & { aspectRatio?: unknown }; 743 - media = [ 744 - ...existingMedia.filter((m) => m.role !== 'thumbnail'), 745 - { 746 - role: 'thumbnail', 747 - content: blobRef, 748 - aspect_ratio: { width: compressed.aspectRatio.width, height: compressed.aspectRatio.height } 749 - } 750 - ]; 751 - } 752 - } else { 753 - const remaining = existingMedia.filter((m) => m.role !== 'thumbnail'); 754 - if (remaining.length > 0) media = remaining; 755 - } 756 - } else if (existingMedia.length > 0) { 757 - media = existingMedia; 758 - } 759 - 760 - const parentUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 761 - 762 - for (let i = 0; i < recurringCount; i++) { 763 - const offset = i + 1; 764 - const step = offset * recurringInterval; 765 - const eventStart = 766 - recurringUnit === 'days' 767 - ? baseStart.add({ days: step }) 768 - : recurringUnit === 'weeks' 769 - ? baseStart.add({ weeks: step }) 770 - : recurringUnit === 'months' 771 - ? baseStart.add({ months: step }) 772 - : baseStart.add({ years: step }); 773 - 774 - const eventStartIso = eventStart.toDate(timezone).toISOString(); 775 - // Preserve the original absolute duration (handles events that 776 - // span midnight or odd wall-clock lengths correctly). 777 - const eventEndIso = durationMs 778 - ? new Date(eventStart.toDate(timezone).getTime() + durationMs).toISOString() 779 - : null; 780 - 781 - let eventName = baseName; 782 - if (recurringNumberInTitle) { 783 - const num = startNum + (i + 1); 784 - eventName = hasHash ? `${baseName} #${num}` : `${baseName} ${num}`; 785 - } 786 - 787 - const newRkey = TID.now(); 788 - const record: Record<string, unknown> = { 789 - $type: 'community.lexicon.calendar.event', 790 - createdWith: 'https://atmo.rsvp', 791 - name: eventName, 792 - mode: `community.lexicon.calendar.event#${mode}`, 793 - status: 'community.lexicon.calendar.event#scheduled', 794 - startsAt: eventStartIso, 795 - timezone, 796 - createdAt: new Date().toISOString(), 797 - recurringEventOf: parentUri 798 - }; 799 - 800 - const trimmedDescription = description.trim(); 801 - if (trimmedDescription) { 802 - record.description = trimmedDescription; 803 - } 804 - if (eventEndIso) { 805 - record.endsAt = eventEndIso; 806 - } 807 - if (media) { 808 - record.media = media; 809 - } 810 - if (links.length > 0) { 811 - record.uris = links; 812 - } 813 - if (location) { 814 - record.locations = [{ 815 - $type: 'community.lexicon.location.address', 816 - ...location 817 - }]; 818 - } 819 - 820 - const response = await putRecord({ 821 - collection: 'community.lexicon.calendar.event', 822 - rkey: newRkey, 823 - record 824 - }); 825 - 826 - if (response.ok) { 827 - const eventUri = `at://${user.did}/community.lexicon.calendar.event/${newRkey}`; 828 - await notifyContrailOfUpdate(eventUri); 829 - recurringCreated = i + 1; 830 - } else { 831 - recurringError = `Failed to create event ${i + 1}. Stopping.`; 832 - return; 833 - } 834 - } 835 - 836 - showRecurringModal = false; 837 - } catch (e) { 838 - console.error('Failed to create recurring events:', e); 839 - recurringError = 'Failed to create recurring events. Please try again.'; 840 - } finally { 841 - recurringCreating = false; 842 - } 843 - } 844 385 </script> 845 386 846 387 <ThemeApply accentColor={eventTheme.accentColor} baseColor={eventTheme.baseColor} /> ··· 868 409 <div 869 410 class="grid grid-cols-1 gap-8 md:grid-cols-[14rem_1fr] md:gap-x-10 md:gap-y-6 lg:grid-cols-[16rem_1fr]" 870 411 > 871 - <!-- Thumbnail (left column) --> 872 - <!-- svelte-ignore a11y_no_static_element_interactions --> 873 - <div 874 - class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none" 875 - ondragover={onDragOver} 876 - ondragleave={onDragLeave} 877 - ondrop={onDrop} 878 - > 879 - <input 880 - bind:this={fileInput} 881 - type="file" 882 - accept="image/*" 883 - onchange={(e) => { onFileChange(e); showThumbnailModal = false; }} 884 - class="hidden" 885 - /> 886 - <div class="group relative"> 887 - {#if thumbnailPreview} 888 - <img 889 - src={thumbnailPreview} 890 - alt="Thumbnail preview" 891 - class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 892 - /> 893 - {:else if selectedPreset && designs[selectedPreset.design]} 894 - <div class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border"> 895 - <canvas bind:this={presetPreviewCanvas} class="h-full w-full"></canvas> 896 - </div> 897 - {:else} 898 - <div 899 - class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full" 900 - > 901 - <Avatar 902 - size={400} 903 - name={rkey} 904 - variant="marble" 905 - colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 906 - square 907 - /> 908 - </div> 909 - {/if} 910 - <button 911 - type="button" 912 - onclick={() => (showThumbnailModal = true)} 913 - class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-2xl bg-black/0 text-white/0 transition-colors group-hover:bg-black/40 group-hover:text-white/90 {isDragOver 914 - ? 'bg-black/40 text-white/90' 915 - : ''}" 916 - > 917 - <svg 918 - xmlns="http://www.w3.org/2000/svg" 919 - fill="none" 920 - viewBox="0 0 24 24" 921 - stroke-width="1.5" 922 - stroke="currentColor" 923 - class="size-6" 924 - > 925 - <path 926 - stroke-linecap="round" 927 - stroke-linejoin="round" 928 - d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" 929 - /> 930 - </svg> 931 - <span class="text-sm font-medium">Change thumbnail</span> 932 - </button> 933 - {#if thumbnailPreview || selectedPreset} 934 - <Button 935 - variant="ghost" 936 - size="iconSm" 937 - onclick={removeThumbnail} 938 - class="bg-base-900/70 absolute top-2 right-2 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600" 939 - > 940 - <svg 941 - xmlns="http://www.w3.org/2000/svg" 942 - viewBox="0 0 20 20" 943 - fill="currentColor" 944 - class="size-3.5" 945 - > 946 - <path 947 - d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 948 - /> 949 - </svg> 950 - </Button> 951 - {/if} 952 - </div> 953 - </div> 954 - <Button 955 - variant="secondary" 956 - class="mt-3 w-full" 957 - onclick={() => (showThemeModal = true)} 958 - > 959 - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4"> 960 - <path stroke-linecap="round" stroke-linejoin="round" d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" /> 961 - </svg> 962 - Theme: {themeBackgrounds[eventTheme.name] || eventTheme.name} 963 - </Button> 964 - <Button 965 - type="submit" 966 - class="mt-3 w-full" 967 - disabled={submitting || !name.trim() || !startsAt || !endsAt} 968 - > 969 - {submitting 970 - ? isNew 971 - ? 'Publishing...' 972 - : 'Saving...' 973 - : isNew 974 - ? 'Publish Event' 975 - : 'Save Event'} 976 - </Button> 977 - <!-- Right column: event details --> 412 + <ThumbnailSection 413 + {rkey} 414 + {name} 415 + dateStr={thumbnailDateStr} 416 + bind:thumbnailFile 417 + bind:thumbnailPreview 418 + bind:thumbnailKey 419 + bind:thumbnailChanged 420 + bind:selectedPreset 421 + /> 422 + 423 + <!-- Right column: event details --> 978 424 <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> 979 425 <!-- Name --> 980 426 <div class="mb-2 min-h-14"> ··· 999 445 </div> 1000 446 1001 447 <!-- Mode toggle --> 1002 - <div class="mb-8"> 448 + <div class="mb-3"> 1003 449 <ToggleGroup 1004 450 type="single" 1005 451 bind:value={ 1006 - () => { 1007 - return mode; 1008 - }, 452 + () => mode, 1009 453 (val) => { 1010 454 if (val) mode = val; 1011 455 } ··· 1019 463 </ToggleGroup> 1020 464 </div> 1021 465 466 + <!-- Visibility toggle --> 467 + <div class="mb-8"> 468 + <ToggleGroup 469 + type="single" 470 + bind:value={ 471 + () => visibility, 472 + (val) => { 473 + if (val) visibility = val as Visibility; 474 + } 475 + } 476 + class="w-fit" 477 + size="xs" 478 + disabled={!isNew && visibility === 'private'} 479 + > 480 + <ToggleGroupItem value="public">Public</ToggleGroupItem> 481 + {#if dev} 482 + <ToggleGroupItem value="private">Private</ToggleGroupItem> 483 + {/if} 484 + <ToggleGroupItem value="unlisted">Unlisted</ToggleGroupItem> 485 + </ToggleGroup> 486 + <div class="text-base-500 dark:text-base-400 mt-1.5 text-xs"> 487 + {#if visibility === 'public'} 488 + Anyone can view and it appears in discovery. 489 + {:else if visibility === 'private'} 490 + Only people you add (or who redeem an invite link) can see it. 491 + {:else} 492 + Public to anyone with the link, but hidden from discovery. 493 + {/if} 494 + </div> 495 + </div> 496 + 1022 497 <!-- Date row --> 1023 498 <div class="mb-4 flex items-stretch gap-3"> 1024 499 <div class="flex flex-col gap-2"> ··· 1036 511 </div> 1037 512 </div> 1038 513 1039 - <!-- Location row --> 1040 - {#if location} 1041 - <div class="mb-6 flex items-center gap-4"> 1042 - <div 1043 - class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border" 1044 - > 1045 - <svg 1046 - xmlns="http://www.w3.org/2000/svg" 1047 - fill="none" 1048 - viewBox="0 0 24 24" 1049 - stroke-width="1.5" 1050 - stroke="currentColor" 1051 - class="text-base-900 dark:text-base-200 size-5" 1052 - > 1053 - <path 1054 - stroke-linecap="round" 1055 - stroke-linejoin="round" 1056 - d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 1057 - /> 1058 - <path 1059 - stroke-linecap="round" 1060 - stroke-linejoin="round" 1061 - d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" 1062 - /> 1063 - </svg> 1064 - </div> 1065 - <p class="text-base-900 dark:text-base-50 flex-1 font-semibold"> 1066 - {getLocationDisplayString(location)} 1067 - </p> 1068 - <Button variant="ghost" size="iconSm" onclick={removeLocation} class="shrink-0"> 1069 - <svg 1070 - xmlns="http://www.w3.org/2000/svg" 1071 - viewBox="0 0 20 20" 1072 - fill="currentColor" 1073 - class="size-3.5" 1074 - > 1075 - <path 1076 - d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 1077 - /> 1078 - </svg> 1079 - </Button> 1080 - </div> 1081 - {:else} 1082 - <div class="mb-6"> 1083 - <Button variant="secondary" onclick={() => (showLocationModal = true)}> 1084 - <svg 1085 - xmlns="http://www.w3.org/2000/svg" 1086 - fill="none" 1087 - viewBox="0 0 24 24" 1088 - stroke-width="1.5" 1089 - stroke="currentColor" 1090 - class="size-4" 1091 - > 1092 - <path 1093 - stroke-linecap="round" 1094 - stroke-linejoin="round" 1095 - d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 1096 - /> 1097 - <path 1098 - stroke-linecap="round" 1099 - stroke-linejoin="round" 1100 - d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" 1101 - /> 1102 - </svg> 1103 - Add location 1104 - </Button> 1105 - </div> 1106 - {/if} 514 + <LocationSection bind:location bind:locationChanged /> 1107 515 1108 516 <!-- About Event --> 1109 517 <div class="mt-8 mb-8"> ··· 1139 547 type="button" 1140 548 variant="secondary" 1141 549 disabled={submitting || !name.trim() || !startsAt || !endsAt} 1142 - onclick={() => { 1143 - recurringError = null; 1144 - recurringCreated = 0; 1145 - showRecurringModal = true; 1146 - }} 550 + onclick={() => (showRecurringModal = true)} 1147 551 > 1148 552 Add recurring events 1149 553 </Button> ··· 1165 569 </div> 1166 570 </div> 1167 571 1168 - <!-- Links --> 1169 - <div class="order-4 md:order-0 md:col-start-1"> 1170 - <p 1171 - class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 1172 - > 1173 - Links 1174 - </p> 1175 - <div class="space-y-3"> 1176 - {#each links as link, i (i)} 1177 - <div class="group flex items-center gap-1.5"> 1178 - <svg 1179 - xmlns="http://www.w3.org/2000/svg" 1180 - fill="none" 1181 - viewBox="0 0 24 24" 1182 - stroke-width="1.5" 1183 - stroke="currentColor" 1184 - class="text-base-700 dark:text-base-300 size-3.5 shrink-0" 1185 - > 1186 - <path 1187 - stroke-linecap="round" 1188 - stroke-linejoin="round" 1189 - d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 1190 - /> 1191 - </svg> 1192 - <span class="text-base-700 dark:text-base-300 truncate text-sm"> 1193 - {link.name || link.uri.replace(/^https?:\/\//, '')} 1194 - </span> 1195 - <Button 1196 - variant="ghost" 1197 - size="iconSm" 1198 - onclick={() => removeLink(i)} 1199 - class="ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100" 1200 - > 1201 - <svg 1202 - xmlns="http://www.w3.org/2000/svg" 1203 - viewBox="0 0 20 20" 1204 - fill="currentColor" 1205 - class="size-3.5" 1206 - > 1207 - <path 1208 - d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 1209 - /> 1210 - </svg> 1211 - </Button> 1212 - </div> 1213 - {/each} 1214 - </div> 1215 - 1216 - <div class="mt-3"> 1217 - <PopoverRoot bind:open={showLinkPopup}> 1218 - <PopoverTrigger> 1219 - <Button size="sm"> 1220 - <svg 1221 - xmlns="http://www.w3.org/2000/svg" 1222 - fill="none" 1223 - viewBox="0 0 24 24" 1224 - stroke-width="1.5" 1225 - stroke="currentColor" 1226 - class="size-4" 1227 - > 1228 - <path 1229 - stroke-linecap="round" 1230 - stroke-linejoin="round" 1231 - d="M12 4.5v15m7.5-7.5h-15" 1232 - /> 1233 - </svg> 1234 - 1235 - Add link 1236 - </Button> 1237 - </PopoverTrigger> 1238 - <PopoverContent side="bottom" sideOffset={8} class="w-64 p-3"> 1239 - <Input 1240 - type="url" 1241 - bind:value={newLinkUri} 1242 - placeholder="https://..." 1243 - variant="secondary" 1244 - class="mb-2" 1245 - onkeydown={(e) => { 1246 - if (e.key === 'Enter') { 1247 - e.preventDefault(); 1248 - addLink(); 1249 - } 1250 - }} 1251 - /> 1252 - <Input 1253 - type="text" 1254 - bind:value={newLinkName} 1255 - placeholder="Label (optional)" 1256 - variant="secondary" 1257 - class="mb-2" 1258 - onkeydown={(e) => { 1259 - if (e.key === 'Enter') { 1260 - e.preventDefault(); 1261 - addLink(); 1262 - } 1263 - }} 1264 - /> 1265 - {#if linkError} 1266 - <p class="mb-2 text-xs text-red-500">{linkError}</p> 1267 - {/if} 1268 - <div class="flex justify-end gap-2"> 1269 - <Button 1270 - variant="ghost" 1271 - size="sm" 1272 - onclick={() => { 1273 - showLinkPopup = false; 1274 - linkError = ''; 1275 - newLinkUri = ''; 1276 - newLinkName = ''; 1277 - }} 1278 - > 1279 - Cancel 1280 - </Button> 1281 - <Button onclick={addLink} size="sm" disabled={!newLinkUri.trim()}>Add</Button> 1282 - </div> 1283 - </PopoverContent> 1284 - </PopoverRoot> 1285 - </div> 572 + <div class="order-4 space-y-6 md:order-0 md:col-start-1"> 573 + <LinksSection bind:links /> 574 + <ThemeSection bind:theme={eventTheme} /> 1286 575 </div> 1287 576 </div> 1288 577 ··· 1315 604 </div> 1316 605 </div> 1317 606 1318 - <!-- Theme modal --> 1319 - <Modal bind:open={showThemeModal}> 1320 - <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Event theme</p> 1321 - <div class="mt-4"> 1322 - <ThemePicker bind:theme={eventTheme} /> 1323 - </div> 1324 - </Modal> 1325 - 1326 - <!-- Thumbnail modal --> 1327 - <Modal bind:open={showThumbnailModal}> 1328 - <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Choose thumbnail</p> 1329 - <div class="mt-4 flex max-h-[70vh] flex-col gap-6 overflow-y-auto"> 1330 - <Button 1331 - variant="secondary" 1332 - class="w-full" 1333 - onclick={() => fileInput?.click()} 1334 - > 1335 - <svg 1336 - xmlns="http://www.w3.org/2000/svg" 1337 - fill="none" 1338 - viewBox="0 0 24 24" 1339 - stroke-width="1.5" 1340 - stroke="currentColor" 1341 - class="size-4" 1342 - > 1343 - <path 1344 - stroke-linecap="round" 1345 - stroke-linejoin="round" 1346 - d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" 1347 - /> 1348 - </svg> 1349 - Upload own thumbnail 1350 - </Button> 1351 - <ThumbnailPresets 1352 - name={name} 1353 - dateStr={thumbnailDateStr} 1354 - bind:selected={selectedPreset} 1355 - onselect={() => { showThumbnailModal = false; thumbnailPreview = null; thumbnailFile = null; thumbnailChanged = true; }} 1356 - /> 1357 - </div> 1358 - </Modal> 1359 - 1360 - <!-- Location modal --> 1361 - <Modal bind:open={showLocationModal}> 1362 - <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Add location</p> 1363 - <form 1364 - onsubmit={(e) => { 1365 - e.preventDefault(); 1366 - searchLocation(); 1367 - }} 1368 - class="mt-2" 1369 - > 1370 - <div class="flex gap-2"> 1371 - <Input type="text" class="flex-1" bind:value={locationSearch} /> 1372 - <Button type="submit" disabled={locationSearching || !locationSearch.trim()}> 1373 - {locationSearching ? 'Searching...' : 'Search'} 1374 - </Button> 1375 - </div> 1376 - </form> 1377 - 1378 - {#if locationError} 1379 - <p class="mt-3 text-sm text-red-600 dark:text-red-400">{locationError}</p> 1380 - {/if} 1381 - 1382 - {#if locationResult} 1383 - <div 1384 - class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 mt-4 overflow-hidden rounded-xl border p-4" 1385 - > 1386 - <div class="flex items-start gap-3"> 1387 - <svg 1388 - xmlns="http://www.w3.org/2000/svg" 1389 - fill="none" 1390 - viewBox="0 0 24 24" 1391 - stroke-width="1.5" 1392 - stroke="currentColor" 1393 - class="text-base-500 mt-0.5 size-5 shrink-0" 1394 - > 1395 - <path 1396 - stroke-linecap="round" 1397 - stroke-linejoin="round" 1398 - d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 1399 - /> 1400 - <path 1401 - stroke-linecap="round" 1402 - stroke-linejoin="round" 1403 - d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" 1404 - /> 1405 - </svg> 1406 - <div class="min-w-0 flex-1"> 1407 - <p class="text-base-900 dark:text-base-50 font-medium"> 1408 - {getLocationDisplayString(locationResult.location)} 1409 - </p> 1410 - <p class="text-base-500 dark:text-base-400 mt-0.5 truncate text-xs"> 1411 - {locationResult.displayName} 1412 - </p> 1413 - </div> 1414 - </div> 1415 - <div class="mt-4 flex justify-end"> 1416 - <Button onclick={confirmLocation}>Use this location</Button> 1417 - </div> 1418 - </div> 1419 - {/if} 1420 - 1421 - <p class="text-base-400 dark:text-base-500 mt-4 text-xs"> 1422 - Geocoding by <a 1423 - href="https://nominatim.openstreetmap.org/" 1424 - class="hover:text-base-600 dark:hover:text-base-400 underline" 1425 - target="_blank">Nominatim</a 1426 - > 1427 - / &copy; 1428 - <a 1429 - href="https://www.openstreetmap.org/copyright" 1430 - class="hover:text-base-600 dark:hover:text-base-400 underline" 1431 - target="_blank">OpenStreetMap contributors</a 1432 - > 1433 - </p> 1434 - </Modal> 1435 - 1436 - <Modal bind:open={showRecurringModal}> 1437 - <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Add recurring events</p> 1438 - <p class="text-base-500 dark:text-base-400 mt-1 text-sm"> 1439 - Create multiple copies of this event at regular intervals. 1440 - </p> 1441 - 1442 - <div class="mt-4 space-y-4"> 1443 - <div> 1444 - <!-- svelte-ignore a11y_label_has_associated_control --> 1445 - <label class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"> 1446 - Number of events to create 1447 - </label> 1448 - <Input type="number" bind:value={recurringCount} min={1} max={52} class="w-24" /> 1449 - </div> 1450 - 1451 - <div> 1452 - <!-- svelte-ignore a11y_label_has_associated_control --> 1453 - <label class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"> 1454 - Repeat every 1455 - </label> 1456 - <div class="flex items-center gap-2"> 1457 - <Input type="number" bind:value={recurringInterval} min={1} max={99} class="w-20" /> 1458 - <ToggleGroup type="single" bind:value={recurringUnit}> 1459 - <ToggleGroupItem value="days">days</ToggleGroupItem> 1460 - <ToggleGroupItem value="weeks">weeks</ToggleGroupItem> 1461 - <ToggleGroupItem value="months">months</ToggleGroupItem> 1462 - <ToggleGroupItem value="years">years</ToggleGroupItem> 1463 - </ToggleGroup> 1464 - </div> 1465 - </div> 1466 - 1467 - <div> 1468 - <div class="flex items-center gap-2"> 1469 - <Checkbox bind:checked={recurringNumberInTitle} sizeVariant="sm" /> 1470 - <span class="text-base-700 dark:text-base-300 text-sm font-medium">Number in title</span> 1471 - </div> 1472 - <p class="text-base-500 dark:text-base-400 mt-1 text-xs"> 1473 - {#if recurringNumberInTitle && detectedStartNumber !== null} 1474 - Titles will count up from #{detectedStartNumber + 1} 1475 - {:else if recurringNumberInTitle} 1476 - A number will be appended to each title 1477 - {:else} 1478 - Append a number to each event title 1479 - {/if} 1480 - </p> 1481 - </div> 1482 - </div> 1483 - 1484 - {#if recurringError} 1485 - <p class="mt-4 text-sm text-red-600 dark:text-red-400">{recurringError}</p> 1486 - {/if} 1487 - 1488 - {#if recurringCreating && recurringCreated > 0} 1489 - <p class="text-base-500 dark:text-base-400 mt-4 text-sm"> 1490 - Created {recurringCreated} of {recurringCount} events... 1491 - </p> 1492 - {/if} 1493 - 1494 - {#if recurringCreated > 0 && !recurringCreating} 1495 - <p class="mt-4 text-sm text-green-600 dark:text-green-400"> 1496 - Successfully created {recurringCreated} recurring events! 1497 - </p> 1498 - {/if} 1499 - 1500 - <div class="mt-4 flex justify-end gap-2"> 1501 - <Button 1502 - variant="secondary" 1503 - onclick={() => (showRecurringModal = false)} 1504 - disabled={recurringCreating} 1505 - > 1506 - {recurringCreated > 0 && !recurringCreating ? 'Close' : 'Cancel'} 1507 - </Button> 1508 - {#if !recurringCreated || recurringCreating} 1509 - <Button 1510 - onclick={handleCreateRecurring} 1511 - disabled={recurringCreating || recurringCount < 1} 1512 - > 1513 - {recurringCreating ? `Creating...` : `Create ${recurringCount} event${recurringCount === 1 ? '' : 's'}`} 1514 - </Button> 1515 - {/if} 1516 - </div> 1517 - </Modal> 607 + <RecurringModal 608 + bind:open={showRecurringModal} 609 + {rkey} 610 + {eventData} 611 + {isNew} 612 + {name} 613 + {startsAt} 614 + {endsAt} 615 + {mode} 616 + {timezone} 617 + {description} 618 + {links} 619 + {location} 620 + {thumbnailDateStr} 621 + {thumbnailFile} 622 + {thumbnailChanged} 623 + {selectedPreset} 624 + />
+50 -22
src/lib/components/EventRsvp.svelte
··· 11 11 eventCid, 12 12 initialRsvpStatus = null, 13 13 initialRsvpRkey = null, 14 + spaceUri = null, 14 15 onrsvp, 15 16 oncancel, 16 17 onlogin ··· 19 20 eventCid: string | null; 20 21 initialRsvpStatus?: 'going' | 'interested' | 'notgoing' | null; 21 22 initialRsvpRkey?: string | null; 23 + /** If set, RSVPs write into this space instead of the user's public PDS. */ 24 + spaceUri?: string | null; 22 25 onrsvp?: (status: 'going' | 'interested', rkey: string) => void; 23 26 oncancel?: () => void; 24 27 onlogin?: () => void; ··· 38 41 rsvpSubmitting = true; 39 42 try { 40 43 const key = rsvpRkey ?? createTID(); 44 + const record = { 45 + $type: 'community.lexicon.calendar.rsvp', 46 + createdWith: 'https://atmo.rsvp', 47 + status: `community.lexicon.calendar.rsvp#${status}`, 48 + subject: { 49 + uri: eventUri, 50 + ...(eventCid ? { cid: eventCid } : {}) 51 + }, 52 + createdAt: new Date().toISOString() 53 + }; 41 54 42 - const response = await putRecord({ 43 - collection: 'community.lexicon.calendar.rsvp', 44 - rkey: key, 45 - record: { 46 - $type: 'community.lexicon.calendar.rsvp', 47 - createdWith: 'https://atmo.rsvp', 48 - status: `community.lexicon.calendar.rsvp#${status}`, 49 - subject: { 50 - uri: eventUri, 51 - ...(eventCid ? { cid: eventCid } : {}) 52 - }, 53 - createdAt: new Date().toISOString() 55 + let ok = false; 56 + if (spaceUri) { 57 + const { putSpaceRecord } = await import('$lib/spaces/server/spaces.remote'); 58 + const result = await putSpaceRecord({ 59 + spaceUri, 60 + collection: 'community.lexicon.calendar.rsvp', 61 + rkey: key, 62 + record 63 + }); 64 + ok = !!result; 65 + } else { 66 + const response = await putRecord({ 67 + collection: 'community.lexicon.calendar.rsvp', 68 + rkey: key, 69 + record 70 + }); 71 + ok = response.ok; 72 + if (ok) { 73 + notifyContrailOfUpdate(`at://${user.did}/community.lexicon.calendar.rsvp/${key}`); 54 74 } 55 - }); 75 + } 56 76 57 - if (response.ok) { 58 - const rsvpUri = `at://${user.did}/community.lexicon.calendar.rsvp/${key}`; 59 - notifyContrailOfUpdate(rsvpUri); 77 + if (ok) { 60 78 rsvpStatusOverride = status; 61 79 rsvpRkeyOverride = key; 62 80 launchConfetti(); ··· 73 91 if (!user.isLoggedIn || !user.did || !rsvpRkey) return; 74 92 rsvpSubmitting = true; 75 93 try { 76 - const rsvpUri = `at://${user.did}/community.lexicon.calendar.rsvp/${rsvpRkey}`; 77 - await deleteRecord({ 78 - collection: 'community.lexicon.calendar.rsvp', 79 - rkey: rsvpRkey 80 - }); 81 - notifyContrailOfUpdate(rsvpUri); 94 + if (spaceUri) { 95 + const { deleteSpaceRecord } = await import('$lib/spaces/server/spaces.remote'); 96 + await deleteSpaceRecord({ 97 + spaceUri, 98 + collection: 'community.lexicon.calendar.rsvp', 99 + rkey: rsvpRkey 100 + }); 101 + } else { 102 + await deleteRecord({ 103 + collection: 'community.lexicon.calendar.rsvp', 104 + rkey: rsvpRkey 105 + }); 106 + notifyContrailOfUpdate( 107 + `at://${user.did}/community.lexicon.calendar.rsvp/${rsvpRkey}` 108 + ); 109 + } 82 110 rsvpStatusOverride = null; 83 111 rsvpRkeyOverride = null; 84 112 oncancel?.();
+316
src/lib/components/EventView.svelte
··· 1 + <script lang="ts"> 2 + import { eventUrl, isEventOngoing, type FlatEventRecord } from '$lib/contrail'; 3 + import { getCDNImageBlobUrl } from '$lib/atproto'; 4 + import { user } from '$lib/atproto/auth.svelte'; 5 + import { Avatar as FoxAvatar, Button } from '@foxui/core'; 6 + import ShareModal from '$lib/components/ShareModal.svelte'; 7 + import Avatar from 'svelte-boring-avatars'; 8 + import EventRsvp from '$lib/components/EventRsvp.svelte'; 9 + import EventCard from '$lib/components/EventCard.svelte'; 10 + import EventAttendees from './EventAttendees.svelte'; 11 + import VodPlayer, { type VodPlayerApi } from '$lib/components/VodPlayer.svelte'; 12 + import { page } from '$app/state'; 13 + import { launchConfetti } from '@foxui/visual'; 14 + import ThemeBackground from '$lib/components/ThemeBackground.svelte'; 15 + import ThemeApply from '$lib/components/ThemeApply.svelte'; 16 + import { defaultTheme, type EventTheme } from '$lib/theme'; 17 + import { onMount } from 'svelte'; 18 + 19 + import EventBadges from './event-view/EventBadges.svelte'; 20 + import EventDateBlock from './event-view/EventDateBlock.svelte'; 21 + import EventLocationBlock from './event-view/EventLocationBlock.svelte'; 22 + import EventLocationMap from './event-view/EventLocationMap.svelte'; 23 + import EventHostedBy from './event-view/EventHostedBy.svelte'; 24 + import EventLinksList from './event-view/EventLinksList.svelte'; 25 + import AddToCalendarButton from './event-view/AddToCalendarButton.svelte'; 26 + import InviteShareFlow from './event-view/InviteShareFlow.svelte'; 27 + import { buildDescriptionHtml, getLocationData, resolveGeoLocation } from './event-view/format'; 28 + 29 + let { data } = $props(); 30 + 31 + let eventData: FlatEventRecord = $derived(data.eventData); 32 + let did: string = $derived(data.actorDid); 33 + let rkey: string = $derived(data.rkey); 34 + let hostProfile = $derived(data.hostProfile); 35 + let attendees = $derived(data.attendees); 36 + 37 + let theme: EventTheme = $derived(eventData.theme ?? defaultTheme); 38 + 39 + let hostUrl = $derived(`/p/${hostProfile?.handle || did}`); 40 + let eventPath = $derived(eventUrl(eventData, hostProfile?.handle || did)); 41 + let shareUrl = $derived( 42 + typeof window !== 'undefined' ? `${window.location.origin}${eventPath}` : eventPath 43 + ); 44 + 45 + // Times are always rendered in the viewer's local timezone — the stored UTC 46 + // instant is what the Date constructor parses, and toLocaleString/Time uses 47 + // the browser's zone by default. 48 + let startDate = $derived(new Date(eventData.startsAt)); 49 + let endDate = $derived(eventData.endsAt ? new Date(eventData.endsAt) : null); 50 + 51 + let locationData = $derived(getLocationData(eventData.locations)); 52 + let geoLocation: { lat: number; lng: number } | null = $state(null); 53 + 54 + let showShareModal = $state(false); 55 + let shareModalTitle = $state('Event created!'); 56 + let shareModalText: string | undefined = $state(undefined); 57 + 58 + onMount(async () => { 59 + geoLocation = await resolveGeoLocation(eventData.locations, locationData); 60 + 61 + const url = new URL(window.location.href); 62 + if (url.searchParams.has('created')) { 63 + url.searchParams.delete('created'); 64 + history.replaceState({}, '', url.pathname); 65 + launchConfetti(); 66 + shareModalTitle = 'Event created!'; 67 + shareModalText = `I'm hosting "${eventData.name}"!\n\n${shareUrl}`; 68 + showShareModal = true; 69 + } 70 + }); 71 + 72 + let thumbnailImage = $derived.by(() => { 73 + if (!eventData.media || eventData.media.length === 0) return null; 74 + const media = eventData.media.find((m) => m.role === 'thumbnail'); 75 + if (!media?.content) return null; 76 + const url = getCDNImageBlobUrl({ did, blob: media.content }); 77 + if (!url) return null; 78 + return { url, alt: media.alt || eventData.name }; 79 + }); 80 + 81 + let bannerImage = $derived.by(() => { 82 + if (!eventData.media || eventData.media.length === 0) return null; 83 + const media = eventData.media.find((m) => m.role === 'header'); 84 + if (!media?.content) return null; 85 + const url = getCDNImageBlobUrl({ did, blob: media.content }); 86 + if (!url) return null; 87 + return { url, alt: media.alt || eventData.name }; 88 + }); 89 + 90 + // Prefer thumbnail; fall back to header/banner image 91 + let displayImage = $derived(thumbnailImage ?? bannerImage); 92 + let isBannerOnly = $derived(!thumbnailImage && !!bannerImage); 93 + 94 + let isOngoing = $derived(isEventOngoing(eventData.startsAt, eventData.endsAt)); 95 + let isPast = $derived(endDate ? endDate < new Date() : false); 96 + 97 + let descriptionHtml = $derived( 98 + buildDescriptionHtml(eventData.description, eventData.facets) 99 + ); 100 + 101 + let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`); 102 + 103 + let ogImageUrl = $derived(`${page.url.origin}${page.url.pathname}/og.png`); 104 + 105 + let isOwner = $derived(user.isLoggedIn && user.did === did); 106 + 107 + let speakers = $derived(data.speakerProfiles ?? []); 108 + 109 + let vodCurrentTime = $state(0); 110 + let vodApi: VodPlayerApi | undefined = $state(); 111 + 112 + let attendeesRef: EventAttendees | undefined = $state(); 113 + 114 + function handleRsvp(status: 'going' | 'interested') { 115 + if (!user.did) return; 116 + attendeesRef?.addAttendee({ 117 + did: user.did, 118 + status, 119 + avatar: user.profile?.avatar, 120 + name: user.profile?.displayName || user.profile?.handle || user.did, 121 + handle: user.profile?.handle, 122 + url: `/${user.profile?.handle || user.did}` 123 + }); 124 + if (status === 'interested') return; 125 + shareModalTitle = "You're going!"; 126 + shareModalText = `I'm going to "${eventData.name}".\n\n${shareUrl}`; 127 + showShareModal = true; 128 + } 129 + 130 + function handleRsvpCancel() { 131 + if (!user.did) return; 132 + attendeesRef?.removeAttendee(user.did); 133 + } 134 + </script> 135 + 136 + <svelte:head> 137 + <title>{eventData.name}</title> 138 + <meta name="description" content={eventData.description || `Event: ${eventData.name}`} /> 139 + <meta property="og:title" content={eventData.name} /> 140 + <meta property="og:description" content={eventData.description || `Event: ${eventData.name}`} /> 141 + <meta name="twitter:card" content="summary_large_image" /> 142 + <meta name="twitter:title" content={eventData.name} /> 143 + <meta name="twitter:description" content={eventData.description || `Event: ${eventData.name}`} /> 144 + <meta name="twitter:image" content={ogImageUrl} /> 145 + </svelte:head> 146 + 147 + <ThemeApply accentColor={theme.accentColor} baseColor={theme.baseColor} /> 148 + <ThemeBackground {theme} /> 149 + 150 + <div class="min-h-screen px-6 py-12 sm:py-12"> 151 + <div class="mx-auto max-w-3xl"> 152 + <!-- Banner image (full width, only when no thumbnail) --> 153 + {#if isBannerOnly && displayImage} 154 + <img 155 + src={displayImage.url} 156 + alt={displayImage.alt} 157 + class="border-base-200 dark:border-base-800 mb-8 aspect-3/1 w-full rounded-2xl border object-cover" 158 + /> 159 + {/if} 160 + 161 + <!-- Two-column layout: image left, details right --> 162 + <div 163 + class="grid grid-cols-1 gap-8 md:grid-cols-[14rem_1fr] md:grid-rows-[auto_1fr] md:gap-x-10 md:gap-y-6 lg:grid-cols-[16rem_1fr]" 164 + > 165 + <!-- Thumbnail image (left column) --> 166 + {#if !isBannerOnly} 167 + <div class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none"> 168 + {#if displayImage} 169 + <img 170 + src={displayImage.url} 171 + alt={displayImage.alt} 172 + class="border-base-200 dark:border-base-800 bg-base-200 dark:bg-base-950/50 aspect-square w-full rounded-2xl border object-cover" 173 + /> 174 + {:else} 175 + <div 176 + class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border [&>svg]:h-full [&>svg]:w-full" 177 + > 178 + <Avatar 179 + size={256} 180 + name={data.rkey} 181 + variant="marble" 182 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 183 + square 184 + /> 185 + </div> 186 + {/if} 187 + {#if isOwner} 188 + <Button href="./{rkey}/edit" class="mt-9 w-full">Edit Event</Button> 189 + {#if data.spaceUri} 190 + <InviteShareFlow 191 + spaceUri={data.spaceUri} 192 + spaceKey={data.spaceKey} 193 + {did} 194 + {rkey} 195 + eventName={eventData.name} 196 + {hostProfile} 197 + /> 198 + {/if} 199 + {/if} 200 + </div> 201 + {/if} 202 + 203 + <!-- Right column: event details --> 204 + <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-2 md:row-start-1"> 205 + <div class="mb-2"> 206 + <h1 class="text-base-900 dark:text-base-50 text-3xl leading-tight font-bold sm:text-4xl"> 207 + {eventData.name} 208 + </h1> 209 + </div> 210 + 211 + <EventBadges mode={eventData.mode} {isOngoing} /> 212 + 213 + <EventDateBlock {startDate} {endDate} /> 214 + 215 + <EventLocationBlock {locationData} /> 216 + 217 + <!-- Part of --> 218 + {#if data.parentEvent} 219 + <div 220 + class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-950/50 mt-8 mb-2 justify-center rounded-2xl border p-4" 221 + > 222 + <p 223 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 224 + > 225 + Part of 226 + </p> 227 + <EventCard event={data.parentEvent} actor="atprotocol.dev" /> 228 + <Button href="/p/atmosphereconf.org" size="lg" class="mt-6 w-full"> 229 + See full schedule 230 + </Button> 231 + </div> 232 + {/if} 233 + 234 + {#if did === 'did:plc:lehcqqkwzcwvjvw66uthu5oq' && rkey === '3lte3c7x43l2e'} 235 + <Button href="/p/atmosphereconf.org" size="lg" class="mb-4 w-full"> 236 + See full schedule 237 + </Button> 238 + {/if} 239 + 240 + {#if !isPast} 241 + <EventRsvp 242 + {eventUri} 243 + eventCid={eventData.cid ?? null} 244 + initialRsvpStatus={data.viewerRsvpStatus} 245 + initialRsvpRkey={data.viewerRsvpRkey} 246 + spaceUri={data.spaceUri ?? null} 247 + onrsvp={handleRsvp} 248 + oncancel={handleRsvpCancel} 249 + /> 250 + {/if} 251 + 252 + <!-- About Event --> 253 + {#if descriptionHtml} 254 + <div class="mt-8 mb-8"> 255 + <p 256 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 257 + > 258 + About 259 + </p> 260 + <div 261 + class="text-base-700 dark:text-base-300 prose dark:prose-invert prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-a:hover:underline prose-a:no-underline max-w-none leading-relaxed wrap-break-word" 262 + > 263 + {@html descriptionHtml} 264 + </div> 265 + </div> 266 + {/if} 267 + 268 + <!-- Recording --> 269 + {#if data.vod} 270 + <div class="mt-8 mb-8"> 271 + <p 272 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 273 + > 274 + Recording 275 + </p> 276 + <VodPlayer 277 + playlistUrl={data.vod.playlistUrl} 278 + title={eventData.name} 279 + subtitlesUrl="/vods/{rkey}-karaoke.vtt" 280 + bind:currentTime={vodCurrentTime} 281 + bind:api={vodApi} 282 + /> 283 + </div> 284 + {/if} 285 + 286 + <EventLocationMap {locationData} {geoLocation} /> 287 + </div> 288 + 289 + <!-- Left column: sidebar info --> 290 + <div class="order-3 space-y-6 md:order-0 md:col-start-1"> 291 + <EventHostedBy {hostProfile} {hostUrl} {did} {speakers} /> 292 + 293 + <EventLinksList uris={eventData.uris} /> 294 + 295 + <AddToCalendarButton {eventData} {eventUri} pageHref={page.url.href} /> 296 + 297 + <EventAttendees 298 + bind:this={attendeesRef} 299 + going={attendees.going} 300 + interested={attendees.interested} 301 + goingCount={attendees.goingCount} 302 + interestedCount={attendees.interestedCount} 303 + /> 304 + </div> 305 + </div> 306 + </div> 307 + </div> 308 + 309 + <ShareModal 310 + bind:open={showShareModal} 311 + url={shareUrl} 312 + title={shareModalTitle} 313 + shareText={shareModalText} 314 + eventName={eventData.name} 315 + {ogImageUrl} 316 + />
+144
src/lib/components/editor/LinksSection.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, PopoverContent, PopoverRoot, PopoverTrigger } from '@foxui/core'; 3 + import { validateLink } from '$lib/cal/helper'; 4 + 5 + type Link = { uri: string; name: string }; 6 + 7 + let { links = $bindable() }: { links: Link[] } = $props(); 8 + 9 + let showPopup = $state(false); 10 + let newUri = $state(''); 11 + let newName = $state(''); 12 + let error = $state(''); 13 + 14 + function addLink() { 15 + const raw = newUri.trim(); 16 + if (!raw) return; 17 + const uri = validateLink(raw); 18 + if (!uri) { 19 + error = 'Please enter a valid URL'; 20 + return; 21 + } 22 + links.push({ uri, name: newName.trim() }); 23 + newUri = ''; 24 + newName = ''; 25 + error = ''; 26 + showPopup = false; 27 + } 28 + 29 + function removeLink(index: number) { 30 + links.splice(index, 1); 31 + } 32 + 33 + function cancel() { 34 + showPopup = false; 35 + error = ''; 36 + newUri = ''; 37 + newName = ''; 38 + } 39 + </script> 40 + 41 + <div> 42 + <p class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase"> 43 + Links 44 + </p> 45 + <div class="space-y-3"> 46 + {#each links as link, i (i)} 47 + <div class="group flex items-center gap-1.5"> 48 + <svg 49 + xmlns="http://www.w3.org/2000/svg" 50 + fill="none" 51 + viewBox="0 0 24 24" 52 + stroke-width="1.5" 53 + stroke="currentColor" 54 + class="text-base-700 dark:text-base-300 size-3.5 shrink-0" 55 + > 56 + <path 57 + stroke-linecap="round" 58 + stroke-linejoin="round" 59 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 60 + /> 61 + </svg> 62 + <span class="text-base-700 dark:text-base-300 truncate text-sm"> 63 + {link.name || link.uri.replace(/^https?:\/\//, '')} 64 + </span> 65 + <Button 66 + variant="ghost" 67 + size="iconSm" 68 + onclick={() => removeLink(i)} 69 + class="ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100" 70 + > 71 + <svg 72 + xmlns="http://www.w3.org/2000/svg" 73 + viewBox="0 0 20 20" 74 + fill="currentColor" 75 + class="size-3.5" 76 + > 77 + <path 78 + d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 79 + /> 80 + </svg> 81 + </Button> 82 + </div> 83 + {/each} 84 + </div> 85 + 86 + <div class="mt-3"> 87 + <PopoverRoot bind:open={showPopup}> 88 + <PopoverTrigger> 89 + <Button size="sm"> 90 + <svg 91 + xmlns="http://www.w3.org/2000/svg" 92 + fill="none" 93 + viewBox="0 0 24 24" 94 + stroke-width="1.5" 95 + stroke="currentColor" 96 + class="size-4" 97 + > 98 + <path 99 + stroke-linecap="round" 100 + stroke-linejoin="round" 101 + d="M12 4.5v15m7.5-7.5h-15" 102 + /> 103 + </svg> 104 + Add link 105 + </Button> 106 + </PopoverTrigger> 107 + <PopoverContent side="bottom" sideOffset={8} class="w-64 p-3"> 108 + <Input 109 + type="url" 110 + bind:value={newUri} 111 + placeholder="https://..." 112 + variant="secondary" 113 + class="mb-2" 114 + onkeydown={(e) => { 115 + if (e.key === 'Enter') { 116 + e.preventDefault(); 117 + addLink(); 118 + } 119 + }} 120 + /> 121 + <Input 122 + type="text" 123 + bind:value={newName} 124 + placeholder="Label (optional)" 125 + variant="secondary" 126 + class="mb-2" 127 + onkeydown={(e) => { 128 + if (e.key === 'Enter') { 129 + e.preventDefault(); 130 + addLink(); 131 + } 132 + }} 133 + /> 134 + {#if error} 135 + <p class="mb-2 text-xs text-red-500">{error}</p> 136 + {/if} 137 + <div class="flex justify-end gap-2"> 138 + <Button variant="ghost" size="sm" onclick={cancel}>Cancel</Button> 139 + <Button onclick={addLink} size="sm" disabled={!newUri.trim()}>Add</Button> 140 + </div> 141 + </PopoverContent> 142 + </PopoverRoot> 143 + </div> 144 + </div>
+215
src/lib/components/editor/LocationSection.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal } from '@foxui/core'; 3 + import { getLocationDisplayString, type EventLocation } from './types'; 4 + 5 + let { 6 + location = $bindable(), 7 + locationChanged = $bindable() 8 + }: { 9 + location: EventLocation | null; 10 + locationChanged: boolean; 11 + } = $props(); 12 + 13 + let showModal = $state(false); 14 + let searchText = $state(''); 15 + let searching = $state(false); 16 + let error = $state(''); 17 + let result: { displayName: string; location: EventLocation } | null = $state(null); 18 + 19 + async function search() { 20 + const q = searchText.trim(); 21 + if (!q) return; 22 + error = ''; 23 + searching = true; 24 + result = null; 25 + 26 + try { 27 + const response = await fetch('/api/geocoding?q=' + encodeURIComponent(q)); 28 + if (!response.ok) throw new Error('response not ok'); 29 + const data: Record<string, unknown> = await response.json(); 30 + if (!data || data.error) throw new Error('no results'); 31 + 32 + const addr = (data.address || {}) as Record<string, string>; 33 + const road = addr.road || ''; 34 + const houseNumber = addr.house_number || ''; 35 + const street = road ? (houseNumber ? `${road} ${houseNumber}` : road) : ''; 36 + const locality = 37 + addr.city || addr.town || addr.village || addr.municipality || addr.hamlet || ''; 38 + const region = addr.state || addr.county || ''; 39 + const country = addr.country || ''; 40 + 41 + result = { 42 + displayName: (data.display_name as string) || q, 43 + location: { 44 + ...(street && { street }), 45 + ...(locality && { locality }), 46 + ...(region && { region }), 47 + ...(country && { country }) 48 + } 49 + }; 50 + } catch { 51 + error = "Couldn't find that location."; 52 + } finally { 53 + searching = false; 54 + } 55 + } 56 + 57 + function confirm() { 58 + if (result) { 59 + location = result.location; 60 + locationChanged = true; 61 + } 62 + showModal = false; 63 + searchText = ''; 64 + result = null; 65 + error = ''; 66 + } 67 + 68 + function remove() { 69 + location = null; 70 + locationChanged = true; 71 + } 72 + </script> 73 + 74 + {#if location} 75 + <div class="mb-6 flex items-center gap-4"> 76 + <div 77 + class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border" 78 + > 79 + <svg 80 + xmlns="http://www.w3.org/2000/svg" 81 + fill="none" 82 + viewBox="0 0 24 24" 83 + stroke-width="1.5" 84 + stroke="currentColor" 85 + class="text-base-900 dark:text-base-200 size-5" 86 + > 87 + <path 88 + stroke-linecap="round" 89 + stroke-linejoin="round" 90 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 91 + /> 92 + <path 93 + stroke-linecap="round" 94 + stroke-linejoin="round" 95 + d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" 96 + /> 97 + </svg> 98 + </div> 99 + <p class="text-base-900 dark:text-base-50 flex-1 font-semibold"> 100 + {getLocationDisplayString(location)} 101 + </p> 102 + <Button variant="ghost" size="iconSm" onclick={remove} class="shrink-0"> 103 + <svg 104 + xmlns="http://www.w3.org/2000/svg" 105 + viewBox="0 0 20 20" 106 + fill="currentColor" 107 + class="size-3.5" 108 + > 109 + <path 110 + d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 111 + /> 112 + </svg> 113 + </Button> 114 + </div> 115 + {:else} 116 + <div class="mb-6"> 117 + <Button variant="secondary" onclick={() => (showModal = true)}> 118 + <svg 119 + xmlns="http://www.w3.org/2000/svg" 120 + fill="none" 121 + viewBox="0 0 24 24" 122 + stroke-width="1.5" 123 + stroke="currentColor" 124 + class="size-4" 125 + > 126 + <path 127 + stroke-linecap="round" 128 + stroke-linejoin="round" 129 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 130 + /> 131 + <path 132 + stroke-linecap="round" 133 + stroke-linejoin="round" 134 + d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" 135 + /> 136 + </svg> 137 + Add location 138 + </Button> 139 + </div> 140 + {/if} 141 + 142 + <Modal bind:open={showModal}> 143 + <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Add location</p> 144 + <form 145 + onsubmit={(e) => { 146 + e.preventDefault(); 147 + search(); 148 + }} 149 + class="mt-2" 150 + > 151 + <div class="flex gap-2"> 152 + <Input type="text" class="flex-1" bind:value={searchText} /> 153 + <Button type="submit" disabled={searching || !searchText.trim()}> 154 + {searching ? 'Searching...' : 'Search'} 155 + </Button> 156 + </div> 157 + </form> 158 + 159 + {#if error} 160 + <p class="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p> 161 + {/if} 162 + 163 + {#if result} 164 + <div 165 + class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 mt-4 overflow-hidden rounded-xl border p-4" 166 + > 167 + <div class="flex items-start gap-3"> 168 + <svg 169 + xmlns="http://www.w3.org/2000/svg" 170 + fill="none" 171 + viewBox="0 0 24 24" 172 + stroke-width="1.5" 173 + stroke="currentColor" 174 + class="text-base-500 mt-0.5 size-5 shrink-0" 175 + > 176 + <path 177 + stroke-linecap="round" 178 + stroke-linejoin="round" 179 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 180 + /> 181 + <path 182 + stroke-linecap="round" 183 + stroke-linejoin="round" 184 + d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" 185 + /> 186 + </svg> 187 + <div class="min-w-0 flex-1"> 188 + <p class="text-base-900 dark:text-base-50 font-medium"> 189 + {getLocationDisplayString(result.location)} 190 + </p> 191 + <p class="text-base-500 dark:text-base-400 mt-0.5 truncate text-xs"> 192 + {result.displayName} 193 + </p> 194 + </div> 195 + </div> 196 + <div class="mt-4 flex justify-end"> 197 + <Button onclick={confirm}>Use this location</Button> 198 + </div> 199 + </div> 200 + {/if} 201 + 202 + <p class="text-base-400 dark:text-base-500 mt-4 text-xs"> 203 + Geocoding by <a 204 + href="https://nominatim.openstreetmap.org/" 205 + class="hover:text-base-600 dark:hover:text-base-400 underline" 206 + target="_blank">Nominatim</a 207 + > 208 + / &copy; 209 + <a 210 + href="https://www.openstreetmap.org/copyright" 211 + class="hover:text-base-600 dark:hover:text-base-400 underline" 212 + target="_blank">OpenStreetMap contributors</a 213 + > 214 + </p> 215 + </Modal>
+264
src/lib/components/editor/RecurringModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Checkbox, Input, Modal, ToggleGroup, ToggleGroupItem } from '@foxui/core'; 3 + import { parseDateTime } from '@internationalized/date'; 4 + import * as TID from '@atcute/tid'; 5 + import { putRecord } from '$lib/atproto/methods'; 6 + import { notifyContrailOfUpdate } from '$lib/contrail'; 7 + import { user } from '$lib/atproto/auth.svelte'; 8 + import type { FlatEventRecord } from '$lib/contrail'; 9 + import type { EventLocation, EventMode } from './types'; 10 + import { buildThumbnailMedia, renderPresetThumbnail } from './save'; 11 + 12 + let { 13 + open = $bindable(), 14 + rkey, 15 + eventData, 16 + isNew, 17 + name, 18 + startsAt, 19 + endsAt, 20 + mode, 21 + timezone, 22 + description, 23 + links, 24 + location, 25 + thumbnailDateStr, 26 + thumbnailFile, 27 + thumbnailChanged, 28 + selectedPreset 29 + }: { 30 + open: boolean; 31 + rkey: string; 32 + eventData: FlatEventRecord | null; 33 + isNew: boolean; 34 + name: string; 35 + startsAt: string; 36 + endsAt: string; 37 + mode: EventMode; 38 + timezone: string; 39 + description: string; 40 + links: Array<{ uri: string; name: string }>; 41 + location: EventLocation | null; 42 + thumbnailDateStr: string; 43 + thumbnailFile: File | null; 44 + thumbnailChanged: boolean; 45 + selectedPreset: { design: string; seed: number } | null; 46 + } = $props(); 47 + 48 + let interval = $state(1); 49 + let unit: 'days' | 'weeks' | 'months' | 'years' = $state('weeks'); 50 + let count = $state(4); 51 + let numberInTitle = $state(false); 52 + let creating = $state(false); 53 + let errorMsg: string | null = $state(null); 54 + let created = $state(0); 55 + 56 + let titleNumberMatch = $derived(name.match(/#?(\d+)\s*$/)); 57 + let detectedStartNumber = $derived(titleNumberMatch ? parseInt(titleNumberMatch[1]) : null); 58 + 59 + $effect(() => { 60 + if (detectedStartNumber !== null) numberInTitle = true; 61 + }); 62 + 63 + async function handleCreate() { 64 + if (!name.trim() || !startsAt || !user.isLoggedIn || !user.did) return; 65 + 66 + creating = true; 67 + errorMsg = null; 68 + created = 0; 69 + 70 + try { 71 + // Recurring instances advance by wall-clock duration (e.g. "every week 72 + // at 10am"), so operate on CalendarDateTime — not absolute instants — 73 + // to preserve the wall time across DST transitions. 74 + const baseStart = parseDateTime(startsAt); 75 + const baseEnd = endsAt ? parseDateTime(endsAt) : null; 76 + const durationMs = baseEnd 77 + ? baseEnd.toDate(timezone).getTime() - baseStart.toDate(timezone).getTime() 78 + : 0; 79 + const baseName = 80 + numberInTitle && titleNumberMatch 81 + ? name.replace(/#?\d+\s*$/, '').trimEnd() 82 + : name.trim(); 83 + const startNum = detectedStartNumber ?? 1; 84 + const hasHash = titleNumberMatch ? titleNumberMatch[0].includes('#') : false; 85 + 86 + // Generate thumbnail from preset if selected and no custom upload. 87 + let fileForUpload = thumbnailFile; 88 + let hasNewThumbnail = thumbnailChanged; 89 + if (selectedPreset && !fileForUpload) { 90 + const rendered = await renderPresetThumbnail({ 91 + design: selectedPreset.design, 92 + seed: selectedPreset.seed, 93 + name, 94 + dateStr: thumbnailDateStr 95 + }); 96 + if (rendered) { 97 + fileForUpload = rendered; 98 + hasNewThumbnail = true; 99 + } 100 + } 101 + 102 + const existingMedia = (eventData?.media ?? []) as Array<Record<string, unknown>>; 103 + const media = await buildThumbnailMedia({ 104 + isNew, 105 + thumbnailChanged: hasNewThumbnail, 106 + thumbnailFile: fileForUpload, 107 + existingMedia 108 + }); 109 + 110 + const parentUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 111 + 112 + for (let i = 0; i < count; i++) { 113 + const offset = i + 1; 114 + const step = offset * interval; 115 + const eventStart = 116 + unit === 'days' 117 + ? baseStart.add({ days: step }) 118 + : unit === 'weeks' 119 + ? baseStart.add({ weeks: step }) 120 + : unit === 'months' 121 + ? baseStart.add({ months: step }) 122 + : baseStart.add({ years: step }); 123 + 124 + const eventStartIso = eventStart.toDate(timezone).toISOString(); 125 + // Preserve the original absolute duration (handles events that 126 + // span midnight or odd wall-clock lengths correctly). 127 + const eventEndIso = durationMs 128 + ? new Date(eventStart.toDate(timezone).getTime() + durationMs).toISOString() 129 + : null; 130 + 131 + let eventName = baseName; 132 + if (numberInTitle) { 133 + const num = startNum + (i + 1); 134 + eventName = hasHash ? `${baseName} #${num}` : `${baseName} ${num}`; 135 + } 136 + 137 + const newRkey = TID.now(); 138 + const record: Record<string, unknown> = { 139 + $type: 'community.lexicon.calendar.event', 140 + createdWith: 'https://atmo.rsvp', 141 + name: eventName, 142 + mode: `community.lexicon.calendar.event#${mode}`, 143 + status: 'community.lexicon.calendar.event#scheduled', 144 + startsAt: eventStartIso, 145 + timezone, 146 + createdAt: new Date().toISOString(), 147 + recurringEventOf: parentUri 148 + }; 149 + 150 + const trimmedDescription = description.trim(); 151 + if (trimmedDescription) record.description = trimmedDescription; 152 + if (eventEndIso) record.endsAt = eventEndIso; 153 + if (media) record.media = media; 154 + if (links.length > 0) record.uris = links; 155 + if (location) { 156 + record.locations = [ 157 + { 158 + $type: 'community.lexicon.location.address', 159 + ...location 160 + } 161 + ]; 162 + } 163 + 164 + const response = await putRecord({ 165 + collection: 'community.lexicon.calendar.event', 166 + rkey: newRkey, 167 + record 168 + }); 169 + 170 + if (response.ok) { 171 + const eventUri = `at://${user.did}/community.lexicon.calendar.event/${newRkey}`; 172 + await notifyContrailOfUpdate(eventUri); 173 + created = i + 1; 174 + } else { 175 + errorMsg = `Failed to create event ${i + 1}. Stopping.`; 176 + return; 177 + } 178 + } 179 + 180 + open = false; 181 + } catch (e) { 182 + console.error('Failed to create recurring events:', e); 183 + errorMsg = 'Failed to create recurring events. Please try again.'; 184 + } finally { 185 + creating = false; 186 + } 187 + } 188 + </script> 189 + 190 + <Modal bind:open> 191 + <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Add recurring events</p> 192 + <p class="text-base-500 dark:text-base-400 mt-1 text-sm"> 193 + Create multiple copies of this event at regular intervals. 194 + </p> 195 + 196 + <div class="mt-4 space-y-4"> 197 + <div> 198 + <!-- svelte-ignore a11y_label_has_associated_control --> 199 + <label class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"> 200 + Number of events to create 201 + </label> 202 + <Input type="number" bind:value={count} min={1} max={52} class="w-24" /> 203 + </div> 204 + 205 + <div> 206 + <!-- svelte-ignore a11y_label_has_associated_control --> 207 + <label class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"> 208 + Repeat every 209 + </label> 210 + <div class="flex items-center gap-2"> 211 + <Input type="number" bind:value={interval} min={1} max={99} class="w-20" /> 212 + <ToggleGroup type="single" bind:value={unit}> 213 + <ToggleGroupItem value="days">days</ToggleGroupItem> 214 + <ToggleGroupItem value="weeks">weeks</ToggleGroupItem> 215 + <ToggleGroupItem value="months">months</ToggleGroupItem> 216 + <ToggleGroupItem value="years">years</ToggleGroupItem> 217 + </ToggleGroup> 218 + </div> 219 + </div> 220 + 221 + <div> 222 + <div class="flex items-center gap-2"> 223 + <Checkbox bind:checked={numberInTitle} sizeVariant="sm" /> 224 + <span class="text-base-700 dark:text-base-300 text-sm font-medium">Number in title</span> 225 + </div> 226 + <p class="text-base-500 dark:text-base-400 mt-1 text-xs"> 227 + {#if numberInTitle && detectedStartNumber !== null} 228 + Titles will count up from #{detectedStartNumber + 1} 229 + {:else if numberInTitle} 230 + A number will be appended to each title 231 + {:else} 232 + Append a number to each event title 233 + {/if} 234 + </p> 235 + </div> 236 + </div> 237 + 238 + {#if errorMsg} 239 + <p class="mt-4 text-sm text-red-600 dark:text-red-400">{errorMsg}</p> 240 + {/if} 241 + 242 + {#if creating && created > 0} 243 + <p class="text-base-500 dark:text-base-400 mt-4 text-sm"> 244 + Created {created} of {count} events... 245 + </p> 246 + {/if} 247 + 248 + {#if created > 0 && !creating} 249 + <p class="mt-4 text-sm text-green-600 dark:text-green-400"> 250 + Successfully created {created} recurring events! 251 + </p> 252 + {/if} 253 + 254 + <div class="mt-4 flex justify-end gap-2"> 255 + <Button variant="secondary" onclick={() => (open = false)} disabled={creating}> 256 + {created > 0 && !creating ? 'Close' : 'Cancel'} 257 + </Button> 258 + {#if !created || creating} 259 + <Button onclick={handleCreate} disabled={creating || count < 1}> 260 + {creating ? `Creating...` : `Create ${count} event${count === 1 ? '' : 's'}`} 261 + </Button> 262 + {/if} 263 + </div> 264 + </Modal>
+39
src/lib/components/editor/ThemeSection.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Modal } from '@foxui/core'; 3 + import ThemePicker from '$lib/components/ThemePicker.svelte'; 4 + import { themeBackgrounds, type EventTheme } from '$lib/theme'; 5 + 6 + let { theme = $bindable() }: { theme: EventTheme } = $props(); 7 + 8 + let showModal = $state(false); 9 + </script> 10 + 11 + <div> 12 + <p class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"> 13 + Theme 14 + </p> 15 + <Button variant="secondary" size="sm" onclick={() => (showModal = true)}> 16 + <svg 17 + xmlns="http://www.w3.org/2000/svg" 18 + fill="none" 19 + viewBox="0 0 24 24" 20 + stroke-width="1.5" 21 + stroke="currentColor" 22 + class="size-4" 23 + > 24 + <path 25 + stroke-linecap="round" 26 + stroke-linejoin="round" 27 + d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" 28 + /> 29 + </svg> 30 + {themeBackgrounds[theme.name] || theme.name} 31 + </Button> 32 + </div> 33 + 34 + <Modal bind:open={showModal}> 35 + <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Event theme</p> 36 + <div class="mt-4"> 37 + <ThemePicker bind:theme /> 38 + </div> 39 + </Modal>
+223
src/lib/components/editor/ThumbnailSection.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Modal } from '@foxui/core'; 3 + import Avatar from 'svelte-boring-avatars'; 4 + import ThumbnailPresets from '$lib/components/ThumbnailPresets.svelte'; 5 + import { designs } from '$lib/components/thumbnails/designs'; 6 + import { deleteImage, putImage } from '$lib/components/image-store'; 7 + 8 + let { 9 + rkey, 10 + name, 11 + dateStr, 12 + thumbnailFile = $bindable(), 13 + thumbnailPreview = $bindable(), 14 + thumbnailKey = $bindable(), 15 + thumbnailChanged = $bindable(), 16 + selectedPreset = $bindable() 17 + }: { 18 + rkey: string; 19 + name: string; 20 + dateStr: string; 21 + thumbnailFile: File | null; 22 + thumbnailPreview: string | null; 23 + thumbnailKey: string | null; 24 + thumbnailChanged: boolean; 25 + selectedPreset: { design: string; seed: number } | null; 26 + } = $props(); 27 + 28 + let fileInput: HTMLInputElement | undefined = $state(); 29 + let presetPreviewCanvas: HTMLCanvasElement | undefined = $state(); 30 + let showModal = $state(false); 31 + let isDragOver = $state(false); 32 + 33 + async function setThumbnail(file: File) { 34 + thumbnailFile = file; 35 + thumbnailChanged = true; 36 + selectedPreset = null; 37 + if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 38 + thumbnailPreview = URL.createObjectURL(file); 39 + 40 + if (thumbnailKey) await deleteImage(thumbnailKey); 41 + thumbnailKey = crypto.randomUUID(); 42 + await putImage(thumbnailKey, file, file.name); 43 + } 44 + 45 + function onFileChange(e: Event) { 46 + const input = e.target as HTMLInputElement; 47 + const file = input.files?.[0]; 48 + if (!file) return; 49 + setThumbnail(file); 50 + showModal = false; 51 + } 52 + 53 + function onDragOver(e: DragEvent) { 54 + e.preventDefault(); 55 + isDragOver = true; 56 + } 57 + 58 + function onDragLeave(e: DragEvent) { 59 + e.preventDefault(); 60 + isDragOver = false; 61 + } 62 + 63 + function onDrop(e: DragEvent) { 64 + e.preventDefault(); 65 + isDragOver = false; 66 + const file = e.dataTransfer?.files?.[0]; 67 + if (file?.type.startsWith('image/')) { 68 + setThumbnail(file); 69 + } 70 + } 71 + 72 + function removeThumbnail() { 73 + thumbnailFile = null; 74 + thumbnailChanged = true; 75 + selectedPreset = null; 76 + if (thumbnailPreview) { 77 + URL.revokeObjectURL(thumbnailPreview); 78 + thumbnailPreview = null; 79 + } 80 + if (thumbnailKey) { 81 + deleteImage(thumbnailKey); 82 + thumbnailKey = null; 83 + } 84 + if (fileInput) fileInput.value = ''; 85 + } 86 + 87 + // Render preset preview canvas whenever the selection, name, or date changes. 88 + $effect(() => { 89 + if (selectedPreset && presetPreviewCanvas && designs[selectedPreset.design]) { 90 + const ctx = presetPreviewCanvas.getContext('2d'); 91 + if (!ctx) return; 92 + presetPreviewCanvas.width = 800; 93 + presetPreviewCanvas.height = 800; 94 + designs[selectedPreset.design]( 95 + ctx, 96 + 800, 97 + 800, 98 + name || 'Event', 99 + dateStr, 100 + selectedPreset.seed 101 + ); 102 + } 103 + }); 104 + </script> 105 + 106 + <!-- svelte-ignore a11y_no_static_element_interactions --> 107 + <div 108 + class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none" 109 + ondragover={onDragOver} 110 + ondragleave={onDragLeave} 111 + ondrop={onDrop} 112 + > 113 + <input 114 + bind:this={fileInput} 115 + type="file" 116 + accept="image/*" 117 + onchange={onFileChange} 118 + class="hidden" 119 + /> 120 + <div class="group relative"> 121 + {#if thumbnailPreview} 122 + <img 123 + src={thumbnailPreview} 124 + alt="Thumbnail preview" 125 + class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 126 + /> 127 + {:else if selectedPreset && designs[selectedPreset.design]} 128 + <div 129 + class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border" 130 + > 131 + <canvas bind:this={presetPreviewCanvas} class="h-full w-full"></canvas> 132 + </div> 133 + {:else} 134 + <div 135 + class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full" 136 + > 137 + <Avatar 138 + size={400} 139 + name={rkey} 140 + variant="marble" 141 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 142 + square 143 + /> 144 + </div> 145 + {/if} 146 + <button 147 + type="button" 148 + onclick={() => (showModal = true)} 149 + class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-2xl bg-black/0 text-white/0 transition-colors group-hover:bg-black/40 group-hover:text-white/90 {isDragOver 150 + ? 'bg-black/40 text-white/90' 151 + : ''}" 152 + > 153 + <svg 154 + xmlns="http://www.w3.org/2000/svg" 155 + fill="none" 156 + viewBox="0 0 24 24" 157 + stroke-width="1.5" 158 + stroke="currentColor" 159 + class="size-6" 160 + > 161 + <path 162 + stroke-linecap="round" 163 + stroke-linejoin="round" 164 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" 165 + /> 166 + </svg> 167 + <span class="text-sm font-medium">Change thumbnail</span> 168 + </button> 169 + {#if thumbnailPreview || selectedPreset} 170 + <Button 171 + variant="ghost" 172 + size="iconSm" 173 + onclick={removeThumbnail} 174 + class="bg-base-900/70 absolute top-2 right-2 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600" 175 + > 176 + <svg 177 + xmlns="http://www.w3.org/2000/svg" 178 + viewBox="0 0 20 20" 179 + fill="currentColor" 180 + class="size-3.5" 181 + > 182 + <path 183 + d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 184 + /> 185 + </svg> 186 + </Button> 187 + {/if} 188 + </div> 189 + </div> 190 + 191 + <Modal bind:open={showModal}> 192 + <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Choose thumbnail</p> 193 + <div class="mt-4 flex max-h-[70vh] flex-col gap-6 overflow-y-auto"> 194 + <Button variant="secondary" class="w-full" onclick={() => fileInput?.click()}> 195 + <svg 196 + xmlns="http://www.w3.org/2000/svg" 197 + fill="none" 198 + viewBox="0 0 24 24" 199 + stroke-width="1.5" 200 + stroke="currentColor" 201 + class="size-4" 202 + > 203 + <path 204 + stroke-linecap="round" 205 + stroke-linejoin="round" 206 + d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" 207 + /> 208 + </svg> 209 + Upload own thumbnail 210 + </Button> 211 + <ThumbnailPresets 212 + {name} 213 + {dateStr} 214 + bind:selected={selectedPreset} 215 + onselect={() => { 216 + showModal = false; 217 + thumbnailPreview = null; 218 + thumbnailFile = null; 219 + thumbnailChanged = true; 220 + }} 221 + /> 222 + </div> 223 + </Modal>
+36
src/lib/components/editor/draft.ts
··· 1 + import type { EventDraft } from './types'; 2 + 3 + const OLD_DRAFT_KEY = 'blento-event-draft'; 4 + 5 + export function draftKeyFor(rkey: string): string { 6 + return `blento-event-edit-${rkey}`; 7 + } 8 + 9 + /** Promote any pre-existing shared "new event" draft into a per-rkey draft. */ 10 + export function migrateLegacyDraft(rkey: string): void { 11 + const key = draftKeyFor(rkey); 12 + const old = localStorage.getItem(OLD_DRAFT_KEY); 13 + if (old && !localStorage.getItem(key)) { 14 + localStorage.setItem(key, old); 15 + localStorage.removeItem(OLD_DRAFT_KEY); 16 + } 17 + } 18 + 19 + export function readDraft(rkey: string): EventDraft | null { 20 + const saved = localStorage.getItem(draftKeyFor(rkey)); 21 + if (!saved) return null; 22 + try { 23 + return JSON.parse(saved) as EventDraft; 24 + } catch { 25 + localStorage.removeItem(draftKeyFor(rkey)); 26 + return null; 27 + } 28 + } 29 + 30 + export function writeDraft(rkey: string, draft: EventDraft): void { 31 + localStorage.setItem(draftKeyFor(rkey), JSON.stringify(draft)); 32 + } 33 + 34 + export function clearDraft(rkey: string): void { 35 + localStorage.removeItem(draftKeyFor(rkey)); 36 + }
+209
src/lib/components/editor/save.ts
··· 1 + import { uploadBlob, resolveHandle } from '$lib/atproto/methods'; 2 + import { compressImage } from '$lib/atproto/image-helper'; 3 + import { tokenize, type Token } from '@atcute/bluesky-richtext-parser'; 4 + import type { Handle } from '@atcute/lexicons'; 5 + import { datetimeLocalToISO } from '$lib/date-format'; 6 + import { designs } from '$lib/components/thumbnails/designs'; 7 + import type { FlatEventRecord } from '$lib/contrail'; 8 + import type { EventTheme } from '$lib/theme'; 9 + import type { EventLocation, EventMode, Visibility } from './types'; 10 + 11 + export async function tokensToFacets(tokens: Token[]): Promise<Record<string, unknown>[]> { 12 + const encoder = new TextEncoder(); 13 + const facets: Record<string, unknown>[] = []; 14 + let byteOffset = 0; 15 + 16 + for (const token of tokens) { 17 + const tokenBytes = encoder.encode(token.raw); 18 + const byteStart = byteOffset; 19 + const byteEnd = byteOffset + tokenBytes.length; 20 + 21 + if (token.type === 'mention') { 22 + try { 23 + const did = await resolveHandle({ handle: token.handle as Handle }); 24 + if (did) { 25 + facets.push({ 26 + index: { byteStart, byteEnd }, 27 + features: [{ $type: 'app.bsky.richtext.facet#mention', did }] 28 + }); 29 + } 30 + } catch { 31 + // skip unresolvable mentions 32 + } 33 + } else if (token.type === 'autolink') { 34 + facets.push({ 35 + index: { byteStart, byteEnd }, 36 + features: [{ $type: 'app.bsky.richtext.facet#link', uri: token.url }] 37 + }); 38 + } else if (token.type === 'topic') { 39 + facets.push({ 40 + index: { byteStart, byteEnd }, 41 + features: [{ $type: 'app.bsky.richtext.facet#tag', tag: token.name }] 42 + }); 43 + } 44 + 45 + byteOffset = byteEnd; 46 + } 47 + 48 + return facets; 49 + } 50 + 51 + /** Render a selected thumbnail preset to a PNG File so it can be uploaded 52 + * as a blob like a user-provided image. Returns null if the preset design 53 + * is missing or the canvas fails to produce a blob. */ 54 + export async function renderPresetThumbnail(args: { 55 + design: string; 56 + seed: number; 57 + name: string; 58 + dateStr: string; 59 + }): Promise<File | null> { 60 + const drawer = designs[args.design]; 61 + if (!drawer) return null; 62 + const canvas = document.createElement('canvas'); 63 + canvas.width = 800; 64 + canvas.height = 800; 65 + const ctx = canvas.getContext('2d'); 66 + if (!ctx) return null; 67 + drawer(ctx, 800, 800, args.name.trim() || 'Event', args.dateStr, args.seed); 68 + const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png')); 69 + if (!blob) return null; 70 + return new File([blob], 'thumbnail.png', { type: 'image/png' }); 71 + } 72 + 73 + export async function buildThumbnailMedia(args: { 74 + isNew: boolean; 75 + thumbnailChanged: boolean; 76 + thumbnailFile: File | null; 77 + existingMedia: Array<Record<string, unknown>>; 78 + }): Promise<Array<Record<string, unknown>> | undefined> { 79 + const { isNew, thumbnailChanged, thumbnailFile, existingMedia } = args; 80 + 81 + if (!isNew && !thumbnailChanged) { 82 + return existingMedia.length > 0 ? existingMedia : undefined; 83 + } 84 + 85 + if (!thumbnailFile) { 86 + const remaining = existingMedia.filter((m) => m.role !== 'thumbnail'); 87 + return remaining.length > 0 ? remaining : undefined; 88 + } 89 + 90 + const compressed = await compressImage(thumbnailFile); 91 + const result = await uploadBlob({ blob: compressed.blob }); 92 + if (!result) return existingMedia.length > 0 ? existingMedia : undefined; 93 + 94 + const { aspectRatio: _ar, ...blobRef } = result as Record<string, unknown> & { 95 + aspectRatio?: unknown; 96 + }; 97 + return [ 98 + ...existingMedia.filter((m) => m.role !== 'thumbnail'), 99 + { 100 + role: 'thumbnail', 101 + content: blobRef, 102 + aspect_ratio: { 103 + width: compressed.aspectRatio.width, 104 + height: compressed.aspectRatio.height 105 + } 106 + } 107 + ]; 108 + } 109 + 110 + export async function buildEventRecord(args: { 111 + eventData: FlatEventRecord | null; 112 + isNew: boolean; 113 + name: string; 114 + description: string; 115 + startsAt: string; 116 + endsAt: string; 117 + timezone: string; 118 + mode: EventMode; 119 + visibility: Visibility; 120 + theme: EventTheme; 121 + links: Array<{ uri: string; name: string }>; 122 + location: EventLocation | null; 123 + locationChanged: boolean; 124 + media: Array<Record<string, unknown>> | undefined; 125 + }): Promise<Record<string, unknown>> { 126 + const { 127 + eventData, 128 + isNew, 129 + name, 130 + description, 131 + startsAt, 132 + endsAt, 133 + timezone, 134 + mode, 135 + visibility, 136 + theme, 137 + links, 138 + location, 139 + locationChanged, 140 + media 141 + } = args; 142 + 143 + const createdAt = isNew 144 + ? new Date().toISOString() 145 + : eventData?.createdAt || new Date().toISOString(); 146 + 147 + // Spread original record to preserve unspecced fields (e.g. additionalData) 148 + const record: Record<string, unknown> = { 149 + ...(eventData ? { ...eventData } : {}), 150 + $type: 'community.lexicon.calendar.event', 151 + createdWith: 'https://atmo.rsvp', 152 + name: name.trim(), 153 + mode: `community.lexicon.calendar.event#${mode}`, 154 + status: 'community.lexicon.calendar.event#scheduled', 155 + startsAt: datetimeLocalToISO(startsAt, timezone), 156 + timezone, 157 + createdAt, 158 + theme 159 + }; 160 + // Remove flattened fields that aren't part of the actual record 161 + for (const k of [ 162 + 'cid', 163 + 'did', 164 + 'rkey', 165 + 'uri', 166 + 'rsvps', 167 + 'rsvpsCount', 168 + 'rsvpsGoingCount', 169 + 'rsvpsInterestedCount', 170 + 'rsvpsNotgoingCount' 171 + ]) { 172 + delete record[k]; 173 + } 174 + 175 + const trimmedDescription = description.trim(); 176 + if (trimmedDescription) { 177 + record.description = trimmedDescription; 178 + const tokens = tokenize(trimmedDescription); 179 + const facets = await tokensToFacets(tokens); 180 + if (facets.length > 0) record.facets = facets; 181 + } 182 + 183 + if (endsAt) record.endsAt = datetimeLocalToISO(endsAt, timezone); 184 + if (media) record.media = media; 185 + if (links.length > 0) record.uris = links; 186 + 187 + if (isNew || locationChanged) { 188 + if (location) { 189 + record.locations = [ 190 + { 191 + $type: 'community.lexicon.location.address', 192 + ...location 193 + } 194 + ]; 195 + } 196 + // If changed/new but no location, locations stays undefined (removed/absent) 197 + } else if (eventData?.locations && eventData.locations.length > 0) { 198 + record.locations = eventData.locations; 199 + } 200 + 201 + const existingPrefs = ((record.preferences as Record<string, unknown> | undefined) ?? 202 + {}) as Record<string, unknown>; 203 + record.preferences = { 204 + ...existingPrefs, 205 + showInDiscovery: visibility !== 'unlisted' 206 + }; 207 + 208 + return record; 209 + }
+37
src/lib/components/editor/types.ts
··· 1 + import type { EventTheme } from '$lib/theme'; 2 + 3 + export type EventMode = 'inperson' | 'virtual' | 'hybrid'; 4 + export type Visibility = 'public' | 'private' | 'unlisted'; 5 + 6 + export interface EventLocation { 7 + street?: string; 8 + locality?: string; 9 + region?: string; 10 + country?: string; 11 + } 12 + 13 + export interface EventDraft { 14 + name: string; 15 + description: string; 16 + startsAt: string; 17 + endsAt: string; 18 + timezone?: string; 19 + theme?: EventTheme; 20 + links: Array<{ uri: string; name: string }>; 21 + mode?: EventMode; 22 + visibility?: Visibility; 23 + thumbnailKey?: string; 24 + thumbnailChanged?: boolean; 25 + location?: EventLocation | null; 26 + locationChanged?: boolean; 27 + } 28 + 29 + export function stripModePrefix(modeStr: string): EventMode { 30 + const stripped = modeStr.replace('community.lexicon.calendar.event#', ''); 31 + if (stripped === 'virtual' || stripped === 'hybrid' || stripped === 'inperson') return stripped; 32 + return 'inperson'; 33 + } 34 + 35 + export function getLocationDisplayString(loc: EventLocation): string { 36 + return [loc.street, loc.locality, loc.region, loc.country].filter(Boolean).join(', '); 37 + }
+42
src/lib/components/event-view/AddToCalendarButton.svelte
··· 1 + <script lang="ts"> 2 + import { generateICalEvent } from '$lib/cal/ical'; 3 + import type { FlatEventRecord } from '$lib/contrail'; 4 + 5 + let { 6 + eventData, 7 + eventUri, 8 + pageHref 9 + }: { eventData: FlatEventRecord; eventUri: string; pageHref: string } = $props(); 10 + 11 + function downloadIcs() { 12 + const ical = generateICalEvent(eventData, eventUri, pageHref); 13 + const blob = new Blob([ical], { type: 'text/calendar;charset=utf-8' }); 14 + const url = URL.createObjectURL(blob); 15 + const a = document.createElement('a'); 16 + a.href = url; 17 + a.download = `${eventData.name.replace(/[^a-zA-Z0-9]/g, '-')}.ics`; 18 + a.click(); 19 + URL.revokeObjectURL(url); 20 + } 21 + </script> 22 + 23 + <button 24 + onclick={downloadIcs} 25 + class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex cursor-pointer items-center gap-2 text-sm font-medium transition-colors" 26 + > 27 + <svg 28 + xmlns="http://www.w3.org/2000/svg" 29 + fill="none" 30 + viewBox="0 0 24 24" 31 + stroke-width="1.5" 32 + stroke="currentColor" 33 + class="size-4" 34 + > 35 + <path 36 + stroke-linecap="round" 37 + stroke-linejoin="round" 38 + d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" 39 + /> 40 + </svg> 41 + Add to Calendar 42 + </button>
+20
src/lib/components/event-view/EventBadges.svelte
··· 1 + <script lang="ts"> 2 + import { Badge } from '@foxui/core'; 3 + import { getModeColor, getModeLabel } from './format'; 4 + 5 + let { mode, isOngoing }: { mode?: string; isOngoing: boolean } = $props(); 6 + </script> 7 + 8 + {#if mode || isOngoing} 9 + <div class="mb-8 flex items-center gap-2"> 10 + {#if isOngoing} 11 + <Badge size="md" variant="primary"> 12 + <span class="bg-accent-500 mr-1 inline-block size-1.5 animate-pulse rounded-full"></span> 13 + Live 14 + </Badge> 15 + {/if} 16 + {#if mode} 17 + <Badge size="md" variant={getModeColor(mode)}>{getModeLabel(mode)}</Badge> 18 + {/if} 19 + </div> 20 + {/if}
+39
src/lib/components/event-view/EventDateBlock.svelte
··· 1 + <script lang="ts"> 2 + import { formatDay, formatFullDate, formatMonth, formatTime, formatWeekday } from './format'; 3 + 4 + let { startDate, endDate }: { startDate: Date; endDate: Date | null } = $props(); 5 + 6 + let isSameDay = $derived( 7 + endDate && 8 + startDate.getFullYear() === endDate.getFullYear() && 9 + startDate.getMonth() === endDate.getMonth() && 10 + startDate.getDate() === endDate.getDate() 11 + ); 12 + </script> 13 + 14 + <div class="mb-4 flex items-center gap-4"> 15 + <div 16 + class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 17 + > 18 + <span class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold"> 19 + {formatMonth(startDate)} 20 + </span> 21 + <span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold"> 22 + {formatDay(startDate)} 23 + </span> 24 + </div> 25 + <div> 26 + <p class="text-base-900 dark:text-base-50 font-semibold"> 27 + {formatWeekday(startDate)}, {formatFullDate(startDate)} 28 + {#if endDate && !isSameDay} 29 + - {formatWeekday(endDate)}, {formatFullDate(endDate)} 30 + {/if} 31 + </p> 32 + <p class="text-base-500 dark:text-base-400 text-sm"> 33 + {formatTime(startDate)} 34 + {#if endDate && isSameDay} 35 + - {formatTime(endDate)} 36 + {/if} 37 + </p> 38 + </div> 39 + </div>
+63
src/lib/components/event-view/EventHostedBy.svelte
··· 1 + <script lang="ts"> 2 + import { Avatar as FoxAvatar } from '@foxui/core'; 3 + import type { HostProfile } from '$lib/contrail'; 4 + 5 + type Speaker = { id?: string; handle?: string; name: string; avatar?: string }; 6 + 7 + let { 8 + hostProfile, 9 + hostUrl, 10 + did, 11 + speakers = [] 12 + }: { 13 + hostProfile: HostProfile | null | undefined; 14 + hostUrl: string; 15 + did: string; 16 + speakers?: Speaker[]; 17 + } = $props(); 18 + </script> 19 + 20 + <div> 21 + <p class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"> 22 + Hosted By 23 + </p> 24 + <a 25 + href={hostUrl} 26 + class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium transition-opacity hover:opacity-80" 27 + > 28 + <FoxAvatar 29 + src={hostProfile?.avatar} 30 + alt={hostProfile?.displayName || hostProfile?.handle || did} 31 + class="size-8 shrink-0" 32 + /> 33 + <span class="truncate text-sm"> 34 + {hostProfile?.displayName || hostProfile?.handle || did} 35 + </span> 36 + </a> 37 + </div> 38 + 39 + {#if speakers.length > 0} 40 + <div> 41 + <p class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"> 42 + Speakers 43 + </p> 44 + <div class="space-y-2"> 45 + {#each speakers as speaker, i (speaker.id || i)} 46 + {#if speaker.handle} 47 + <a 48 + href="/p/{speaker.handle}" 49 + class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium transition-opacity hover:opacity-80" 50 + > 51 + <FoxAvatar src={speaker.avatar} alt={speaker.name} class="size-8 shrink-0" /> 52 + <span class="truncate text-sm">{speaker.name}</span> 53 + </a> 54 + {:else} 55 + <div class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium"> 56 + <FoxAvatar alt={speaker.name} class="size-8 shrink-0" /> 57 + <span class="truncate text-sm">{speaker.name}</span> 58 + </div> 59 + {/if} 60 + {/each} 61 + </div> 62 + </div> 63 + {/if}
+37
src/lib/components/event-view/EventLinksList.svelte
··· 1 + <script lang="ts"> 2 + let { uris = [] }: { uris?: Array<{ uri: string; name?: string }> } = $props(); 3 + </script> 4 + 5 + {#if uris.length > 0} 6 + <div> 7 + <p class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase"> 8 + Links 9 + </p> 10 + <div class="space-y-3"> 11 + {#each uris as link (link.name + link.uri)} 12 + <a 13 + href={link.uri} 14 + target="_blank" 15 + rel="noopener noreferrer" 16 + class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex items-center gap-1.5 text-sm transition-colors" 17 + > 18 + <svg 19 + xmlns="http://www.w3.org/2000/svg" 20 + fill="none" 21 + viewBox="0 0 24 24" 22 + stroke-width="1.5" 23 + stroke="currentColor" 24 + class="size-3.5 shrink-0" 25 + > 26 + <path 27 + stroke-linecap="round" 28 + stroke-linejoin="round" 29 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 30 + /> 31 + </svg> 32 + <span class="truncate">{link.name || link.uri.replace(/^https?:\/\//, '')}</span> 33 + </a> 34 + {/each} 35 + </div> 36 + </div> 37 + {/if}
+48
src/lib/components/event-view/EventLocationBlock.svelte
··· 1 + <script lang="ts"> 2 + import type { LocationData } from './format'; 3 + 4 + let { locationData }: { locationData: LocationData | null } = $props(); 5 + </script> 6 + 7 + {#if locationData} 8 + <a 9 + href={locationData.mapsUrl} 10 + target="_blank" 11 + rel="noopener noreferrer" 12 + class="mb-6 flex items-center gap-4 transition-opacity hover:opacity-80" 13 + > 14 + <div 15 + class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border" 16 + > 17 + <svg 18 + xmlns="http://www.w3.org/2000/svg" 19 + fill="none" 20 + viewBox="0 0 24 24" 21 + stroke-width="1.5" 22 + stroke="currentColor" 23 + class="text-base-900 dark:text-base-200 size-5" 24 + > 25 + <path 26 + stroke-linecap="round" 27 + stroke-linejoin="round" 28 + d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 29 + /> 30 + <path 31 + stroke-linecap="round" 32 + stroke-linejoin="round" 33 + d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" 34 + /> 35 + </svg> 36 + </div> 37 + <div> 38 + {#if locationData.name} 39 + <p class="text-base-900 dark:text-base-50 font-semibold">{locationData.name}</p> 40 + <p class="text-base-500 dark:text-base-400 text-sm">{locationData.shortAddress}</p> 41 + {:else} 42 + <p class="text-base-900 dark:text-base-50 font-semibold"> 43 + {locationData.shortAddress} 44 + </p> 45 + {/if} 46 + </div> 47 + </a> 48 + {/if}
+35
src/lib/components/event-view/EventLocationMap.svelte
··· 1 + <script lang="ts"> 2 + import Map from '$lib/components/Map.svelte'; 3 + import type { LocationData } from './format'; 4 + 5 + let { 6 + locationData, 7 + geoLocation 8 + }: { 9 + locationData: LocationData | null; 10 + geoLocation: { lat: number; lng: number } | null; 11 + } = $props(); 12 + </script> 13 + 14 + {#if geoLocation && locationData} 15 + <div class="mt-8 mb-8"> 16 + <p 17 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 18 + > 19 + Location 20 + </p> 21 + <a 22 + href={locationData.mapsUrl} 23 + target="_blank" 24 + rel="noopener noreferrer" 25 + class="block transition-opacity hover:opacity-80" 26 + > 27 + <div class="h-64 w-full overflow-hidden rounded-xl"> 28 + <Map lat={geoLocation.lat} lng={geoLocation.lng} /> 29 + </div> 30 + <p class="text-base-500 dark:text-base-400 mt-2 text-sm"> 31 + {locationData.fullString} 32 + </p> 33 + </a> 34 + </div> 35 + {/if}
+168
src/lib/components/event-view/InviteShareFlow.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Checkbox, Input, Label, Modal } from '@foxui/core'; 3 + import DateTimePicker from '$lib/components/DateTimePicker.svelte'; 4 + import ShareModal from '$lib/components/ShareModal.svelte'; 5 + import { datetimeLocalToISO } from '$lib/date-format'; 6 + import type { HostProfile } from '$lib/contrail'; 7 + 8 + let { 9 + spaceUri, 10 + spaceKey, 11 + did, 12 + rkey, 13 + eventName, 14 + hostProfile 15 + }: { 16 + spaceUri: string; 17 + spaceKey: string; 18 + did: string; 19 + rkey: string; 20 + eventName: string; 21 + hostProfile: HostProfile | null | undefined; 22 + } = $props(); 23 + 24 + let inviteUrl: string | null = $state(null); 25 + let inviteBusy = $state(false); 26 + let inviteError: string | null = $state(null); 27 + let showInviteModal = $state(false); 28 + 29 + // Invite-options dialog (shown before the share modal) — owner picks 30 + // whether to allow anonymous reads, max uses, and expiry. 31 + let showInviteForm = $state(false); 32 + let inviteAllowAnonRead = $state(true); 33 + let inviteMaxUsesText = $state(''); 34 + let inviteHasExpiry = $state(false); 35 + let inviteExpiresAt = $state(''); 36 + const inviteTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 37 + 38 + function openInviteForm() { 39 + inviteError = null; 40 + inviteAllowAnonRead = true; 41 + inviteMaxUsesText = ''; 42 + inviteHasExpiry = false; 43 + showInviteForm = true; 44 + } 45 + 46 + async function submitInviteForm() { 47 + if (inviteBusy) return; 48 + inviteBusy = true; 49 + inviteError = null; 50 + try { 51 + let maxUses: number | undefined; 52 + if (inviteMaxUsesText.trim()) { 53 + const n = Number(inviteMaxUsesText); 54 + if (!Number.isInteger(n) || n < 1) { 55 + throw new Error('Max uses must be a positive integer.'); 56 + } 57 + maxUses = n; 58 + } 59 + 60 + let expiresAt: number | undefined; 61 + if (inviteHasExpiry && inviteExpiresAt.trim()) { 62 + const iso = datetimeLocalToISO(inviteExpiresAt, inviteTimezone); 63 + const ts = new Date(iso).getTime(); 64 + if (!Number.isFinite(ts)) throw new Error('Invalid expiry date.'); 65 + if (ts <= Date.now()) throw new Error('Expiry must be in the future.'); 66 + expiresAt = ts; 67 + } 68 + 69 + const { createInvite } = await import('$lib/spaces/server/spaces.remote'); 70 + const result = await createInvite({ 71 + spaceUri, 72 + kind: inviteAllowAnonRead ? 'read-join' : 'join', 73 + ...(maxUses != null ? { maxUses } : {}), 74 + ...(expiresAt != null ? { expiresAt } : {}) 75 + }); 76 + inviteUrl = `${window.location.origin}/p/${hostProfile?.handle || did}/e/${rkey}/s/${spaceKey}?invite=${result.token}`; 77 + showInviteForm = false; 78 + showInviteModal = true; 79 + } catch (e) { 80 + inviteError = e instanceof Error ? e.message : String(e); 81 + } finally { 82 + inviteBusy = false; 83 + } 84 + } 85 + </script> 86 + 87 + <Button variant="secondary" class="mt-3 w-full" onclick={openInviteForm}> 88 + Share invite link 89 + </Button> 90 + 91 + <Modal bind:open={showInviteForm} interactOutsideBehavior={inviteBusy ? 'ignore' : 'close'}> 92 + <h2 class="mb-4 text-lg font-semibold">Create invite link</h2> 93 + 94 + <form 95 + class="space-y-4" 96 + onsubmit={(e) => { 97 + e.preventDefault(); 98 + submitInviteForm(); 99 + }} 100 + > 101 + <div class="flex items-start gap-2"> 102 + <Checkbox id="invite-allow-anon" bind:checked={inviteAllowAnonRead} disabled={inviteBusy} /> 103 + <div> 104 + <Label for="invite-allow-anon">Allow viewing event without being logged in</Label> 105 + <p class="text-base-500 dark:text-base-400 mt-0.5 text-xs"> 106 + Anyone with this link can read the event details. Signed-in users can still join with the 107 + same link. 108 + </p> 109 + </div> 110 + </div> 111 + 112 + <div> 113 + <Label for="invite-max-uses">Max uses</Label> 114 + <Input 115 + id="invite-max-uses" 116 + type="number" 117 + min="1" 118 + bind:value={inviteMaxUsesText} 119 + placeholder="Unlimited" 120 + disabled={inviteBusy} 121 + /> 122 + <p class="text-base-500 dark:text-base-400 mt-1 text-xs"> 123 + Caps how many people can join — anonymous reads are always unlimited. Leave empty for no 124 + limit. 125 + </p> 126 + </div> 127 + 128 + <div> 129 + <div class="mb-1 flex items-center gap-2"> 130 + <Checkbox id="invite-has-expiry" bind:checked={inviteHasExpiry} disabled={inviteBusy} /> 131 + <Label for="invite-has-expiry">Set an expiry</Label> 132 + </div> 133 + {#if inviteHasExpiry} 134 + <DateTimePicker bind:value={inviteExpiresAt} /> 135 + {:else} 136 + <p class="text-base-500 dark:text-base-400 text-xs">Link never expires.</p> 137 + {/if} 138 + </div> 139 + 140 + {#if inviteError} 141 + <p class="text-sm text-red-600 dark:text-red-400">{inviteError}</p> 142 + {/if} 143 + 144 + <div class="flex justify-end gap-2 pt-2"> 145 + <Button 146 + type="button" 147 + variant="secondary" 148 + onclick={() => (showInviteForm = false)} 149 + disabled={inviteBusy} 150 + > 151 + Cancel 152 + </Button> 153 + <Button type="submit" disabled={inviteBusy}> 154 + {inviteBusy ? 'Creating…' : 'Create invite'} 155 + </Button> 156 + </div> 157 + </form> 158 + </Modal> 159 + 160 + {#if inviteUrl} 161 + <ShareModal 162 + bind:open={showInviteModal} 163 + url={inviteUrl} 164 + title="Invite link" 165 + shareText={`You're invited to "${eventName}".\n\n${inviteUrl}`} 166 + {eventName} 167 + /> 168 + {/if}
+159
src/lib/components/event-view/format.ts
··· 1 + import { marked } from 'marked'; 2 + import { sanitize } from '$lib/cal/sanitize'; 3 + import type { FlatEventRecord } from '$lib/contrail'; 4 + 5 + export function formatMonth(date: Date): string { 6 + return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); 7 + } 8 + 9 + export function formatDay(date: Date): number { 10 + return date.getDate(); 11 + } 12 + 13 + export function formatWeekday(date: Date): string { 14 + return date.toLocaleDateString('en-US', { weekday: 'long' }); 15 + } 16 + 17 + export function formatFullDate(date: Date): string { 18 + const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' }; 19 + if (date.getFullYear() !== new Date().getFullYear()) { 20 + options.year = 'numeric'; 21 + } 22 + return date.toLocaleDateString('en-US', options); 23 + } 24 + 25 + export function formatTime(date: Date): string { 26 + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); 27 + } 28 + 29 + export function getModeLabel(mode: string): string { 30 + if (mode.includes('virtual')) return 'Virtual'; 31 + if (mode.includes('hybrid')) return 'Hybrid'; 32 + if (mode.includes('inperson')) return 'In-Person'; 33 + return 'Event'; 34 + } 35 + 36 + export function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' { 37 + if (mode.includes('virtual')) return 'cyan'; 38 + if (mode.includes('hybrid')) return 'purple'; 39 + if (mode.includes('inperson')) return 'amber'; 40 + return 'secondary'; 41 + } 42 + 43 + export type LocationData = { 44 + name?: string; 45 + shortAddress: string; 46 + fullAddress: string; 47 + fullString: string; 48 + mapsUrl: string; 49 + }; 50 + 51 + export function getLocationData(locations: FlatEventRecord['locations']): LocationData | null { 52 + if (!locations || locations.length === 0) return null; 53 + 54 + const loc = locations.find((v) => v.$type === 'community.lexicon.location.address') as 55 + | { name?: string; street?: string; locality?: string; region?: string; country?: string } 56 + | undefined; 57 + if (!loc) return null; 58 + 59 + const shortParts = [loc.street, loc.locality].filter(Boolean); 60 + const fullParts = [loc.street, loc.locality, loc.region, loc.country].filter(Boolean); 61 + if (fullParts.length === 0) return null; 62 + 63 + const shortAddress = shortParts.join(', '); 64 + const fullAddress = fullParts.join(', '); 65 + const displayName = loc.name || undefined; 66 + const fullString = displayName ? `${displayName}, ${fullAddress}` : fullAddress; 67 + const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullString)}`; 68 + 69 + return { name: displayName, shortAddress, fullAddress, fullString, mapsUrl }; 70 + } 71 + 72 + export async function resolveGeoLocation( 73 + locations: FlatEventRecord['locations'], 74 + locationData: LocationData | null 75 + ): Promise<{ lat: number; lng: number } | null> { 76 + if (!locations || locations.length === 0) return null; 77 + 78 + const geo = locations.find((v) => v.$type === 'community.lexicon.location.geo') as 79 + | { latitude?: string; longitude?: string } 80 + | undefined; 81 + if (geo?.latitude && geo?.longitude) { 82 + const lat = parseFloat(geo.latitude); 83 + const lng = parseFloat(geo.longitude); 84 + if (!isNaN(lat) && !isNaN(lng)) return { lat, lng }; 85 + } 86 + 87 + const addressQuery = locationData?.fullAddress; 88 + if (!addressQuery) return null; 89 + 90 + try { 91 + const r = await fetch(`/api/geocoding?q=${encodeURIComponent(addressQuery)}`); 92 + if (!r.ok) return null; 93 + const data = (await r.json()) as Record<string, unknown> | null; 94 + if (!data || !data.lat || !data.lon) return null; 95 + return { lat: parseFloat(data.lat as string), lng: parseFloat(data.lon as string) }; 96 + } catch { 97 + return null; 98 + } 99 + } 100 + 101 + const renderer = new marked.Renderer(); 102 + renderer.link = ({ href, text }) => 103 + `<a target="_blank" rel="noopener noreferrer nofollow" href="${href}" class="text-accent-600 dark:text-accent-400 hover:underline">${text}</a>`; 104 + 105 + type Facet = { 106 + index: { byteStart: number; byteEnd: number }; 107 + features: { $type: string; did?: string; uri?: string; tag?: string }[]; 108 + }; 109 + 110 + function renderDescription(text: string, facets?: Facet[]): string { 111 + let result = text; 112 + 113 + if (facets && facets.length > 0) { 114 + const encoder = new TextEncoder(); 115 + const encoded = encoder.encode(text); 116 + const decoder = new TextDecoder(); 117 + 118 + const sorted = [...facets].sort((a, b) => b.index.byteStart - a.index.byteStart); 119 + 120 + for (const facet of sorted) { 121 + const feature = facet.features?.[0]; 122 + if (!feature) continue; 123 + 124 + const segmentBytes = encoded.slice(facet.index.byteStart, facet.index.byteEnd); 125 + const segmentText = decoder.decode(segmentBytes); 126 + 127 + let mdLink: string | null = null; 128 + switch (feature.$type) { 129 + case 'app.bsky.richtext.facet#mention': 130 + mdLink = `[${segmentText}](/${feature.did})`; 131 + break; 132 + case 'app.bsky.richtext.facet#link': 133 + mdLink = `[${segmentText}](${feature.uri})`; 134 + break; 135 + case 'app.bsky.richtext.facet#tag': 136 + mdLink = `[${segmentText}](https://bsky.app/hashtag/${feature.tag})`; 137 + break; 138 + } 139 + 140 + if (mdLink) { 141 + const before = decoder.decode(encoded.slice(0, facet.index.byteStart)); 142 + const after = decoder.decode(encoded.slice(facet.index.byteEnd)); 143 + result = before + mdLink + after; 144 + } 145 + } 146 + } 147 + 148 + return marked.parse(result, { renderer }) as string; 149 + } 150 + 151 + export function buildDescriptionHtml( 152 + description: string | undefined, 153 + facets: unknown 154 + ): string | null { 155 + if (!description) return null; 156 + return sanitize(renderDescription(description, facets as Facet[] | undefined), { 157 + ADD_ATTR: ['target'] 158 + }); 159 + }
+56 -19
src/lib/contrail.ts
··· 2 2 import type { EventData } from '$lib/event-types'; 3 3 import type { 4 4 RsvpAtmoGetProfile, 5 - CommunityLexiconCalendarEventGetRecord, 6 - CommunityLexiconCalendarEventListRecords, 7 - CommunityLexiconCalendarRsvpListRecords 5 + RsvpAtmoEventGetRecord, 6 + RsvpAtmoEventListRecords, 7 + RsvpAtmoRsvpListRecords 8 8 } from '../lexicon-types'; 9 9 import type { Client } from '@atcute/client'; 10 10 import type { ActorIdentifier } from '@atcute/lexicons'; ··· 16 16 export const RSVP_INTERESTED = 'community.lexicon.calendar.rsvp#interested'; 17 17 18 18 type ProfileOutput = RsvpAtmoGetProfile.$output; 19 - type EventListOutput = CommunityLexiconCalendarEventListRecords.$output; 20 - type EventListRecord = CommunityLexiconCalendarEventListRecords.Record; 21 - type EventProfileEntry = CommunityLexiconCalendarEventListRecords.ProfileEntry; 22 - type EventGetOutput = CommunityLexiconCalendarEventGetRecord.$output; 23 - type EventGetProfileEntry = CommunityLexiconCalendarEventGetRecord.ProfileEntry; 24 - type RsvpListRecord = CommunityLexiconCalendarRsvpListRecords.Record; 25 - type RsvpProfileEntry = CommunityLexiconCalendarRsvpListRecords.ProfileEntry; 26 - type HydratedEventRecord = CommunityLexiconCalendarRsvpListRecords.RefEventRecord; 19 + type EventListOutput = RsvpAtmoEventListRecords.$output; 20 + type EventListRecord = RsvpAtmoEventListRecords.Record; 21 + type EventProfileEntry = RsvpAtmoEventListRecords.ProfileEntry; 22 + type EventGetOutput = RsvpAtmoEventGetRecord.$output; 23 + type EventGetProfileEntry = RsvpAtmoEventGetRecord.ProfileEntry; 24 + type RsvpListRecord = RsvpAtmoRsvpListRecords.Record; 25 + type RsvpProfileEntry = RsvpAtmoRsvpListRecords.ProfileEntry; 26 + type HydratedEventRecord = RsvpAtmoRsvpListRecords.RefEventRecord; 27 27 type FlattenableEventRecord = EventListRecord | EventGetOutput | HydratedEventRecord; 28 28 type EventProfiles = EventProfileEntry[] | EventGetProfileEntry[] | undefined; 29 29 type EventRsvps = EventListRecord['rsvps'] | EventGetOutput['rsvps']; ··· 33 33 did: string; 34 34 rkey: string; 35 35 uri: string; 36 + /** Populated when the event was read from a permissioned space. */ 37 + space?: string; 36 38 rsvps?: EventRsvps; 37 39 rsvpsCount?: number; 38 40 rsvpsGoingCount?: number; ··· 73 75 rsvpsGoingCountMin?: number; 74 76 hydrateRsvps?: number; 75 77 profiles?: boolean; 78 + preferencesShowInDiscovery?: string; 76 79 sort?: string; 77 80 order?: 'asc' | 'desc'; 78 81 limit?: number; ··· 113 116 did: record.did, 114 117 rkey: record.rkey, 115 118 uri: record.uri, 119 + ...('space' in record && typeof record.space === 'string' ? { space: record.space } : {}), 116 120 ...('rsvps' in record ? { rsvps: record.rsvps } : {}), 117 121 ...('rsvpsCount' in record ? { rsvpsCount: record.rsvpsCount } : {}), 118 122 ...('rsvpsGoingCount' in record ? { rsvpsGoingCount: record.rsvpsGoingCount } : {}), ··· 129 133 .filter((record): record is FlatEventRecord => record !== null); 130 134 } 131 135 136 + /** Build the canonical path for an event. Private events (those with a `space` 137 + * field from contrail's union) live under `/p/<actor>/e/<rkey>/s/<skey>` so 138 + * the page knows both which event to show and which space to look in. Public 139 + * events use `/p/<actor>/e/<rkey>`. */ 140 + export function eventUrl(event: FlatEventRecord, actor?: string): string { 141 + const who = actor || event.did; 142 + if (event.space) { 143 + const m = event.space.match(/^at:\/\/[^/]+\/[^/]+\/([^/]+)$/); 144 + const skey = m?.[1]; 145 + if (skey) return `/p/${who}/e/${event.rkey}/s/${skey}`; 146 + } 147 + return `/p/${who}/e/${event.rkey}`; 148 + } 149 + 132 150 export function getHostProfile(did: string, profiles?: EventProfiles): HostProfile | null { 133 151 const profile = profiles?.find((entry) => entry.did === did); 134 152 if (!profile) return null; ··· 215 233 export async function getProfileFromContrail( 216 234 client: Client, 217 235 actor: ActorIdentifier 218 - ): Promise<ProfileOutput | null> { 236 + ): Promise<ProfileOutput['profiles'][number] | null> { 219 237 const response = await client.get('rsvp.atmo.getProfile', { 220 238 params: { actor } 221 239 }); 222 240 223 241 if (!response.ok) return null; 224 - return response.data; 242 + return response.data.profiles?.[0] ?? null; 225 243 } 226 244 227 245 export async function listEventRecordsFromContrail( 228 246 client: Client, 229 247 params: ListEventsParams 230 248 ): Promise<EventListOutput | null> { 231 - const response = await client.get('community.lexicon.calendar.event.listRecords', { 249 + const response = await client.get('rsvp.atmo.event.listRecords', { 232 250 params 233 251 }); 234 252 ··· 236 254 return response.data; 237 255 } 238 256 257 + /** 258 + * Hits the `listDiscoverable` pipelineQuery, which reuses the listRecords 259 + * pipeline but adds a WHERE condition excluding events where 260 + * `preferences.showInDiscovery === false`. Missing field is treated as true. 261 + * Response shape is identical to listRecords. 262 + */ 263 + export async function listDiscoverableEventsFromContrail( 264 + client: Client, 265 + params: Omit<ListEventsParams, 'preferencesShowInDiscovery'> 266 + ): Promise<EventListOutput | null> { 267 + const response = await client.get( 268 + 'rsvp.atmo.event.listDiscoverable' as 'rsvp.atmo.event.listRecords', 269 + { params } 270 + ); 271 + 272 + if (!response.ok) return null; 273 + return response.data; 274 + } 275 + 239 276 export async function getEventRecordFromContrail( 240 277 client: Client, 241 278 { ··· 250 287 profiles?: boolean; 251 288 } 252 289 ): Promise<EventGetOutput | null> { 253 - const response = await client.get('community.lexicon.calendar.event.getRecord', { 290 + const response = await client.get('rsvp.atmo.event.getRecord', { 254 291 params: { 255 292 uri: `at://${did}/community.lexicon.calendar.event/${rkey}`, 256 293 ...(hydrateRsvps ? { hydrateRsvps } : {}), ··· 272 309 actor: ActorIdentifier; 273 310 } 274 311 ): Promise<RsvpListRecord | null> { 275 - const response = await client.get('community.lexicon.calendar.rsvp.listRecords', { 312 + const response = await client.get('rsvp.atmo.rsvp.listRecords', { 276 313 params: { 277 314 actor, 278 315 subjectUri: eventUri, ··· 289 326 eventUri: string 290 327 ): Promise<EventAttendeesResult> { 291 328 const [goingResponse, interestedResponse] = await Promise.all([ 292 - client.get('community.lexicon.calendar.rsvp.listRecords', { 329 + client.get('rsvp.atmo.rsvp.listRecords', { 293 330 params: { 294 331 subjectUri: eventUri, 295 332 status: RSVP_GOING, ··· 297 334 limit: 200 298 335 } 299 336 }), 300 - client.get('community.lexicon.calendar.rsvp.listRecords', { 337 + client.get('rsvp.atmo.rsvp.listRecords', { 301 338 params: { 302 339 subjectUri: eventUri, 303 340 status: RSVP_INTERESTED, ··· 337 374 } 338 375 339 376 export async function listAttendingEventsFromContrail(client: Client, actor: ActorIdentifier) { 340 - const response = await client.get('community.lexicon.calendar.rsvp.listRecords', { 377 + const response = await client.get('rsvp.atmo.rsvp.listRecords', { 341 378 params: { 342 379 actor, 343 380 hydrateEvent: true,
+36 -4
src/lib/contrail/config.ts
··· 1 1 import type { ContrailConfig } from '@atmo-dev/contrail'; 2 + import { SPACE_TYPE } from '../spaces/config'; 2 3 3 4 export const config: ContrailConfig = { 4 5 namespace: 'rsvp.atmo', 6 + // Enable the rsvp.atmo.notifyOfUpdate endpoint. The client calls it after 7 + // writing records to the PDS so contrail re-fetches and indexes them 8 + // immediately instead of waiting for the jetstream. 9 + notify: true, 10 + // `spaces` is declared statically so `pnpm generate` emits the `rsvp.atmo.space.*` 11 + // lexicons. The real serviceDid is injected at runtime in `$lib/contrail/index.ts` 12 + // via `getSpacesConfig()` — generate doesn't serialize it. 13 + spaces: { type: SPACE_TYPE, serviceDid: 'did:web:placeholder' }, 14 + permissionSet: { 15 + title: 'Atmo Events', 16 + description: 'Manage your private events and rsvps.' 17 + // NOTE: permission-set lexicons can only reference NSIDs under their own 18 + // namespace (`rsvp.atmo.*`). Repo writes for `community.lexicon.*` and 19 + // blob uploads are declared as standalone `scope.repo(...)` / 20 + // `scope.blob(...)` entries in `atproto/settings.ts`, not here. 21 + }, 5 22 collections: { 6 - 'community.lexicon.calendar.event': { 23 + event: { 24 + collection: 'community.lexicon.calendar.event', 7 25 queryable: { 8 26 mode: {}, 9 27 name: {}, 10 28 status: {}, 11 29 description: {}, 30 + 'preferences.showInDiscovery': {}, 12 31 startsAt: { type: 'range' }, 13 32 endsAt: { type: 'range' }, 14 33 createdAt: { type: 'range' } ··· 16 35 searchable: ['mode', 'name', 'status', 'description'], 17 36 relations: { 18 37 rsvps: { 19 - collection: 'community.lexicon.calendar.rsvp', 38 + collection: 'rsvp', 20 39 groupBy: 'status', 21 40 groups: { 22 41 going: 'community.lexicon.calendar.rsvp#going', ··· 24 43 notgoing: 'community.lexicon.calendar.rsvp#notgoing' 25 44 } 26 45 } 46 + }, 47 + pipelineQueries: { 48 + // Endpoint: rsvp.atmo.event.listDiscoverable 49 + // Same shape as listRecords, but filters out unlisted events 50 + // (preferences.showInDiscovery === false). Missing field defaults 51 + // to true, so pre-existing records without `preferences` are included. 52 + listDiscoverable: async () => ({ 53 + conditions: [ 54 + `(json_extract(r.record, '$.preferences.showInDiscovery') IS NULL 55 + OR json_extract(r.record, '$.preferences.showInDiscovery') != 0)` 56 + ] 57 + }) 27 58 } 28 59 }, 29 - 'community.lexicon.calendar.rsvp': { 60 + rsvp: { 61 + collection: 'community.lexicon.calendar.rsvp', 30 62 queryable: { 31 63 status: {}, 32 64 'subject.uri': {} 33 65 }, 34 66 references: { 35 67 event: { 36 - collection: 'community.lexicon.calendar.event', 68 + collection: 'event', 37 69 field: 'subject.uri' 38 70 } 39 71 }
+9 -1
src/lib/contrail/index.ts
··· 2 2 import { createHandler } from '@atmo-dev/contrail/server'; 3 3 import { Client } from '@atcute/client'; 4 4 import { config } from './config'; 5 + import { getSpacesConfig, spacesAvailable } from '../spaces/config'; 5 6 6 - export const contrail = new Contrail(config); 7 + const spaces = getSpacesConfig(); 8 + if (!spacesAvailable()) { 9 + console.warn( 10 + '[contrail/spaces] No service DID configured — spaces features will be inactive. Run `pnpm tunnel` in dev to enable.' 11 + ); 12 + } 13 + 14 + export const contrail = new Contrail({ ...config, ...(spaces ? { spaces } : {}) }); 7 15 8 16 let initialized = false; 9 17
+24
src/lib/spaces/config.ts
··· 1 + import type { SpacesConfig } from '@atmo-dev/contrail'; 2 + import { SERVICE_DID, SERVICE_URL } from './tunnel-service.generated'; 3 + 4 + /** The NSID identifying our kind of permissioned space. */ 5 + export const SPACE_TYPE = 'tools.atmo.event.space'; 6 + 7 + /** Build the spaces config for contrail, or null if we can't run spaces 8 + * (no service DID => dev without tunnel, prod before service is published). */ 9 + export function getSpacesConfig(): SpacesConfig | null { 10 + if (!SERVICE_DID) { 11 + return null; 12 + } 13 + return { 14 + type: SPACE_TYPE, 15 + serviceDid: SERVICE_DID 16 + }; 17 + } 18 + 19 + /** True if spaces are available in this environment. */ 20 + export function spacesAvailable(): boolean { 21 + return SERVICE_DID != null; 22 + } 23 + 24 + export { SERVICE_DID, SERVICE_URL };
+66
src/lib/spaces/server/client.ts
··· 1 + import type { Client } from '@atcute/client'; 2 + import { Client as AtcuteClient } from '@atcute/client'; 3 + import { createHandler } from '@atmo-dev/contrail/server'; 4 + import { contrail, ensureInit } from '$lib/contrail/index'; 5 + import { SERVICE_DID } from '../config'; 6 + 7 + // Register lexicon ambient types (atmo-events/generated) 8 + import '../../../lexicon-types/index.js'; 9 + 10 + const handle = createHandler(contrail); 11 + 12 + /** Cache-key per (did→lxm). Keyed per-request client to avoid bleed across users. */ 13 + function makeJwtCache() { 14 + return new Map<string, { token: string; expiresAt: number }>(); 15 + } 16 + 17 + async function mintServiceJwt( 18 + oauthClient: Client, 19 + aud: string, 20 + lxm: string 21 + ): Promise<string> { 22 + const response = await oauthClient.get('com.atproto.server.getServiceAuth', { 23 + params: { 24 + aud: aud as `did:${string}:${string}`, 25 + lxm: lxm as `${string}.${string}.${string}`, 26 + exp: Math.floor(Date.now() / 1000) + 300 27 + } 28 + }); 29 + if (!response.ok) { 30 + throw new Error( 31 + `getServiceAuth failed: ${response.status} ${JSON.stringify(response.data)}` 32 + ); 33 + } 34 + return response.data.token; 35 + } 36 + 37 + /** Build a typed @atcute/client that routes rsvp.atmo.* calls through 38 + * contrail's handler in-process, attaching a real service-auth JWT per request. 39 + * Each JWT is cached for ~4 minutes to avoid hammering the user's PDS. */ 40 + export function getSpacesClient(oauthClient: Client, db: D1Database): Client { 41 + if (!SERVICE_DID) { 42 + throw new Error('Spaces not configured (no SERVICE_DID). Run `pnpm tunnel` in dev.'); 43 + } 44 + const aud = SERVICE_DID; 45 + const jwtCache = makeJwtCache(); 46 + 47 + async function jwtFor(lxm: string): Promise<string> { 48 + const cached = jwtCache.get(lxm); 49 + if (cached && cached.expiresAt > Date.now() + 10_000) return cached.token; 50 + const token = await mintServiceJwt(oauthClient, aud, lxm); 51 + jwtCache.set(lxm, { token, expiresAt: Date.now() + 250_000 }); 52 + return token; 53 + } 54 + 55 + return new AtcuteClient({ 56 + handler: async (pathname, init) => { 57 + await ensureInit(db); 58 + const url = new URL(pathname, 'http://localhost'); 59 + const nsid = url.pathname.replace(/^\/xrpc\//, ''); 60 + const token = await jwtFor(nsid); 61 + const headers = new Headers(init?.headers as HeadersInit); 62 + headers.set('Authorization', `Bearer ${token}`); 63 + return handle(new Request(url, { ...init, headers }), db) as Promise<Response>; 64 + } 65 + }); 66 + }
+255
src/lib/spaces/server/spaces.remote.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { command, query, getRequestEvent } from '$app/server'; 3 + import { dev } from '$app/environment'; 4 + import * as v from 'valibot'; 5 + import '../../../lexicon-types/index.js'; 6 + import { getSpacesClient } from './client'; 7 + import { SPACE_TYPE, spacesAvailable } from '../config'; 8 + 9 + const atUriSchema = v.pipe(v.string(), v.regex(/^at:\/\/.+/)); 10 + const didSchema = v.pipe(v.string(), v.regex(/^did:[a-z]+:.+/)); 11 + const nsidSchema = v.pipe(v.string(), v.regex(/^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/)); 12 + 13 + function getClient() { 14 + const { locals, platform } = getRequestEvent(); 15 + if (!spacesAvailable()) error(503, 'Spaces are not configured in this environment'); 16 + if (!locals.client || !locals.did) error(401, 'Not authenticated'); 17 + if (!platform?.env.DB) error(500, 'No database binding'); 18 + return { client: getSpacesClient(locals.client, platform.env.DB), did: locals.did }; 19 + } 20 + 21 + // ── Spaces ──────────────────────────────────────────────────────── 22 + 23 + /** Create a private event: makes a space owned by the caller and writes the event record inside it. */ 24 + export const createPrivateEvent = command( 25 + v.object({ 26 + key: v.optional(v.string()), 27 + record: v.record(v.string(), v.unknown()) 28 + }), 29 + async (input) => { 30 + if (!dev) error(403, 'Private events are not available yet'); 31 + const { client } = getClient(); 32 + 33 + const createRes = await client.post('rsvp.atmo.space.createSpace', { 34 + input: { type: SPACE_TYPE, key: input.key } 35 + }); 36 + if (!createRes.ok) { 37 + console.error('createSpace failed', createRes.status, createRes.data); 38 + error(500, `createSpace failed: ${createRes.status} ${JSON.stringify(createRes.data)}`); 39 + } 40 + const spaceUri = createRes.data.space.uri; 41 + 42 + const putRes = await client.post('rsvp.atmo.space.putRecord', { 43 + input: { 44 + spaceUri, 45 + collection: 'community.lexicon.calendar.event', 46 + record: input.record 47 + } 48 + }); 49 + if (!putRes.ok) { 50 + console.error('putRecord (space) failed', putRes.status, putRes.data); 51 + error(500, `putRecord failed: ${putRes.status} ${JSON.stringify(putRes.data)}`); 52 + } 53 + 54 + return { spaceUri, rkey: putRes.data.rkey }; 55 + } 56 + ); 57 + 58 + export const listMyPrivateSpaces = query(async () => { 59 + const { client } = getClient(); 60 + const res = await client.get('rsvp.atmo.space.listSpaces', { 61 + params: { scope: 'owner', type: SPACE_TYPE } 62 + }); 63 + if (!res.ok) error(500, 'listSpaces failed'); 64 + return res.data.spaces; 65 + }); 66 + 67 + export const listMySpaceMemberships = query(async () => { 68 + const { client } = getClient(); 69 + const res = await client.get('rsvp.atmo.space.listSpaces', { 70 + params: { scope: 'member', type: SPACE_TYPE } 71 + }); 72 + if (!res.ok) error(500, 'listSpaces failed'); 73 + return res.data.spaces; 74 + }); 75 + 76 + export const getPrivateSpace = query(v.object({ spaceUri: atUriSchema }), async ({ spaceUri }) => { 77 + const { client } = getClient(); 78 + const spaceRes = await client.get('rsvp.atmo.space.getSpace', { 79 + params: { uri: spaceUri as unknown as import('@atcute/lexicons').ResourceUri } 80 + }); 81 + // Return ok:false instead of throwing — callers (load functions) need to branch on 82 + // access state, and SvelteKit's error() registers with request-level error tracking 83 + // even when caught, which would show the default error page. 84 + if (!spaceRes.ok) return { ok: false as const, status: spaceRes.status }; 85 + 86 + const [eventsRes, rsvpsRes] = await Promise.all([ 87 + client.get('rsvp.atmo.space.listRecords', { 88 + params: { 89 + spaceUri: spaceUri as unknown as import('@atcute/lexicons').ResourceUri, 90 + collection: 'community.lexicon.calendar.event' 91 + } 92 + }), 93 + client.get('rsvp.atmo.space.listRecords', { 94 + params: { 95 + spaceUri: spaceUri as unknown as import('@atcute/lexicons').ResourceUri, 96 + collection: 'community.lexicon.calendar.rsvp' 97 + } 98 + }) 99 + ]); 100 + const events = eventsRes.ok ? eventsRes.data.records : []; 101 + const rsvps = rsvpsRes.ok ? rsvpsRes.data.records : []; 102 + 103 + return { ok: true as const, space: spaceRes.data.space, events, rsvps }; 104 + }); 105 + 106 + // ── Members ─────────────────────────────────────────────────────── 107 + 108 + export const listMembers = query(v.object({ spaceUri: atUriSchema }), async ({ spaceUri }) => { 109 + const { client } = getClient(); 110 + const res = await client.get('rsvp.atmo.space.listMembers', { 111 + params: { spaceUri: spaceUri as unknown as import('@atcute/lexicons').ResourceUri } 112 + }); 113 + if (!res.ok) error(res.status, 'listMembers failed'); 114 + return res.data.members; 115 + }); 116 + 117 + export const addMember = command( 118 + v.object({ 119 + spaceUri: atUriSchema, 120 + did: didSchema, 121 + perms: v.optional(v.string()) 122 + }), 123 + async (input) => { 124 + const { client } = getClient(); 125 + const res = await client.post('rsvp.atmo.space.addMember', { 126 + input: { 127 + spaceUri: input.spaceUri as unknown as import('@atcute/lexicons').ResourceUri, 128 + did: input.did as `did:${string}:${string}`, 129 + perms: input.perms 130 + } 131 + }); 132 + if (!res.ok) error(res.status, 'addMember failed'); 133 + return { ok: true }; 134 + } 135 + ); 136 + 137 + export const removeMember = command( 138 + v.object({ 139 + spaceUri: atUriSchema, 140 + did: didSchema 141 + }), 142 + async (input) => { 143 + const { client } = getClient(); 144 + const res = await client.post('rsvp.atmo.space.removeMember', { 145 + input: { 146 + spaceUri: input.spaceUri as unknown as import('@atcute/lexicons').ResourceUri, 147 + did: input.did as `did:${string}:${string}` 148 + } 149 + }); 150 + if (!res.ok) error(res.status, 'removeMember failed'); 151 + return { ok: true }; 152 + } 153 + ); 154 + 155 + // ── Invites ─────────────────────────────────────────────────────── 156 + 157 + export const createInvite = command( 158 + v.object({ 159 + spaceUri: atUriSchema, 160 + kind: v.optional(v.picklist(['join', 'read', 'read-join'])), 161 + perms: v.optional(v.string()), 162 + expiresAt: v.optional(v.number()), 163 + maxUses: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))), 164 + note: v.optional(v.string()) 165 + }), 166 + async (input) => { 167 + const { client } = getClient(); 168 + const res = await client.post('rsvp.atmo.space.invite.create', { 169 + input: { ...input, spaceUri: input.spaceUri as unknown as import('@atcute/lexicons').ResourceUri } 170 + }); 171 + if (!res.ok) error(res.status, 'createInvite failed'); 172 + return res.data; 173 + } 174 + ); 175 + 176 + export const listInvites = query( 177 + v.object({ spaceUri: atUriSchema, includeRevoked: v.optional(v.boolean()) }), 178 + async ({ spaceUri, includeRevoked }) => { 179 + const { client } = getClient(); 180 + const res = await client.get('rsvp.atmo.space.invite.list', { 181 + params: { 182 + spaceUri: spaceUri as unknown as import('@atcute/lexicons').ResourceUri, 183 + includeRevoked: includeRevoked ?? false 184 + } 185 + }); 186 + if (!res.ok) error(res.status, 'listInvites failed'); 187 + return res.data.invites; 188 + } 189 + ); 190 + 191 + export const revokeInvite = command( 192 + v.object({ spaceUri: atUriSchema, tokenHash: v.string() }), 193 + async (input) => { 194 + const { client } = getClient(); 195 + const res = await client.post('rsvp.atmo.space.invite.revoke', { 196 + input: { ...input, spaceUri: input.spaceUri as unknown as import('@atcute/lexicons').ResourceUri } 197 + }); 198 + if (!res.ok) error(res.status, 'revokeInvite failed'); 199 + return res.data; 200 + } 201 + ); 202 + 203 + // ── Generic space record ops (for inside-space writes like private RSVPs) ── 204 + 205 + export const putSpaceRecord = command( 206 + v.object({ 207 + spaceUri: atUriSchema, 208 + collection: nsidSchema, 209 + rkey: v.optional(v.string()), 210 + record: v.record(v.string(), v.unknown()) 211 + }), 212 + async (input) => { 213 + const { client } = getClient(); 214 + const res = await client.post('rsvp.atmo.space.putRecord', { 215 + input: { 216 + spaceUri: input.spaceUri as unknown as import('@atcute/lexicons').ResourceUri, 217 + collection: input.collection as `${string}.${string}.${string}`, 218 + rkey: input.rkey, 219 + record: input.record 220 + } 221 + }); 222 + if (!res.ok) error(res.status, `putSpaceRecord failed`); 223 + return res.data; 224 + } 225 + ); 226 + 227 + export const deleteSpaceRecord = command( 228 + v.object({ 229 + spaceUri: atUriSchema, 230 + collection: nsidSchema, 231 + rkey: v.string() 232 + }), 233 + async (input) => { 234 + const { client } = getClient(); 235 + const res = await client.post('rsvp.atmo.space.deleteRecord', { 236 + input: { 237 + spaceUri: input.spaceUri as unknown as import('@atcute/lexicons').ResourceUri, 238 + collection: input.collection as `${string}.${string}.${string}`, 239 + rkey: input.rkey 240 + } 241 + }); 242 + if (!res.ok) error(res.status, `deleteSpaceRecord failed`); 243 + return res.data; 244 + } 245 + ); 246 + 247 + export const redeemInvite = command(v.object({ token: v.string() }), async ({ token }) => { 248 + const { client } = getClient(); 249 + const res = await client.post('rsvp.atmo.space.invite.redeem', { input: { token } }); 250 + if (!res.ok) { 251 + console.error('[redeemInvite] xrpc error', res.status, res.data); 252 + error(res.status, `redeemInvite ${res.status}: ${JSON.stringify(res.data)}`); 253 + } 254 + return res.data; 255 + });
+6
src/lib/spaces/tunnel-service.generated.ts
··· 1 + /** Auto-generated by `pnpm tunnel`. Do not edit by hand. 2 + * When the tunnel is running, this file is rewritten with the tunnel's 3 + * service DID + URL; when the tunnel stops, it is reset to null values. */ 4 + 5 + export const SERVICE_DID: string | null = "did:web:described-yamaha-fame-social.trycloudflare.com"; 6 + export const SERVICE_URL: string | null = "https://described-yamaha-fame-social.trycloudflare.com";
+6 -2
src/routes/(app)/+page.server.ts
··· 1 - import { flattenEventRecords, getServerClient, listEventRecordsFromContrail } from '$lib/contrail'; 1 + import { 2 + flattenEventRecords, 3 + getServerClient, 4 + listDiscoverableEventsFromContrail 5 + } from '$lib/contrail'; 2 6 import type { PageServerLoad } from './$types'; 3 7 4 8 export const load: PageServerLoad = async ({ platform }) => { 5 9 const client = getServerClient(platform!.env.DB); 6 10 const now = new Date().toISOString(); 7 11 8 - const response = await listEventRecordsFromContrail(client, { 12 + const response = await listDiscoverableEventsFromContrail(client, { 9 13 startsAtMin: now, 10 14 rsvpsGoingCountMin: 2, 11 15 hydrateRsvps: 5,
+10 -2
src/routes/(app)/calendar/+page.server.ts
··· 4 4 getServerClient, 5 5 listEventRecordsFromContrail 6 6 } from '$lib/contrail'; 7 + import { getSpacesClient } from '$lib/spaces/server/client'; 8 + import { spacesAvailable } from '$lib/spaces/config'; 7 9 import type { PageServerLoad } from './$types'; 8 10 9 11 export const load: PageServerLoad = async ({ locals, platform }) => { 10 - const client = getServerClient(platform!.env.DB); 11 12 if (!locals.did) { 12 13 return { events: [], loggedIn: false }; 13 14 } 15 + // Authenticated + spaces configured → use the service-auth client so the 16 + // server unions public events with events from every space the user is in. 17 + // Falls back to the unauthenticated client otherwise (public-only). 18 + const client = 19 + locals.client && spacesAvailable() 20 + ? getSpacesClient(locals.client, platform!.env.DB) 21 + : getServerClient(platform!.env.DB); 14 22 15 23 const now = new Date().toISOString(); 16 24 17 25 const [rsvpResponse, hostingResponse] = await Promise.all([ 18 - client.get('community.lexicon.calendar.rsvp.listRecords', { 26 + client.get('rsvp.atmo.rsvp.listRecords', { 19 27 params: { actor: locals.did, hydrateEvent: true, limit: 100 } 20 28 }), 21 29 listEventRecordsFromContrail(client, {
+3 -1
src/routes/(app)/create/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import EventEditor from '$lib/components/EventEditor.svelte'; 3 + import { page } from '$app/state'; 3 4 4 5 let { data } = $props(); 6 + let privateMode = $derived(page.url.searchParams.get('private') === '1'); 5 7 </script> 6 8 7 9 <svelte:head> 8 10 <title>Create Event</title> 9 11 </svelte:head> 10 12 11 - <EventEditor eventData={null} actorDid={data.actorDid} rkey={data.rkey} /> 13 + <EventEditor eventData={null} actorDid={data.actorDid} rkey={data.rkey} {privateMode} />
+6 -2
src/routes/(app)/events/+page.server.ts
··· 1 - import { flattenEventRecords, getServerClient, listEventRecordsFromContrail } from '$lib/contrail'; 1 + import { 2 + flattenEventRecords, 3 + getServerClient, 4 + listDiscoverableEventsFromContrail 5 + } from '$lib/contrail'; 2 6 import type { PageServerLoad } from './$types'; 3 7 4 8 const PAGE_SIZE = 20; ··· 8 12 const now = new Date().toISOString(); 9 13 const cursor = url.searchParams.get('cursor') ?? undefined; 10 14 11 - const response = await listEventRecordsFromContrail(client, { 15 + const response = await listDiscoverableEventsFromContrail(client, { 12 16 startsAtMin: now, 13 17 profiles: true, 14 18 sort: 'startsAt',
+11 -2
src/routes/(app)/p/[actor]/+page.server.ts
··· 6 6 listAttendingEventsFromContrail, 7 7 listEventRecordsFromContrail 8 8 } from '$lib/contrail'; 9 + import { getSpacesClient } from '$lib/spaces/server/client'; 10 + import { spacesAvailable } from '$lib/spaces/config'; 9 11 import { isActorIdentifier } from '@atcute/lexicons/syntax'; 10 12 import { error } from '@sveltejs/kit'; 11 13 12 14 const PREVIEW_LIMIT = 6; 13 15 14 - export async function load({ params, platform }) { 15 - const client = getServerClient(platform!.env.DB); 16 + export async function load({ params, platform, locals }) { 17 + // Authenticated viewer + spaces configured → service-auth client so contrail 18 + // unions public events with private events from spaces the viewer is in. 19 + // Profile pages show another user's events; the viewer only sees the private 20 + // ones where *they* are a member (filtered server-side by caller DID). 21 + const client = 22 + locals.client && locals.did && spacesAvailable() 23 + ? getSpacesClient(locals.client, platform!.env.DB) 24 + : getServerClient(platform!.env.DB); 16 25 if (!isActorIdentifier(params.actor)) return; 17 26 18 27 const actor = params.actor;
+1 -1
src/routes/(app)/p/[actor]/calendar.ics/+server.ts
··· 26 26 27 27 const actorId = did as ActorIdentifier; 28 28 const [rsvpResponse, hostingResponse] = await Promise.all([ 29 - client.get('community.lexicon.calendar.rsvp.listRecords', { 29 + client.get('rsvp.atmo.rsvp.listRecords', { 30 30 params: { actor: actorId, hydrateEvent: true, limit: 100 } 31 31 }), 32 32 listEventRecordsFromContrail(client, {
+2 -700
src/routes/(app)/p/[actor]/e/[rkey]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { isEventOngoing, type FlatEventRecord } from '$lib/contrail'; 3 - import { getCDNImageBlobUrl } from '$lib/atproto'; 4 - import { user } from '$lib/atproto/auth.svelte'; 5 - import { Avatar as FoxAvatar, Badge, Button } from '@foxui/core'; 6 - import Map from '$lib/components/Map.svelte'; 7 - import ShareModal from '$lib/components/ShareModal.svelte'; 8 - import Avatar from 'svelte-boring-avatars'; 9 - import EventRsvp from '$lib/components/EventRsvp.svelte'; 10 - import EventCard from '$lib/components/EventCard.svelte'; 11 - import EventAttendees from './EventAttendees.svelte'; 12 - import VodPlayer, { type VodPlayerApi } from '$lib/components/VodPlayer.svelte'; 13 - import VodTranscript from '$lib/components/VodTranscript.svelte'; 14 - import { page } from '$app/state'; 15 - import { marked } from 'marked'; 16 - import { sanitize } from '$lib/cal/sanitize'; 17 - import { generateICalEvent } from '$lib/cal/ical'; 18 - import { launchConfetti } from '@foxui/visual'; 19 - import ThemeBackground from '$lib/components/ThemeBackground.svelte'; 20 - import ThemeApply from '$lib/components/ThemeApply.svelte'; 21 - import { defaultTheme, type EventTheme } from '$lib/theme'; 22 - 2 + import EventView from '$lib/components/EventView.svelte'; 23 3 let { data } = $props(); 24 - 25 - let eventData: FlatEventRecord = $derived(data.eventData); 26 - let did: string = $derived(data.actorDid); 27 - let rkey: string = $derived(data.rkey); 28 - let hostProfile = $derived(data.hostProfile); 29 - let attendees = $derived(data.attendees); 30 - 31 - let theme: EventTheme = $derived(eventData.theme ?? defaultTheme); 32 - 33 - 34 - let hostUrl = $derived(`/p/${hostProfile?.handle || did}`); 35 - let eventPath = $derived(`/p/${hostProfile?.handle || did}/e/${data.rkey}`); 36 - let shareUrl = $derived( 37 - typeof window !== 'undefined' ? `${window.location.origin}${eventPath}` : eventPath 38 - ); 39 - 40 - // Times are always rendered in the viewer's local timezone — the stored UTC 41 - // instant is what the Date constructor parses, and toLocaleString/Time uses 42 - // the browser's zone by default. 43 - let startDate = $derived(new Date(eventData.startsAt)); 44 - let endDate = $derived(eventData.endsAt ? new Date(eventData.endsAt) : null); 45 - 46 - function formatMonth(date: Date): string { 47 - return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); 48 - } 49 - 50 - function formatDay(date: Date): number { 51 - return date.getDate(); 52 - } 53 - 54 - function formatWeekday(date: Date): string { 55 - return date.toLocaleDateString('en-US', { weekday: 'long' }); 56 - } 57 - 58 - function formatFullDate(date: Date): string { 59 - const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' }; 60 - if (date.getFullYear() !== new Date().getFullYear()) { 61 - options.year = 'numeric'; 62 - } 63 - return date.toLocaleDateString('en-US', options); 64 - } 65 - 66 - function formatTime(date: Date): string { 67 - return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); 68 - } 69 - 70 - function getModeLabel(mode: string): string { 71 - if (mode.includes('virtual')) return 'Virtual'; 72 - if (mode.includes('hybrid')) return 'Hybrid'; 73 - if (mode.includes('inperson')) return 'In-Person'; 74 - return 'Event'; 75 - } 76 - 77 - function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' { 78 - if (mode.includes('virtual')) return 'cyan'; 79 - if (mode.includes('hybrid')) return 'purple'; 80 - if (mode.includes('inperson')) return 'amber'; 81 - return 'secondary'; 82 - } 83 - 84 - function getLocationData(locations: FlatEventRecord['locations']) { 85 - if (!locations || locations.length === 0) return null; 86 - 87 - const loc = locations.find((v) => v.$type === 'community.lexicon.location.address') as 88 - | { name?: string; street?: string; locality?: string; region?: string; country?: string } 89 - | undefined; 90 - if (!loc) return null; 91 - 92 - const shortParts = [loc.street, loc.locality].filter(Boolean); 93 - const fullParts = [loc.street, loc.locality, loc.region, loc.country].filter(Boolean); 94 - if (fullParts.length === 0) return null; 95 - 96 - const shortAddress = shortParts.join(', '); 97 - const fullAddress = fullParts.join(', '); 98 - const displayName = loc.name || undefined; 99 - const fullString = displayName ? `${displayName}, ${fullAddress}` : fullAddress; 100 - const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullString)}`; 101 - 102 - return { name: displayName, shortAddress, fullAddress, fullString, mapsUrl }; 103 - } 104 - 105 - let locationData = $derived(getLocationData(eventData.locations)); 106 - let location = $derived(locationData?.fullString); 107 - 108 - let geoLocation: { lat: number; lng: number } | null = $state(null); 109 - 110 - function initGeoLocation() { 111 - if (!eventData.locations || eventData.locations.length === 0) return; 112 - 113 - // Check for explicit geo coordinates first 114 - const geo = eventData.locations.find((v) => v.$type === 'community.lexicon.location.geo') as 115 - | { latitude?: string; longitude?: string } 116 - | undefined; 117 - if (geo?.latitude && geo?.longitude) { 118 - const lat = parseFloat(geo.latitude); 119 - const lng = parseFloat(geo.longitude); 120 - if (!isNaN(lat) && !isNaN(lng)) { 121 - geoLocation = { lat, lng }; 122 - return; 123 - } 124 - } 125 - 126 - // Geocode from address if available 127 - const addressQuery = locationData?.fullAddress; 128 - if (addressQuery) { 129 - fetch(`/api/geocoding?q=${encodeURIComponent(addressQuery)}`) 130 - .then((r) => (r.ok ? r.json() : null)) 131 - .then((data: unknown) => { 132 - const d = data as Record<string, unknown> | null; 133 - if (!d) return; 134 - if (d.lat && d.lon) { 135 - geoLocation = { lat: parseFloat(d.lat as string), lng: parseFloat(d.lon as string) }; 136 - } 137 - }) 138 - .catch(() => {}); 139 - } 140 - } 141 - 142 - let showShareModal = $state(false); 143 - let shareModalTitle = $state('Event created!'); 144 - let shareModalText: string | undefined = $state(undefined); 145 - 146 - import { onMount } from 'svelte'; 147 - onMount(() => { 148 - initGeoLocation(); 149 - 150 - const url = new URL(window.location.href); 151 - if (url.searchParams.has('created')) { 152 - url.searchParams.delete('created'); 153 - history.replaceState({}, '', url.pathname); 154 - launchConfetti(); 155 - shareModalTitle = 'Event created!'; 156 - shareModalText = `I'm hosting "${eventData.name}"!\n\n${shareUrl}`; 157 - showShareModal = true; 158 - } 159 - }); 160 - 161 - let thumbnailImage = $derived.by(() => { 162 - if (!eventData.media || eventData.media.length === 0) return null; 163 - const media = eventData.media.find((m) => m.role === 'thumbnail'); 164 - if (!media?.content) return null; 165 - const url = getCDNImageBlobUrl({ did, blob: media.content }); 166 - if (!url) return null; 167 - return { url, alt: media.alt || eventData.name }; 168 - }); 169 - 170 - let bannerImage = $derived.by(() => { 171 - if (!eventData.media || eventData.media.length === 0) return null; 172 - const media = eventData.media.find((m) => m.role === 'header'); 173 - if (!media?.content) return null; 174 - const url = getCDNImageBlobUrl({ did, blob: media.content }); 175 - if (!url) return null; 176 - return { url, alt: media.alt || eventData.name }; 177 - }); 178 - 179 - // Prefer thumbnail; fall back to header/banner image 180 - let displayImage = $derived(thumbnailImage ?? bannerImage); 181 - let isBannerOnly = $derived(!thumbnailImage && !!bannerImage); 182 - 183 - let isSameDay = $derived( 184 - endDate && 185 - startDate.getFullYear() === endDate.getFullYear() && 186 - startDate.getMonth() === endDate.getMonth() && 187 - startDate.getDate() === endDate.getDate() 188 - ); 189 - 190 - let isOngoing = $derived(isEventOngoing(eventData.startsAt, eventData.endsAt)); 191 - let isPast = $derived(endDate ? endDate < new Date() : false); 192 - 193 - const renderer = new marked.Renderer(); 194 - renderer.link = ({ href, text }) => 195 - `<a target="_blank" rel="noopener noreferrer nofollow" href="${href}" class="text-accent-600 dark:text-accent-400 hover:underline">${text}</a>`; 196 - 197 - function renderDescription( 198 - text: string, 199 - facets?: { 200 - index: { byteStart: number; byteEnd: number }; 201 - features: { $type: string; did?: string; uri?: string; tag?: string }[]; 202 - }[] 203 - ): string { 204 - let result = text; 205 - 206 - if (facets && facets.length > 0) { 207 - const encoder = new TextEncoder(); 208 - const encoded = encoder.encode(text); 209 - const decoder = new TextDecoder(); 210 - 211 - // Sort facets in reverse order by byteStart so replacements don't shift positions 212 - const sorted = [...facets].sort((a, b) => b.index.byteStart - a.index.byteStart); 213 - 214 - for (const facet of sorted) { 215 - const feature = facet.features?.[0]; 216 - if (!feature) continue; 217 - 218 - const segmentBytes = encoded.slice(facet.index.byteStart, facet.index.byteEnd); 219 - const segmentText = decoder.decode(segmentBytes); 220 - 221 - let mdLink: string | null = null; 222 - switch (feature.$type) { 223 - case 'app.bsky.richtext.facet#mention': 224 - mdLink = `[${segmentText}](/${feature.did})`; 225 - break; 226 - case 'app.bsky.richtext.facet#link': 227 - mdLink = `[${segmentText}](${feature.uri})`; 228 - break; 229 - case 'app.bsky.richtext.facet#tag': 230 - mdLink = `[${segmentText}](https://bsky.app/hashtag/${feature.tag})`; 231 - break; 232 - } 233 - 234 - if (mdLink) { 235 - // Convert byte offsets to character offsets for string replacement 236 - const before = decoder.decode(encoded.slice(0, facet.index.byteStart)); 237 - const after = decoder.decode(encoded.slice(facet.index.byteEnd)); 238 - result = before + mdLink + after; 239 - } 240 - } 241 - } 242 - 243 - return marked.parse(result, { renderer }) as string; 244 - } 245 - 246 - let descriptionHtml = $derived( 247 - eventData.description 248 - ? sanitize( 249 - renderDescription( 250 - eventData.description, 251 - eventData.facets as 252 - | { 253 - index: { byteStart: number; byteEnd: number }; 254 - features: { $type: string; did?: string; uri?: string; tag?: string }[]; 255 - }[] 256 - | undefined 257 - ), 258 - { ADD_ATTR: ['target'] } 259 - ) 260 - : null 261 - ); 262 - 263 - let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`); 264 - 265 - let ogImageUrl = $derived(`${page.url.origin}${page.url.pathname}/og.png`); 266 - 267 - let isOwner = $derived(user.isLoggedIn && user.did === did); 268 - 269 - let speakers = $derived(data.speakerProfiles ?? []); 270 - 271 - let vodCurrentTime = $state(0); 272 - let vodApi: VodPlayerApi | undefined = $state(); 273 - 274 - let attendeesRef: EventAttendees | undefined = $state(); 275 - 276 - function handleRsvp(status: 'going' | 'interested') { 277 - if (!user.did) return; 278 - attendeesRef?.addAttendee({ 279 - did: user.did, 280 - status, 281 - avatar: user.profile?.avatar, 282 - name: user.profile?.displayName || user.profile?.handle || user.did, 283 - handle: user.profile?.handle, 284 - url: `/${user.profile?.handle || user.did}` 285 - }); 286 - if (status === 'interested') return; 287 - shareModalTitle = "You're going!"; 288 - shareModalText = `I'm going to "${eventData.name}".\n\n${shareUrl}`; 289 - showShareModal = true; 290 - } 291 - 292 - function handleRsvpCancel() { 293 - if (!user.did) return; 294 - attendeesRef?.removeAttendee(user.did); 295 - } 296 - 297 - function downloadIcs() { 298 - const ical = generateICalEvent(eventData, eventUri, page.url.href); 299 - const blob = new Blob([ical], { type: 'text/calendar;charset=utf-8' }); 300 - const url = URL.createObjectURL(blob); 301 - const a = document.createElement('a'); 302 - a.href = url; 303 - a.download = `${eventData.name.replace(/[^a-zA-Z0-9]/g, '-')}.ics`; 304 - a.click(); 305 - URL.revokeObjectURL(url); 306 - } 307 4 </script> 308 5 309 - <svelte:head> 310 - <title>{eventData.name}</title> 311 - <meta name="description" content={eventData.description || `Event: ${eventData.name}`} /> 312 - <meta property="og:title" content={eventData.name} /> 313 - <meta property="og:description" content={eventData.description || `Event: ${eventData.name}`} /> 314 - <meta name="twitter:card" content="summary_large_image" /> 315 - <meta name="twitter:title" content={eventData.name} /> 316 - <meta name="twitter:description" content={eventData.description || `Event: ${eventData.name}`} /> 317 - <meta name="twitter:image" content={ogImageUrl} /> 318 - </svelte:head> 319 - 320 - <ThemeApply accentColor={theme.accentColor} baseColor={theme.baseColor} /> 321 - <ThemeBackground {theme} /> 322 - 323 - <div class="min-h-screen px-6 py-12 sm:py-12"> 324 - <div class="mx-auto max-w-3xl"> 325 - <!-- Banner image (full width, only when no thumbnail) --> 326 - {#if isBannerOnly && displayImage} 327 - <img 328 - src={displayImage.url} 329 - alt={displayImage.alt} 330 - class="border-base-200 dark:border-base-800 mb-8 aspect-3/1 w-full rounded-2xl border object-cover" 331 - /> 332 - {/if} 333 - 334 - <!-- Two-column layout: image left, details right --> 335 - <div 336 - class="grid grid-cols-1 gap-8 md:grid-cols-[14rem_1fr] md:grid-rows-[auto_1fr] md:gap-x-10 md:gap-y-6 lg:grid-cols-[16rem_1fr]" 337 - > 338 - <!-- Thumbnail image (left column) --> 339 - {#if !isBannerOnly} 340 - <div class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none"> 341 - {#if displayImage} 342 - <img 343 - src={displayImage.url} 344 - alt={displayImage.alt} 345 - class="border-base-200 dark:border-base-800 bg-base-200 dark:bg-base-950/50 aspect-square w-full rounded-2xl border object-cover" 346 - /> 347 - {:else} 348 - <div 349 - class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border [&>svg]:h-full [&>svg]:w-full" 350 - > 351 - <Avatar 352 - size={256} 353 - name={data.rkey} 354 - variant="marble" 355 - colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 356 - square 357 - /> 358 - </div> 359 - {/if} 360 - {#if isOwner} 361 - <Button href="./{rkey}/edit" class="mt-9 w-full">Edit Event</Button> 362 - {/if} 363 - </div> 364 - {/if} 365 - 366 - <!-- Right column: event details --> 367 - <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-2 md:row-start-1"> 368 - <div class="mb-2"> 369 - <h1 class="text-base-900 dark:text-base-50 text-3xl leading-tight font-bold sm:text-4xl"> 370 - {eventData.name} 371 - </h1> 372 - </div> 373 - 374 - <!-- Badges --> 375 - {#if eventData.mode || isOngoing} 376 - <div class="mb-8 flex items-center gap-2"> 377 - {#if isOngoing} 378 - <Badge size="md" variant="primary"> 379 - <span class="bg-accent-500 mr-1 inline-block size-1.5 animate-pulse rounded-full" 380 - ></span> 381 - Live 382 - </Badge> 383 - {/if} 384 - {#if eventData.mode} 385 - <Badge size="md" variant={getModeColor(eventData.mode)} 386 - >{getModeLabel(eventData.mode)}</Badge 387 - > 388 - {/if} 389 - </div> 390 - {/if} 391 - 392 - <!-- Date row --> 393 - <div class="mb-4 flex items-center gap-4"> 394 - <div 395 - class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 396 - > 397 - <span class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold"> 398 - {formatMonth(startDate)} 399 - </span> 400 - <span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold"> 401 - {formatDay(startDate)} 402 - </span> 403 - </div> 404 - <div> 405 - <p class="text-base-900 dark:text-base-50 font-semibold"> 406 - {formatWeekday(startDate)}, {formatFullDate(startDate)} 407 - {#if endDate && !isSameDay} 408 - - {formatWeekday(endDate)}, {formatFullDate(endDate)} 409 - {/if} 410 - </p> 411 - <p class="text-base-500 dark:text-base-400 text-sm"> 412 - {formatTime(startDate)} 413 - {#if endDate && isSameDay} 414 - - {formatTime(endDate)} 415 - {/if} 416 - </p> 417 - </div> 418 - </div> 419 - 420 - <!-- Location row --> 421 - {#if locationData} 422 - <a 423 - href={locationData.mapsUrl} 424 - target="_blank" 425 - rel="noopener noreferrer" 426 - class="mb-6 flex items-center gap-4 transition-opacity hover:opacity-80" 427 - > 428 - <div 429 - class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border" 430 - > 431 - <svg 432 - xmlns="http://www.w3.org/2000/svg" 433 - fill="none" 434 - viewBox="0 0 24 24" 435 - stroke-width="1.5" 436 - stroke="currentColor" 437 - class="text-base-900 dark:text-base-200 size-5" 438 - > 439 - <path 440 - stroke-linecap="round" 441 - stroke-linejoin="round" 442 - d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 443 - /> 444 - <path 445 - stroke-linecap="round" 446 - stroke-linejoin="round" 447 - d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" 448 - /> 449 - </svg> 450 - </div> 451 - <div> 452 - {#if locationData.name} 453 - <p class="text-base-900 dark:text-base-50 font-semibold">{locationData.name}</p> 454 - <p class="text-base-500 dark:text-base-400 text-sm">{locationData.shortAddress}</p> 455 - {:else} 456 - <p class="text-base-900 dark:text-base-50 font-semibold"> 457 - {locationData.shortAddress} 458 - </p> 459 - {/if} 460 - </div> 461 - </a> 462 - {/if} 463 - 464 - <!-- Part of --> 465 - {#if data.parentEvent} 466 - <div 467 - class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-950/50 mt-8 mb-2 justify-center rounded-2xl border p-4" 468 - > 469 - <p 470 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 471 - > 472 - Part of 473 - </p> 474 - <EventCard event={data.parentEvent} actor="atprotocol.dev" /> 475 - <Button href="/p/atmosphereconf.org" size="lg" class="mt-6 w-full"> 476 - See full schedule 477 - </Button> 478 - </div> 479 - {/if} 480 - 481 - {#if did === 'did:plc:lehcqqkwzcwvjvw66uthu5oq' && rkey === '3lte3c7x43l2e'} 482 - <Button href="/p/atmosphereconf.org" size="lg" class="mb-4 w-full"> 483 - See full schedule 484 - </Button> 485 - {/if} 486 - 487 - {#if !isPast} 488 - <EventRsvp 489 - {eventUri} 490 - eventCid={eventData.cid ?? null} 491 - initialRsvpStatus={data.viewerRsvpStatus} 492 - initialRsvpRkey={data.viewerRsvpRkey} 493 - onrsvp={handleRsvp} 494 - oncancel={handleRsvpCancel} 495 - /> 496 - {/if} 497 - 498 - <!-- About Event --> 499 - {#if descriptionHtml} 500 - <div class="mt-8 mb-8"> 501 - <p 502 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 503 - > 504 - About 505 - </p> 506 - <div 507 - class="text-base-700 dark:text-base-300 prose dark:prose-invert prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-a:hover:underline prose-a:no-underline max-w-none leading-relaxed wrap-break-word" 508 - > 509 - {@html descriptionHtml} 510 - </div> 511 - </div> 512 - {/if} 513 - 514 - <!-- Recording --> 515 - {#if data.vod} 516 - <div class="mt-8 mb-8"> 517 - <p 518 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 519 - > 520 - Recording 521 - </p> 522 - <VodPlayer 523 - playlistUrl={data.vod.playlistUrl} 524 - title={eventData.name} 525 - subtitlesUrl="/vods/{rkey}-karaoke.vtt" 526 - bind:currentTime={vodCurrentTime} 527 - bind:api={vodApi} 528 - /> 529 - </div> 530 - 531 - <!-- <div class="mt-4 mb-8"> 532 - <p 533 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 534 - > 535 - Transcript 536 - </p> 537 - <VodTranscript 538 - transcriptUrl="/vods/{rkey}.json" 539 - currentTime={vodCurrentTime} 540 - onseek={(time) => vodApi?.seek(time)} 541 - /> 542 - </div> --> 543 - {/if} 544 - 545 - <!-- Map --> 546 - {#if geoLocation && locationData} 547 - <div class="mt-8 mb-8"> 548 - <p 549 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 550 - > 551 - Location 552 - </p> 553 - <a 554 - href={locationData.mapsUrl} 555 - target="_blank" 556 - rel="noopener noreferrer" 557 - class="block transition-opacity hover:opacity-80" 558 - > 559 - <div class="h-64 w-full overflow-hidden rounded-xl"> 560 - <Map lat={geoLocation.lat} lng={geoLocation.lng} /> 561 - </div> 562 - <p class="text-base-500 dark:text-base-400 mt-2 text-sm"> 563 - {locationData.fullString} 564 - </p> 565 - </a> 566 - </div> 567 - {/if} 568 - </div> 569 - 570 - <!-- Left column: sidebar info --> 571 - <div class="order-3 space-y-6 md:order-0 md:col-start-1"> 572 - <!-- Hosted By --> 573 - <div> 574 - <p 575 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 576 - > 577 - Hosted By 578 - </p> 579 - <a 580 - href={hostUrl} 581 - class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium transition-opacity hover:opacity-80" 582 - > 583 - <FoxAvatar 584 - src={hostProfile?.avatar} 585 - alt={hostProfile?.displayName || hostProfile?.handle || did} 586 - class="size-8 shrink-0" 587 - /> 588 - <span class="truncate text-sm"> 589 - {hostProfile?.displayName || hostProfile?.handle || did} 590 - </span> 591 - </a> 592 - </div> 593 - 594 - <!-- Speakers --> 595 - {#if speakers.length > 0} 596 - <div> 597 - <p 598 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 599 - > 600 - Speakers 601 - </p> 602 - <div class="space-y-2"> 603 - {#each speakers as speaker, i (speaker.id || i)} 604 - {#if speaker.handle} 605 - <a 606 - href="/p/{speaker.handle}" 607 - class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium transition-opacity hover:opacity-80" 608 - > 609 - <FoxAvatar src={speaker.avatar} alt={speaker.name} class="size-8 shrink-0" /> 610 - <span class="truncate text-sm">{speaker.name}</span> 611 - </a> 612 - {:else} 613 - <div 614 - class="text-base-900 dark:text-base-100 flex items-center gap-2.5 font-medium" 615 - > 616 - <FoxAvatar alt={speaker.name} class="size-8 shrink-0" /> 617 - <span class="truncate text-sm">{speaker.name}</span> 618 - </div> 619 - {/if} 620 - {/each} 621 - </div> 622 - </div> 623 - {/if} 624 - 625 - {#if eventData.uris && eventData.uris.length > 0} 626 - <!-- Links --> 627 - <div> 628 - <p 629 - class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 630 - > 631 - Links 632 - </p> 633 - <div class="space-y-3"> 634 - {#each eventData.uris as link (link.name + link.uri)} 635 - <a 636 - href={link.uri} 637 - target="_blank" 638 - rel="noopener noreferrer" 639 - class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex items-center gap-1.5 text-sm transition-colors" 640 - > 641 - <svg 642 - xmlns="http://www.w3.org/2000/svg" 643 - fill="none" 644 - viewBox="0 0 24 24" 645 - stroke-width="1.5" 646 - stroke="currentColor" 647 - class="size-3.5 shrink-0" 648 - > 649 - <path 650 - stroke-linecap="round" 651 - stroke-linejoin="round" 652 - d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 653 - /> 654 - </svg> 655 - <span class="truncate">{link.name || link.uri.replace(/^https?:\/\//, '')}</span> 656 - </a> 657 - {/each} 658 - </div> 659 - </div> 660 - {/if} 661 - 662 - <!-- Add to Calendar --> 663 - <button 664 - onclick={downloadIcs} 665 - class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex cursor-pointer items-center gap-2 text-sm font-medium transition-colors" 666 - > 667 - <svg 668 - xmlns="http://www.w3.org/2000/svg" 669 - fill="none" 670 - viewBox="0 0 24 24" 671 - stroke-width="1.5" 672 - stroke="currentColor" 673 - class="size-4" 674 - > 675 - <path 676 - stroke-linecap="round" 677 - stroke-linejoin="round" 678 - d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" 679 - /> 680 - </svg> 681 - Add to Calendar 682 - </button> 683 - 684 - <!-- Attendees --> 685 - <EventAttendees 686 - bind:this={attendeesRef} 687 - going={attendees.going} 688 - interested={attendees.interested} 689 - goingCount={attendees.goingCount} 690 - interestedCount={attendees.interestedCount} 691 - /> 692 - </div> 693 - </div> 694 - </div> 695 - </div> 696 - 697 - <ShareModal 698 - bind:open={showShareModal} 699 - url={shareUrl} 700 - title={shareModalTitle} 701 - shareText={shareModalText} 702 - eventName={eventData.name} 703 - {ogImageUrl} 704 - /> 6 + <EventView {data} />
src/routes/(app)/p/[actor]/e/[rkey]/EventAttendees.svelte src/lib/components/EventAttendees.svelte
+303
src/routes/(app)/p/[actor]/e/[rkey]/s/[skey]/+page.server.ts
··· 1 + import type { PageServerLoad } from './$types'; 2 + import type { Did, ActorIdentifier } from '@atcute/lexicons'; 3 + import { getActor } from '$lib/actor'; 4 + import { 5 + flattenEventRecord, 6 + getProfileFromContrail, 7 + getProfileBlobUrl, 8 + getServerClient, 9 + type FlatEventRecord, 10 + type HostProfile 11 + } from '$lib/contrail'; 12 + import { getPrivateSpace } from '$lib/spaces/server/spaces.remote'; 13 + import { SPACE_TYPE } from '$lib/spaces/config'; 14 + 15 + export const load: PageServerLoad = async ({ params, locals, platform, url }) => { 16 + const ownerDid = await getActor(params.actor); 17 + if (!ownerDid) { 18 + return { authState: 'not-found' as const }; 19 + } 20 + 21 + const spaceUri = `at://${ownerDid}/${SPACE_TYPE}/${params.skey}`; 22 + const inviteToken = url.searchParams.get('invite') ?? undefined; 23 + const hasInvite = inviteToken != null; 24 + 25 + // Anonymous viewer with a `?invite=...` token: try to read the space using 26 + // the bearer-token path (works only for `read` / `read-join` invites). On 27 + // success we render the event in read-only mode; on failure we drop back to 28 + // the standard auth flow (anon prompt or pending-invite redeem). 29 + if (!locals.did) { 30 + if (!hasInvite) return { authState: 'anon' as const }; 31 + const anon = await loadByInviteToken( 32 + platform!.env.DB, 33 + spaceUri, 34 + inviteToken!, 35 + ownerDid, 36 + params.actor, 37 + params.rkey 38 + ); 39 + if (anon) return anon; 40 + return { authState: 'anon' as const }; 41 + } 42 + 43 + const spaceResult = await getPrivateSpace({ spaceUri }).catch((e) => { 44 + console.error('[private-event-load] getPrivateSpace threw unexpectedly:', e); 45 + return { ok: false as const, status: 500 }; 46 + }); 47 + if (!spaceResult.ok) { 48 + if (hasInvite) return { authState: 'pending-invite' as const }; 49 + return { authState: 'no-access' as const }; 50 + } 51 + const spaceData = spaceResult; 52 + 53 + // Find the specific event this URL is for, not just the first one in the space. 54 + // Supports spaces that hold multiple events (future) without breaking existing 55 + // links when a space has a single event. 56 + const stored = spaceData.events.find((e) => e.rkey === params.rkey); 57 + if (!stored) { 58 + return { authState: 'no-access' as const }; 59 + } 60 + 61 + const synthesizedRecord = { 62 + record: stored.record as Record<string, unknown>, 63 + cid: stored.cid ?? null, 64 + did: stored.authorDid, 65 + rkey: stored.rkey, 66 + uri: `at://${stored.authorDid}/${stored.collection}/${stored.rkey}`, 67 + space: spaceUri 68 + }; 69 + const eventData = flattenEventRecord(synthesizedRecord as never) as FlatEventRecord | null; 70 + if (!eventData) { 71 + return { authState: 'no-access' as const }; 72 + } 73 + 74 + const client = getServerClient(platform!.env.DB); 75 + let hostProfile: HostProfile | null = null; 76 + try { 77 + const p = await getProfileFromContrail(client, ownerDid as ActorIdentifier); 78 + if (p) { 79 + hostProfile = { 80 + did: p.did, 81 + handle: p.handle && p.handle !== 'handle.invalid' ? p.handle : ownerDid, 82 + displayName: p.record?.displayName, 83 + avatar: p.record?.avatar ? getProfileBlobUrl(p.did, p.record.avatar) : undefined 84 + }; 85 + } 86 + } catch { 87 + // best-effort 88 + } 89 + 90 + const rsvps = (spaceData.rsvps ?? []) as Array<{ 91 + authorDid: string; 92 + rkey: string; 93 + record: { status?: string; createdAt?: string; subject?: { uri?: string } }; 94 + }>; 95 + const going: Array<{ did: string; rkey: string; createdAt?: string }> = []; 96 + const interested: Array<{ did: string; rkey: string; createdAt?: string }> = []; 97 + let viewerRsvpStatus: 'going' | 'interested' | 'notgoing' | null = null; 98 + let viewerRsvpRkey: string | null = null; 99 + 100 + for (const r of rsvps) { 101 + const statusFull = r.record?.status ?? ''; 102 + const shortStatus = statusFull.split('#')[1] as 'going' | 'interested' | 'notgoing' | undefined; 103 + if (shortStatus === 'going') 104 + going.push({ did: r.authorDid, rkey: r.rkey, createdAt: r.record?.createdAt }); 105 + else if (shortStatus === 'interested') 106 + interested.push({ did: r.authorDid, rkey: r.rkey, createdAt: r.record?.createdAt }); 107 + if (r.authorDid === locals.did && shortStatus) { 108 + viewerRsvpStatus = shortStatus; 109 + viewerRsvpRkey = r.rkey; 110 + } 111 + } 112 + 113 + const attendeeDids = Array.from(new Set([...going, ...interested].map((a) => a.did))); 114 + const profileMap = new Map<string, HostProfile>(); 115 + await Promise.all( 116 + attendeeDids.map(async (did) => { 117 + try { 118 + const p = await getProfileFromContrail(client, did as ActorIdentifier); 119 + if (p) { 120 + profileMap.set(did, { 121 + did: p.did, 122 + handle: p.handle && p.handle !== 'handle.invalid' ? p.handle : did, 123 + displayName: p.record?.displayName, 124 + avatar: p.record?.avatar ? getProfileBlobUrl(p.did, p.record.avatar) : undefined 125 + }); 126 + } 127 + } catch { 128 + /* best-effort */ 129 + } 130 + }) 131 + ); 132 + 133 + const shapeAttendee = (a: { did: string; rkey: string; createdAt?: string }) => { 134 + const p = profileMap.get(a.did); 135 + return { 136 + did: a.did, 137 + rkey: a.rkey, 138 + handle: p?.handle ?? a.did, 139 + displayName: p?.displayName, 140 + avatar: p?.avatar, 141 + createdAt: a.createdAt 142 + }; 143 + }; 144 + 145 + return { 146 + authState: 'member' as const, 147 + ownerDid: ownerDid as Did, 148 + spaceUri, 149 + spaceKey: params.skey, 150 + isOwner: ownerDid === locals.did, 151 + eventData, 152 + actorDid: ownerDid, 153 + rkey: params.rkey, 154 + hostProfile, 155 + attendees: { 156 + going: going.map(shapeAttendee), 157 + interested: interested.map(shapeAttendee), 158 + goingCount: going.length, 159 + interestedCount: interested.length 160 + }, 161 + viewerRsvpStatus, 162 + viewerRsvpRkey, 163 + parentEvent: null, 164 + vod: null, 165 + speakerProfiles: [] as Array<{ id?: string; name: string; avatar?: string; handle?: string }>, 166 + ogImage: undefined as string | undefined 167 + }; 168 + }; 169 + 170 + /** Anonymous read using a `read` / `read-join` invite token. Hits the spaces 171 + * XRPCs through the in-process server client with `inviteToken=...` instead 172 + * of a service-auth JWT. Returns the page data on success, or null if the 173 + * token is invalid/expired/revoked or the event isn't in the space. */ 174 + async function loadByInviteToken( 175 + db: D1Database, 176 + spaceUri: string, 177 + inviteToken: string, 178 + ownerDid: string, 179 + _actor: string, 180 + eventRkey: string 181 + ) { 182 + const client = getServerClient(db); 183 + const spaceUriParam = spaceUri as unknown as import('@atcute/lexicons').ResourceUri; 184 + 185 + const [spaceRes, eventsRes, rsvpsRes] = await Promise.all([ 186 + client.get('rsvp.atmo.space.getSpace', { 187 + params: { uri: spaceUriParam, inviteToken } 188 + }), 189 + client.get('rsvp.atmo.space.listRecords', { 190 + params: { 191 + spaceUri: spaceUriParam, 192 + collection: 'community.lexicon.calendar.event' as `${string}.${string}.${string}`, 193 + inviteToken 194 + } 195 + }), 196 + client.get('rsvp.atmo.space.listRecords', { 197 + params: { 198 + spaceUri: spaceUriParam, 199 + collection: 'community.lexicon.calendar.rsvp' as `${string}.${string}.${string}`, 200 + inviteToken 201 + } 202 + }) 203 + ]); 204 + 205 + if (!spaceRes.ok || !eventsRes.ok) return null; 206 + 207 + const events = (eventsRes.data.records ?? []) as Array<{ 208 + authorDid: string; 209 + rkey: string; 210 + cid?: string | null; 211 + collection: string; 212 + record: Record<string, unknown>; 213 + }>; 214 + const stored = events.find((e) => e.rkey === eventRkey); 215 + if (!stored) return null; 216 + 217 + const eventData = flattenEventRecord({ 218 + record: stored.record, 219 + cid: stored.cid ?? null, 220 + did: stored.authorDid, 221 + rkey: stored.rkey, 222 + uri: `at://${stored.authorDid}/${stored.collection}/${stored.rkey}`, 223 + space: spaceUri 224 + } as never) as FlatEventRecord | null; 225 + if (!eventData) return null; 226 + 227 + const rsvps = (rsvpsRes.ok ? rsvpsRes.data.records ?? [] : []) as Array<{ 228 + authorDid: string; 229 + rkey: string; 230 + record: { status?: string; createdAt?: string }; 231 + }>; 232 + 233 + // Resolve profiles for host + RSVP authors via the public profile endpoint 234 + // (no auth needed). Without this, attendees show as bare DIDs. 235 + const profileDids = Array.from(new Set([ownerDid, ...rsvps.map((r) => r.authorDid)])); 236 + const profileMap = new Map<string, HostProfile>(); 237 + await Promise.all( 238 + profileDids.map(async (d) => { 239 + try { 240 + const p = await getProfileFromContrail(client, d as ActorIdentifier); 241 + if (p) { 242 + profileMap.set(d, { 243 + did: p.did, 244 + handle: p.handle && p.handle !== 'handle.invalid' ? p.handle : d, 245 + displayName: p.record?.displayName, 246 + avatar: p.record?.avatar ? getProfileBlobUrl(p.did, p.record.avatar) : undefined 247 + }); 248 + } 249 + } catch { 250 + /* best-effort */ 251 + } 252 + }) 253 + ); 254 + 255 + const hostProfile = profileMap.get(ownerDid) ?? null; 256 + 257 + const shapeAttendee = (a: { did: string; rkey: string; createdAt?: string }) => { 258 + const p = profileMap.get(a.did); 259 + return { 260 + did: a.did, 261 + rkey: a.rkey, 262 + handle: p?.handle ?? a.did, 263 + displayName: p?.displayName, 264 + avatar: p?.avatar, 265 + createdAt: a.createdAt 266 + }; 267 + }; 268 + 269 + const going: Array<{ did: string; rkey: string; createdAt?: string }> = []; 270 + const interested: Array<{ did: string; rkey: string; createdAt?: string }> = []; 271 + for (const r of rsvps) { 272 + const short = r.record?.status?.split('#')[1]; 273 + if (short === 'going') 274 + going.push({ did: r.authorDid, rkey: r.rkey, createdAt: r.record?.createdAt }); 275 + else if (short === 'interested') 276 + interested.push({ did: r.authorDid, rkey: r.rkey, createdAt: r.record?.createdAt }); 277 + } 278 + 279 + return { 280 + authState: 'member' as const, 281 + ownerDid: ownerDid as Did, 282 + spaceUri, 283 + spaceKey: spaceUri.split('/').pop() ?? '', 284 + isOwner: false, 285 + eventData, 286 + actorDid: ownerDid, 287 + rkey: eventRkey, 288 + hostProfile, 289 + attendees: { 290 + going: going.map(shapeAttendee), 291 + interested: interested.map(shapeAttendee), 292 + goingCount: going.length, 293 + interestedCount: interested.length 294 + }, 295 + viewerRsvpStatus: null as 'going' | 'interested' | 'notgoing' | null, 296 + viewerRsvpRkey: null as string | null, 297 + parentEvent: null, 298 + vod: null, 299 + speakerProfiles: [] as Array<{ id?: string; name: string; avatar?: string; handle?: string }>, 300 + ogImage: undefined as string | undefined, 301 + viaInviteToken: true 302 + }; 303 + }
+82
src/routes/(app)/p/[actor]/e/[rkey]/s/[skey]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { goto, invalidateAll } from '$app/navigation'; 4 + import { page } from '$app/state'; 5 + import EventView from '$lib/components/EventView.svelte'; 6 + import { redeemInvite } from '$lib/spaces/server/spaces.remote'; 7 + import { atProtoLoginModalState } from '$lib/components/LoginModal.svelte'; 8 + 9 + let { data } = $props(); 10 + 11 + let inviteBusy = $state(false); 12 + let inviteError: string | null = $state(null); 13 + 14 + onMount(async () => { 15 + const token = page.url.searchParams.get('invite'); 16 + if (!token) return; 17 + if (data.authState === 'anon') return; 18 + // Anonymous viewer who got in via the read-token bearer path — no 19 + // redemption to do (they're not logged in, the link is just for reading). 20 + if ('viaInviteToken' in data && data.viaInviteToken) return; 21 + 22 + inviteBusy = true; 23 + try { 24 + await redeemInvite({ token }); 25 + const u = new URL(page.url); 26 + u.searchParams.delete('invite'); 27 + await goto(u.pathname + u.search, { replaceState: true, invalidateAll: true }); 28 + } catch (e) { 29 + inviteError = e instanceof Error ? e.message : String(e); 30 + await invalidateAll(); 31 + } finally { 32 + inviteBusy = false; 33 + } 34 + }); 35 + </script> 36 + 37 + {#if data.authState === 'anon'} 38 + <div class="mx-auto max-w-md px-4 pt-16 text-center"> 39 + <h1 class="mb-2 text-xl font-semibold">Login to see event</h1> 40 + <p class="text-base-500 mb-4 text-sm"> 41 + This is a private event. Sign in with atproto to view. 42 + </p> 43 + <button 44 + class="bg-base-900 dark:bg-base-50 dark:text-base-900 rounded-md px-4 py-2 text-sm font-medium text-white" 45 + onclick={() => atProtoLoginModalState.show()} 46 + > 47 + Sign in 48 + </button> 49 + </div> 50 + {:else if data.authState === 'pending-invite'} 51 + <div class="mx-auto max-w-md px-4 pt-16 text-center"> 52 + <p class="text-base-500 text-sm">Redeeming invite…</p> 53 + {#if inviteError} 54 + <p class="mt-3 text-xs text-red-600">{inviteError}</p> 55 + {/if} 56 + </div> 57 + {:else if data.authState === 'no-access' || data.authState === 'not-found'} 58 + <div class="mx-auto max-w-md px-4 pt-16 text-center"> 59 + <h1 class="mb-2 text-xl font-semibold">Event not found</h1> 60 + <p class="text-base-500 text-sm"> 61 + This event doesn't exist, or you don't have permission to view it. 62 + </p> 63 + {#if inviteError} 64 + <p class="text-base-500 mt-3 text-xs">Invite redemption failed: {inviteError}</p> 65 + {/if} 66 + </div> 67 + {:else} 68 + {#if inviteBusy} 69 + <div class="mx-auto max-w-md px-4 pt-4 text-center"> 70 + <p class="text-base-500 text-sm">Redeeming invite…</p> 71 + </div> 72 + {/if} 73 + <EventView {data} /> 74 + {#if data.isOwner} 75 + <div class="mx-auto max-w-3xl px-4 pb-12"> 76 + <a 77 + href="/p/{data.hostProfile?.handle || data.ownerDid}/e/{data.rkey}/s/{data.spaceKey}/admin" 78 + class="text-base-500 text-sm underline">Manage members & invites →</a 79 + > 80 + </div> 81 + {/if} 82 + {/if}
+29
src/routes/(app)/p/[actor]/e/[rkey]/s/[skey]/admin/+page.server.ts
··· 1 + import { error, redirect } from '@sveltejs/kit'; 2 + import type { PageServerLoad } from './$types'; 3 + import { getActor } from '$lib/actor'; 4 + import { listMembers, listInvites } from '$lib/spaces/server/spaces.remote'; 5 + import { SPACE_TYPE } from '$lib/spaces/config'; 6 + 7 + export const load: PageServerLoad = async ({ params, locals }) => { 8 + if (!locals.did) redirect(303, '/login'); 9 + 10 + const ownerDid = await getActor(params.actor); 11 + if (!ownerDid) throw error(404, 'Not found'); 12 + if (ownerDid !== locals.did) throw error(403, 'Only the host can manage this event'); 13 + 14 + const spaceUri = `at://${ownerDid}/${SPACE_TYPE}/${params.skey}`; 15 + 16 + const [members, invites] = await Promise.all([ 17 + listMembers({ spaceUri }).catch(() => []), 18 + listInvites({ spaceUri }).catch(() => []) 19 + ]); 20 + 21 + return { 22 + spaceUri, 23 + actor: params.actor, 24 + rkey: params.rkey, 25 + spaceKey: params.skey, 26 + members, 27 + invites 28 + }; 29 + };
+216
src/routes/(app)/p/[actor]/e/[rkey]/s/[skey]/admin/+page.svelte
··· 1 + <script lang="ts"> 2 + import { invalidateAll } from '$app/navigation'; 3 + import { 4 + addMember, 5 + removeMember, 6 + createInvite, 7 + revokeInvite 8 + } from '$lib/spaces/server/spaces.remote'; 9 + 10 + let { data } = $props(); 11 + 12 + let tab: 'members' | 'invites' = $state('members'); 13 + 14 + let newMemberDid = $state(''); 15 + let newMemberPerms: 'read' | 'write' = $state('write'); 16 + let memberErr: string | null = $state(null); 17 + let memberBusy = $state(false); 18 + 19 + let inviteExpiryDays = $state(7); 20 + let inviteMaxUses = $state<number | ''>(''); 21 + let inviteNote = $state(''); 22 + let inviteBusy = $state(false); 23 + let lastInviteToken: string | null = $state(null); 24 + 25 + async function handleAddMember(e: Event) { 26 + e.preventDefault(); 27 + memberErr = null; 28 + memberBusy = true; 29 + try { 30 + await addMember({ 31 + spaceUri: data.spaceUri, 32 + did: newMemberDid.trim(), 33 + perms: newMemberPerms || undefined 34 + }); 35 + newMemberDid = ''; 36 + await invalidateAll(); 37 + } catch (err) { 38 + memberErr = (err as Error).message || 'Failed to add'; 39 + } finally { 40 + memberBusy = false; 41 + } 42 + } 43 + 44 + async function handleRemoveMember(did: string) { 45 + if (!confirm(`Remove ${did}?`)) return; 46 + await removeMember({ spaceUri: data.spaceUri, did }); 47 + await invalidateAll(); 48 + } 49 + 50 + async function handleCreateInvite(e: Event) { 51 + e.preventDefault(); 52 + inviteBusy = true; 53 + try { 54 + const result = await createInvite({ 55 + spaceUri: data.spaceUri, 56 + expiresAt: 57 + inviteExpiryDays > 0 ? Date.now() + inviteExpiryDays * 24 * 3600 * 1000 : undefined, 58 + maxUses: typeof inviteMaxUses === 'number' ? inviteMaxUses : undefined, 59 + note: inviteNote || undefined 60 + }); 61 + lastInviteToken = result.token; 62 + inviteNote = ''; 63 + await invalidateAll(); 64 + } finally { 65 + inviteBusy = false; 66 + } 67 + } 68 + 69 + async function handleRevoke(tokenHash: string) { 70 + if (!confirm('Revoke this invite?')) return; 71 + await revokeInvite({ spaceUri: data.spaceUri, tokenHash }); 72 + await invalidateAll(); 73 + } 74 + 75 + function inviteUrl(token: string) { 76 + const origin = typeof location !== 'undefined' ? location.origin : ''; 77 + return `${origin}/p/${data.actor}/e/${data.rkey}/s/${data.spaceKey}?invite=${token}`; 78 + } 79 + </script> 80 + 81 + <svelte:head> 82 + <title>Manage private event</title> 83 + </svelte:head> 84 + 85 + <div class="mx-auto max-w-3xl px-4 pt-6 pb-12"> 86 + <div class="mb-4"> 87 + <a 88 + href="/p/{data.actor}/e/{data.rkey}/s/{data.spaceKey}" 89 + class="text-base-500 text-sm">← back to event</a 90 + > 91 + </div> 92 + 93 + <h1 class="mb-4 text-2xl font-semibold">Manage private event</h1> 94 + 95 + <nav class="mb-4 flex gap-4 border-b border-base-200 dark:border-base-800"> 96 + {#each ['members', 'invites'] as t (t)} 97 + <button 98 + class:border-b-2={tab === t} 99 + class:border-base-900={tab === t} 100 + class:dark:border-base-50={tab === t} 101 + class="-mb-px pb-2 text-sm capitalize" 102 + onclick={() => (tab = t as typeof tab)} 103 + > 104 + {t} 105 + {#if t === 'members'}<span class="text-base-500 ml-1">({data.members.length})</span>{/if} 106 + {#if t === 'invites'}<span class="text-base-500 ml-1">({data.invites.length})</span>{/if} 107 + </button> 108 + {/each} 109 + </nav> 110 + 111 + {#if tab === 'members'} 112 + <form onsubmit={handleAddMember} class="mb-6 flex flex-wrap gap-2"> 113 + <input 114 + bind:value={newMemberDid} 115 + placeholder="did:plc:..." 116 + class="border-base-300 dark:border-base-700 dark:bg-base-900 flex-1 rounded-md border px-2 py-1.5 text-sm" 117 + /> 118 + <select 119 + bind:value={newMemberPerms} 120 + class="border-base-300 dark:border-base-700 dark:bg-base-900 w-28 rounded-md border px-2 py-1.5 text-sm" 121 + > 122 + <option value="write">write</option> 123 + <option value="read">read</option> 124 + </select> 125 + <button 126 + type="submit" 127 + disabled={memberBusy} 128 + class="bg-base-900 dark:bg-base-50 dark:text-base-900 rounded-md px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50" 129 + >Add</button 130 + > 131 + </form> 132 + {#if memberErr} 133 + <p class="mb-3 text-sm text-red-600">{memberErr}</p> 134 + {/if} 135 + <ul class="divide-base-200 dark:divide-base-800 divide-y"> 136 + {#each data.members as m (m.did)} 137 + <li class="flex items-center justify-between py-2"> 138 + <div> 139 + <div class="font-mono text-xs">{m.did}</div> 140 + <div class="text-base-500 text-xs">{m.perms}</div> 141 + </div> 142 + <button class="text-xs text-red-600" onclick={() => handleRemoveMember(m.did)} 143 + >Remove</button 144 + > 145 + </li> 146 + {/each} 147 + </ul> 148 + {:else} 149 + <form onsubmit={handleCreateInvite} class="mb-6 space-y-2"> 150 + <div class="flex gap-2"> 151 + <label class="text-sm"> 152 + Expires in 153 + <input 154 + type="number" 155 + min="0" 156 + bind:value={inviteExpiryDays} 157 + class="border-base-300 dark:border-base-700 dark:bg-base-900 ml-1 w-16 rounded-md border px-2 py-1 text-sm" 158 + /> 159 + days (0 = never) 160 + </label> 161 + <label class="text-sm"> 162 + Max uses 163 + <input 164 + type="number" 165 + min="1" 166 + bind:value={inviteMaxUses} 167 + placeholder="∞" 168 + class="border-base-300 dark:border-base-700 dark:bg-base-900 ml-1 w-16 rounded-md border px-2 py-1 text-sm" 169 + /> 170 + </label> 171 + </div> 172 + <input 173 + bind:value={inviteNote} 174 + placeholder="Note (optional)" 175 + class="border-base-300 dark:border-base-700 dark:bg-base-900 w-full rounded-md border px-2 py-1.5 text-sm" 176 + /> 177 + <button 178 + type="submit" 179 + disabled={inviteBusy} 180 + class="bg-base-900 dark:bg-base-50 dark:text-base-900 rounded-md px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50" 181 + >Create invite</button 182 + > 183 + </form> 184 + 185 + {#if lastInviteToken} 186 + <div 187 + class="mb-6 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/40" 188 + > 189 + <div class="mb-1 font-medium">Invite URL — copy now, not shown again:</div> 190 + <code class="dark:bg-base-900 block rounded bg-white p-2 font-mono text-xs break-all" 191 + >{inviteUrl(lastInviteToken)}</code 192 + > 193 + </div> 194 + {/if} 195 + 196 + <ul class="divide-base-200 dark:divide-base-800 divide-y"> 197 + {#each data.invites as inv (inv.tokenHash)} 198 + <li class="flex items-start justify-between py-2 text-sm"> 199 + <div> 200 + <div class="font-medium">{inv.note || '(no note)'}</div> 201 + <div class="text-base-500 text-xs"> 202 + uses {inv.usedCount}{inv.maxUses ? `/${inv.maxUses}` : ''} 203 + {#if inv.expiresAt}• expires {new Date(inv.expiresAt).toLocaleDateString()}{/if} 204 + {#if inv.revokedAt}• <span class="text-red-600">revoked</span>{/if} 205 + </div> 206 + </div> 207 + {#if !inv.revokedAt} 208 + <button class="text-xs text-red-600" onclick={() => handleRevoke(inv.tokenHash)} 209 + >Revoke</button 210 + > 211 + {/if} 212 + </li> 213 + {/each} 214 + </ul> 215 + {/if} 216 + </div>
+1 -1
src/routes/(app)/p/atmosphereconf.org/+page.server.ts
··· 19 19 limit: 200 20 20 }), 21 21 locals.did 22 - ? client.get('community.lexicon.calendar.rsvp.listRecords', { 22 + ? client.get('rsvp.atmo.rsvp.listRecords', { 23 23 params: { actor: locals.did, limit: 200 } 24 24 }) 25 25 : null
+6 -2
src/routes/(app)/search/+page.server.ts
··· 1 - import { flattenEventRecords, getServerClient, listEventRecordsFromContrail } from '$lib/contrail'; 1 + import { 2 + flattenEventRecords, 3 + getServerClient, 4 + listDiscoverableEventsFromContrail 5 + } from '$lib/contrail'; 2 6 import type { PageServerLoad } from './$types'; 3 7 4 8 const PAGE_SIZE = 20; ··· 10 14 11 15 if (!q) return { events: [], handles: {}, cursor: null, query: '' }; 12 16 13 - const response = await listEventRecordsFromContrail(client, { 17 + const response = await listDiscoverableEventsFromContrail(client, { 14 18 search: q, 15 19 profiles: true, 16 20 sort: 'startsAt',
+1 -1
vite.config.ts
··· 9 9 server: { 10 10 host: '127.0.0.1', 11 11 port: DEV_PORT, 12 - allowedHosts: [] 12 + allowedHosts: ['described-yamaha-fame-social.trycloudflare.com'] 13 13 } 14 14 });
+2 -2
wrangler.jsonc
··· 17 17 "d1_databases": [ 18 18 { 19 19 "binding": "DB", 20 - "database_name": "atmo-events", 21 - "database_id": "b57d0c5a-e5ed-47f2-9f30-b39fa70cc068", 20 + "database_name": "atmo-events-v2", 21 + "database_id": "7ac1d7f2-afc2-4ac6-8fce-d192c02f63a5", 22 22 "remote": true 23 23 } 24 24 ],