Skip to content

Commit 45ac86f

Browse files
committed
Profile list improvement
1 parent 96c972a commit 45ac86f

9 files changed

Lines changed: 256 additions & 40 deletions

File tree

apps/auth/Auth/Auth.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
<None Update="GraphQL\GetUserByEmail.graphql">
3636
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
3737
</None>
38+
<None Update="GraphQL\GetUserBySlug.graphql">
39+
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
40+
</None>
3841
<None Update="GraphQL\GetUserRoles.graphql">
3942
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
4043
</None>
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
mutation CreateUser($username: String!, $email_address: String, $slug: String!) {
2-
insert_user_one(object: {username: $username, email_address: $email_address, slug: $slug}) {
1+
mutation CreateUser($username: String!, $email_address: String, $slug: String!, $greeting_name: String) {
2+
insert_user_one(object: {username: $username, email_address: $email_address, slug: $slug, greeting_name: $greeting_name}) {
33
user_id
44
}
55
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
query GetUserBySlug($slug: String!) {
2+
user(where: {slug: {_eq: $slug}}) {
3+
user_id
4+
}
5+
}

apps/auth/Auth/HandleGenerator.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace SteveR.Auth;
2+
3+
/// <summary>
4+
/// Generates friendly, speccy-themed handles (slug + display name) for accounts
5+
/// whose username is an opaque provider identifier (e.g. "auth0|...").
6+
/// </summary>
7+
public static class HandleGenerator
8+
{
9+
private static readonly string[] Words1 =
10+
{
11+
"beeper", "raster", "attr", "pixel", "sprite", "scanline", "border", "ink", "paper",
12+
"bright", "flash", "loader", "tape", "micro", "turbo", "retro", "neon", "vector",
13+
"scroll", "blitz", "basic", "rom", "beam", "clash"
14+
};
15+
16+
private static readonly string[] Words2 =
17+
{
18+
"wizard", "runner", "hacker", "clash", "byte", "loop", "coder", "ghost", "droid",
19+
"knight", "racer", "pilot", "ranger", "goblin", "comet", "falcon", "glitch", "demon",
20+
"crawler", "smith", "phantom", "jumper", "blaster", "rebel"
21+
};
22+
23+
/// <summary>
24+
/// Generates a candidate handle. Uniqueness must be checked by the caller,
25+
/// retrying as needed against the slug unique index.
26+
/// </summary>
27+
public static (string Slug, string DisplayName) Generate()
28+
{
29+
var a = Words1[Random.Shared.Next(Words1.Length)];
30+
var b = Words2[Random.Shared.Next(Words2.Length)];
31+
var n = Random.Shared.Next(10, 10000); // 2 to 4 digits
32+
var slug = $"{a}-{b}-{n}";
33+
var displayName = $"{Capitalize(a)} {Capitalize(b)}";
34+
return (slug, displayName);
35+
}
36+
37+
private static string Capitalize(string s) => char.ToUpperInvariant(s[0]) + s[1..];
38+
}

apps/auth/Auth/Repositories/UserRepository.cs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,44 @@ public async Task CreateUser(string username, string? emailAddress = null)
4949

5050
emailAddress = emailAddress?.Trim();
5151

52-
// Generate slug from username (lowercase, alphanumeric with hyphens)
53-
var slug = GenerateSlug(username);
52+
// Opaque provider identifiers (e.g. "auth0|...", "google-oauth2|...") make
53+
// ugly slugs when derived directly. For those, assign a friendly, unique
54+
// speccy-themed handle and a display name. Real usernames keep deriving a
55+
// slug from the username as before.
56+
string slug;
57+
string? greetingName = null;
58+
if (username.Contains('|'))
59+
{
60+
string displayName;
61+
do
62+
{
63+
(slug, displayName) = HandleGenerator.Generate();
64+
} while (await SlugExists(slug));
65+
greetingName = displayName;
66+
}
67+
else
68+
{
69+
slug = GenerateSlug(username);
70+
}
5471

5572
// Query to create new user record.
5673
var client = GetGraphQlClient();
5774
var query = await File.ReadAllTextAsync(Path.Combine("GraphQL", "CreateUser.graphql"));
58-
var result = await client.Query(query, new { username, email_address = emailAddress, slug });
75+
var result = await client.Query(query, new { username, email_address = emailAddress, slug, greeting_name = greetingName });
76+
result.EnsureNoErrors();
77+
}
78+
79+
/// <param name="slug">Slug to look up.</param>
80+
/// <returns>True if a user already has this slug, otherwise false.</returns>
81+
private async Task<bool> SlugExists(string slug)
82+
{
83+
var client = GetGraphQlClient();
84+
var query = await File.ReadAllTextAsync(Path.Combine("GraphQL", "GetUserBySlug.graphql"));
85+
var result = await client.Query(query, new { slug });
5986
result.EnsureNoErrors();
87+
88+
var users = result.Get<User[]>("user");
89+
return users != null && users.Length > 0;
6090
}
6191

6292
private static string GenerateSlug(string input)

apps/web/src/components/PublicProfiles.jsx

Lines changed: 110 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { Link } from "react-router-dom";
33
import { Titled } from "react-titled";
44
import { Card } from "primereact/card";
55
import { Avatar } from "primereact/avatar";
6+
import { Dropdown } from "primereact/dropdown";
7+
import { InputSwitch } from "primereact/inputswitch";
68
import { ProgressSpinner } from "primereact/progressspinner";
7-
import { Message } from "primereact/message";
89
import { Paginator } from "primereact/paginator";
910
import gql from "graphql-tag";
1011
import { gqlFetch } from "../graphql_fetch";
@@ -13,18 +14,18 @@ import { formatDistanceToNow } from "date-fns";
1314
import { generateRetroAvatar } from "../lib/avatar";
1415

1516
const GET_PUBLIC_PROFILES = gql`
16-
query GetPublicProfiles($limit: Int!, $offset: Int!) {
17-
user_aggregate(where: { profile_is_public: { _eq: true } }) {
17+
query GetPublicProfiles(
18+
$where: user_bool_exp!
19+
$orderBy: [user_order_by!]!
20+
$limit: Int!
21+
$offset: Int!
22+
) {
23+
user_aggregate(where: $where) {
1824
aggregate {
1925
count
2026
}
2127
}
22-
user(
23-
where: { profile_is_public: { _eq: true } }
24-
order_by: { created_at: desc }
25-
limit: $limit
26-
offset: $offset
27-
) {
28+
user(where: $where, order_by: $orderBy, limit: $limit, offset: $offset) {
2829
user_id
2930
greeting_name
3031
slug
@@ -37,11 +38,48 @@ const GET_PUBLIC_PROFILES = gql`
3738
count
3839
}
3940
}
41+
following_aggregate {
42+
aggregate {
43+
count
44+
}
45+
}
46+
projects_aggregate(where: { is_public: { _eq: true } }) {
47+
aggregate {
48+
count
49+
}
50+
}
4051
}
4152
}
4253
`;
4354

55+
const SORT_OPTIONS = [
56+
{
57+
label: "Recent activity",
58+
value: "recent",
59+
orderBy: [{ projects_aggregate: { max: { updated_at: "desc_nulls_last" } } }],
60+
},
61+
{
62+
label: "Most public projects",
63+
value: "projects",
64+
orderBy: [{ projects_aggregate: { count: "desc" } }],
65+
},
66+
{
67+
label: "Most followers",
68+
value: "followers",
69+
orderBy: [{ followers_aggregate: { count: "desc" } }],
70+
},
71+
{
72+
label: "Newest",
73+
value: "newest",
74+
orderBy: [{ created_at: "desc" }],
75+
},
76+
];
77+
4478
function ProfileCard({ user }) {
79+
const projectCount = user.projects_aggregate?.aggregate?.count || 0;
80+
const followerCount = user.followers_aggregate?.aggregate?.count || 0;
81+
const followingCount = user.following_aggregate?.aggregate?.count || 0;
82+
4583
return (
4684
<Link to={`/u/${user.slug || user.user_id}`} className="no-underline">
4785
<Card
@@ -61,15 +99,19 @@ function ProfileCard({ user }) {
6199
}}
62100
/>
63101
<div className="flex-1">
64-
<h4 className="m-0 mb-1 text-white">{user.greeting_name}</h4>
102+
<h4 className="m-0 mb-1 text-white">
103+
{user.greeting_name || `@${user.slug}`}
104+
</h4>
65105
<p className="text-500 m-0 text-sm">@{user.slug}</p>
66106
{user.bio && (
67107
<p className="text-400 m-0 mt-2 text-sm">{user.bio}</p>
68108
)}
69109
<div className="text-400 text-sm mt-2">
70-
<span>
71-
{user.followers_aggregate?.aggregate?.count || 0} followers
72-
</span>
110+
<span>{projectCount} public projects</span>
111+
<span className="mx-2">·</span>
112+
<span>{followerCount} followers</span>
113+
<span className="mx-2">·</span>
114+
<span>{followingCount} following</span>
73115
<span className="mx-2">·</span>
74116
<span>
75117
Joined{" "}
@@ -89,21 +131,33 @@ export default function PublicProfiles() {
89131
const [profiles, setProfiles] = useState([]);
90132
const [totalCount, setTotalCount] = useState(0);
91133
const [page, setPage] = useState(0);
134+
const [sort, setSort] = useState("recent");
135+
const [hideEmpty, setHideEmpty] = useState(true);
92136
const [loading, setLoading] = useState(true);
93137
const pageSize = 20;
94138

95139
useEffect(() => {
96140
const fetchProfiles = async () => {
97141
try {
98142
setLoading(true);
143+
144+
const where = { profile_is_public: { _eq: true } };
145+
if (hideEmpty) {
146+
where.projects = { is_public: { _eq: true } };
147+
}
148+
149+
const orderBy =
150+
SORT_OPTIONS.find((o) => o.value === sort)?.orderBy ||
151+
SORT_OPTIONS[0].orderBy;
152+
99153
const response = await gqlFetch(null, GET_PUBLIC_PROFILES, {
154+
where,
155+
orderBy,
100156
limit: pageSize,
101157
offset: page * pageSize,
102158
});
103159
setProfiles(response?.data?.user || []);
104-
setTotalCount(
105-
response?.data?.user_aggregate?.aggregate?.count || 0
106-
);
160+
setTotalCount(response?.data?.user_aggregate?.aggregate?.count || 0);
107161
} catch (err) {
108162
console.error("Failed to fetch public profiles:", err);
109163
} finally {
@@ -112,31 +166,52 @@ export default function PublicProfiles() {
112166
};
113167

114168
fetchProfiles();
115-
}, [page]);
116-
117-
if (loading) {
118-
return (
119-
<div className="flex justify-content-center align-items-center spinner-container-centered">
120-
<ProgressSpinner />
121-
</div>
122-
);
123-
}
169+
}, [page, sort, hideEmpty]);
124170

125171
return (
126172
<Titled title={(s) => `Public Profiles ${sep} ${s}`}>
127173
<div className="m-2">
128174
<Card title="Public Profiles">
129-
<p className="text-500 mb-4">
130-
Browse public profiles on ZX Play
131-
{totalCount > 0 && (
132-
<span className="ml-2">
133-
(showing {page * pageSize + 1} -{" "}
134-
{Math.min((page + 1) * pageSize, totalCount)} of {totalCount})
135-
</span>
136-
)}
137-
</p>
175+
<div className="flex flex-wrap align-items-center justify-content-between gap-3 mb-4">
176+
<p className="text-500 m-0">
177+
Browse public profiles on ZX Play
178+
{totalCount > 0 && (
179+
<span className="ml-2">
180+
(showing {page * pageSize + 1} -{" "}
181+
{Math.min((page + 1) * pageSize, totalCount)} of {totalCount})
182+
</span>
183+
)}
184+
</p>
185+
<div className="flex align-items-center gap-3">
186+
<div className="flex align-items-center gap-2">
187+
<InputSwitch
188+
inputId="hideEmpty"
189+
checked={hideEmpty}
190+
onChange={(e) => {
191+
setHideEmpty(e.value);
192+
setPage(0);
193+
}}
194+
/>
195+
<label htmlFor="hideEmpty" className="text-500 text-sm">
196+
Only with public projects
197+
</label>
198+
</div>
199+
<Dropdown
200+
value={sort}
201+
options={SORT_OPTIONS}
202+
onChange={(e) => {
203+
setSort(e.value);
204+
setPage(0);
205+
}}
206+
/>
207+
</div>
208+
</div>
138209

139-
{profiles.length > 0 ? (
210+
{loading ? (
211+
<div className="flex justify-content-center align-items-center py-6">
212+
<ProgressSpinner />
213+
</div>
214+
) : profiles.length > 0 ? (
140215
<>
141216
<div className="grid">
142217
{profiles.map((user) => (

hasura/metadata/databases/default/tables/public_project.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ select_permissions:
3737
filter:
3838
is_public:
3939
_eq: true
40+
allow_aggregations: true
4041
- role: zxplay-user
4142
permission:
4243
columns:
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- Irreversible: original slugs and prior profile_is_public values are not stored,
2+
-- so there is nothing to restore. This migration intentionally has no down step.

0 commit comments

Comments
 (0)