@@ -304,56 +286,6 @@
gap: 24px;
}
- .hidden-input {
- z-index: var(--z-ground);
- position: absolute;
- width: 100%;
- height: 100%;
- cursor: pointer;
- opacity: 0;
- }
-
- .profile-pic-wrapper {
- position: relative;
- width: 100px;
- height: 100px;
- overflow: hidden;
- border-radius: var(--radius-m);
- background-color: var(--clr-scale-pop-70);
- transition: opacity var(--transition-medium);
-
- &:hover,
- &:focus-within {
- & .profile-pic__edit-label {
- opacity: 1;
- }
-
- & .profile-pic {
- opacity: 0.8;
- }
- }
- }
-
- .profile-pic {
- width: 100%;
- height: 100%;
-
- object-fit: cover;
- background-color: var(--clr-scale-pop-70);
- }
-
- .profile-pic__edit-label {
- position: absolute;
- bottom: 8px;
- left: 8px;
- padding: 4px 6px;
- border-radius: var(--radius-m);
- background-color: var(--clr-scale-ntrl-20);
- color: var(--clr-core-ntrl-100);
- opacity: 0;
- transition: opacity var(--transition-medium);
- }
-
.contact-info {
display: flex;
flex: 1;
diff --git a/apps/desktop/src/lib/notifications/toasts.ts b/apps/desktop/src/lib/notifications/toasts.ts
index aacaddc3aa..a95cd7529e 100644
--- a/apps/desktop/src/lib/notifications/toasts.ts
+++ b/apps/desktop/src/lib/notifications/toasts.ts
@@ -8,7 +8,7 @@ import {
} from '$lib/error/parser';
import posthog from 'posthog-js';
import { writable, type Writable } from 'svelte/store';
-import type { MessageStyle } from '$components/InfoMessage.svelte';
+import type { MessageStyle } from '@gitbutler/ui';
type ExtraAction = {
label: string;
diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js
index f0f8abc1f6..50e78f37b0 100644
--- a/apps/web/postcss.config.js
+++ b/apps/web/postcss.config.js
@@ -5,7 +5,7 @@ import postcssNesting from 'postcss-nesting';
import pxToRem from 'postcss-pxtorem';
import path from 'path';
-const mediaQueriesCssPath = path.resolve('src/lib/styles/media-queries.css');
+const mediaQueriesCssPath = path.resolve('src/styles/media-queries.css');
/** @type {import('postcss-load-config').Config} */
const config = {
diff --git a/apps/web/src/lib/images/blank-chat.svg b/apps/web/src/lib/assets/blank-chat.svg
similarity index 100%
rename from apps/web/src/lib/images/blank-chat.svg
rename to apps/web/src/lib/assets/blank-chat.svg
diff --git a/apps/web/src/lib/images/github.svg b/apps/web/src/lib/assets/github.svg
similarity index 100%
rename from apps/web/src/lib/images/github.svg
rename to apps/web/src/lib/assets/github.svg
diff --git a/apps/web/src/lib/assets/splash-illustrations/new-project.svg b/apps/web/src/lib/assets/splash-illustrations/new-project.svg
new file mode 100644
index 0000000000..33ef5e25ac
--- /dev/null
+++ b/apps/web/src/lib/assets/splash-illustrations/new-project.svg
@@ -0,0 +1,35 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/assets/splash-illustrations/walkin.svg b/apps/web/src/lib/assets/splash-illustrations/walkin.svg
new file mode 100644
index 0000000000..a3f5775c7f
--- /dev/null
+++ b/apps/web/src/lib/assets/splash-illustrations/walkin.svg
@@ -0,0 +1,88 @@
+
\ No newline at end of file
diff --git a/apps/web/src/lib/auth/RedirectIfLoggedIn.svelte b/apps/web/src/lib/auth/RedirectIfLoggedIn.svelte
new file mode 100644
index 0000000000..0b0a8e3d19
--- /dev/null
+++ b/apps/web/src/lib/auth/RedirectIfLoggedIn.svelte
@@ -0,0 +1,18 @@
+
diff --git a/apps/web/src/lib/auth/RedirectIfNotFinalized.svelte b/apps/web/src/lib/auth/RedirectIfNotFinalized.svelte
new file mode 100644
index 0000000000..e173a5c285
--- /dev/null
+++ b/apps/web/src/lib/auth/RedirectIfNotFinalized.svelte
@@ -0,0 +1,28 @@
+
diff --git a/apps/web/src/lib/auth/RedirectToProfileIfLoggedIn.svelte b/apps/web/src/lib/auth/RedirectToProfileIfLoggedIn.svelte
new file mode 100644
index 0000000000..12e9775435
--- /dev/null
+++ b/apps/web/src/lib/auth/RedirectToProfileIfLoggedIn.svelte
@@ -0,0 +1,18 @@
+
diff --git a/apps/web/src/lib/components/ChatComponent.svelte b/apps/web/src/lib/components/ChatComponent.svelte
index 6ec454ffc7..a1976e5938 100644
--- a/apps/web/src/lib/components/ChatComponent.svelte
+++ b/apps/web/src/lib/components/ChatComponent.svelte
@@ -1,10 +1,10 @@
-
-
-
{title}
-
- {@render children()}
-
-
-
-
diff --git a/apps/web/src/lib/components/Footer.svelte b/apps/web/src/lib/components/Footer.svelte
deleted file mode 100644
index 1c85bfb52f..0000000000
--- a/apps/web/src/lib/components/Footer.svelte
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
diff --git a/apps/web/src/lib/components/GitbutlerLogoLink.svelte b/apps/web/src/lib/components/GitbutlerLogoLink.svelte
new file mode 100644
index 0000000000..37b8b57ac6
--- /dev/null
+++ b/apps/web/src/lib/components/GitbutlerLogoLink.svelte
@@ -0,0 +1,65 @@
+
+
+{#snippet logoContent()}
+ {#if !markOnly}
+
GitButler
+ {/if}
+
+{/snippet}
+
+{#if disabled}
+
+ {@render logoContent()}
+
+{:else}
+
+ {@render logoContent()}
+
+{/if}
+
+
diff --git a/apps/web/src/lib/components/HeaderAuthSection.svelte b/apps/web/src/lib/components/HeaderAuthSection.svelte
new file mode 100644
index 0000000000..e1ca5d12a8
--- /dev/null
+++ b/apps/web/src/lib/components/HeaderAuthSection.svelte
@@ -0,0 +1,35 @@
+
+
+{#if $user && !hideIfUserAuthenticated}
+
+{:else if !$user}
+
+
+
+
+{/if}
+
+
diff --git a/apps/web/src/lib/components/HomePage.svelte b/apps/web/src/lib/components/HomePage.svelte
deleted file mode 100644
index 42154f46df..0000000000
--- a/apps/web/src/lib/components/HomePage.svelte
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
Home Page
diff --git a/apps/web/src/lib/components/Navigation.svelte b/apps/web/src/lib/components/Navigation.svelte
index 13ae54e897..a2ca72dedd 100644
--- a/apps/web/src/lib/components/Navigation.svelte
+++ b/apps/web/src/lib/components/Navigation.svelte
@@ -1,211 +1,78 @@
-
diff --git a/apps/web/src/lib/components/UserAuthAvatar.svelte b/apps/web/src/lib/components/UserAuthAvatar.svelte
new file mode 100644
index 0000000000..82acb13cc5
--- /dev/null
+++ b/apps/web/src/lib/components/UserAuthAvatar.svelte
@@ -0,0 +1,50 @@
+
+
+
+ {
+ goto(routes.profilePath());
+ }}
+ >
+ {#if user?.picture}
+
+ {:else}
+
+ {/if}
+
+
+
+
diff --git a/apps/web/src/lib/components/UserDashboard.svelte b/apps/web/src/lib/components/UserDashboard.svelte
deleted file mode 100644
index 29bf547b79..0000000000
--- a/apps/web/src/lib/components/UserDashboard.svelte
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
- GitButler | User
-
-
-{#if !$token}
-
Unauthorized
-{:else if !$user?.id}
-
Loading...
-{:else}
-
-
Welcome to GitButler Cloud
-
-{/if}
-
-
diff --git a/apps/web/src/lib/components/UserPage.svelte b/apps/web/src/lib/components/UserPage.svelte
deleted file mode 100644
index 9257c9aea0..0000000000
--- a/apps/web/src/lib/components/UserPage.svelte
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
-{#if !$token}
-
Unauthorized
-{:else if !$user?.id}
-
Loading...
-{:else}
-
-
- {#if $user?.picture}
-

- {/if}
-
{$user?.name}
-
-
Email: {$user?.email}
-
Joined: {$user?.created_at}
-
Supporter: {$user?.supporter}
-
-{/if}
-
-
diff --git a/apps/web/src/lib/components/auth/OAuthButtons.svelte b/apps/web/src/lib/components/auth/OAuthButtons.svelte
new file mode 100644
index 0000000000..4465e2932c
--- /dev/null
+++ b/apps/web/src/lib/components/auth/OAuthButtons.svelte
@@ -0,0 +1,112 @@
+
+
+
+
+
diff --git a/apps/web/src/lib/components/auth/PasswordConfirmation.svelte b/apps/web/src/lib/components/auth/PasswordConfirmation.svelte
new file mode 100644
index 0000000000..0d2f3d2fa9
--- /dev/null
+++ b/apps/web/src/lib/components/auth/PasswordConfirmation.svelte
@@ -0,0 +1,115 @@
+
+
+
+ {
+ passwordTouched = true;
+ }}
+ />
+ {
+ passwordConfirmationTouched = true;
+ }}
+ onblur={() => {
+ passwordConfirmationTouched = true;
+ }}
+ />
+
+
+
diff --git a/apps/web/src/lib/components/auth/UsernameTextbox.svelte b/apps/web/src/lib/components/auth/UsernameTextbox.svelte
new file mode 100644
index 0000000000..9972ade9ca
--- /dev/null
+++ b/apps/web/src/lib/components/auth/UsernameTextbox.svelte
@@ -0,0 +1,135 @@
+
+
+
diff --git a/apps/web/src/lib/components/marketing/Footer.svelte b/apps/web/src/lib/components/marketing/Footer.svelte
new file mode 100644
index 0000000000..87942c09f2
--- /dev/null
+++ b/apps/web/src/lib/components/marketing/Footer.svelte
@@ -0,0 +1,424 @@
+
+
+
+
+
diff --git a/apps/web/src/lib/components/marketing/Header.svelte b/apps/web/src/lib/components/marketing/Header.svelte
new file mode 100644
index 0000000000..fa80bff7bd
--- /dev/null
+++ b/apps/web/src/lib/components/marketing/Header.svelte
@@ -0,0 +1,125 @@
+
+
+
+{#snippet link(props: {
+ href: string;
+ label: string;
+ icon?: keyof typeof iconsJson;
+ target?: string;
+ rel?: string;
+})}
+
+ {props.label}
+ {#if props.icon}
+
+ {/if}
+
+{/snippet}
+
+
+
+
diff --git a/apps/web/src/lib/components/marketing/ReleaseCard.svelte b/apps/web/src/lib/components/marketing/ReleaseCard.svelte
new file mode 100644
index 0000000000..c2f8987ecd
--- /dev/null
+++ b/apps/web/src/lib/components/marketing/ReleaseCard.svelte
@@ -0,0 +1,241 @@
+
+
+
+
+
+
+ {#if release.notes}
+
+ {@html marked(release.notes)}
+
+ {/if}
+
+ {#if showDownloadLinks && release.builds && release.builds.length > 0}
+ {#if !downloadLinksVisible}
+
(downloadLinksVisible = true)}
+ >
+ Show download options
+ ⭳
+
+ {/if}
+
+ {#if downloadLinksVisible}
+
+ {#each Object.entries(groupedBuilds) as [os, builds]}
+
+ {/each}
+
+ {/if}
+ {/if}
+
+
+
+
diff --git a/apps/web/src/lib/components/service/FullscreenIllustrationCard.svelte b/apps/web/src/lib/components/service/FullscreenIllustrationCard.svelte
new file mode 100644
index 0000000000..f1a0001acb
--- /dev/null
+++ b/apps/web/src/lib/components/service/FullscreenIllustrationCard.svelte
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+ {@render title()}
+
+
+ {@render children()}
+
+
+
+ {@render footer?.()}
+
+
+
+
+ {@html illustration ?? walkininSvg}
+
+
+
+
+
diff --git a/apps/web/src/lib/components/service/FullscreenUtilityCard.svelte b/apps/web/src/lib/components/service/FullscreenUtilityCard.svelte
new file mode 100644
index 0000000000..4f9d295347
--- /dev/null
+++ b/apps/web/src/lib/components/service/FullscreenUtilityCard.svelte
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
diff --git a/apps/web/src/lib/data/links.json b/apps/web/src/lib/data/links.json
index 7d7ccdd73e..15002b0a4f 100644
--- a/apps/web/src/lib/data/links.json
+++ b/apps/web/src/lib/data/links.json
@@ -20,6 +20,11 @@
"url": "https://app.gitbutler.com/downloads/release/linux/x86_64/deb",
"label": "Linux DEB"
},
+ "linuxRpm": {
+ "id": "linuxRpm",
+ "url": "https://app.gitbutler.com/downloads/release/linux/x86_64/rpm",
+ "label": "Linux RPM"
+ },
"windowsMsi": {
"id": "windowsMsi",
"url": "https://app.gitbutler.com/downloads/release/windows/x86_64/msi",
@@ -27,15 +32,25 @@
}
},
"resources": {
- "github": {
- "id": "github",
- "url": "https://github.com/gitbutlerapp/gitbutler",
- "label": "GitHub"
+ "downloads": {
+ "id": "downloads",
+ "url": "/downloads",
+ "label": "Downloads"
},
"documentation": {
"id": "documentation",
"url": "https://docs.gitbutler.com",
- "label": "Documentation"
+ "label": "Docs"
+ },
+ "source": {
+ "id": "source",
+ "url": "https://github.com/gitbutlerapp/gitbutler",
+ "label": "Source"
+ },
+ "jobs": {
+ "id": "jobs",
+ "url": "https://jobs.gitbutler.com",
+ "label": "Jobs"
},
"blog": {
"id": "blog",
@@ -59,6 +74,11 @@
"url": "https://x.com/gitbutler",
"label": "X"
},
+ "bluesky": {
+ "id": "bluesky",
+ "url": "https://bsky.app/profile/gitbutler.com",
+ "label": "Bluesky"
+ },
"instagram": {
"id": "instagram",
"url": "https://www.instagram.com/gitbutler",
@@ -94,6 +114,11 @@
"id": "youtube-demo",
"url": "https://youtu.be/agfyTN3HpRM?si=jVyMeMrWIaMa0Jdp",
"label": "YouTube Demo"
+ },
+ "support": {
+ "id": "help",
+ "url": "https://github.com/gitbutlerapp/gitbutler/issues/new?template=BLANK_ISSUE",
+ "label": "Submit a ticket"
}
}
}
diff --git a/apps/web/src/lib/data/os-icons.json b/apps/web/src/lib/data/os-icons.json
new file mode 100644
index 0000000000..3d0ba24d56
--- /dev/null
+++ b/apps/web/src/lib/data/os-icons.json
@@ -0,0 +1,5 @@
+{
+ "macos": "M14.5124 1C14.5609 1 14.6094 1 14.6607 1C14.7798 2.41044 14.2185 3.46432 13.5363 4.22751C12.867 4.98542 11.9504 5.7205 10.468 5.60897C10.3691 4.21872 10.9313 3.24301 11.6125 2.48158C12.2443 1.77197 13.4026 1.14052 14.5124 1Z M19 15.6805C19 15.6946 19 15.7069 19 15.7201C18.5834 16.9303 17.9891 17.9675 17.2639 18.93C16.6019 19.8038 15.7906 20.9798 14.3421 20.9798C13.0904 20.9798 12.259 20.2078 10.9761 20.1868C9.61914 20.1657 8.87289 20.8323 7.63218 21C7.49025 21 7.34832 21 7.20915 21C6.29807 20.8735 5.5628 20.1815 5.02715 19.5579C3.44765 17.7154 2.22708 15.3354 2 12.2897C2 11.9911 2 11.6934 2 11.3948C2.09614 9.21499 3.20042 7.44272 4.66821 6.58381C5.44285 6.12712 6.50776 5.73807 7.69353 5.91196C8.20171 5.98748 8.72089 6.15435 9.17597 6.31946C9.60724 6.47842 10.1466 6.76033 10.6575 6.7454C11.0036 6.73574 11.3479 6.56273 11.6968 6.44065C12.7186 6.08673 13.7203 5.68098 15.0407 5.87156C16.6275 6.10166 17.7538 6.77789 18.4497 7.82124C17.1073 8.64063 16.0461 9.87542 16.2274 11.9841C16.3886 13.8995 17.5496 15.0201 19 15.6805Z",
+ "windows": "M8.53359 4.68442L2.75 5.72786V10.5553L8.53355 10.4673L8.53359 4.68442ZM19.25 11.6954L9.85695 11.55V17.5536L19.25 19.25V11.6954ZM8.53359 11.532L2.75004 11.4432V16.2698L8.53359 17.314V11.532ZM19.25 2.75L9.85695 4.44478V10.4484L19.25 10.3039V2.75Z",
+ "linux": "M13.6372 18.7335C13.7705 18.936 13.632 19.2506 13.3746 19.2499H8.63011C8.37922 19.2505 8.23119 18.9405 8.36753 18.7335C9.5959 16.8297 12.4089 16.8297 13.6372 18.7335ZM18.5951 19.2499H15.4887C15.3581 19.2497 15.2345 19.1591 15.1904 19.0313C13.8185 14.9973 8.12488 15.1777 6.81431 19.0313C6.77023 19.1591 6.64666 19.2497 6.51605 19.2499H3.40485C2.85633 19.2594 2.57308 18.5904 2.89081 18.1774C4.88527 15.7575 5.29045 11.6426 5.29045 8.68997C5.29045 5.55268 7.79631 2.75 11.002 2.75C14.2076 2.75 16.7135 5.55268 16.7135 8.68997C16.7135 11.7738 17.1908 15.6075 19.1139 18.1791C19.437 18.6018 19.1245 19.2591 18.5951 19.2499ZM7.8289 9.67996C7.8289 10.2739 8.37584 10.7731 8.96654 10.6509C9.33585 10.5745 9.641 10.2572 9.71446 9.8731C9.82601 9.28986 9.38428 8.68997 8.78083 8.68997C8.24655 8.68997 7.8289 9.15708 7.8289 9.67996ZM14.1084 12.3546C13.9579 12.0415 13.5583 11.9029 13.2572 12.0592L11.002 13.2324L8.74751 12.0592C8.44623 11.9028 8.04636 12.0417 7.89594 12.355C7.74551 12.6683 7.87905 13.0842 8.18032 13.2406L10.7188 14.5606C10.8949 14.6521 11.1098 14.6521 11.286 14.5606L13.8244 13.2406C14.1388 13.0774 14.2591 12.6675 14.1084 12.3546ZM14.175 9.67996C14.175 9.09285 13.6364 8.58508 13.0374 8.70899C12.6681 8.78539 12.363 9.10274 12.2895 9.48682C12.1791 10.0641 12.6119 10.67 13.2231 10.67C13.7574 10.67 14.175 10.2028 14.175 9.67996Z"
+}
diff --git a/apps/web/src/lib/meta/opengraph.ts b/apps/web/src/lib/meta/opengraph.ts
index c142189f0c..f831a7230f 100644
--- a/apps/web/src/lib/meta/opengraph.ts
+++ b/apps/web/src/lib/meta/opengraph.ts
@@ -93,5 +93,7 @@ export async function fillMeta(html: string, url: string) {
}
}
+ // Default fallback for non-review pages
+ metaTags = metaTags.replaceAll('%image%', `${env.PUBLIC_APP_HOST}og-image.png`);
return html.replace('%metatags%', metaTags);
}
diff --git a/apps/web/src/lib/store.ts b/apps/web/src/lib/store.ts
index 327e340fd6..b325f72a37 100644
--- a/apps/web/src/lib/store.ts
+++ b/apps/web/src/lib/store.ts
@@ -1,5 +1,3 @@
-import * as jsonLinks from '$lib/data/links.json';
import { writable } from 'svelte/store';
-export const targetDownload = writable(jsonLinks.downloads.appleSilicon);
export const latestClientVersion = writable('...');
diff --git a/apps/web/src/lib/styles/global.css b/apps/web/src/lib/styles/global.css
deleted file mode 100644
index 97210bc1cd..0000000000
--- a/apps/web/src/lib/styles/global.css
+++ /dev/null
@@ -1,36 +0,0 @@
-@import '@gitbutler/ui/main.css';
-
-:root {
- --layout-col-gap: 20px;
- --off-white: #f5f5f3;
- --clr-bg: var(--off-white);
-}
-
-body {
- min-height: 100vh;
- background: var(--clr-bg-2);
-}
-
-body:has(.marketing-page) {
- --off-white: #f5f5f3;
- --clr-bg: var(--off-white);
- /* gray */
- --clr-white: #ffffff;
- --clr-black: #000000;
- --clr-dark-gray: #707070;
- --clr-gray: #d0cfcb;
- --clr-light-gray: #f1f1ed;
- /* accent */
- --clr-accent: #97eae5;
-}
-
-body:has(.marketing-page)::-webkit-scrollbar {
- /* For vertical scrollbars */
- width: 8px;
- /* For horizontal scrollbars */
- height: 8px;
-}
-
-body:has(.marketing-page)::-webkit-scrollbar-thumb {
- background: color-mix(in srgb, var(--clr-accent) 96%, var(--clr-black));
-}
diff --git a/apps/web/src/lib/user/userService.ts b/apps/web/src/lib/user/userService.ts
index c175cbfdb5..c3798f04c7 100644
--- a/apps/web/src/lib/user/userService.ts
+++ b/apps/web/src/lib/user/userService.ts
@@ -73,6 +73,16 @@ export class UserService {
return user;
}
+ async refreshUser() {
+ try {
+ const user = await this.fetchUser();
+ this.user.set(user);
+ this.error.set(undefined);
+ } catch (error) {
+ this.error.set(error);
+ }
+ }
+
clearUser() {
this.user.set(undefined);
}
diff --git a/apps/web/src/lib/utils/releaseUtils.ts b/apps/web/src/lib/utils/releaseUtils.ts
new file mode 100644
index 0000000000..8011b9bcc3
--- /dev/null
+++ b/apps/web/src/lib/utils/releaseUtils.ts
@@ -0,0 +1,79 @@
+import { getValidReleases, type Build, type Release } from '$lib/types/releases';
+
+const API_BASE_URL = 'https://app.gitbutler.com/api/downloads';
+
+/**
+ * Process builds by filtering out .zip files, removing duplicates, and sorting by platform
+ */
+export function processBuilds(builds: Build[]): Build[] {
+ return builds
+ .filter((build) => !build.url.endsWith('.zip'))
+ .filter((build, index, self) => self.findIndex((b) => b.url === build.url) === index)
+ .sort((a, b) => b.platform.localeCompare(a.platform));
+}
+
+/**
+ * Find a specific build based on OS, architecture, and optional file includes criteria
+ */
+export function findBuild(
+ builds: Build[],
+ os: string,
+ arch?: string,
+ fileIncludes?: string
+): Build | undefined {
+ return builds.find(
+ (build: Build) =>
+ build.os === os &&
+ (!arch || build.arch === arch) &&
+ (!fileIncludes || build.file.includes(fileIncludes))
+ );
+}
+
+/**
+ * Create standardized build mapping for the latest release with common platform configurations
+ */
+export function createLatestReleaseBuilds(latestRelease: Release): {
+ [key: string]: Build | undefined;
+} {
+ return {
+ darwin_x86_64: findBuild(latestRelease.builds, 'darwin', 'x86_64'),
+ darwin_aarch64: findBuild(latestRelease.builds, 'darwin', 'aarch64'),
+ windows_x86_64: findBuild(latestRelease.builds, 'windows', 'x86_64'),
+ linux_appimage: findBuild(latestRelease.builds, 'linux', undefined, 'AppImage'),
+ linux_deb: findBuild(latestRelease.builds, 'linux', undefined, 'deb'),
+ linux_rpm: findBuild(latestRelease.builds, 'linux', undefined, 'rpm')
+ };
+}
+
+/**
+ * Process all releases by applying processBuilds to each release's builds array
+ */
+export function processAllReleases(releases: Release[]): Release[] {
+ return releases.map((release) => ({
+ ...release,
+ builds: processBuilds(release.builds)
+ }));
+}
+
+/**
+ * Fetch releases from the GitButler API
+ */
+export async function fetchReleases(
+ limit: number = 10,
+ channel: 'release' | 'nightly' = 'release'
+): Promise
{
+ const response = await fetch(`${API_BASE_URL}?limit=${limit}&channel=${channel}`);
+ const data = await response.json();
+ return getValidReleases(data);
+}
+
+/**
+ * Fetch and process releases from the GitButler API
+ */
+export async function fetchAndProcessReleases(
+ limit: number = 10,
+ channel: 'release' | 'nightly' = 'release'
+): Promise {
+ const releases = await fetchReleases(limit, channel);
+ return processAllReleases(releases);
+}
diff --git a/apps/web/src/lib/youtube.ts b/apps/web/src/lib/youtube.ts
new file mode 100644
index 0000000000..525f5d5f59
--- /dev/null
+++ b/apps/web/src/lib/youtube.ts
@@ -0,0 +1,204 @@
+export interface YouTubeVideo {
+ id: string;
+ title: string;
+ description: string;
+ thumbnail: string;
+ publishedAt: string;
+ channelTitle: string;
+ videoId: string;
+ url: string;
+}
+
+export interface YouTubePlaylist {
+ id: string;
+ title: string;
+ description: string;
+ videos: YouTubeVideo[];
+}
+
+/**
+ * Extract playlist ID from YouTube playlist URL
+ */
+export function extractPlaylistId(url: string): string | null {
+ try {
+ const urlObj = new URL(url);
+ return urlObj.searchParams.get('list');
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Get YouTube video URL from video ID
+ */
+export function getVideoUrl(videoId: string): string {
+ return `https://www.youtube.com/watch?v=${videoId}`;
+}
+
+/**
+ * Get YouTube video embed URL from video ID
+ */
+export function getEmbedUrl(videoId: string): string {
+ return `https://www.youtube.com/embed/${videoId}`;
+}
+
+/**
+ * Get high-quality YouTube thumbnail URL
+ * maxresdefault.jpg (1280x720) - highest quality, may not exist for all videos
+ * hqdefault.jpg (480x360) - high quality, more reliable
+ * mqdefault.jpg (320x180) - medium quality (default)
+ */
+export function getHighQualityThumbnail(videoId: string): string {
+ // Try maxres first for highest quality
+ return `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
+}
+
+/**
+ * Get fallback thumbnail URL if high quality fails
+ */
+export function getFallbackThumbnail(videoId: string): string {
+ return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
+}
+
+/**
+ * Fetches videos from a YouTube playlist without requiring an API key
+ * Uses a combination of RSS feed and fallback data
+ */
+export async function fetchPlaylistVideos(playlistId: string): Promise {
+ try {
+ // Try to fetch from YouTube RSS feed (works without API key but has limitations)
+ const rssUrl = `https://www.youtube.com/feeds/videos.xml?playlist_id=${playlistId}`;
+
+ // Use a CORS proxy to access the RSS feed
+ const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(rssUrl)}`;
+ const response = await fetch(proxyUrl, {
+ // Add timeout to prevent hanging
+ signal: AbortSignal.timeout(5000)
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ const parser = new DOMParser();
+ const xmlDoc = parser.parseFromString(data.contents, 'text/xml');
+
+ const entries = Array.from(xmlDoc.querySelectorAll('entry')).slice(0, 10);
+ const playlistTitle = xmlDoc.querySelector('title')?.textContent || 'YouTube Playlist';
+ const channelTitle = xmlDoc.querySelector('author > name')?.textContent || 'YouTube Channel';
+
+ const videos: YouTubeVideo[] = entries
+ .map((entry) => {
+ // Try multiple ways to get videoId for better compatibility
+ const videoId =
+ entry.querySelector('yt\\:videoId')?.textContent ||
+ entry.querySelector('videoId')?.textContent ||
+ entry.querySelector('id')?.textContent?.split(':').pop() ||
+ null;
+
+ if (!videoId) return null;
+
+ const title = entry.querySelector('title')?.textContent || 'Untitled Video';
+ const description =
+ entry.querySelector('media\\:description')?.textContent ||
+ entry.querySelector('description')?.textContent ||
+ '';
+ const publishedAt =
+ entry.querySelector('published')?.textContent || new Date().toISOString();
+
+ return {
+ id: videoId,
+ title,
+ description,
+ thumbnail: getHighQualityThumbnail(videoId),
+ publishedAt,
+ channelTitle,
+ videoId,
+ url: getVideoUrl(videoId)
+ };
+ })
+ .filter((video): video is YouTubeVideo => video !== null);
+
+ return {
+ id: playlistId,
+ title: playlistTitle,
+ description: 'GitButler Feature Updates and Tutorials',
+ videos
+ };
+ }
+ } catch (error) {
+ console.warn('Failed to fetch from RSS feed:', error);
+ }
+
+ // Fallback to hardcoded playlist data for the specific GitButler playlist
+ return getGitButlerPlaylistFallback(playlistId);
+}
+
+/**
+ * Fallback data with actual GitButler video information
+ * This can be updated manually when new videos are added to the playlist
+ */
+function getGitButlerPlaylistFallback(playlistId: string): YouTubePlaylist {
+ const videos: YouTubeVideo[] = [
+ {
+ id: '1',
+ title: 'GitButler: A New Way to Git',
+ description:
+ 'Introducing GitButler - a Git client that makes complex Git workflows simple and visual.',
+ thumbnail: 'https://img.youtube.com/vi/A8-aLZ8e5tw/mqdefault.jpg',
+ publishedAt: '2024-03-15T10:00:00Z',
+ channelTitle: 'GitButler',
+ videoId: 'A8-aLZ8e5tw',
+ url: getVideoUrl('A8-aLZ8e5tw')
+ },
+ {
+ id: '2',
+ title: 'Virtual Branches Explained',
+ description:
+ 'Learn about GitButlers virtual branches feature that lets you work on multiple features simultaneously.',
+ thumbnail: 'https://img.youtube.com/vi/ChNLvCmJFss/mqdefault.jpg',
+ publishedAt: '2024-03-20T14:30:00Z',
+ channelTitle: 'GitButler',
+ videoId: 'ChNLvCmJFss',
+ url: getVideoUrl('ChNLvCmJFss')
+ },
+ {
+ id: '3',
+ title: 'Getting Started with GitButler',
+ description:
+ 'A quick tutorial on how to get started with GitButler and set up your first project.',
+ thumbnail: 'https://img.youtube.com/vi/YjCY-3rBd5g/mqdefault.jpg',
+ publishedAt: '2024-03-25T16:45:00Z',
+ channelTitle: 'GitButler',
+ videoId: 'YjCY-3rBd5g',
+ url: getVideoUrl('YjCY-3rBd5g')
+ },
+ {
+ id: '4',
+ title: 'Advanced GitButler Features',
+ description:
+ 'Explore advanced features like AI commit messages, hunk management, and workflow automation.',
+ thumbnail: 'https://img.youtube.com/vi/Qz8Bz9QvVpU/mqdefault.jpg',
+ publishedAt: '2024-04-01T12:15:00Z',
+ channelTitle: 'GitButler',
+ videoId: 'Qz8Bz9QvVpU',
+ url: getVideoUrl('Qz8Bz9QvVpU')
+ },
+ {
+ id: '5',
+ title: 'GitButler vs Traditional Git',
+ description:
+ 'Compare GitButler with traditional Git workflows and see why teams are switching.',
+ thumbnail: 'https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg',
+ publishedAt: '2024-04-05T09:30:00Z',
+ channelTitle: 'GitButler',
+ videoId: 'dQw4w9WgXcQ',
+ url: getVideoUrl('dQw4w9WgXcQ')
+ }
+ ];
+
+ return {
+ id: playlistId,
+ title: 'GitButler Feature Updates',
+ description: 'Latest GitButler tutorials, feature demonstrations, and updates',
+ videos
+ };
+}
diff --git a/apps/web/src/params/ownerSlug.ts b/apps/web/src/params/ownerSlug.ts
new file mode 100644
index 0000000000..b09aa7f0d9
--- /dev/null
+++ b/apps/web/src/params/ownerSlug.ts
@@ -0,0 +1,8 @@
+/**
+ * Parameter matcher for owner slugs.
+ * Matches URL-safe strings that can be used as owner identifiers.
+ * Allows letters, numbers, hyphens, underscores, and dots.
+ */
+export function match(param: string): param is string {
+ return /^[a-zA-Z0-9_.-]+$/.test(param);
+}
diff --git a/apps/web/src/routes/(app)/+layout.svelte b/apps/web/src/routes/(app)/+layout.svelte
index 2f55f15357..aedce5ebf9 100644
--- a/apps/web/src/routes/(app)/+layout.svelte
+++ b/apps/web/src/routes/(app)/+layout.svelte
@@ -1,9 +1,10 @@
-