feat: Privacy settings with functional toggles, sticky sidebar, roadmap doc
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
This commit is contained in:
parent
824a70d303
commit
2da46eaa16
5 changed files with 201 additions and 66 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
165
docs/ROADMAP.md
165
docs/ROADMAP.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue