The repository screen gets Like and Save keys. Save opens a sheet with the lists you can change (yours and your organizations'), ticked where the repository is, and makes a new list with it on the spot. Profiles and the You tab show repositories, lists and liked repositories in tabs, and a list's screen removes repositories, renames the list, makes it public or private, or deletes it. /lists/<owner>/<slug> links open in the app. /api/v1 gains POST …/like, GET and POST …/lists, POST /lists and GET, PATCH, DELETE /lists/<owner>/<slug>; repositories carry their likes, and profiles their lists and liked repositories. RefreshOnReturn kept its "first show" flag in remember, which is lost while another screen is on top, so screens never reloaded on return. It is saved with the screen's state now. The app is version 1.2.0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 14 changed files with 838 additions and 26 deletions.
| @@ -12,8 +12,8 @@ android { | ||
| 12 | 12 | applicationId = "cc.vectrum.app" |
| 13 | 13 | minSdk = 26 |
| 14 | 14 | targetSdk = 36 |
| 15 | - versionCode = 2 | |
| 16 | - versionName = "1.1.0" | |
| 15 | + versionCode = 3 | |
| 16 | + versionName = "1.2.0" | |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | 19 | buildTypes { |
| @@ -169,6 +169,40 @@ class Api(val http: OkHttpClient, private val server: HttpUrl, private val token | ||
| 169 | 169 | suspend fun run(owner: String, name: String, number: Int): RunDetail = get("${repo(owner, name)}/runs/$number") |
| 170 | 170 | suspend fun releases(owner: String, name: String): ReleasesData = get("${repo(owner, name)}/releases") |
| 171 | 171 | |
| 172 | + suspend fun like(owner: String, name: String, liked: Boolean): LikeResult = | |
| 173 | + send("POST", "${repo(owner, name)}/like", buildJsonObject { put("liked", JsonPrimitive(liked)) }) | |
| 174 | + suspend fun saveTargets(owner: String, name: String): SaveTargets = get("${repo(owner, name)}/lists") | |
| 175 | + suspend fun setSaved(owner: String, name: String, listId: String, saved: Boolean): SaveTargets = | |
| 176 | + send("POST", "${repo(owner, name)}/lists", buildJsonObject { put("list", JsonPrimitive(listId)); put("saved", JsonPrimitive(saved)) }) | |
| 177 | + | |
| 178 | + /** A new list for [owner] (you, or one of your organizations), with the repository [repo] ("owner/name") in it if given. */ | |
| 179 | + suspend fun createList(owner: String, name: String, private: Boolean, description: String = "", repo: String? = null): RepoListInfo = | |
| 180 | + send<ListResponse>( | |
| 181 | + "POST", | |
| 182 | + "lists", | |
| 183 | + buildJsonObject { | |
| 184 | + put("owner", JsonPrimitive(owner)) | |
| 185 | + put("name", JsonPrimitive(name)) | |
| 186 | + put("description", JsonPrimitive(description)) | |
| 187 | + put("visibility", JsonPrimitive(if (private) "private" else "public")) | |
| 188 | + if (repo != null) put("repo", JsonPrimitive(repo)) | |
| 189 | + }, | |
| 190 | + ).list | |
| 191 | + suspend fun list(owner: String, slug: String): ListDetail = get("lists/$owner/$slug") | |
| 192 | + suspend fun updateList(owner: String, slug: String, name: String, description: String, private: Boolean): RepoListInfo = | |
| 193 | + send<ListResponse>( | |
| 194 | + "PATCH", | |
| 195 | + "lists/$owner/$slug", | |
| 196 | + buildJsonObject { | |
| 197 | + put("name", JsonPrimitive(name)) | |
| 198 | + put("description", JsonPrimitive(description)) | |
| 199 | + put("visibility", JsonPrimitive(if (private) "private" else "public")) | |
| 200 | + }, | |
| 201 | + ).list | |
| 202 | + suspend fun deleteList(owner: String, slug: String) { | |
| 203 | + call(request(url("lists/$owner/$slug")).delete().build()) | |
| 204 | + } | |
| 205 | + | |
| 172 | 206 | companion object { |
| 173 | 207 | private val JSON = "application/json; charset=utf-8".toMediaType() |
| 174 | 208 | |
| @@ -39,6 +39,7 @@ data class RepoSummary( | ||
| 39 | 39 | val createdAt: Long = 0, |
| 40 | 40 | val updatedAt: Long = 0, |
| 41 | 41 | val empty: Boolean = false, |
| 42 | + val likes: Int = 0, | |
| 42 | 43 | ) { |
| 43 | 44 | val isPrivate: Boolean get() = visibility == "private" |
| 44 | 45 | val fullName: String get() = "$owner/$name" |
| @@ -90,7 +91,43 @@ data class HomeData( | ||
| 90 | 91 | ) |
| 91 | 92 | |
| 92 | 93 | @Serializable data class ReposData(val repos: List<RepoSummary>) |
| 93 | - @Serializable data class ProfileData(val user: User, val repos: List<RepoSummary>) | |
| 94 | + @Serializable | |
| 95 | + data class ProfileData( | |
| 96 | + val user: User, | |
| 97 | + val repos: List<RepoSummary>, | |
| 98 | + val isOrg: Boolean = false, | |
| 99 | + val lists: List<RepoListInfo> = emptyList(), | |
| 100 | + /** The repositories a person likes (none for organizations). */ | |
| 101 | + val liked: List<RepoSummary> = emptyList(), | |
| 102 | + /** Whether the viewer may make and change this account's lists. */ | |
| 103 | + val canManageLists: Boolean = false, | |
| 104 | + ) | |
| 105 | + | |
| 106 | + /** A list of repositories that a person or an organization keeps, public or private. */ | |
| 107 | + @Serializable | |
| 108 | + data class RepoListInfo( | |
| 109 | + val id: String, | |
| 110 | + val owner: String, | |
| 111 | + val ownerIsOrg: Boolean = false, | |
| 112 | + val name: String, | |
| 113 | + val slug: String, | |
| 114 | + val description: String = "", | |
| 115 | + val visibility: String = "public", | |
| 116 | + val createdAt: Long = 0, | |
| 117 | + val updatedAt: Long = 0, | |
| 118 | + /** How many of its repositories the viewer may see. */ | |
| 119 | + val repoCount: Int = 0, | |
| 120 | + /** In the Save sheet: whether the repository is in it. */ | |
| 121 | + val saved: Boolean = false, | |
| 122 | + ) { | |
| 123 | + val isPrivate: Boolean get() = visibility == "private" | |
| 124 | + } | |
| 125 | + | |
| 126 | + /** The lists a repository can be saved to (the viewer's and their organizations'), for the Save sheet. */ | |
| 127 | + @Serializable data class SaveTargets(val owners: List<String> = emptyList(), val lists: List<RepoListInfo> = emptyList()) | |
| 128 | + @Serializable data class ListDetail(val list: RepoListInfo, val repos: List<RepoSummary> = emptyList(), val canManage: Boolean = false) | |
| 129 | + @Serializable data class ListResponse(val list: RepoListInfo) | |
| 130 | + @Serializable data class LikeResult(val liked: Boolean, val likes: Int) | |
| 94 | 131 | |
| 95 | 132 | @Serializable data class TreeEntry(val name: String, val path: String, val type: String, val size: Long? = null) { |
| 96 | 133 | val isDir: Boolean get() = type == "tree" |
| @@ -106,6 +143,9 @@ data class RepoDetail( | ||
| 106 | 143 | val repo: RepoSummary, |
| 107 | 144 | val access: String, |
| 108 | 145 | val canWrite: Boolean, |
| 146 | + /** Whether the viewer likes it, and whether it's in any of their lists. */ | |
| 147 | + val liked: Boolean = false, | |
| 148 | + val saved: Boolean = false, | |
| 109 | 149 | val defaultBranch: String? = null, |
| 110 | 150 | val branches: List<String> = emptyList(), |
| 111 | 151 | val tags: List<String> = emptyList(), |
| @@ -64,6 +64,7 @@ import cc.vectrum.app.ui.screens.ExploreScreen | ||
| 64 | 64 | import cc.vectrum.app.ui.screens.FileScreen |
| 65 | 65 | import cc.vectrum.app.ui.screens.HomeScreen |
| 66 | 66 | import cc.vectrum.app.ui.screens.IssueScreen |
| 67 | + import cc.vectrum.app.ui.screens.ListScreen | |
| 67 | 68 | import cc.vectrum.app.ui.screens.MeScreen |
| 68 | 69 | import cc.vectrum.app.ui.screens.NewIssueScreen |
| 69 | 70 | import cc.vectrum.app.ui.screens.ProfileScreen |
| @@ -92,6 +93,7 @@ import kotlinx.serialization.Serializable | ||
| 92 | 93 | @Serializable data class NewIssueRoute(val owner: String, val name: String) |
| 93 | 94 | @Serializable data class PullRoute(val owner: String, val name: String, val number: Int) |
| 94 | 95 | @Serializable data class RunRoute(val owner: String, val name: String, val number: Int) |
| 96 | + @Serializable data class ListRoute(val owner: String, val slug: String) | |
| 95 | 97 | |
| 96 | 98 | val LocalApi = staticCompositionLocalOf<Api?> { null } |
| 97 | 99 | val LocalSession = staticCompositionLocalOf<Session?> { null } |
| @@ -112,6 +114,7 @@ class Nav(val controller: NavHostController, private val openUrl: (String) -> Un | ||
| 112 | 114 | fun pull(owner: String, name: String, number: Int) = controller.navigate(PullRoute(owner, name, number)) |
| 113 | 115 | fun run(owner: String, name: String, number: Int) = controller.navigate(RunRoute(owner, name, number)) |
| 114 | 116 | fun profile(username: String) = controller.navigate(ProfileRoute(username)) |
| 117 | + fun list(owner: String, slug: String) = controller.navigate(ListRoute(owner, slug)) | |
| 115 | 118 | |
| 116 | 119 | /** Switches to a top-level tab (Home, Explore, You), keeping each tab's state. */ |
| 117 | 120 | fun tab(route: Any) = controller.navigate(route) { |
| @@ -153,6 +156,11 @@ class Nav(val controller: NavHostController, private val openUrl: (String) -> Un | ||
| 153 | 156 | if (parts.size == 1) { |
| 154 | 157 | profile(parts[0]); return true |
| 155 | 158 | } |
| 159 | + // /lists/<owner>/<slug>; "lists" can't be anyone's name. | |
| 160 | + if (parts[0] == "lists") { | |
| 161 | + if (parts.size != 3) return false | |
| 162 | + list(parts[1], parts[2]); return true | |
| 163 | + } | |
| 156 | 164 | val (owner, name) = parts[0] to parts[1].removeSuffix(".git") |
| 157 | 165 | val rest = parts.drop(2) |
| 158 | 166 | when (rest.firstOrNull()) { |
| @@ -230,6 +238,7 @@ fun MainShell(session: Session, api: Api, theme: ThemeMode, onTheme: (ThemeMode) | ||
| 230 | 238 | } |
| 231 | 239 | composable<PullRoute> { val r = it.toRoute<PullRoute>(); PullScreen(r.owner, r.name, r.number) } |
| 232 | 240 | composable<RunRoute> { val r = it.toRoute<RunRoute>(); RunScreen(r.owner, r.name, r.number) } |
| 241 | + composable<ListRoute> { val r = it.toRoute<ListRoute>(); ListScreen(r.owner, r.slug) } | |
| 233 | 242 | } |
| 234 | 243 | |
| 235 | 244 | if (topLevel) { |
| @@ -2,7 +2,7 @@ package cc.vectrum.app.ui | ||
| 2 | 2 | |
| 3 | 3 | import androidx.compose.runtime.Composable |
| 4 | 4 | import androidx.compose.runtime.getValue |
| 5 | - import androidx.compose.runtime.remember | |
| 5 | + import androidx.compose.runtime.saveable.rememberSaveable | |
| 6 | 6 | import androidx.compose.runtime.rememberUpdatedState |
| 7 | 7 | import androidx.lifecycle.compose.LifecycleResumeEffect |
| 8 | 8 | import androidx.compose.runtime.mutableStateListOf |
| @@ -166,7 +166,8 @@ fun <T> rememberPager(key: String, fetch: suspend (page: Int) -> Pair<List<T>, B | ||
| 166 | 166 | /** Runs `onReturn` when the screen comes back into view (after a screen on top of it closes), not on first show. */ |
| 167 | 167 | @Composable |
| 168 | 168 | fun RefreshOnReturn(onReturn: () -> Unit) { |
| 169 | - val first = remember { mutableStateOf(true) } | |
| 169 | + // Saved with the screen's state: it leaves the composition while another screen is on top of it. | |
| 170 | + val first = rememberSaveable { mutableStateOf(true) } | |
| 170 | 171 | val action by rememberUpdatedState(onReturn) |
| 171 | 172 | LifecycleResumeEffect(Unit) { |
| 172 | 173 | if (first.value) first.value = false else action() |
| @@ -111,5 +111,9 @@ object VIcons { | ||
| 111 | 111 | val Key = icon("Key", Stroke("M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"), dot(16.5f, 7.5f, 1.5f)) |
| 112 | 112 | val Image = icon("Image", Stroke(rect(3f, 3f, 18f, 18f, 3f), fill = 0.08f), Stroke(circle(9f, 9f, 2f)), Stroke("m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21")) |
| 113 | 113 | val Refresh = icon("Refresh", Stroke("M3 12a9 9 0 0 1 15.36-6.36L21 8"), Stroke("M21 3v5h-5"), Stroke("M21 12a9 9 0 0 1-15.36 6.36L3 16"), Stroke("M3 21v-5h5")) |
| 114 | + val Heart = icon("Heart", Stroke("M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z")) | |
| 115 | + val HeartFilled = icon("HeartFilled", Fill("M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"), Stroke("M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z")) | |
| 116 | + val Bookmark = icon("Bookmark", Stroke("m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z")) | |
| 117 | + val BookmarkFilled = icon("BookmarkFilled", Fill("m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"), Stroke("m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z")) | |
| 114 | 118 | val Send = icon("Send", Stroke("M14.54 21.69a.5.5 0 0 0 .94-.03l6.5-19a.5.5 0 0 0-.64-.64l-19 6.5a.5.5 0 0 0-.03.94l7.93 3.18a2 2 0 0 1 1.11 1.11z", fill = 0.1f), Stroke("m21.85 2.15-10.94 10.94")) |
| 115 | 119 | } |
| @@ -0,0 +1,450 @@ | ||
| 1 | + package cc.vectrum.app.ui.screens | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Arrangement | |
| 4 | + import androidx.compose.foundation.layout.Box | |
| 5 | + import androidx.compose.foundation.layout.Column | |
| 6 | + import androidx.compose.foundation.layout.ExperimentalLayoutApi | |
| 7 | + import androidx.compose.foundation.layout.FlowRow | |
| 8 | + import androidx.compose.foundation.layout.Row | |
| 9 | + import androidx.compose.foundation.layout.Spacer | |
| 10 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 11 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 12 | + import androidx.compose.foundation.layout.height | |
| 13 | + import androidx.compose.foundation.layout.heightIn | |
| 14 | + import androidx.compose.foundation.layout.padding | |
| 15 | + import androidx.compose.foundation.layout.size | |
| 16 | + import androidx.compose.foundation.layout.width | |
| 17 | + import androidx.compose.foundation.lazy.rememberLazyListState | |
| 18 | + import androidx.compose.foundation.rememberScrollState | |
| 19 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 20 | + import androidx.compose.foundation.verticalScroll | |
| 21 | + import androidx.compose.material3.Icon | |
| 22 | + import androidx.compose.material3.Text | |
| 23 | + import androidx.compose.runtime.Composable | |
| 24 | + import androidx.compose.runtime.LaunchedEffect | |
| 25 | + import androidx.compose.runtime.getValue | |
| 26 | + import androidx.compose.runtime.mutableStateOf | |
| 27 | + import androidx.compose.runtime.remember | |
| 28 | + import androidx.compose.runtime.rememberCoroutineScope | |
| 29 | + import androidx.compose.runtime.saveable.rememberSaveable | |
| 30 | + import androidx.compose.runtime.setValue | |
| 31 | + import androidx.compose.ui.Alignment | |
| 32 | + import androidx.compose.ui.Modifier | |
| 33 | + import androidx.compose.ui.text.style.TextOverflow | |
| 34 | + import androidx.compose.ui.unit.dp | |
| 35 | + import cc.vectrum.app.data.RepoListInfo | |
| 36 | + import cc.vectrum.app.data.SaveTargets | |
| 37 | + import cc.vectrum.app.ui.Load | |
| 38 | + import cc.vectrum.app.ui.LocalApi | |
| 39 | + import cc.vectrum.app.ui.LocalNav | |
| 40 | + import cc.vectrum.app.ui.LocalSession | |
| 41 | + import cc.vectrum.app.ui.RefreshOnReturn | |
| 42 | + import cc.vectrum.app.ui.components.ButtonKind | |
| 43 | + import cc.vectrum.app.ui.components.EmptyState | |
| 44 | + import cc.vectrum.app.ui.components.Engraved | |
| 45 | + import cc.vectrum.app.ui.components.Field | |
| 46 | + import cc.vectrum.app.ui.components.IconText | |
| 47 | + import cc.vectrum.app.ui.components.IconTile | |
| 48 | + import cc.vectrum.app.ui.components.Loaded | |
| 49 | + import cc.vectrum.app.ui.components.LocalToaster | |
| 50 | + import cc.vectrum.app.ui.components.Pill | |
| 51 | + import cc.vectrum.app.ui.components.Plate | |
| 52 | + import cc.vectrum.app.ui.components.PressableRow | |
| 53 | + import cc.vectrum.app.ui.components.RoundButton | |
| 54 | + import cc.vectrum.app.ui.components.Rule | |
| 55 | + import cc.vectrum.app.ui.components.Section | |
| 56 | + import cc.vectrum.app.ui.components.SegmentOption | |
| 57 | + import cc.vectrum.app.ui.components.Segmented | |
| 58 | + import cc.vectrum.app.ui.components.Tone | |
| 59 | + import cc.vectrum.app.ui.components.TopBar | |
| 60 | + import cc.vectrum.app.ui.components.VButton | |
| 61 | + import cc.vectrum.app.ui.components.VSheet | |
| 62 | + import cc.vectrum.app.ui.components.isScrolled | |
| 63 | + import cc.vectrum.app.ui.describe | |
| 64 | + import cc.vectrum.app.ui.icons.VIcons | |
| 65 | + import cc.vectrum.app.ui.plural | |
| 66 | + import cc.vectrum.app.ui.rememberLoader | |
| 67 | + import cc.vectrum.app.ui.theme.Vectrum | |
| 68 | + import cc.vectrum.app.ui.theme.well | |
| 69 | + import cc.vectrum.app.ui.timeAgo | |
| 70 | + import kotlinx.coroutines.launch | |
| 71 | + | |
| 72 | + // Likes and lists of repositories, like the website's (lib/lists.ts on the server): a person likes a | |
| 73 | + // repository once; people and organizations keep public or private lists of repositories. | |
| 74 | + | |
| 75 | + /** The repository's Like and Save keys, with how many people like it. */ | |
| 76 | + @Composable | |
| 77 | + fun LikeSaveRow(liked: Boolean, likes: Int, saved: Boolean, onLike: () -> Unit, onSave: () -> Unit) { | |
| 78 | + val c = Vectrum.colors | |
| 79 | + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 80 | + VButton(if (liked) "Liked" else "Like", onLike, icon = if (liked) VIcons.HeartFilled else VIcons.Heart, small = true) | |
| 81 | + Text(plural(likes, "like"), style = Vectrum.type.readout, color = c.ink2) | |
| 82 | + Spacer(Modifier.weight(1f)) | |
| 83 | + VButton(if (saved) "Saved" else "Save", onSave, icon = if (saved) VIcons.BookmarkFilled else VIcons.Bookmark, small = true) | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + /** | |
| 88 | + * The lists the repository can go in (yours, then your organizations'), ticked where it is, and a | |
| 89 | + * field to make a new list with it. [onChanged] says whether it's in any list now. | |
| 90 | + */ | |
| 91 | + @Composable | |
| 92 | + fun SaveSheet(owner: String, name: String, onDismiss: () -> Unit, onChanged: (saved: Boolean) -> Unit) { | |
| 93 | + val api = LocalApi.current!! | |
| 94 | + val session = LocalSession.current!! | |
| 95 | + val toaster = LocalToaster.current | |
| 96 | + val scope = rememberCoroutineScope() | |
| 97 | + val loader = rememberLoader("save:$owner/$name") { api.saveTargets(owner, name) } | |
| 98 | + val c = Vectrum.colors | |
| 99 | + var newName by rememberSaveable { mutableStateOf("") } | |
| 100 | + var newPrivate by rememberSaveable { mutableStateOf(false) } | |
| 101 | + var newOwner by rememberSaveable { mutableStateOf(session.user.username) } | |
| 102 | + var creating by remember { mutableStateOf(false) } | |
| 103 | + // Lists may have changed elsewhere since the sheet was last open. | |
| 104 | + LaunchedEffect(Unit) { if (loader.state is Load.Ready) loader.load(refresh = true) } | |
| 105 | + | |
| 106 | + fun show(targets: SaveTargets) { | |
| 107 | + loader.set(targets) | |
| 108 | + onChanged(targets.lists.any { it.saved }) | |
| 109 | + } | |
| 110 | + | |
| 111 | + VSheet("Save to a list", onDismiss) { | |
| 112 | + Column(Modifier.heightIn(max = 600.dp).verticalScroll(rememberScrollState())) { | |
| 113 | + when (val s = loader.state) { | |
| 114 | + is Load.Loading -> Text("Loading…", style = Vectrum.type.meta, color = c.ink3, modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) | |
| 115 | + is Load.Failed -> Text(s.message, style = Vectrum.type.body, color = c.red, modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) | |
| 116 | + is Load.Ready -> { | |
| 117 | + val targets = s.data | |
| 118 | + if (targets.lists.isEmpty()) { | |
| 119 | + Text( | |
| 120 | + "Lists gather repositories to find them again or share them. Make your first one below.", | |
| 121 | + style = Vectrum.type.body, | |
| 122 | + color = c.ink2, | |
| 123 | + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), | |
| 124 | + ) | |
| 125 | + } | |
| 126 | + for (group in targets.owners) { | |
| 127 | + val lists = targets.lists.filter { it.owner == group } | |
| 128 | + if (lists.isEmpty()) continue | |
| 129 | + if (targets.owners.size > 1) { | |
| 130 | + Engraved(if (group == session.user.username) "Your lists" else group, Modifier.padding(start = 24.dp, end = 24.dp, top = 12.dp, bottom = 4.dp)) | |
| 131 | + } | |
| 132 | + for (list in lists) { | |
| 133 | + SaveTargetRow(list) { | |
| 134 | + val saved = !list.saved | |
| 135 | + // Ticks at once; what the server answers replaces it. | |
| 136 | + show(targets.copy(lists = targets.lists.map { if (it.id == list.id) it.copy(saved = saved) else it })) | |
| 137 | + scope.launch { | |
| 138 | + try { | |
| 139 | + show(api.setSaved(owner, name, list.id, saved)) | |
| 140 | + toaster.show(if (saved) "Saved to ${list.name}." else "Removed from ${list.name}.") | |
| 141 | + } catch (e: Exception) { | |
| 142 | + toaster.error(describe(e)) | |
| 143 | + loader.load(refresh = true) | |
| 144 | + } | |
| 145 | + } | |
| 146 | + } | |
| 147 | + } | |
| 148 | + } | |
| 149 | + Spacer(Modifier.height(12.dp)) | |
| 150 | + Rule(Modifier.padding(horizontal = 24.dp)) | |
| 151 | + Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { | |
| 152 | + Engraved("New list", Modifier.padding(start = 4.dp)) | |
| 153 | + Field(newName, { newName = it.take(60) }, placeholder = "e.g. Tools I use") | |
| 154 | + if (targets.owners.size > 1) { | |
| 155 | + Segmented(targets.owners.map { SegmentOption(it, it) }, selected = newOwner, onSelect = { newOwner = it }, scrollable = true) | |
| 156 | + } | |
| 157 | + Segmented( | |
| 158 | + listOf(SegmentOption(false, "Public", VIcons.Globe), SegmentOption(true, "Private", VIcons.Lock)), | |
| 159 | + selected = newPrivate, | |
| 160 | + onSelect = { newPrivate = it }, | |
| 161 | + ) | |
| 162 | + VButton( | |
| 163 | + "Create and Save", | |
| 164 | + { | |
| 165 | + creating = true | |
| 166 | + scope.launch { | |
| 167 | + try { | |
| 168 | + val list = api.createList(newOwner, newName.trim(), newPrivate, repo = "$owner/$name") | |
| 169 | + show(api.saveTargets(owner, name)) | |
| 170 | + newName = "" | |
| 171 | + toaster.show("Saved to ${list.name}.") | |
| 172 | + } catch (e: Exception) { | |
| 173 | + toaster.error(describe(e)) | |
| 174 | + } finally { | |
| 175 | + creating = false | |
| 176 | + } | |
| 177 | + } | |
| 178 | + }, | |
| 179 | + kind = ButtonKind.Primary, | |
| 180 | + icon = VIcons.Plus, | |
| 181 | + enabled = newName.isNotBlank(), | |
| 182 | + loading = creating, | |
| 183 | + fill = true, | |
| 184 | + ) | |
| 185 | + } | |
| 186 | + } | |
| 187 | + } | |
| 188 | + } | |
| 189 | + } | |
| 190 | + } | |
| 191 | + | |
| 192 | + /** A list in the Save sheet: a tick in a well, its name, and a lock if it's private. */ | |
| 193 | + @Composable | |
| 194 | + private fun SaveTargetRow(list: RepoListInfo, onToggle: () -> Unit) { | |
| 195 | + val c = Vectrum.colors | |
| 196 | + PressableRow(onToggle) { | |
| 197 | + Row(Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 198 | + Box(Modifier.size(24.dp).well(RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { | |
| 199 | + if (list.saved) Icon(VIcons.Check, "Saved", Modifier.size(17.dp), tint = c.greenDeep) | |
| 200 | + } | |
| 201 | + Spacer(Modifier.width(14.dp)) | |
| 202 | + Text(list.name, style = Vectrum.type.lead, color = c.ink, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) | |
| 203 | + if (list.isPrivate) Icon(VIcons.Lock, "Private", Modifier.size(15.dp), tint = c.ink3) | |
| 204 | + } | |
| 205 | + } | |
| 206 | + } | |
| 207 | + | |
| 208 | + /** A list in a profile's Lists tab. */ | |
| 209 | + @Composable | |
| 210 | + fun ListRow(list: RepoListInfo, onClick: () -> Unit) { | |
| 211 | + val c = Vectrum.colors | |
| 212 | + PressableRow(onClick) { | |
| 213 | + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.Top) { | |
| 214 | + IconTile(VIcons.Bookmark, size = 40.dp) | |
| 215 | + Spacer(Modifier.width(14.dp)) | |
| 216 | + Column(Modifier.weight(1f)) { | |
| 217 | + Text(list.name, style = Vectrum.type.title, color = c.ink, maxLines = 1, overflow = TextOverflow.Ellipsis) | |
| 218 | + if (list.description.isNotBlank()) { | |
| 219 | + Text(list.description, style = Vectrum.type.body, color = c.ink2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp)) | |
| 220 | + } | |
| 221 | + Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 222 | + if (list.isPrivate) IconText(VIcons.Lock, "Private") | |
| 223 | + IconText(VIcons.Repo, plural(list.repoCount, "repository", "repositories")) | |
| 224 | + Text("Updated ${timeAgo(list.updatedAt)}", style = Vectrum.type.meta, color = c.ink3, maxLines = 1) | |
| 225 | + } | |
| 226 | + } | |
| 227 | + } | |
| 228 | + } | |
| 229 | + } | |
| 230 | + | |
| 231 | + /** A list: its repositories, and for those who may change it, removing them, editing and deleting it. */ | |
| 232 | + @OptIn(ExperimentalLayoutApi::class) | |
| 233 | + @Composable | |
| 234 | + fun ListScreen(owner: String, slug: String) { | |
| 235 | + val api = LocalApi.current!! | |
| 236 | + val nav = LocalNav.current | |
| 237 | + val toaster = LocalToaster.current | |
| 238 | + val scope = rememberCoroutineScope() | |
| 239 | + val loader = rememberLoader("list:$owner/$slug") { api.list(owner, slug) } | |
| 240 | + val list = rememberLazyListState() | |
| 241 | + var editing by rememberSaveable { mutableStateOf(false) } | |
| 242 | + val c = Vectrum.colors | |
| 243 | + RefreshOnReturn { loader.load(refresh = true) } | |
| 244 | + | |
| 245 | + Column(Modifier.fillMaxSize()) { | |
| 246 | + TopBar( | |
| 247 | + loader.data?.list?.name ?: "List", | |
| 248 | + kicker = owner, | |
| 249 | + onBack = nav::back, | |
| 250 | + scrolled = list.isScrolled(), | |
| 251 | + titleVisible = list.firstVisibleItemIndex > 0, | |
| 252 | + actions = { if (loader.data?.canManage == true) RoundButton(VIcons.Pen, "Edit list", { editing = true }) }, | |
| 253 | + ) | |
| 254 | + Loaded(loader, state = list) { detail -> | |
| 255 | + val info = detail.list | |
| 256 | + item("header") { | |
| 257 | + Column(Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 22.dp)) { | |
| 258 | + Row(verticalAlignment = Alignment.CenterVertically) { | |
| 259 | + IconTile(VIcons.Bookmark, size = 56.dp) | |
| 260 | + Spacer(Modifier.width(16.dp)) | |
| 261 | + Column(Modifier.weight(1f)) { | |
| 262 | + Text(info.owner, style = Vectrum.type.ui, color = c.ink3, maxLines = 1, modifier = Modifier.padding(bottom = 2.dp)) | |
| 263 | + Text(info.name, style = Vectrum.type.display.copy(fontSize = Vectrum.type.stat.fontSize), color = c.ink, maxLines = 2, overflow = TextOverflow.Ellipsis) | |
| 264 | + } | |
| 265 | + } | |
| 266 | + if (info.description.isNotBlank()) { | |
| 267 | + Spacer(Modifier.height(14.dp)) | |
| 268 | + Text(info.description, style = Vectrum.type.lead, color = c.ink2) | |
| 269 | + } | |
| 270 | + Spacer(Modifier.height(14.dp)) | |
| 271 | + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), itemVerticalAlignment = Alignment.CenterVertically) { | |
| 272 | + Pill(if (info.isPrivate) "Private" else "Public", tone = if (info.isPrivate) Tone.Attention else Tone.Default) | |
| 273 | + if (info.ownerIsOrg) Pill("Organization", icon = VIcons.People) | |
| 274 | + } | |
| 275 | + } | |
| 276 | + } | |
| 277 | + item("repos") { | |
| 278 | + Section("Repositories", count = detail.repos.size) { | |
| 279 | + Plate(Modifier.fillMaxWidth()) { | |
| 280 | + if (detail.repos.isEmpty()) { | |
| 281 | + EmptyState( | |
| 282 | + VIcons.Bookmark, | |
| 283 | + "This list is empty", | |
| 284 | + text = if (detail.canManage) "Open a repository and use its Save key to put it here." else "Nothing has been saved to it yet.", | |
| 285 | + ) | |
| 286 | + } else { | |
| 287 | + PlateRows(detail.repos) { repo -> | |
| 288 | + RepoRow( | |
| 289 | + repo, | |
| 290 | + { nav.repo(repo.owner, repo.name) }, | |
| 291 | + trailing = if (detail.canManage) { | |
| 292 | + { | |
| 293 | + RoundButton(VIcons.Close, "Remove ${repo.name} from the list", { | |
| 294 | + scope.launch { | |
| 295 | + try { | |
| 296 | + api.setSaved(repo.owner, repo.name, info.id, false) | |
| 297 | + loader.set(detail.copy(repos = detail.repos.filter { it.id != repo.id }, list = info.copy(repoCount = info.repoCount - 1))) | |
| 298 | + toaster.show("Removed ${repo.name}.") | |
| 299 | + } catch (e: Exception) { | |
| 300 | + toaster.error(describe(e)) | |
| 301 | + } | |
| 302 | + } | |
| 303 | + }, size = 34.dp, ghost = true, tint = c.ink3) | |
| 304 | + } | |
| 305 | + } else { | |
| 306 | + null | |
| 307 | + }, | |
| 308 | + ) | |
| 309 | + } | |
| 310 | + } | |
| 311 | + } | |
| 312 | + } | |
| 313 | + } | |
| 314 | + } | |
| 315 | + } | |
| 316 | + | |
| 317 | + val current = loader.data?.list | |
| 318 | + if (editing && current != null) { | |
| 319 | + EditListSheet( | |
| 320 | + current, | |
| 321 | + onDismiss = { editing = false }, | |
| 322 | + onSaved = { updated -> | |
| 323 | + editing = false | |
| 324 | + if (updated.slug != slug) { | |
| 325 | + // Renaming moved it to a new address. | |
| 326 | + nav.back() | |
| 327 | + nav.list(updated.owner, updated.slug) | |
| 328 | + } else { | |
| 329 | + loader.load(refresh = true) | |
| 330 | + } | |
| 331 | + }, | |
| 332 | + onDeleted = { | |
| 333 | + editing = false | |
| 334 | + nav.back() | |
| 335 | + }, | |
| 336 | + ) | |
| 337 | + } | |
| 338 | + } | |
| 339 | + | |
| 340 | + /** A new list for [owner] (you, or an organization you can push in), from a profile's Lists tab. */ | |
| 341 | + @Composable | |
| 342 | + fun NewListSheet(owner: String, onDismiss: () -> Unit, onCreated: (RepoListInfo) -> Unit) { | |
| 343 | + val api = LocalApi.current!! | |
| 344 | + val toaster = LocalToaster.current | |
| 345 | + val scope = rememberCoroutineScope() | |
| 346 | + ListFieldsSheet("New list", initial = null, onDismiss = onDismiss) { name, description, private -> | |
| 347 | + scope.launch { | |
| 348 | + try { | |
| 349 | + val list = api.createList(owner, name, private, description) | |
| 350 | + toaster.show("Created ${list.name}.") | |
| 351 | + onCreated(list) | |
| 352 | + } catch (e: Exception) { | |
| 353 | + toaster.error(describe(e)) | |
| 354 | + } | |
| 355 | + } | |
| 356 | + } | |
| 357 | + } | |
| 358 | + | |
| 359 | + @Composable | |
| 360 | + private fun EditListSheet(list: RepoListInfo, onDismiss: () -> Unit, onSaved: (RepoListInfo) -> Unit, onDeleted: () -> Unit) { | |
| 361 | + val api = LocalApi.current!! | |
| 362 | + val toaster = LocalToaster.current | |
| 363 | + val scope = rememberCoroutineScope() | |
| 364 | + var confirming by rememberSaveable { mutableStateOf(false) } | |
| 365 | + var deleting by remember { mutableStateOf(false) } | |
| 366 | + ListFieldsSheet( | |
| 367 | + "Edit list", | |
| 368 | + initial = list, | |
| 369 | + onDismiss = onDismiss, | |
| 370 | + footer = { | |
| 371 | + Spacer(Modifier.height(4.dp)) | |
| 372 | + if (confirming) { | |
| 373 | + Text("The list goes for good; the repositories in it stay as they are.", style = Vectrum.type.body, color = Vectrum.colors.red) | |
| 374 | + VButton( | |
| 375 | + "Delete ${list.name}", | |
| 376 | + { | |
| 377 | + deleting = true | |
| 378 | + scope.launch { | |
| 379 | + try { | |
| 380 | + api.deleteList(list.owner, list.slug) | |
| 381 | + toaster.show("Deleted ${list.name}.") | |
| 382 | + onDeleted() | |
| 383 | + } catch (e: Exception) { | |
| 384 | + toaster.error(describe(e)) | |
| 385 | + } finally { | |
| 386 | + deleting = false | |
| 387 | + } | |
| 388 | + } | |
| 389 | + }, | |
| 390 | + kind = ButtonKind.Danger, | |
| 391 | + icon = VIcons.Close, | |
| 392 | + loading = deleting, | |
| 393 | + fill = true, | |
| 394 | + ) | |
| 395 | + } else { | |
| 396 | + VButton("Delete List…", { confirming = true }, kind = ButtonKind.Ghost, icon = VIcons.Close, fill = true) | |
| 397 | + } | |
| 398 | + }, | |
| 399 | + ) { name, description, private -> | |
| 400 | + scope.launch { | |
| 401 | + try { | |
| 402 | + val updated = api.updateList(list.owner, list.slug, name, description, private) | |
| 403 | + toaster.show("Saved ${updated.name}.") | |
| 404 | + onSaved(updated) | |
| 405 | + } catch (e: Exception) { | |
| 406 | + toaster.error(describe(e)) | |
| 407 | + } | |
| 408 | + } | |
| 409 | + } | |
| 410 | + } | |
| 411 | + | |
| 412 | + /** A list's name, description and visibility in a sheet. */ | |
| 413 | + @Composable | |
| 414 | + private fun ListFieldsSheet( | |
| 415 | + title: String, | |
| 416 | + initial: RepoListInfo?, | |
| 417 | + onDismiss: () -> Unit, | |
| 418 | + footer: @Composable () -> Unit = {}, | |
| 419 | + onSubmit: (name: String, description: String, private: Boolean) -> Unit, | |
| 420 | + ) { | |
| 421 | + var name by rememberSaveable { mutableStateOf(initial?.name ?: "") } | |
| 422 | + var description by rememberSaveable { mutableStateOf(initial?.description ?: "") } | |
| 423 | + var private by rememberSaveable { mutableStateOf(initial?.isPrivate ?: false) } | |
| 424 | + VSheet(title, onDismiss) { | |
| 425 | + Column(Modifier.padding(horizontal = 20.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp)) { | |
| 426 | + Field(name, { name = it.take(60) }, label = "Name", placeholder = "Tools I use") | |
| 427 | + Field(description, { description = it.take(300) }, label = "Description", placeholder = "What do these repositories have in common?", singleLine = false, minLines = 2) | |
| 428 | + Segmented( | |
| 429 | + listOf(SegmentOption(false, "Public", VIcons.Globe), SegmentOption(true, "Private", VIcons.Lock)), | |
| 430 | + selected = private, | |
| 431 | + onSelect = { private = it }, | |
| 432 | + ) | |
| 433 | + Text( | |
| 434 | + if (private) "Only you can see it, or for an organization’s list, its members." else "Anyone can see it. Private repositories in it stay hidden from those who can’t see them.", | |
| 435 | + style = Vectrum.type.meta, | |
| 436 | + color = Vectrum.colors.ink3, | |
| 437 | + ) | |
| 438 | + VButton( | |
| 439 | + if (initial == null) "Create List" else "Save List", | |
| 440 | + { onSubmit(name.trim(), description.trim(), private) }, | |
| 441 | + kind = ButtonKind.Primary, | |
| 442 | + icon = if (initial == null) VIcons.Plus else VIcons.Check, | |
| 443 | + enabled = name.isNotBlank(), | |
| 444 | + fill = true, | |
| 445 | + ) | |
| 446 | + footer() | |
| 447 | + Spacer(Modifier.height(4.dp)) | |
| 448 | + } | |
| 449 | + } | |
| 450 | + } | |
| @@ -20,6 +20,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState | ||
| 20 | 20 | import androidx.compose.material3.Icon |
| 21 | 21 | import androidx.compose.material3.Text |
| 22 | 22 | import androidx.compose.runtime.Composable |
| 23 | + import androidx.compose.runtime.getValue | |
| 24 | + import androidx.compose.runtime.mutableStateOf | |
| 25 | + import androidx.compose.runtime.saveable.rememberSaveable | |
| 26 | + import androidx.compose.runtime.setValue | |
| 23 | 27 | import androidx.compose.ui.Alignment |
| 24 | 28 | import androidx.compose.ui.Modifier |
| 25 | 29 | import androidx.compose.ui.text.style.TextAlign |
| @@ -29,6 +33,7 @@ import cc.vectrum.app.data.ProfileData | ||
| 29 | 33 | import cc.vectrum.app.ui.LocalApi |
| 30 | 34 | import cc.vectrum.app.ui.LocalNav |
| 31 | 35 | import cc.vectrum.app.ui.LocalSession |
| 36 | + import cc.vectrum.app.ui.RefreshOnReturn | |
| 32 | 37 | import cc.vectrum.app.ui.components.Avatar |
| 33 | 38 | import cc.vectrum.app.ui.components.BottomClearance |
| 34 | 39 | import cc.vectrum.app.ui.components.ButtonKind |
| @@ -50,8 +55,14 @@ import cc.vectrum.app.ui.rememberLoader | ||
| 50 | 55 | import cc.vectrum.app.ui.theme.ThemeMode |
| 51 | 56 | import cc.vectrum.app.ui.theme.Vectrum |
| 52 | 57 | |
| 53 | - /** A person's card and their repositories, for their profile and the "You" tab. */ | |
| 54 | - private fun LazyListScope.profileBody(data: ProfileData, onRepo: (String, String) -> Unit) { | |
| 58 | + /** What a profile shows below its card, like the website's tabs. Organizations don't like repositories. */ | |
| 59 | + enum class ProfileTab(val label: String) { Repositories("Repositories"), Lists("Lists"), Liked("Liked") } | |
| 60 | + | |
| 61 | + /** What a profile's rows open. */ | |
| 62 | + class ProfileActions(val repo: (String, String) -> Unit, val list: (String, String) -> Unit, val newList: () -> Unit) | |
| 63 | + | |
| 64 | + /** A person's or organization's card, then their repositories, lists or liked repositories, for their profile and the "You" tab. */ | |
| 65 | + private fun LazyListScope.profileBody(data: ProfileData, tab: ProfileTab, onTab: (ProfileTab) -> Unit, actions: ProfileActions) { | |
| 55 | 66 | item("card") { |
| 56 | 67 | val c = Vectrum.colors |
| 57 | 68 | Column(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) { |
| @@ -64,17 +75,52 @@ private fun LazyListScope.profileBody(data: ProfileData, onRepo: (String, String | ||
| 64 | 75 | Text(data.user.bio, style = Vectrum.type.body, color = c.ink2, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 24.dp)) |
| 65 | 76 | } |
| 66 | 77 | Spacer(Modifier.height(10.dp)) |
| 67 | - if (data.user.createdAt > 0) IconText(VIcons.Calendar, "Joined ${formatDate(data.user.createdAt)}") | |
| 68 | - Spacer(Modifier.height(26.dp)) | |
| 78 | + if (data.user.createdAt > 0) IconText(VIcons.Calendar, "${if (data.isOrg) "Created" else "Joined"} ${formatDate(data.user.createdAt)}") | |
| 79 | + Spacer(Modifier.height(22.dp)) | |
| 69 | 80 | } |
| 70 | 81 | } |
| 71 | - item("repos") { | |
| 72 | - Section("Repositories", count = data.repos.size) { | |
| 73 | - Plate(Modifier.fillMaxWidth()) { | |
| 82 | + item("tabs") { | |
| 83 | + Segmented( | |
| 84 | + ProfileTab.entries.filter { !(data.isOrg && it == ProfileTab.Liked) }.map { | |
| 85 | + SegmentOption( | |
| 86 | + it, | |
| 87 | + it.label, | |
| 88 | + count = when (it) { | |
| 89 | + ProfileTab.Repositories -> data.repos.size | |
| 90 | + ProfileTab.Lists -> data.lists.size | |
| 91 | + ProfileTab.Liked -> data.liked.size | |
| 92 | + }, | |
| 93 | + ) | |
| 94 | + }, | |
| 95 | + selected = tab, | |
| 96 | + onSelect = onTab, | |
| 97 | + ) | |
| 98 | + Spacer(Modifier.height(18.dp)) | |
| 99 | + } | |
| 100 | + item("content") { | |
| 101 | + when (tab) { | |
| 102 | + ProfileTab.Repositories -> Plate(Modifier.fillMaxWidth()) { | |
| 74 | 103 | if (data.repos.isEmpty()) { |
| 75 | 104 | EmptyState(VIcons.Repo, "No repositories to show") |
| 76 | 105 | } else { |
| 77 | - PlateRows(data.repos) { repo -> RepoRow(repo, { onRepo(repo.owner, repo.name) }, showOwner = false) } | |
| 106 | + PlateRows(data.repos) { repo -> RepoRow(repo, { actions.repo(repo.owner, repo.name) }, showOwner = false) } | |
| 107 | + } | |
| 108 | + } | |
| 109 | + ProfileTab.Lists -> Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { | |
| 110 | + Plate(Modifier.fillMaxWidth()) { | |
| 111 | + if (data.lists.isEmpty()) { | |
| 112 | + EmptyState(VIcons.Bookmark, "No lists yet", text = if (data.canManageLists) "Save repositories to lists to find them again or share them." else null) | |
| 113 | + } else { | |
| 114 | + PlateRows(data.lists) { list -> ListRow(list) { actions.list(list.owner, list.slug) } } | |
| 115 | + } | |
| 116 | + } | |
| 117 | + if (data.canManageLists) VButton("New List", actions.newList, icon = VIcons.Plus, fill = true) | |
| 118 | + } | |
| 119 | + ProfileTab.Liked -> Plate(Modifier.fillMaxWidth()) { | |
| 120 | + if (data.liked.isEmpty()) { | |
| 121 | + EmptyState(VIcons.Heart, "No liked repositories yet") | |
| 122 | + } else { | |
| 123 | + PlateRows(data.liked) { repo -> RepoRow(repo, { actions.repo(repo.owner, repo.name) }) } | |
| 78 | 124 | } |
| 79 | 125 | } |
| 80 | 126 | } |
| @@ -82,15 +128,33 @@ private fun LazyListScope.profileBody(data: ProfileData, onRepo: (String, String | ||
| 82 | 128 | } |
| 83 | 129 | } |
| 84 | 130 | |
| 131 | + /** Opens a profile's lists and repositories, and makes new lists for it. */ | |
| 132 | + @Composable | |
| 133 | + private fun rememberProfileTabs(username: String, onCreated: () -> Unit): Triple<ProfileTab, (ProfileTab) -> Unit, ProfileActions> { | |
| 134 | + val nav = LocalNav.current | |
| 135 | + var tab by rememberSaveable { mutableStateOf(ProfileTab.Repositories) } | |
| 136 | + var creating by rememberSaveable { mutableStateOf(false) } | |
| 137 | + if (creating) { | |
| 138 | + NewListSheet(username, onDismiss = { creating = false }) { list -> | |
| 139 | + creating = false | |
| 140 | + onCreated() | |
| 141 | + nav.list(list.owner, list.slug) | |
| 142 | + } | |
| 143 | + } | |
| 144 | + return Triple(tab, { t: ProfileTab -> tab = t }, ProfileActions(repo = { o, n -> nav.repo(o, n) }, list = { o, s -> nav.list(o, s) }, newList = { creating = true })) | |
| 145 | + } | |
| 146 | + | |
| 85 | 147 | @Composable |
| 86 | 148 | fun ProfileScreen(username: String) { |
| 87 | 149 | val api = LocalApi.current!! |
| 88 | 150 | val nav = LocalNav.current |
| 89 | 151 | val loader = rememberLoader("profile:$username") { api.profile(username) } |
| 90 | 152 | val list = rememberLazyListState() |
| 153 | + val (tab, onTab, actions) = rememberProfileTabs(username) { loader.load(refresh = true) } | |
| 154 | + RefreshOnReturn { loader.load(refresh = true) } | |
| 91 | 155 | Column(Modifier.fillMaxSize()) { |
| 92 | 156 | TopBar("@$username", kicker = "Profile", onBack = nav::back, scrolled = list.isScrolled()) |
| 93 | - Loaded(loader, state = list) { data -> profileBody(data) { o, n -> nav.repo(o, n) } } | |
| 157 | + Loaded(loader, state = list) { data -> profileBody(data, tab, onTab, actions) } | |
| 94 | 158 | } |
| 95 | 159 | } |
| 96 | 160 | |
| @@ -98,14 +162,15 @@ fun ProfileScreen(username: String) { | ||
| 98 | 162 | @Composable |
| 99 | 163 | fun MeScreen(theme: ThemeMode, onTheme: (ThemeMode) -> Unit, onSignOut: () -> Unit) { |
| 100 | 164 | val api = LocalApi.current!! |
| 101 | - val nav = LocalNav.current | |
| 102 | 165 | val session = LocalSession.current!! |
| 103 | 166 | val loader = rememberLoader("me") { api.profile(session.user.username) } |
| 167 | + val (tab, onTab, actions) = rememberProfileTabs(session.user.username) { loader.load(refresh = true) } | |
| 168 | + RefreshOnReturn { loader.load(refresh = true) } | |
| 104 | 169 | val top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() |
| 105 | 170 | val c = Vectrum.colors |
| 106 | 171 | |
| 107 | 172 | Loaded(loader, contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = top + 20.dp, bottom = BottomClearance)) { data -> |
| 108 | - profileBody(data) { o, n -> nav.repo(o, n) } | |
| 173 | + profileBody(data, tab, onTab, actions) | |
| 109 | 174 | item("appearance") { |
| 110 | 175 | Section("Appearance") { |
| 111 | 176 | Segmented( |
| @@ -74,6 +74,7 @@ import cc.vectrum.app.ui.components.Tone | ||
| 74 | 74 | import cc.vectrum.app.ui.components.TopBar |
| 75 | 75 | import cc.vectrum.app.ui.components.VButton |
| 76 | 76 | import cc.vectrum.app.ui.components.isScrolled |
| 77 | + import cc.vectrum.app.ui.describe | |
| 77 | 78 | import cc.vectrum.app.ui.formatBytes |
| 78 | 79 | import cc.vectrum.app.ui.icons.VIcons |
| 79 | 80 | import cc.vectrum.app.ui.plural |
| @@ -119,6 +120,9 @@ fun RepoScreen(owner: String, name: String, initialTab: String) { | ||
| 119 | 120 | var tab by rememberSaveable { mutableStateOf(RepoTab.entries.firstOrNull { it.key == initialTab } ?: RepoTab.Code) } |
| 120 | 121 | var ref by rememberSaveable { mutableStateOf<String?>(null) } |
| 121 | 122 | var showBranches by rememberSaveable { mutableStateOf(false) } |
| 123 | + var showSave by rememberSaveable { mutableStateOf(false) } | |
| 124 | + val toaster = LocalToaster.current | |
| 125 | + val scope = rememberCoroutineScope() | |
| 122 | 126 | val list = rememberLazyListState() |
| 123 | 127 | // Each tab loads when it's first opened, and keeps its data (in its view model) while you switch around. |
| 124 | 128 | val issues = if (tab == RepoTab.Issues) rememberIssuesTab(owner, name) else null |
| @@ -155,7 +159,27 @@ fun RepoScreen(owner: String, name: String, initialTab: String) { | ||
| 155 | 159 | val currentRef = ref ?: detail.defaultBranch |
| 156 | 160 | PullToRefreshBox(isRefreshing = repoLoader.refreshing, onRefresh = { repoLoader.load(true) }, modifier = Modifier.fillMaxSize()) { |
| 157 | 161 | LazyColumn(Modifier.fillMaxSize(), state = list, contentPadding = PaddingValues(bottom = BottomClearance)) { |
| 158 | - item("header") { RepoHeader(detail) } | |
| 162 | + item("header") { | |
| 163 | + RepoHeader( | |
| 164 | + detail, | |
| 165 | + onLike = { | |
| 166 | + val liked = !detail.liked | |
| 167 | + val before = detail | |
| 168 | + // Shows at once; the server's count replaces it. | |
| 169 | + repoLoader.set(detail.copy(liked = liked, repo = detail.repo.copy(likes = (detail.repo.likes + if (liked) 1 else -1).coerceAtLeast(0)))) | |
| 170 | + scope.launch { | |
| 171 | + try { | |
| 172 | + val r = api.like(owner, name, liked) | |
| 173 | + repoLoader.data?.let { repoLoader.set(it.copy(liked = r.liked, repo = it.repo.copy(likes = r.likes))) } | |
| 174 | + } catch (e: Exception) { | |
| 175 | + repoLoader.set(before) | |
| 176 | + toaster.error(describe(e)) | |
| 177 | + } | |
| 178 | + } | |
| 179 | + }, | |
| 180 | + onSave = { showSave = true }, | |
| 181 | + ) | |
| 182 | + } | |
| 159 | 183 | stickyHeader("tabs") { |
| 160 | 184 | // The page fades out under the tabs, so rows slide away beneath them. |
| 161 | 185 | val page = Vectrum.colors.page |
| @@ -188,6 +212,11 @@ fun RepoScreen(owner: String, name: String, initialTab: String) { | ||
| 188 | 212 | } |
| 189 | 213 | } |
| 190 | 214 | } |
| 215 | + if (showSave) { | |
| 216 | + SaveSheet(owner, name, onDismiss = { showSave = false }, onChanged = { saved -> | |
| 217 | + repoLoader.data?.let { if (it.saved != saved) repoLoader.set(it.copy(saved = saved)) } | |
| 218 | + }) | |
| 219 | + } | |
| 191 | 220 | if (showBranches) { |
| 192 | 221 | BranchSheet(detail, currentRef, onPick = { ref = it; showBranches = false }, onDismiss = { showBranches = false }) |
| 193 | 222 | } |
| @@ -198,7 +227,7 @@ fun RepoScreen(owner: String, name: String, initialTab: String) { | ||
| 198 | 227 | |
| 199 | 228 | @OptIn(ExperimentalLayoutApi::class) |
| 200 | 229 | @Composable |
| 201 | - private fun RepoHeader(detail: RepoDetail) { | |
| 230 | + private fun RepoHeader(detail: RepoDetail, onLike: () -> Unit, onSave: () -> Unit) { | |
| 202 | 231 | val c = Vectrum.colors |
| 203 | 232 | val nav = LocalNav.current |
| 204 | 233 | val repo = detail.repo |
| @@ -234,6 +263,8 @@ private fun RepoHeader(detail: RepoDetail) { | ||
| 234 | 263 | } |
| 235 | 264 | } |
| 236 | 265 | Spacer(Modifier.height(14.dp)) |
| 266 | + LikeSaveRow(detail.liked, repo.likes, detail.saved, onLike, onSave) | |
| 267 | + Spacer(Modifier.height(14.dp)) | |
| 237 | 268 | Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { |
| 238 | 269 | IconText(VIcons.History, plural(detail.counts.commits, "commit")) |
| 239 | 270 | IconText(VIcons.Branch, plural(detail.counts.branches, "branch", "branches")) |
| @@ -60,7 +60,7 @@ fun <T> PlateRows(items: List<T>, row: @Composable (T) -> Unit) { | ||
| 60 | 60 | } |
| 61 | 61 | |
| 62 | 62 | @Composable |
| 63 | - fun RepoRow(repo: RepoSummary, onClick: () -> Unit, showOwner: Boolean = true) { | |
| 63 | + fun RepoRow(repo: RepoSummary, onClick: () -> Unit, showOwner: Boolean = true, trailing: (@Composable () -> Unit)? = null) { | |
| 64 | 64 | val c = Vectrum.colors |
| 65 | 65 | PressableRow(onClick) { |
| 66 | 66 | Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.Top) { |
| @@ -83,9 +83,14 @@ fun RepoRow(repo: RepoSummary, onClick: () -> Unit, showOwner: Boolean = true) { | ||
| 83 | 83 | Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { |
| 84 | 84 | if (repo.isPrivate) IconText(VIcons.Lock, "Private") |
| 85 | 85 | if (repo.isFork) IconText(VIcons.Fork, repo.forkOf?.let { "Fork of ${it.owner}/${it.name}" } ?: "Fork") |
| 86 | + if (repo.likes > 0) IconText(VIcons.Heart, "${repo.likes}") | |
| 86 | 87 | Text(if (repo.empty) "Empty" else "Updated ${timeAgo(repo.updatedAt)}", style = Vectrum.type.meta, color = c.ink3, maxLines = 1) |
| 87 | 88 | } |
| 88 | 89 | } |
| 90 | + if (trailing != null) { | |
| 91 | + Spacer(Modifier.width(8.dp)) | |
| 92 | + trailing() | |
| 93 | + } | |
| 89 | 94 | } |
| 90 | 95 | } |
| 91 | 96 | } |
| @@ -13,6 +13,10 @@ import { ApiError, apiViewer, errorResponse, json } from "@/lib/app-api"; | ||
| 13 | 13 | // GET, PATCH …/issues/<n>; POST …/issues, …/issues/<n>/comments |
| 14 | 14 | // GET, PATCH …/pulls/<n>; GET …/pulls/<n>/files; POST …/pulls/<n>/comments, /reviews, /merge |
| 15 | 15 | // GET …/runs/<n> |
| 16 | + // POST …/like { liked } → { liked, likes } | |
| 17 | + // GET, POST …/lists the lists the repository can be saved to; POST { list, saved } | |
| 18 | + // POST /api/v1/lists a new list: { owner?, name, description?, visibility?, repo? } | |
| 19 | + // GET, PATCH, DELETE /api/v1/lists/<owner>/<slug> | |
| 16 | 20 | |
| 17 | 21 | type Ctx = RouteContext<"/api/v1/[...path]">; |
| 18 | 22 | |
| @@ -42,6 +46,12 @@ async function handle(request: Request, ctx: Ctx) { | ||
| 42 | 46 | if (first === "explore") return json(await api.explore(viewer, params.get("q") ?? "")); |
| 43 | 47 | } |
| 44 | 48 | if (first === "users" && rest.length === 1 && method === "GET") return json(await api.profile(viewer, rest[0])); |
| 49 | + if (first === "lists") { | |
| 50 | + if (rest.length === 0 && method === "POST") return json(await api.newList(request, viewer), 201); | |
| 51 | + if (rest.length === 2 && method === "GET") return json(await api.listPage(viewer, rest[0], rest[1])); | |
| 52 | + if (rest.length === 2 && method === "PATCH") return json(await api.changeList(request, viewer, rest[0], rest[1])); | |
| 53 | + if (rest.length === 2 && method === "DELETE") return json(await api.removeList(viewer, rest[0], rest[1])); | |
| 54 | + } | |
| 45 | 55 | |
| 46 | 56 | if (first === "repos" && rest.length >= 2) { |
| 47 | 57 | const [owner, name, section, id, sub, ...more] = rest; |
| @@ -96,6 +106,12 @@ async function handle(request: Request, ctx: Ctx) { | ||
| 96 | 106 | return json(await api.run(viewer, owner, name, n())); |
| 97 | 107 | case "GET releases": |
| 98 | 108 | return json(await api.releases(viewer, owner, name)); |
| 109 | + case "POST like": | |
| 110 | + return json(await api.likeRepo(request, viewer, owner, name)); | |
| 111 | + case "GET lists": | |
| 112 | + return json(await api.repoLists(viewer, owner, name)); | |
| 113 | + case "POST lists": | |
| 114 | + return json(await api.saveRepo(request, viewer, owner, name)); | |
| 99 | 115 | } |
| 100 | 116 | } |
| 101 | 117 | throw new ApiError(404, "Not found."); |
| @@ -107,3 +123,4 @@ async function handle(request: Request, ctx: Ctx) { | ||
| 107 | 123 | export const GET = handle; |
| 108 | 124 | export const POST = handle; |
| 109 | 125 | export const PATCH = handle; |
| 126 | + export const DELETE = handle; | |
| @@ -22,6 +22,26 @@ import { | ||
| 22 | 22 | validateIssuePatch, |
| 23 | 23 | } from "./issues"; |
| 24 | 24 | import { recentActivity, withActivity } from "./listing"; |
| 25 | + import { | |
| 26 | + countLikes, | |
| 27 | + createList, | |
| 28 | + deleteList, | |
| 29 | + findList, | |
| 30 | + findListById, | |
| 31 | + likes, | |
| 32 | + listLikedRepos, | |
| 33 | + listListsOf, | |
| 34 | + listReposInList, | |
| 35 | + ListError, | |
| 36 | + mayManageLists, | |
| 37 | + maySeeList, | |
| 38 | + saveTargets, | |
| 39 | + setInList, | |
| 40 | + setLiked, | |
| 41 | + updateList, | |
| 42 | + withRepoCounts, | |
| 43 | + type RepoList, | |
| 44 | + } from "./lists"; | |
| 25 | 45 | import { IMAGE_TYPES } from "./mime"; |
| 26 | 46 | import { ProtectionError } from "./protection"; |
| 27 | 47 | import { |
| @@ -73,7 +93,7 @@ export class ApiError extends Error { | ||
| 73 | 93 | } |
| 74 | 94 | |
| 75 | 95 | const isUserError = (e: unknown) => |
| 76 | - e instanceof IssueError || e instanceof LabelError || e instanceof PullError || e instanceof ProtectionError || e instanceof GitError; | |
| 96 | + e instanceof IssueError || e instanceof LabelError || e instanceof PullError || e instanceof ProtectionError || e instanceof GitError || e instanceof ListError; | |
| 77 | 97 | |
| 78 | 98 | /** Turns what a handler threw into a JSON answer: user errors become 4xx, anything else is rethrown. */ |
| 79 | 99 | export function errorResponse(e: unknown) { |
| @@ -180,7 +200,7 @@ export async function signIn(request: Request) { | ||
| 180 | 200 | |
| 181 | 201 | const userJson = (u: User) => ({ id: u.id, username: u.username, name: u.name, email: u.email, bio: u.bio, createdAt: u.createdAt }); |
| 182 | 202 | |
| 183 | - type Listed = RepoRecord & { updatedAt?: number; empty?: boolean; parent?: RepoRecord["forkOf"] }; | |
| 203 | + type Listed = RepoRecord & { updatedAt?: number; empty?: boolean; parent?: RepoRecord["forkOf"]; likes?: number }; | |
| 184 | 204 | |
| 185 | 205 | function repoJson(r: Listed) { |
| 186 | 206 | return { |
| @@ -197,6 +217,7 @@ function repoJson(r: Listed) { | ||
| 197 | 217 | createdAt: r.createdAt, |
| 198 | 218 | updatedAt: r.updatedAt ?? r.pushedAt ?? r.createdAt, |
| 199 | 219 | empty: r.empty ?? false, |
| 220 | + likes: r.likes ?? 0, | |
| 200 | 221 | }; |
| 201 | 222 | } |
| 202 | 223 | |
| @@ -329,11 +350,24 @@ export async function explore(viewer: User | null, query: string) { | ||
| 329 | 350 | return { repos: repos.map(repoJson) }; |
| 330 | 351 | } |
| 331 | 352 | |
| 353 | + /** A person's or an organization's page: their repositories and lists, and for people the repositories they like. */ | |
| 332 | 354 | export async function profile(viewer: User | null, username: string) { |
| 333 | 355 | const user = await getUserByUsername(username); |
| 334 | 356 | if (!user) throw new ApiError(404, "There's no such user."); |
| 335 | - const repos = await withActivity(await listReposByOwner(user.id, viewer?.id ?? null), viewer?.id); | |
| 336 | - return { user: { ...userJson(user), email: viewer?.id === user.id ? user.email : "" }, repos: repos.map(repoJson) }; | |
| 357 | + const viewerId = viewer?.id ?? null; | |
| 358 | + const [repos, lists, liked] = await Promise.all([ | |
| 359 | + listReposByOwner(user.id, viewerId).then((r) => withActivity(r, viewerId)), | |
| 360 | + listListsOf(user, viewerId).then((l) => withRepoCounts(l, viewerId)), | |
| 361 | + user.isOrg ? [] : listLikedRepos(user.id, viewerId).then((r) => withActivity(r, viewerId)), | |
| 362 | + ]); | |
| 363 | + return { | |
| 364 | + user: { ...userJson(user), email: viewer?.id === user.id ? user.email : "" }, | |
| 365 | + isOrg: user.isOrg, | |
| 366 | + repos: repos.map(repoJson), | |
| 367 | + lists: lists.map(listJson), | |
| 368 | + liked: liked.map(repoJson), | |
| 369 | + canManageLists: await mayManageLists(user, viewerId), | |
| 370 | + }; | |
| 337 | 371 | } |
| 338 | 372 | |
| 339 | 373 | // --------------------------------------------------------------------------- |
| @@ -379,7 +413,7 @@ const README = /^readme(\.md|\.markdown|\.txt)?$/i; | ||
| 379 | 413 | export async function repository(viewer: User | null, owner: string, name: string) { |
| 380 | 414 | const { repo, access } = await openRepo(viewer, owner, name); |
| 381 | 415 | const branch = await defaultBranch(repo.dir); |
| 382 | - const [parent, urls, branches, tags, issues, pulls, releases] = await Promise.all([ | |
| 416 | + const [parent, urls, branches, tags, issues, pulls, releases, likeCount, liked] = await Promise.all([ | |
| 383 | 417 | visibleParent(repo, viewer?.id), |
| 384 | 418 | cloneUrls(repo), |
| 385 | 419 | listBranches(repo.dir), |
| @@ -387,7 +421,11 @@ export async function repository(viewer: User | null, owner: string, name: strin | ||
| 387 | 421 | openIssueCount(repo.id), |
| 388 | 422 | countOpenPulls(repo.id), |
| 389 | 423 | countReleases(repo.id), |
| 424 | + countLikes(repo.id), | |
| 425 | + likes(repo.id, viewer?.id), | |
| 390 | 426 | ]); |
| 427 | + // Whether it's in any of the lists the viewer keeps (theirs and their organizations'). | |
| 428 | + const saved = viewer ? (await saveTargets(viewer, repo.id)).lists.some((l) => l.saved) : false; | |
| 391 | 429 | let readme: { path: string; text: string } | null = null; |
| 392 | 430 | let entries: Awaited<ReturnType<typeof listTree>> = []; |
| 393 | 431 | let lastCommit: Commit | null = null; |
| @@ -402,8 +440,10 @@ export async function repository(viewer: User | null, owner: string, name: strin | ||
| 402 | 440 | } |
| 403 | 441 | } |
| 404 | 442 | return { |
| 405 | - repo: repoJson({ ...repo, parent, updatedAt: lastCommit?.date ?? repo.pushedAt ?? repo.createdAt, empty: !branch }), | |
| 443 | + repo: repoJson({ ...repo, parent, updatedAt: lastCommit?.date ?? repo.pushedAt ?? repo.createdAt, empty: !branch, likes: likeCount }), | |
| 406 | 444 | access, |
| 445 | + liked, | |
| 446 | + saved, | |
| 407 | 447 | canWrite: canWrite(access), |
| 408 | 448 | defaultBranch: branch, |
| 409 | 449 | branches: branches.map((b) => b.name), |
| @@ -782,3 +822,111 @@ export async function releases(viewer: User | null, owner: string, name: string) | ||
| 782 | 822 | }; |
| 783 | 823 | } |
| 784 | 824 | |
| 825 | + | |
| 826 | + // --------------------------------------------------------------------------- | |
| 827 | + // Likes and lists (lib/lists.ts) | |
| 828 | + | |
| 829 | + const listJson = (l: RepoList & { repoCount?: number }) => ({ | |
| 830 | + id: l.id, | |
| 831 | + owner: l.owner.username, | |
| 832 | + ownerIsOrg: l.owner.isOrg, | |
| 833 | + name: l.name, | |
| 834 | + slug: l.slug, | |
| 835 | + description: l.description, | |
| 836 | + visibility: l.visibility, | |
| 837 | + createdAt: l.createdAt, | |
| 838 | + updatedAt: l.updatedAt, | |
| 839 | + repoCount: l.repoCount ?? 0, | |
| 840 | + }); | |
| 841 | + | |
| 842 | + /** Likes or unlikes a repository: `{ liked }`. */ | |
| 843 | + export async function likeRepo(request: Request, viewer: User | null, owner: string, name: string) { | |
| 844 | + const user = requireUser(viewer); | |
| 845 | + const { repo } = await openRepo(viewer, owner, name); | |
| 846 | + const liked = (await readJson(request)).liked === true; | |
| 847 | + await setLiked(repo, user, liked); | |
| 848 | + return { liked, likes: await countLikes(repo.id) }; | |
| 849 | + } | |
| 850 | + | |
| 851 | + /** The lists the person may save the repository to, and whether it's in each: for the app's Save sheet. */ | |
| 852 | + export async function repoLists(viewer: User | null, owner: string, name: string) { | |
| 853 | + const user = requireUser(viewer); | |
| 854 | + const { repo } = await openRepo(viewer, owner, name); | |
| 855 | + const targets = await saveTargets(user, repo.id); | |
| 856 | + return { owners: targets.owners.map((o) => o.username), lists: targets.lists.map((l) => ({ ...listJson(l), saved: l.saved })) }; | |
| 857 | + } | |
| 858 | + | |
| 859 | + /** A list the person may change, by id. */ | |
| 860 | + async function managedList(user: User, id: string) { | |
| 861 | + const list = await findListById(id); | |
| 862 | + if (!list || !(await mayManageLists(list.owner, user.id))) throw new ApiError(404, "There's no such list, or you can't change it."); | |
| 863 | + return list; | |
| 864 | + } | |
| 865 | + | |
| 866 | + /** Puts the repository in a list or takes it out: `{ list, saved }`. Answers like repoLists. */ | |
| 867 | + export async function saveRepo(request: Request, viewer: User | null, owner: string, name: string) { | |
| 868 | + const user = requireUser(viewer); | |
| 869 | + const { repo } = await openRepo(viewer, owner, name); | |
| 870 | + const body = await readJson(request); | |
| 871 | + const list = await managedList(user, text(body.list, 40)); | |
| 872 | + await setInList(list, repo.id, body.saved === true); | |
| 873 | + return repoLists(viewer, owner, name); | |
| 874 | + } | |
| 875 | + | |
| 876 | + const listInput = (body: Record<string, unknown>) => ({ | |
| 877 | + name: text(body.name, 200), | |
| 878 | + description: text(body.description, 1000), | |
| 879 | + visibility: body.visibility === "private" ? ("private" as const) : ("public" as const), | |
| 880 | + }); | |
| 881 | + | |
| 882 | + /** Makes a list for the person or one of their organizations: `{ owner?, name, description?, visibility?, repo? }`, `repo` as "owner/name" to put in it. */ | |
| 883 | + export async function newList(request: Request, viewer: User | null) { | |
| 884 | + const user = requireUser(viewer); | |
| 885 | + const body = await readJson(request); | |
| 886 | + const owner = body.owner ? await getUserByUsername(text(body.owner, 100)) : user; | |
| 887 | + if (!owner || !(await mayManageLists(owner, user.id))) throw new ApiError(403, "You can't make lists for that account."); | |
| 888 | + const [repoOwner, repoName] = text(body.repo, 300).split("/"); | |
| 889 | + const repo = repoOwner && repoName ? (await openRepo(viewer, repoOwner, repoName)).repo : null; | |
| 890 | + const list = await createList(owner, listInput(body)); | |
| 891 | + if (repo) await setInList(list, repo.id, true); | |
| 892 | + return { list: listJson({ ...list, repoCount: repo ? 1 : 0 }) }; | |
| 893 | + } | |
| 894 | + | |
| 895 | + async function visibleList(viewer: User | null, owner: string, slug: string) { | |
| 896 | + const list = await findList(owner, slug); | |
| 897 | + // Lists the viewer can't see are "not found", like private repositories. | |
| 898 | + if (!list || !(await maySeeList(list, viewer?.id))) throw new ApiError(404, "There's no such list, or you can't see it."); | |
| 899 | + return list; | |
| 900 | + } | |
| 901 | + | |
| 902 | + /** A list and the repositories in it the viewer may see. */ | |
| 903 | + export async function listPage(viewer: User | null, owner: string, slug: string) { | |
| 904 | + const found = await visibleList(viewer, owner, slug); | |
| 905 | + const repos = await withActivity(await listReposInList(found.id, viewer?.id ?? null), viewer?.id); | |
| 906 | + return { list: listJson({ ...found, repoCount: repos.length }), repos: repos.map(repoJson), canManage: await mayManageLists(found.owner, viewer?.id) }; | |
| 907 | + } | |
| 908 | + | |
| 909 | + /** A list the viewer may change, by its address. */ | |
| 910 | + async function changeableList(viewer: User | null, owner: string, slug: string) { | |
| 911 | + const user = requireUser(viewer); | |
| 912 | + const found = await visibleList(viewer, owner, slug); | |
| 913 | + if (!(await mayManageLists(found.owner, user.id))) throw new ApiError(403, "You can't change this list."); | |
| 914 | + return found; | |
| 915 | + } | |
| 916 | + | |
| 917 | + /** Renames a list or changes its description or visibility: `{ name?, description?, visibility? }`. Its address follows the name. */ | |
| 918 | + export async function changeList(request: Request, viewer: User | null, owner: string, slug: string) { | |
| 919 | + const found = await changeableList(viewer, owner, slug); | |
| 920 | + const body = await readJson(request); | |
| 921 | + const updated = await updateList(found, { | |
| 922 | + name: typeof body.name === "string" ? text(body.name, 200) : found.name, | |
| 923 | + description: typeof body.description === "string" ? text(body.description, 1000) : found.description, | |
| 924 | + visibility: body.visibility === "public" || body.visibility === "private" ? body.visibility : found.visibility, | |
| 925 | + }); | |
| 926 | + return { list: listJson(updated) }; | |
| 927 | + } | |
| 928 | + | |
| 929 | + export async function removeList(viewer: User | null, owner: string, slug: string) { | |
| 930 | + await deleteList(await changeableList(viewer, owner, slug)); | |
| 931 | + return {}; | |
| 932 | + } | |
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | - **Repositories**: public or private, rename, delete, optional README on creation |
| 6 | 6 | - **Collaborators**: give other users push access to a repository |
| 7 | 7 | - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`) |
| 8 | - - **Likes and lists**: anyone signed in can *Like* a repository they can see; the repository shows how many people like it (and who, under *likes*), and a person's profile lists what they like under *Liked*. The *Save* key puts a repository in lists, or makes a new one on the spot. Lists belong to a person or an organization, live at `/lists/<owner>/<list>` and on the profile's *Lists* tab, and are public (anyone can see them) or private (only that person, or the organization's members). Members with *Write* or more manage an organization's lists. A list never shows a repository to someone who couldn't see it anyway, so private repositories can go in public lists | |
| 8 | + - **Likes and lists**: anyone signed in can *Like* a repository they can see; the repository shows how many people like it (and who, under *likes*), and a person's profile lists what they like under *Liked*. The *Save* key puts a repository in lists, or makes a new one on the spot. Lists belong to a person or an organization, live at `/lists/<owner>/<list>` and on the profile's *Lists* tab, and are public (anyone can see them) or private (only that person, or the organization's members). Members with *Write* or more manage an organization's lists. A list never shows a repository to someone who couldn't see it anyway, so private repositories can go in public lists. The Android app does all of this too | |
| 9 | 9 | - **Forks**: anyone who can read a repository can fork it (the *Fork* button) into a copy of their own, of its default branch or all branches. Forks carry a *Fork* label and a "forked from" link everywhere they're listed, and the original lists them under *Forks*. A fork's front page says how far it is ahead of and behind the original, with *Sync Fork* (a fast-forward, or a merge commit if the fork has commits of its own) and *Contribute* (a pull request back). Forks of private repositories stay private |
| 10 | 10 | - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys* |
| 11 | 11 | - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens |
| @@ -17,6 +17,10 @@ What it does: | ||
| 17 | 17 | - **Commits**: history by day with CI status, and each commit's diff |
| 18 | 18 | - **Actions**: workflow runs, their jobs and steps, and each step's log, live while it runs |
| 19 | 19 | - **Releases**: notes and files to download |
| 20 | + - **Likes and lists**: like a repository, and save it to your lists or your organizations' (the *Save* key, | |
| 21 | + which also makes a new list on the spot). Profiles show repositories, lists and liked repositories in tabs; | |
| 22 | + a list's page lets those who may change it remove repositories, rename it, make it public or private, or | |
| 23 | + delete it | |
| 20 | 24 | - Light, dark or following the system, like the website's theme switch |
| 21 | 25 | |
| 22 | 26 | ## Building |
| @@ -75,6 +79,10 @@ approving, requesting changes and merging need push access and the base branch's | ||
| 75 | 79 | | `GET …/pulls?state=`, `GET, PATCH …/pulls/<n>`, `GET …/pulls/<n>/files` | | |
| 76 | 80 | | `POST …/pulls/<n>/comments`, `…/pulls/<n>/reviews`, `…/pulls/<n>/merge` | | |
| 77 | 81 | | `GET …/runs?page=`, `…/runs/<n>`, `…/releases` | | |
| 82 | + | `POST …/like` | `{ liked }` → `{ liked, likes }` | | |
| 83 | + | `GET, POST …/lists` | the lists the repository can be saved to, and whether it's in each; `POST { list, saved }` puts it in a list or takes it out | | |
| 84 | + | `POST /api/v1/lists` | a new list: `{ owner?, name, description?, visibility?, repo? }` (`repo` as `owner/name` goes in it at once) | | |
| 85 | + | `GET, PATCH, DELETE /api/v1/lists/<owner>/<slug>` | a list and the repositories in it the viewer may see; change its name, description or visibility; delete it | | |
| 78 | 86 | |
| 79 | 87 | Errors are `{ "error": "…" }` with a 4xx status; 401 means the session ended. |
| 80 | 88 | |