feat: Privacy settings with functional toggles, sticky sidebar, roadmap doc
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-23 13:08:48 -08:00
parent 824a70d303
commit 2da46eaa16
5 changed files with 201 additions and 66 deletions

View file

@ -176,6 +176,11 @@ class User(SQLModel, table=True):
avatar_bg_color: Optional[str] = Field(default="#3B82F6", description="Hex color for avatar background") avatar_bg_color: Optional[str] = Field(default="#3B82F6", description="Hex color for avatar background")
avatar_text: Optional[str] = Field(default=None, description="1-3 character text overlay on avatar") avatar_text: Optional[str] = Field(default=None, description="1-3 character text overlay on avatar")
# Privacy settings
profile_public: bool = Field(default=True, description="Allow others to view profile")
show_attendance_public: bool = Field(default=True, description="Show attended shows on profile")
appear_in_leaderboards: bool = Field(default=True, description="Appear in community leaderboards")
# Gamification # Gamification
xp: int = Field(default=0, description="Experience points") xp: int = Field(default=0, description="Experience points")
level: int = Field(default=1, description="User level based on XP") level: int = Field(default=1, description="User level based on XP")

View file

@ -136,6 +136,35 @@ def get_avatar_colors():
"""Get available avatar preset colors""" """Get available avatar preset colors"""
return {"colors": AVATAR_COLORS} return {"colors": AVATAR_COLORS}
class PrivacyUpdate(BaseModel):
profile_public: Optional[bool] = None
show_attendance_public: Optional[bool] = None
appear_in_leaderboards: Optional[bool] = None
@router.patch("/me/privacy")
def update_privacy(
update: PrivacyUpdate,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Update privacy settings"""
if update.profile_public is not None:
current_user.profile_public = update.profile_public
if update.show_attendance_public is not None:
current_user.show_attendance_public = update.show_attendance_public
if update.appear_in_leaderboards is not None:
current_user.appear_in_leaderboards = update.appear_in_leaderboards
session.add(current_user)
session.commit()
session.refresh(current_user)
return {
"profile_public": current_user.profile_public,
"show_attendance_public": current_user.show_attendance_public,
"appear_in_leaderboards": current_user.appear_in_leaderboards
}
@router.patch("/me/preferences", response_model=UserPreferencesUpdate) @router.patch("/me/preferences", response_model=UserPreferencesUpdate)
def update_preferences( def update_preferences(
prefs: UserPreferencesUpdate, prefs: UserPreferencesUpdate,

View file

@ -14,6 +14,9 @@ class UserRead(SQLModel):
is_superuser: bool is_superuser: bool
avatar_bg_color: Optional[str] = "#3B82F6" avatar_bg_color: Optional[str] = "#3B82F6"
avatar_text: Optional[str] = None avatar_text: Optional[str] = None
profile_public: bool = True
show_attendance_public: bool = True
appear_in_leaderboards: bool = True
class Token(SQLModel): class Token(SQLModel):
access_token: str access_token: str

View file

@ -1,72 +1,137 @@
# Future Roadmap & Implementation Plan # Elmeg Platform Roadmap
## 1. Cross-Vertical "Fandom Federation" (Future Feature) **Last Updated:** 2023-12-23
**Concept**: Enable cross-pollination between different band/fandom instances (Verticals).
**Use Case**: A user mentions `@Phish` in the `Goose` instance, or a guest artist like "Trey Anastasio" links to his stats in the Phish vertical.
**Implementation Strategy**:
* **Federated Identity**: A single `User` account works across all verticals (already partially supported by our schema).
* **Universal Resolver**: A service that resolves links like `elmeg://phish/shows/123` or `@phish:user_123`.
* **Shared Artist Database**: A global table of Artists that links to specific performances across all verticals.
--- ---
## 2. Immediate Implementation Plan (V1.1 Polish) ## Current Status Summary
We will tackle the following gaps to round out the V1 experience: ### ✅ Analytics
### Phase A: Personalization & "Wiki Mode" **Status: NOT CONFIGURED**
**Goal**: Allow users to customize their experience, specifically enabling the "pure archive" feel. - No Google Analytics, Plausible, or similar tracking implemented
- No gtag or measurement ID found in codebase
- **Action needed:** Choose analytics provider (recommend Plausible for privacy-first)
1. **Settings Page**: Create `/settings` route. ### ✅ AWS SES Email
2. **Preferences UI**: Toggles for:
* `Wiki Mode` (Hides comments, ratings, social noise).
* `Show Ratings` (Toggle visibility of 1-10 scores).
* `Show Comments` (Toggle visibility of discussion sections).
3. **Frontend Logic**: Wrap social components in a context provider that respects these flags.
### Phase B: Moderation Dashboard **Status: CONFIGURED & OPERATIONAL**
**Goal**: Empower admins to maintain data quality and community standards. - IAM user: `AKIAVFNHG5QATGYJHVCZ` (scoped SES permissions)
- Region: `us-east-1`
- From address: `noreply@elmeg.xyz`
- Frontend URL: `https://elmeg.xyz`
- Client initialized: ✅ Yes
- **Note:** Domain verification status needs manual check in AWS console
1. **Admin Route**: Create `/admin` (protected by `is_superuser` or `role=admin`). ### Templates Available
2. **Nickname Queue**: List `pending` nicknames with Approve/Reject actions.
3. **Report Queue**: List reported content with Dismiss/Delete actions.
4. **User Management**: Basic list of users with Ban/Promote options.
### Phase C: Activity Feed (The "Pulse") | Template | Status |
|----------|--------|
| Email Verification | ✅ Ready |
| Password Reset | ✅ Ready |
| Security Alert | ✅ Ready |
**Goal**: Make the platform feel alive and aid discovery. ---
1. **Global Feed**: Aggregated stream of: ## Settings Page Roadmap
* New Reviews
1. **Global Feed**: Aggregated stream of:
* New Reviews
* New Show Attendance
* New Groups created
* Rare stats/milestones (e.g., "User X attended their 100th show")
2. **Home Page Widget**: Replace static content on Home with this dynamic feed.
### Phase D: Visualizations & Deep Stats ### Phase 1: Quick Wins (Current Sprint)
**Goal**: Provide the "crunchy" data fans love. | Feature | Backend Change | Frontend Change | Effort |
|---------|----------------|-----------------|--------|
| ~~Heady Badges toggle~~ | Add to UserPreferences | Toggle in Display | ⏭️ Skip (UI only, no effect) |
| Privacy: Public Profile | Add `profile_public` to User | Toggle in Privacy | 🟢 Small |
| Privacy: Show Attendance | Add `show_attendance_public` | Toggle in Privacy | 🟢 Small |
| Privacy: Leaderboards | Add `appear_in_leaderboards` | Toggle in Privacy | 🟢 Small |
| Theme Persistence | Store in localStorage/UserPrefs | Already works client-side | 🟢 Small |
1. **Gap Chart**: A visual bar chart on Song Pages showing the gap between performances. ### Phase 2: Notifications (Deferred)
2. **Heatmaps**: "Shows by Year" or "Shows by State" maps on Artist/Band pages.
3. **Graph View**: (Mind Map precursor) Simple node-link diagram of related songs/shows.
### Phase E: Glossary (Wiki-Style Knowledge Base) | Feature | Dependency | Status |
|---------|------------|--------|
| Comment Replies | Notification system exists | Ready to implement |
| New Show Added | Import trigger hook | Needs backend work |
| Chase Song Played | Post-import check | Needs backend work |
| Weekly Digest | Email templates + cron | Future |
**Goal**: Build a community-curated glossary of fandom terms. ### Phase 3: Data & Account (Deferred)
1. **Glossary Entry Model**: Term, definition, example, category, status. | Feature | Notes |
2. **Edit History**: Track suggested edits with approval workflow. |---------|-------|
3. **Public Pages**: `/glossary` index and `/glossary/[term]` detail pages. | Export My Data | GDPR compliance, JSON download |
4. **Moderation**: Admin queue for approving/rejecting entries and edits. | Delete Account | Cascade delete + confirmation |
5. **Integration**: Include in global search, auto-link in comments. | Connected Accounts | OAuth providers (future) |
## 3. Execution Order ---
4. **Phase D (Stats)**: "Nice to have" polish. ## Avatar System Roadmap
### ✅ Phase 1: Jewel Tones (Complete)
12 gemstone-named colors available to all users
### Phase 2: Pastels (Unlock 1)
- Trigger: 5 shows attended OR 10 ratings
- Colors: Soft versions of jewel tones
### Phase 3: Neons (Unlock 2)
- Trigger: 15 shows attended OR Level 5
- Colors: Vibrant high-saturation
### Phase 4: Gradients (Unlock 3)
- Trigger: Level 10 OR special achievement
- Two-tone diagonal gradients
---
## Blockers & Clarifications Needed
### 1. Analytics Provider
**Question:** Which analytics do you prefer?
- **Plausible** (privacy-first, GDPR compliant, paid ~$9/mo)
- **Umami** (self-hosted, free, privacy-first)
- **Google Analytics 4** (free, most features, privacy concerns)
- **PostHog** (product analytics + session replay)
### 2. Email Domain Verification
**Question:** Has `elmeg.xyz` been verified in AWS SES console?
- If yes: Emails are ready to send
- If no: Need to add DKIM/TXT records to DNS
### 3. SES Sandbox Status
**Question:** Has production access been requested?
- Sandbox = can only send to verified email addresses
- Production = can send to anyone
---
## Implementation Priority
### Today (Phase 1 Quick Wins)
1. ✅ Create this roadmap document
2. 🔄 Add privacy columns to User model
3. 🔄 Add API endpoint for privacy settings
4. 🔄 Wire up Privacy section toggles
### This Week
- [ ] Answer analytics question
- [ ] Verify SES domain status
- [ ] Theme persistence to user preferences
### Next Sprint
- [ ] Notification preferences backend
- [ ] Avatar unlock system

View file

@ -52,19 +52,31 @@ export default function SettingsPage() {
const [profileSaved, setProfileSaved] = useState(false) const [profileSaved, setProfileSaved] = useState(false)
// Avatar state // Avatar state
const [avatarBgColor, setAvatarBgColor] = useState("#1E3A8A") const [avatarBgColor, setAvatarBgColor] = useState("#0F4C81")
const [avatarText, setAvatarText] = useState("") const [avatarText, setAvatarText] = useState("")
const [avatarSaving, setAvatarSaving] = useState(false) const [avatarSaving, setAvatarSaving] = useState(false)
const [avatarSaved, setAvatarSaved] = useState(false) const [avatarSaved, setAvatarSaved] = useState(false)
const [avatarError, setAvatarError] = useState("") const [avatarError, setAvatarError] = useState("")
// Privacy state
const [privacySettings, setPrivacySettings] = useState({
profile_public: true,
show_attendance_public: true,
appear_in_leaderboards: true
})
useEffect(() => { useEffect(() => {
if (user) { if (user) {
const extUser = user as any const extUser = user as any
setBio(extUser.bio || "") setBio(extUser.bio || "")
setUsername(extUser.email?.split('@')[0] || "") setUsername(extUser.email?.split('@')[0] || "")
setAvatarBgColor(extUser.avatar_bg_color || "#1E3A8A") setAvatarBgColor(extUser.avatar_bg_color || "#0F4C81")
setAvatarText(extUser.avatar_text || "") setAvatarText(extUser.avatar_text || "")
setPrivacySettings({
profile_public: extUser.profile_public ?? true,
show_attendance_public: extUser.show_attendance_public ?? true,
appear_in_leaderboards: extUser.appear_in_leaderboards ?? true
})
} }
}, [user]) }, [user])
@ -130,6 +142,27 @@ export default function SettingsPage() {
} }
} }
const handlePrivacyChange = async (key: string, value: boolean) => {
// Optimistic update
setPrivacySettings(prev => ({ ...prev, [key]: value }))
try {
const token = localStorage.getItem("token")
await fetch(`${getApiUrl()}/users/me/privacy`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ [key]: value }),
})
} catch (e) {
// Revert on error
setPrivacySettings(prev => ({ ...prev, [key]: !value }))
console.error("Failed to update privacy setting:", e)
}
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[50vh]"> <div className="flex items-center justify-center min-h-[50vh]">
@ -167,8 +200,8 @@ export default function SettingsPage() {
{/* Desktop: Side-by-side layout, Mobile: Tabs */} {/* Desktop: Side-by-side layout, Mobile: Tabs */}
<div className="hidden lg:grid lg:grid-cols-[280px_1fr] lg:gap-8"> <div className="hidden lg:grid lg:grid-cols-[280px_1fr] lg:gap-8">
{/* Sidebar Navigation */} {/* Sidebar Navigation - Sticky */}
<nav className="space-y-1"> <nav className="space-y-1 sticky top-24 self-start">
<SidebarLink icon={User} label="Profile" href="#profile" active /> <SidebarLink icon={User} label="Profile" href="#profile" active />
<SidebarLink icon={Palette} label="Appearance" href="#appearance" /> <SidebarLink icon={Palette} label="Appearance" href="#appearance" />
<SidebarLink icon={Eye} label="Display" href="#display" /> <SidebarLink icon={Eye} label="Display" href="#display" />
@ -215,7 +248,7 @@ export default function SettingsPage() {
<Separator /> <Separator />
<PrivacySection /> <PrivacySection settings={privacySettings} onChange={handlePrivacyChange} />
</div> </div>
</div> </div>
@ -278,7 +311,7 @@ export default function SettingsPage() {
</TabsContent> </TabsContent>
<TabsContent value="privacy"> <TabsContent value="privacy">
<PrivacySection /> <PrivacySection settings={privacySettings} onChange={handlePrivacyChange} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
@ -568,7 +601,10 @@ function NotificationsSection() {
} }
// Privacy Section // Privacy Section
function PrivacySection() { function PrivacySection({ settings, onChange }: {
settings: { profile_public: boolean; show_attendance_public: boolean; appear_in_leaderboards: boolean };
onChange: (key: string, value: boolean) => void;
}) {
return ( return (
<Card id="privacy"> <Card id="privacy">
<CardHeader> <CardHeader>
@ -584,9 +620,8 @@ function PrivacySection() {
<SettingRow <SettingRow
label="Public Profile" label="Public Profile"
description="Allow other users to view your profile, attendance history, and reviews" description="Allow other users to view your profile, attendance history, and reviews"
checked={true} checked={settings.profile_public}
onChange={() => { }} onChange={(checked) => onChange("profile_public", checked)}
comingSoon
/> />
<Separator /> <Separator />
@ -594,9 +629,8 @@ function PrivacySection() {
<SettingRow <SettingRow
label="Show Attendance" label="Show Attendance"
description="Display which shows you've attended on your public profile" description="Display which shows you've attended on your public profile"
checked={true} checked={settings.show_attendance_public}
onChange={() => { }} onChange={(checked) => onChange("show_attendance_public", checked)}
comingSoon
/> />
<Separator /> <Separator />
@ -604,9 +638,8 @@ function PrivacySection() {
<SettingRow <SettingRow
label="Appear in Leaderboards" label="Appear in Leaderboards"
description="Allow your name to appear on community leaderboards" description="Allow your name to appear on community leaderboards"
checked={true} checked={settings.appear_in_leaderboards}
onChange={() => { }} onChange={(checked) => onChange("appear_in_leaderboards", checked)}
comingSoon
/> />
<Separator /> <Separator />