diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 98d5b450c9..cd153c215e 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -109,15 +109,17 @@ Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](re ### Organization pages -GitHub organizations, like `@laravel`, have a page at `/@{handle}` too, with their GitHub avatar (square, so they never pass for a person), name, description, location, website, and a link to GitHub. An organization gets its page once an approved commit mentions it, from its public GitHub profile, and the profile refreshes when commits keep mentioning it, at most once a day. Nobody signs in as an organization or edits its page: everything on it comes from GitHub. Shared links to an organization's page preview as a card with its square avatar, name, handle, and description, like a developer's profile. +GitHub organizations, like `@laravel`, have a page at `/@{handle}` too, with their GitHub avatar (square, so they never pass for a person), name, description, location, website, and a link to GitHub. An organization gets its page once an approved commit mentions it, from its public GitHub profile, and the profile refreshes when commits keep mentioning it, at most once a day. Nobody signs in as an organization, and everything on its page comes from GitHub, except its color. Shared links to an organization's page preview as a card with its square avatar, name, handle, and description, like a developer's profile. The **Mentions** tab lists the commits and replies that mention the organization, newest first, a reply with the commit it answers. A commit stays listed after the organization is renamed, since it is linked to the organization rather than to the handle. It follows the usual visibility: guests see approved commits only, you also see your own pending ones, and commits across a block or by people you muted are left out. A member of an organization can **Claim** its page, which lists them as its admin. Only the first member to claim it becomes its admin: once a page has one, it can't be claimed again, and the Claim button is gone. Claiming asks GitHub once for permission to read your organizations, then checks that you are an owner or a member there, not just invited. Organizations that restrict third-party apps only share their members once an owner approved /dev/null: the page then links to GitHub, where an owner grants access and a member requests it. GitHub is only the way in: leaving the organization on GitHub doesn't remove you as an admin on /dev/null. Claims are in your data export and go when you delete your account. +Every organization has a color, /dev/null's blue until its admin chooses another with **Color** on its page: one of the same six lane colors as developers, or a custom one. It rings the organization's avatar, leads its handle, and colors its link preview. + Following organizations is planned, not shipped yet. -Sources: [organization page](resources/js/pages/organization/show.tsx), [mentions](app/Queries/OrganizationMentionsQuery.php), [organization lookups](app/Actions/UpdateCommitTopics.php), [claims](app/Http/Controllers/OrganizationAdminController.php). +Sources: [organization page](resources/js/pages/organization/show.tsx), [mentions](app/Queries/OrganizationMentionsQuery.php), [organization lookups](app/Actions/UpdateCommitTopics.php), [claims](app/Http/Controllers/OrganizationAdminController.php), [color](resources/js/components/organization-lane-dialog.tsx). ### Going live diff --git a/app/Actions/CreateCommitOpenGraphImage.php b/app/Actions/CreateCommitOpenGraphImage.php index f501feb46d..7f140ac790 100644 --- a/app/Actions/CreateCommitOpenGraphImage.php +++ b/app/Actions/CreateCommitOpenGraphImage.php @@ -51,7 +51,7 @@ public function handle(User $user, Commit $commit): string private function render(User $user, Commit $commit): string { $canvas = new OpenGraphCanvas; - $lane = $canvas->lane($user); + $lane = $canvas->lane($user->lane()); $replyingTo = $commit->parent?->user?->handle; $headerHeight = self::AVATAR_SIZE + ($replyingTo === null ? 0 : self::REPLYING_TO_HEIGHT) + self::BODY_GAP; diff --git a/app/Actions/CreateOrganizationOpenGraphImage.php b/app/Actions/CreateOrganizationOpenGraphImage.php index 4840c4eb92..457e6d7f94 100644 --- a/app/Actions/CreateOrganizationOpenGraphImage.php +++ b/app/Actions/CreateOrganizationOpenGraphImage.php @@ -28,12 +28,12 @@ public function handle(Profile $organization): string } /** - * Drawn like a profile's card, in the muted color organizations have in the app, since they have no lane. + * Drawn like a profile's card, in the organization's lane, /dev/null's blue until its admins choose one. */ private function render(Profile $organization): string { $canvas = new OpenGraphCanvas; - $lane = OpenGraphCanvas::INK_MUTED; + $lane = $organization->lane() === null ? OpenGraphCanvas::PRIMARY : $canvas->lane($organization->lane()); $width = OpenGraphCanvas::CONTENT_RIGHT - OpenGraphCanvas::CONTENT_X; $bio = $canvas->clamp( diff --git a/app/Actions/CreateUserOpenGraphImage.php b/app/Actions/CreateUserOpenGraphImage.php index 6a18075bb3..0f66a13455 100644 --- a/app/Actions/CreateUserOpenGraphImage.php +++ b/app/Actions/CreateUserOpenGraphImage.php @@ -33,7 +33,7 @@ public function handle(User $user): string private function render(User $user): string { $canvas = new OpenGraphCanvas; - $lane = $canvas->lane($user); + $lane = $canvas->lane($user->lane()); $width = OpenGraphCanvas::CONTENT_RIGHT - OpenGraphCanvas::CONTENT_X; $bio = $canvas->clamp( diff --git a/app/Actions/UpdateOrganizationLane.php b/app/Actions/UpdateOrganizationLane.php new file mode 100644 index 0000000000..b200630b14 --- /dev/null +++ b/app/Actions/UpdateOrganizationLane.php @@ -0,0 +1,15 @@ +update(['preferred_lane' => $lane, 'lane_color' => $color]); + } +} diff --git a/app/Http/Controllers/OrganizationController.php b/app/Http/Controllers/OrganizationController.php index b31aff726b..800b57631f 100644 --- a/app/Http/Controllers/OrganizationController.php +++ b/app/Http/Controllers/OrganizationController.php @@ -19,8 +19,8 @@ { /** * Organization pages are public, at the "@handle" of users, and show the organization's GitHub profile and the - * commits that mention it, with the users who claimed it, first claim first, and the organization's link preview. - * Like users' pages, they move for good to the handle's own case. + * commits that mention it, in the lane its admins chose or /dev/null's blue, with the users who claimed it, first + * claim first, and the organization's link preview. Like users' pages, they move for good to the handle's own case. */ public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $currentUser, OrganizationMentionsQuery $mentions, PageMetadata $metadata): RedirectResponse|Response { @@ -37,6 +37,7 @@ public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $cur return Inertia::render('organization/show', [ // The Mentions tab tells a restored commit belongs to it by the ID, whatever handle the commit mentions. 'organization' => $organization->makeVisible('id'), + 'lane' => $organization->lane(), 'tab' => $request->tab(...), 'commits' => Inertia::scroll(fn () => $mentions->builder($organization, $currentUser) ->cursorPaginate(20) diff --git a/app/Http/Controllers/OrganizationLaneController.php b/app/Http/Controllers/OrganizationLaneController.php new file mode 100644 index 0000000000..81a9892715 --- /dev/null +++ b/app/Http/Controllers/OrganizationLaneController.php @@ -0,0 +1,26 @@ +handle($organization, $request->lane(), $request->color()); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => 'Color saved.', + ]); + + return back(); + } +} diff --git a/app/Http/Requests/UpdateOrganizationLaneRequest.php b/app/Http/Requests/UpdateOrganizationLaneRequest.php new file mode 100644 index 0000000000..d3167be71a --- /dev/null +++ b/app/Http/Requests/UpdateOrganizationLaneRequest.php @@ -0,0 +1,49 @@ +user(); + assert($user instanceof User); + + $organization = $this->route('organization'); + assert($organization instanceof Profile); + + return $organization->admins()->whereKey($user->id)->exists() ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'lane' => ['present', 'nullable', 'integer', 'between:0,5'], + 'color' => ['nullable', 'string', 'regex:/^#[0-9a-f]{6}$/', 'prohibits:lane'], + ]; + } + + public function lane(): ?int + { + return $this->filled('lane') ? $this->integer('lane') : null; + } + + public function color(): ?string + { + return $this->filled('color') ? $this->string('color')->toString() : null; + } +} diff --git a/app/Models/Profile.php b/app/Models/Profile.php index 2dcac92e9a..aef8c4e145 100644 --- a/app/Models/Profile.php +++ b/app/Models/Profile.php @@ -30,6 +30,8 @@ * @property-read string|null $bio * @property-read string|null $location * @property-read string|null $website + * @property-read int|null $preferred_lane + * @property-read string|null $lane_color * @property-read CarbonInterface|null $github_synced_at * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at @@ -76,6 +78,8 @@ public function casts(): array 'bio' => 'string', 'location' => 'string', 'website' => 'string', + 'preferred_lane' => 'integer', + 'lane_color' => 'string', 'github_synced_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', @@ -87,7 +91,16 @@ public function casts(): array */ public function openGraphImageVersion(): string { - return hash('xxh3', (string) json_encode([$this->name, $this->handle, $this->avatar_url, $this->bio])); + return hash('xxh3', (string) json_encode([$this->name, $this->handle, $this->avatar_url, $this->bio, $this->lane()])); + } + + /** + * The lane the organization's admins chose, like a user's, or null for /dev/null's blue, which organizations have + * until then. + */ + public function lane(): int|string|null + { + return $this->lane_color ?? $this->preferred_lane; } /** diff --git a/app/Services/OpenGraphCanvas.php b/app/Services/OpenGraphCanvas.php index fa87aad59f..a4b5636f1c 100644 --- a/app/Services/OpenGraphCanvas.php +++ b/app/Services/OpenGraphCanvas.php @@ -25,6 +25,11 @@ public const int INK_MUTED = 0x5B616B; + /** + * /dev/null's blue, the lane of organizations until their admins choose one. + */ + public const int PRIMARY = 0x3B6FE0; + public const int RAIL_X = 112; public const int CONTENT_X = 184; @@ -54,10 +59,8 @@ public function __construct() $this->image = $this->blank(self::WIDTH * self::SCALE, self::HEIGHT * self::SCALE); } - public function lane(User $user): int + public function lane(int|string $lane): int { - $lane = $user->lane(); - return is_string($lane) ? (int) hexdec(mb_substr($lane, 1)) : self::LANES[$lane]; } diff --git a/database/migrations/2026_10_29_120000_add_lane_to_profiles_table.php b/database/migrations/2026_10_29_120000_add_lane_to_profiles_table.php new file mode 100644 index 0000000000..9ee2361c80 --- /dev/null +++ b/database/migrations/2026_10_29_120000_add_lane_to_profiles_table.php @@ -0,0 +1,23 @@ +unsignedTinyInteger('preferred_lane')->nullable(); + $table->string('lane_color', 7)->nullable(); + }); + } +}; diff --git a/resources/js/components/lane-picker.tsx b/resources/js/components/lane-picker.tsx index b18274f5ba..fe582aa42a 100644 --- a/resources/js/components/lane-picker.tsx +++ b/resources/js/components/lane-picker.tsx @@ -7,7 +7,12 @@ import Heading from '@/components/heading'; import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; import { useUser } from '@/hooks/use-user'; -import { cn, laneColor, withViewTransition } from '@/lib/utils'; +import { + cn, + laneColor, + organizationLaneColor, + withViewTransition, +} from '@/lib/utils'; import type { Lane } from '@/types'; const laneNames = ['Orange', 'Green', 'Violet', 'Teal', 'Rose', 'Ochre']; @@ -52,7 +57,10 @@ export function LaneOptions({ onSelect, }: { selected: Lane | null; - automaticLane: number; + /** + * The lane picked when none is chosen, or null when that is /dev/null's blue, like organizations. + */ + automaticLane: number | null; onSelect: (lane: Lane | null) => void; }) { const colorInputRef = useRef(null); @@ -62,17 +70,24 @@ export function LaneOptions({ const isCustom = typeof selected === 'string'; const options = [ - { - value: null, - label: 'Auto', - ariaLabel: `Automatic (${laneNames[automaticLane].toLowerCase()})`, - lane: automaticLane, - }, + automaticLane === null + ? { + value: null, + label: 'Blue', + ariaLabel: 'Blue (default)', + color: organizationLaneColor(null), + } + : { + value: null, + label: 'Auto', + ariaLabel: `Automatic (${laneNames[automaticLane].toLowerCase()})`, + color: laneColor(automaticLane), + }, ...laneNames.map((name, index) => ({ value: index, label: name, ariaLabel: undefined, - lane: index, + color: laneColor(index), })), ]; @@ -109,7 +124,7 @@ export function LaneOptions({ className={optionClassName} style={ { - '--lane': laneColor(option.lane), + '--lane': option.color, } as CSSProperties } > @@ -156,11 +171,9 @@ export function LaneOptions({ { '--lane': customColor ?? - laneColor( - typeof selected === 'number' - ? selected - : automaticLane, - ), + (typeof selected === 'number' + ? laneColor(selected) + : options[0].color), } as CSSProperties } data-test="custom-lane-option" diff --git a/resources/js/components/organization-avatar.tsx b/resources/js/components/organization-avatar.tsx index 22dbb9cd81..4dddc76d76 100644 --- a/resources/js/components/organization-avatar.tsx +++ b/resources/js/components/organization-avatar.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties } from 'react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useInitials } from '@/hooks/use-initials'; import { sizedAvatarUrl } from '@/lib/urls'; @@ -10,10 +11,12 @@ import type { Organization } from '@/types'; export function OrganizationAvatar({ organization, className, + style, pixels = 80, }: { organization: Pick; className?: string; + style?: CSSProperties; /** * The image size to fetch: twice the largest size the avatar is drawn at, for high-density screens. */ @@ -27,6 +30,7 @@ export function OrganizationAvatar({ 'size-8 overflow-hidden rounded-md border bg-muted', className, )} + style={style} > void; +}) { + const [selected, setSelected] = useState(lane); + + return ( + { + onOpenChange(isOpen); + + if (!isOpen) { + setSelected(lane); + } + }} + > + + Color + + @{organization.handle}'s lane: the ring around its avatar + and the dot before its handle. + + +
+ +
+

+ {organization.name} +

+

+ + + @{organization.handle} + +

+
+
+ +
{ + onOpenChange(false); + router.flushAll(); + }} + > + {({ processing, errors }) => ( + <> + { + if ( + typeof choice === 'string' && + typeof selected === 'string' + ) { + setSelected(choice); + + return; + } + + void withViewTransition(() => + setSelected(choice), + ); + }} + /> + + + + + + + + + + + )} + +
+
+ ); +} diff --git a/resources/js/lib/utils.ts b/resources/js/lib/utils.ts index d63dccf6a5..a40c0d2a4d 100644 --- a/resources/js/lib/utils.ts +++ b/resources/js/lib/utils.ts @@ -186,6 +186,13 @@ export function laneColor(lane: Lane): string { return typeof lane === 'string' ? lane : `var(--lane-${lane})`; } +/** + * Organizations are in /dev/null's blue until their admins choose a lane. + */ +export function organizationLaneColor(lane: Lane | null): string { + return lane === null ? 'var(--primary)' : laneColor(lane); +} + /** * A palette lane is a CSS variable, so a hex alpha cannot be appended to it. */ diff --git a/resources/js/pages/organization/show.tsx b/resources/js/pages/organization/show.tsx index c86f70d041..d21197f1c9 100644 --- a/resources/js/pages/organization/show.tsx +++ b/resources/js/pages/organization/show.tsx @@ -1,27 +1,38 @@ import { Head, Link, usePage } from '@inertiajs/react'; -import { AlertCircleIcon, Link2, MapPin } from 'lucide-react'; +import { AlertCircleIcon, Link2, MapPin, Palette } from 'lucide-react'; +import type { CSSProperties } from 'react'; import { useState } from 'react'; import { show } from '@/actions/App/Http/Controllers/UserController'; import { BioText } from '@/components/bio-text'; import { CommitFeed, EmptyFeed } from '@/components/commit-feed'; import { GitHubIcon } from '@/components/github-icon'; import { OrganizationAvatar } from '@/components/organization-avatar'; +import { OrganizationLaneDialog } from '@/components/organization-lane-dialog'; import { PageTabs } from '@/components/page-tabs'; import { ProfileShareButton } from '@/components/profile-share-button'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { UserAvatar } from '@/components/user-avatar'; import AppLayout from '@/layouts/app-layout'; -import type { Commit, CursorPaginated, Organization, User } from '@/types'; +import { organizationLaneColor } from '@/lib/utils'; +import type { + Commit, + CursorPaginated, + Lane, + Organization, + User, +} from '@/types'; export default function Show({ organization, + lane, tab, commits, claimUrl, admins, }: { organization: Organization & { id: string }; + lane: Lane | null; tab: 'mentions'; commits: CursorPaginated; claimUrl: string; @@ -34,6 +45,8 @@ export default function Show({ // Only the first claim makes an admin. While a claim failed, its alert offers to try again instead. const claimable = auth.user !== null && admins.length === 0; const canClaim = claimable && claimFailed === undefined; + const isAdmin = admins.some((admin) => admin.id === auth.user?.id); + const [isLaneDialogOpen, setIsLaneDialogOpen] = useState(false); return ( <> @@ -52,11 +65,34 @@ export default function Show({
+ {isAdmin && ( + <> + + + + )} {canClaim && (