Yōten: A social tracker for your language learning journey built on the atproto.
1package views
2
3import (
4 "fmt"
5
6 "yoten.app/internal/db"
7 "yoten.app/internal/server/views/layouts"
8 "yoten.app/internal/server/views/partials"
9)
10
11templ EditProfilePage(params EditProfilePageParams) {
12 @layouts.Base(layouts.BaseParams{Title: "edit profile"}) {
13 @partials.Header(partials.HeaderProps{User: params.User})
14 <div class="container mx-auto px-4 py-8 max-w-2xl">
15 <form
16 hx-post="/profile/edit"
17 hx-swap="none"
18 hx-disabled-elt="#save-button,#cancel-button"
19 class="flex flex-col gap-8 group"
20 >
21 <div>
22 <h1 class="text-3xl font-bold">Edit Profile</h1>
23 <p class="text-text-muted mt-1">Update your display name and learning languages</p>
24 </div>
25 <div class="card">
26 <h1 class="text-2xl font-bold">Profile Information</h1>
27 <div class="flex flex-col gap-1">
28 <label for="display-name" class="font-medium text-sm">Display Name</label>
29 <div x-data={ templ.JSONString(JsonText{Text: params.Profile.DisplayName}) }>
30 <input
31 x-model="text"
32 id="display-name"
33 name="display_name"
34 value={ params.Profile.DisplayName }
35 placeholder="Enter your display name"
36 class="input w-full"
37 maxLength="64"
38 />
39 <div class="text-right text-sm text-text-muted mt-1">
40 <span x-text="text.length"></span> / 64
41 </div>
42 </div>
43 </div>
44 <div class="flex flex-col gap-1">
45 <label for="location" class="font-medium text-sm">Location</label>
46 <div x-data={ templ.JSONString(JsonText{Text: params.Profile.Location}) }>
47 <input
48 x-model="text"
49 id="location"
50 name="location"
51 value={ params.Profile.Location }
52 placeholder="Where are you based?"
53 class="input w-full"
54 maxLength="40"
55 />
56 <div class="text-right text-sm text-text-muted mt-1">
57 <span x-text="text.length"></span> / 40
58 </div>
59 </div>
60 </div>
61 <div class="flex flex-col gap-1">
62 <label for="description" class="font-medium text-sm">About You</label>
63 <div
64 x-data="{ text: '' }"
65 x-init="text = $el.querySelector('textarea').value"
66 >
67 <textarea
68 x-model="text"
69 id="description"
70 name="description"
71 placeholder="Tell others about your language learning journey, interests, or goals..."
72 class="input w-full"
73 maxLength="256"
74 rows="3"
75 >
76 { params.Profile.Description }
77 </textarea>
78 <div class="text-right text-sm text-text-muted mt-1">
79 <span x-text="text.length"></span> / 256
80 </div>
81 </div>
82 </div>
83 </div>
84 <div
85 class="card"
86 x-data="languagePicker()"
87 >
88 <div class="flex flex-col sm:flex-row gap-2 justify-between sm:items-center">
89 <h1 class="text-2xl font-bold">Languages You're Learning</h1>
90 <div class="pill pill-muted">
91 <span x-text="selected.length"></span>/10 selected
92 </div>
93 </div>
94 <p class="text-sm text-text-muted mt-1">
95 Select up to 10 languages you're currently learning.
96 </p>
97 <div class="relative mt-4">
98 <i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted"></i>
99 <input type="text" x-model.debounce.300ms="search" placeholder="Search languages..." class="input w-full pl-9"/>
100 </div>
101 <div x-show="selected.length > 0" class="mt-4">
102 <h3 class="text-sm font-medium mb-2">Selected Languages:</h3>
103 <div class="flex flex-wrap gap-2">
104 <template x-for="lang in selectedObjects" :key="lang.Code">
105 <div class="pill pill-primary flex items-center justify-center gap-2" x-init="lucide.createIcons()">
106 <span x-text="lang.Flag + ' ' + lang.Name"></span>
107 <button type="button" @click="toggle(lang.Code)" class="hover:text-text hover:cursor-pointer">
108 <i class="w-4 h-4" data-lucide="x"></i>
109 </button>
110 </div>
111 </template>
112 </div>
113 </div>
114 <div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-4 border-t pt-4 max-h-72 overflow-y-auto">
115 for _, l := range params.AllLanguages {
116 <div
117 key={ l.Code }
118 x-show={ fmt.Sprintf("search === '' || '%s'.toLowerCase().includes(search.toLowerCase())", db.Languages[l.Code].Name) }
119 @click={ fmt.Sprintf("toggle('%s')", l.Code) }
120 :class={ fmt.Sprintf("isSelected('%s') ? 'btn-primary' : 'btn-secondary'", l.Code) }
121 class="input flex items-center gap-3 cursor-pointer"
122 >
123 <input
124 type="checkbox"
125 name="languages[]"
126 value={ l.Code }
127 :checked={ fmt.Sprintf("isSelected('%s')", l.Code) }
128 :disabled={ fmt.Sprintf("!isSelected('%s') && selected.length >= 10", l.Code) }
129 class="pointer-events-none"
130 />
131 <label class="pointer-events-none">
132 <span>{ l.Flag } { l.Name }</span>
133 if l.NativeName != nil {
134 <span class="text-text-muted">({ *l.NativeName })</span>
135 }
136 </label>
137 </div>
138 }
139 </div>
140 </div>
141 <div class="flex flex-col sm:flex-row gap-4 w-full">
142 <button id="save-button" type="submit" class="btn btn-primary">
143 <i class="w-4 h-4" data-lucide="save"></i>
144 Save
145 <i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
146 </button>
147 <button id="cancel-button" type="button" class="w-full">
148 <a href={ templ.SafeURL(fmt.Sprintf("/%s", params.User.Did)) } class="btn btn-secondary">
149 Cancel
150 </a>
151 </button>
152 </div>
153 </form>
154 </div>
155 <script>
156 function languagePicker() {
157 return {
158 search: '',
159 all: {{ params.AllLanguages }} || [],
160 selected: {{ params.InitialSelectedLanguages }} || [],
161
162 get selectedObjects() {
163 return this.all.filter(lang => this.selected.includes(lang.Code));
164 },
165
166 get filteredLanguages() {
167 if (!this.search) return this.all;
168 return this.all.filter(lang =>
169 lang.Name.toLowerCase().includes(this.search.toLowerCase())
170 );
171 },
172
173 isSelected(code) {
174 return this.selected.includes(code);
175 },
176
177 toggle(code) {
178 if (this.isSelected(code)) {
179 this.selected = this.selected.filter(c => c !== code);
180 } else {
181 if (this.selected.length < 10) {
182 this.selected.push(code);
183 }
184 }
185 }
186 }
187 }
188 </script>
189 }
190}