hsila Claude commited on
Commit
7c8619c
·
1 Parent(s): c15fa2f

Implement mandatory authentication with Supabase integration

Browse files

- Add mandatory login system - users must authenticate to access any content
- Integrate Supabase database for user authentication and task tracking
- Create LoginForm component with dark theme and proper validation
- Implement task completion tracking with database persistence
- Add session management with 1-week expiration
- Update UI to hide content from non-authenticated users
- Add user info display and logout functionality
- Create comprehensive README with Supabase setup instructions
- Add .env.example file with configuration guidance
- Include SQL queries for database table creation and sample users
- Implement proper authentication flow with localStorage session storage
- Add filtering by completion status (all/not-completed/completed)
- Enhance CSS for responsive design and professional dark theme

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>

.env.example ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Your Supabase Project URL
2
+ # Example: https://your-project-id.supabase.co
3
+ SUPABASE_URL=your_supabase_project_url
4
+
5
+ # Your Supabase Anonymous/Public Key
6
+ SUPABASE_ANON_KEY=your_supabase_anon_key
7
+
8
+ # =================================================================
9
+ # SETUP INSTRUCTIONS:
10
+ # =================================================================
11
+ # 1. Create a Supabase project at https://supabase.com
12
+ # 2. Go to Settings → API in your project dashboard
13
+ # 3. Copy the Project URL and anon key
14
+ # 4. Replace the placeholder values above
15
+ # 5. Save this file as .env (remove the .example extension)
16
+ # 6. Run the SQL queries from README.md to set up your database
17
+ # =================================================================
README.md CHANGED
@@ -1,31 +1,242 @@
1
- # Speaking Practice Browser (Astro)
2
 
3
- New Astro-based UI that replicates the Gradio experience with full control over styling and responsiveness.
4
 
5
- ## Getting Started
6
 
7
- 1. Install dependencies (Node 18+ recommended):
8
- ```bash
9
- cd astra
10
- npm install
11
- ```
12
- 2. Start the dev server:
13
- ```bash
14
- npm run dev
15
- ```
16
- The app defaults to http://localhost:4321 and supports hot reloading.
17
- 3. Build for production:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  ```bash
19
- npm run build
20
- npm run preview
21
  ```
22
 
23
- ## Data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
- `speaking.jsonl` lives in `data/` and is loaded at build time. Drop a `writing.jsonl` file in the same folder if you have writing tasks�the UI will surface a Writing category automatically.
 
 
 
26
 
27
- ## Customisation
28
 
29
- - Update task mappings or UI copy in `src/pages/index.astro`.
30
- - Tweak styling in `src/styles/global.css`.
31
- - Data loading helpers live in `src/utils/loadData.ts`.
 
 
 
1
+ # Speaking Practice Browser - Astro Version
2
 
3
+ A modern web application for browsing and practicing CELPIP-style speaking and writing tasks with brainstorming prompts, curated vocabulary, and sample responses. Features mandatory user authentication, task tracking, and progress management with Supabase integration.
4
 
5
+ ## Features
6
 
7
+ - **Mandatory Authentication**: Users must log in to access any content
8
+ - **Task Tracking**: Mark tasks as completed to avoid re-practicing
9
+ - **Supabase Integration**: Real database for user authentication and progress tracking
10
+ - **Responsive Design**: Works on desktop and mobile devices
11
+ - **Dark Theme**: Professional dark mode interface
12
+ - **Advanced Filtering**: Filter by category, task type, and completion status
13
+
14
+ ## Prerequisites
15
+
16
+ - Node.js (v16 or higher)
17
+ - npm or yarn
18
+ - Supabase account and project
19
+
20
+ ## Setup Instructions
21
+
22
+ ### 1. Install Dependencies
23
+
24
+ ```bash
25
+ cd astra
26
+ npm install
27
+ ```
28
+
29
+ ### 2. Supabase Database Setup
30
+
31
+ #### Create Tables
32
+ Run these SQL queries in your Supabase SQL Editor:
33
+
34
+ ```sql
35
+ -- Users table for authentication
36
+ CREATE TABLE users (
37
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
38
+ username VARCHAR(50) UNIQUE NOT NULL,
39
+ password_hash VARCHAR(255) NOT NULL,
40
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
41
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
42
+ );
43
+
44
+ -- Task completions table for tracking progress
45
+ CREATE TABLE task_completions (
46
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
47
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
48
+ task_id VARCHAR(50) NOT NULL,
49
+ task_type VARCHAR(50) NOT NULL,
50
+ category VARCHAR(20) NOT NULL,
51
+ completed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
52
+ UNIQUE(user_id, task_id, task_type)
53
+ );
54
+
55
+ -- Indexes for better performance
56
+ CREATE INDEX idx_task_completions_user_id ON task_completions(user_id);
57
+ CREATE INDEX idx_users_username ON users(username);
58
+ ```
59
+
60
+ #### Create Sample Users
61
+ Run these queries to create test users:
62
+
63
+ ```sql
64
+ -- Create test users (passwords are stored as plain text for testing - in production, use proper hashing)
65
+ INSERT INTO users (username, password_hash) VALUES
66
+ ('testuser', 'password123'),
67
+ ('demo', 'demo123'),
68
+ ('student', 'student123');
69
+
70
+ -- Verify users were created
71
+ SELECT * FROM users;
72
+ ```
73
+
74
+ #### Set Up Row Level Security (RLS)
75
+ Enable RLS for better security:
76
+
77
+ ```sql
78
+ -- Enable RLS on both tables
79
+ ALTER TABLE users ENABLE ROW LEVEL SECURITY;
80
+ ALTER TABLE task_completions ENABLE ROW LEVEL SECURITY;
81
+
82
+ -- Users can only see their own completions
83
+ CREATE POLICY "Users can view own completions" ON task_completions
84
+ FOR SELECT USING (auth.uid() = user_id);
85
+
86
+ -- Users can only insert their own completions
87
+ CREATE POLICY "Users can insert own completions" ON task_completions
88
+ FOR INSERT WITH CHECK (auth.uid() = user_id);
89
+
90
+ -- Users can only update their own completions
91
+ CREATE POLICY "Users can update own completions" ON task_completions
92
+ FOR UPDATE USING (auth.uid() = user_id);
93
+
94
+ -- Users can only delete their own completions
95
+ CREATE POLICY "Users can delete own completions" ON task_completions
96
+ FOR DELETE USING (auth.uid() = user_id);
97
+ ```
98
+
99
+ ### 3. Environment Configuration
100
+
101
+ 1. Copy the example environment file:
102
  ```bash
103
+ cp .env.example .env
 
104
  ```
105
 
106
+ 2. Fill in your Supabase credentials:
107
+ - Go to your Supabase project dashboard
108
+ - Settings → API
109
+ - Copy the Project URL and anon/public key
110
+
111
+ ### 4. Prepare Data Files
112
+
113
+ Place your data files in the `astra/data/` directory:
114
+ - `speaking.jsonl` - Speaking tasks data
115
+ - `writing.jsonl` - Writing tasks data (optional)
116
+
117
+ ### 5. Run the Development Server
118
+
119
+ ```bash
120
+ npm run dev
121
+ ```
122
+
123
+ The application will be available at `http://localhost:4323`
124
+
125
+ ## Environment Variables
126
+
127
+ Required environment variables (see `.env.example`):
128
+
129
+ ```env
130
+ SUPABASE_URL=your_supabase_project_url
131
+ SUPABASE_ANON_KEY=your_supabase_anon_key
132
+ ```
133
+
134
+ ## Usage
135
+
136
+ ### Authentication
137
+ - **Login**: Users must log in with valid credentials to access any content
138
+ - **Session**: Sessions last for 1 week
139
+ - **Logout**: Clears session and refreshes the page
140
+
141
+ ### Task Management
142
+ - **Browse**: Filter tasks by category, type, and completion status
143
+ - **Track**: Mark tasks as completed to track progress
144
+ - **Avoid Repetition**: Completed tasks can be filtered out
145
+
146
+ ### Data Structure
147
+
148
+ #### Speaking Tasks (`speaking.jsonl`)
149
+ ```json
150
+ {
151
+ "id": "unique_id",
152
+ "task_type": "giving_advice",
153
+ "category": "Speaking",
154
+ "rephrased_task": "Task description...",
155
+ "brainstorm": [{"title": "Idea", "description": "Description"}],
156
+ "vocabulary": ["word1", "word2"],
157
+ "response": "Sample response..."
158
+ }
159
+ ```
160
+
161
+ #### Writing Tasks (`writing.jsonl`)
162
+ ```json
163
+ {
164
+ "id": "unique_id",
165
+ "task_type": "writing_email",
166
+ "category": "Writing",
167
+ "rephrased_task": "Task description...",
168
+ "brainstorm": [{"title": "Idea", "description": "Description"}],
169
+ "response": "Sample response..."
170
+ }
171
+ ```
172
+
173
+ ## Development
174
+
175
+ ### Project Structure
176
+ ```
177
+ astra/
178
+ ├── src/
179
+ │ ├── components/ # Astro components
180
+ │ ├── layouts/ # Layout components
181
+ │ ├── pages/ # Page components
182
+ │ ├── styles/ # Global styles
183
+ │ └── utils/ # Utility functions
184
+ ├── data/ # JSON data files
185
+ ├── public/ # Static assets
186
+ └── scripts/ # Build scripts
187
+ ```
188
+
189
+ ### Key Components
190
+ - `src/pages/index.astro` - Main application page
191
+ - `src/components/LoginForm.astro` - Authentication form
192
+ - `src/layouts/BaseLayout.astro` - Base layout component
193
+
194
+ ### Authentication Flow
195
+ 1. Page loads → Check localStorage for valid session
196
+ 2. If no session → Show login overlay, hide content
197
+ 3. User submits form → Authenticate with Supabase
198
+ 4. If successful → Store session, show content
199
+ 5. Session persists for 1 week
200
+
201
+ ## Security Notes
202
+
203
+ - **Important**: This is a demo implementation
204
+ - In production, implement proper password hashing
205
+ - Use HTTPS in production
206
+ - Consider implementing proper Supabase Auth instead of custom authentication
207
+ - Add input validation and sanitization
208
+ - Implement rate limiting for login attempts
209
+
210
+ ## Troubleshooting
211
+
212
+ ### Common Issues
213
+
214
+ 1. **Login form not working**
215
+ - Check browser console for JavaScript errors
216
+ - Verify Supabase credentials are correct
217
+ - Ensure database tables exist
218
+
219
+ 2. **Data not loading**
220
+ - Check that JSONL files are in the correct format
221
+ - Verify file paths in the data loading functions
222
+ - Check browser network tab for 404 errors
223
+
224
+ 3. **Authentication issues**
225
+ - Verify Supabase URL and keys are correct
226
+ - Check that users exist in the database
227
+ - Ensure RLS policies are set up correctly
228
+
229
+ ### Build for Production
230
 
231
+ ```bash
232
+ npm run build
233
+ npm run preview
234
+ ```
235
 
236
+ ## Deployment
237
 
238
+ The app can be deployed to any static hosting service that supports environment variables:
239
+ - Hugging Face Spaces
240
+ - Vercel
241
+ - Netlify
242
+ - Cloudflare Pages
package-lock.json CHANGED
@@ -8,6 +8,7 @@
8
  "name": "speaking-ideas-astro",
9
  "version": "0.0.1",
10
  "dependencies": {
 
11
  "astro": "^4.10.0"
12
  }
13
  },
@@ -1584,6 +1585,80 @@
1584
  "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
1585
  "license": "MIT"
1586
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1587
  "node_modules/@types/babel__core": {
1588
  "version": "7.20.5",
1589
  "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1679,12 +1754,36 @@
1679
  "@types/unist": "*"
1680
  }
1681
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1682
  "node_modules/@types/unist": {
1683
  "version": "3.0.3",
1684
  "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
1685
  "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
1686
  "license": "MIT"
1687
  },
 
 
 
 
 
 
 
 
 
1688
  "node_modules/@ungap/structured-clone": {
1689
  "version": "1.3.0",
1690
  "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -4935,6 +5034,12 @@
4935
  "node": ">=8.0"
4936
  }
4937
  },
 
 
 
 
 
 
4938
  "node_modules/trim-lines": {
4939
  "version": "3.0.1",
4940
  "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -5008,6 +5113,12 @@
5008
  "node": ">=14.17"
5009
  }
5010
  },
 
 
 
 
 
 
5011
  "node_modules/unified": {
5012
  "version": "11.0.5",
5013
  "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -5310,6 +5421,22 @@
5310
  "url": "https://github.com/sponsors/wooorm"
5311
  }
5312
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5313
  "node_modules/which-pm": {
5314
  "version": "3.0.1",
5315
  "resolved": "https://registry.npmjs.org/which-pm/-/which-pm-3.0.1.tgz",
@@ -5363,6 +5490,27 @@
5363
  "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
5364
  }
5365
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5366
  "node_modules/xxhash-wasm": {
5367
  "version": "1.1.0",
5368
  "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
 
8
  "name": "speaking-ideas-astro",
9
  "version": "0.0.1",
10
  "dependencies": {
11
+ "@supabase/supabase-js": "^2.75.1",
12
  "astro": "^4.10.0"
13
  }
14
  },
 
1585
  "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
1586
  "license": "MIT"
1587
  },
1588
+ "node_modules/@supabase/auth-js": {
1589
+ "version": "2.75.1",
1590
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.75.1.tgz",
1591
+ "integrity": "sha512-zktlxtXstQuVys/egDpVsargD9hQtG20CMdtn+mMn7d2Ulkzy2tgUT5FUtpppvCJtd9CkhPHO/73rvi5W6Am5A==",
1592
+ "license": "MIT",
1593
+ "dependencies": {
1594
+ "@supabase/node-fetch": "2.6.15"
1595
+ }
1596
+ },
1597
+ "node_modules/@supabase/functions-js": {
1598
+ "version": "2.75.1",
1599
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.75.1.tgz",
1600
+ "integrity": "sha512-xO+01SUcwVmmo67J7Htxq8FmhkYLFdWkxfR/taxBOI36wACEUNQZmroXGPl4PkpYxBO7TaDsRHYGxUpv9zTKkg==",
1601
+ "license": "MIT",
1602
+ "dependencies": {
1603
+ "@supabase/node-fetch": "2.6.15"
1604
+ }
1605
+ },
1606
+ "node_modules/@supabase/node-fetch": {
1607
+ "version": "2.6.15",
1608
+ "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
1609
+ "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
1610
+ "license": "MIT",
1611
+ "dependencies": {
1612
+ "whatwg-url": "^5.0.0"
1613
+ },
1614
+ "engines": {
1615
+ "node": "4.x || >=6.0.0"
1616
+ }
1617
+ },
1618
+ "node_modules/@supabase/postgrest-js": {
1619
+ "version": "2.75.1",
1620
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.75.1.tgz",
1621
+ "integrity": "sha512-FiYBD0MaKqGW8eo4Xqu7/100Xm3ddgh+3qHtqS18yQRoglJTFRQCJzY1xkrGS0JFHE2YnbjL6XCiOBXiG8DK4Q==",
1622
+ "license": "MIT",
1623
+ "dependencies": {
1624
+ "@supabase/node-fetch": "2.6.15"
1625
+ }
1626
+ },
1627
+ "node_modules/@supabase/realtime-js": {
1628
+ "version": "2.75.1",
1629
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.75.1.tgz",
1630
+ "integrity": "sha512-lBIJ855bUsBFScHA/AY+lxIFkubduUvmwbagbP1hq0wDBNAsYdg3ql80w8YmtXCDjkCwlE96SZqcFn7BGKKJKQ==",
1631
+ "license": "MIT",
1632
+ "dependencies": {
1633
+ "@supabase/node-fetch": "2.6.15",
1634
+ "@types/phoenix": "^1.6.6",
1635
+ "@types/ws": "^8.18.1",
1636
+ "ws": "^8.18.2"
1637
+ }
1638
+ },
1639
+ "node_modules/@supabase/storage-js": {
1640
+ "version": "2.75.1",
1641
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.75.1.tgz",
1642
+ "integrity": "sha512-WdGEhroflt5O398Yg3dpf1uKZZ6N3CGloY9iGsdT873uWbkQKoP0wG8mtx98dh0fhj6dAlzBqOAvnlV12cJfzA==",
1643
+ "license": "MIT",
1644
+ "dependencies": {
1645
+ "@supabase/node-fetch": "2.6.15"
1646
+ }
1647
+ },
1648
+ "node_modules/@supabase/supabase-js": {
1649
+ "version": "2.75.1",
1650
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.75.1.tgz",
1651
+ "integrity": "sha512-GEPVBvjQimcMd9z5K1eTKTixTRb6oVbudoLQ9JKqTUJnR6GQdBU4OifFZean1AnHfsQwtri1fop2OWwsMv019w==",
1652
+ "license": "MIT",
1653
+ "dependencies": {
1654
+ "@supabase/auth-js": "2.75.1",
1655
+ "@supabase/functions-js": "2.75.1",
1656
+ "@supabase/node-fetch": "2.6.15",
1657
+ "@supabase/postgrest-js": "2.75.1",
1658
+ "@supabase/realtime-js": "2.75.1",
1659
+ "@supabase/storage-js": "2.75.1"
1660
+ }
1661
+ },
1662
  "node_modules/@types/babel__core": {
1663
  "version": "7.20.5",
1664
  "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
 
1754
  "@types/unist": "*"
1755
  }
1756
  },
1757
+ "node_modules/@types/node": {
1758
+ "version": "24.8.1",
1759
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
1760
+ "integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
1761
+ "license": "MIT",
1762
+ "dependencies": {
1763
+ "undici-types": "~7.14.0"
1764
+ }
1765
+ },
1766
+ "node_modules/@types/phoenix": {
1767
+ "version": "1.6.6",
1768
+ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
1769
+ "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
1770
+ "license": "MIT"
1771
+ },
1772
  "node_modules/@types/unist": {
1773
  "version": "3.0.3",
1774
  "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
1775
  "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
1776
  "license": "MIT"
1777
  },
1778
+ "node_modules/@types/ws": {
1779
+ "version": "8.18.1",
1780
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
1781
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
1782
+ "license": "MIT",
1783
+ "dependencies": {
1784
+ "@types/node": "*"
1785
+ }
1786
+ },
1787
  "node_modules/@ungap/structured-clone": {
1788
  "version": "1.3.0",
1789
  "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
 
5034
  "node": ">=8.0"
5035
  }
5036
  },
5037
+ "node_modules/tr46": {
5038
+ "version": "0.0.3",
5039
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
5040
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
5041
+ "license": "MIT"
5042
+ },
5043
  "node_modules/trim-lines": {
5044
  "version": "3.0.1",
5045
  "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
 
5113
  "node": ">=14.17"
5114
  }
5115
  },
5116
+ "node_modules/undici-types": {
5117
+ "version": "7.14.0",
5118
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
5119
+ "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
5120
+ "license": "MIT"
5121
+ },
5122
  "node_modules/unified": {
5123
  "version": "11.0.5",
5124
  "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
 
5421
  "url": "https://github.com/sponsors/wooorm"
5422
  }
5423
  },
5424
+ "node_modules/webidl-conversions": {
5425
+ "version": "3.0.1",
5426
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
5427
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
5428
+ "license": "BSD-2-Clause"
5429
+ },
5430
+ "node_modules/whatwg-url": {
5431
+ "version": "5.0.0",
5432
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
5433
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
5434
+ "license": "MIT",
5435
+ "dependencies": {
5436
+ "tr46": "~0.0.3",
5437
+ "webidl-conversions": "^3.0.0"
5438
+ }
5439
+ },
5440
  "node_modules/which-pm": {
5441
  "version": "3.0.1",
5442
  "resolved": "https://registry.npmjs.org/which-pm/-/which-pm-3.0.1.tgz",
 
5490
  "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
5491
  }
5492
  },
5493
+ "node_modules/ws": {
5494
+ "version": "8.18.3",
5495
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
5496
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
5497
+ "license": "MIT",
5498
+ "engines": {
5499
+ "node": ">=10.0.0"
5500
+ },
5501
+ "peerDependencies": {
5502
+ "bufferutil": "^4.0.1",
5503
+ "utf-8-validate": ">=5.0.2"
5504
+ },
5505
+ "peerDependenciesMeta": {
5506
+ "bufferutil": {
5507
+ "optional": true
5508
+ },
5509
+ "utf-8-validate": {
5510
+ "optional": true
5511
+ }
5512
+ }
5513
+ },
5514
  "node_modules/xxhash-wasm": {
5515
  "version": "1.1.0",
5516
  "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
package.json CHANGED
@@ -10,6 +10,7 @@
10
  "check": "astro check"
11
  },
12
  "dependencies": {
 
13
  "astro": "^4.10.0"
14
  }
15
  }
 
10
  "check": "astro check"
11
  },
12
  "dependencies": {
13
+ "@supabase/supabase-js": "^2.75.1",
14
  "astro": "^4.10.0"
15
  }
16
  }
src/components/LoginForm.astro ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ interface Props {
3
+ isVisible?: boolean
4
+ }
5
+
6
+ const { isVisible = true } = Astro.props
7
+ ---
8
+
9
+ <div id="login-overlay" class={`login-overlay ${isVisible ? 'visible' : 'hidden'}`}>
10
+ <div class="login-modal">
11
+ <div class="login-header">
12
+ <h2>Login to Track Progress</h2>
13
+ <p>Enter your username and password to track completed tasks</p>
14
+ </div>
15
+
16
+ <form id="login-form" class="login-form">
17
+ <div class="form-field">
18
+ <label for="username">Username</label>
19
+ <input type="text" id="username" name="username" required autocomplete="username">
20
+ </div>
21
+
22
+ <div class="form-field">
23
+ <label for="password">Password</label>
24
+ <input type="password" id="password" name="password" required autocomplete="current-password">
25
+ </div>
26
+
27
+ <div class="form-actions">
28
+ <button type="submit" class="login-btn">Login</button>
29
+ </div>
30
+ </form>
31
+
32
+ <div id="login-error" class="login-error hidden">
33
+ Invalid username or password
34
+ </div>
35
+
36
+ <div class="login-footer">
37
+ <p>Session expires after 1 week</p>
38
+ </div>
39
+ </div>
40
+ </div>
41
+
42
+ <style>
43
+ .login-overlay {
44
+ position: fixed;
45
+ top: 0;
46
+ left: 0;
47
+ right: 0;
48
+ bottom: 0;
49
+ background-color: rgba(0, 0, 0, 0.9);
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ z-index: 9999;
54
+ backdrop-filter: blur(8px);
55
+ }
56
+
57
+ .login-overlay.hidden {
58
+ display: none;
59
+ }
60
+
61
+ .login-modal {
62
+ background-color: #161b22;
63
+ border: 1px solid #30363d;
64
+ border-radius: 12px;
65
+ padding: 2rem;
66
+ width: 100%;
67
+ max-width: 400px;
68
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
69
+ }
70
+
71
+ .login-header {
72
+ text-align: center;
73
+ margin-bottom: 2rem;
74
+ }
75
+
76
+ .login-header h2 {
77
+ color: #f0f6fc;
78
+ font-size: 1.5rem;
79
+ margin-bottom: 0.5rem;
80
+ }
81
+
82
+ .login-header p {
83
+ color: #8b949e;
84
+ font-size: 0.9rem;
85
+ }
86
+
87
+ .login-form {
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: 1.5rem;
91
+ }
92
+
93
+ .form-field {
94
+ display: flex;
95
+ flex-direction: column;
96
+ gap: 0.5rem;
97
+ }
98
+
99
+ .form-field label {
100
+ color: #f0f6fc;
101
+ font-weight: 500;
102
+ font-size: 0.9rem;
103
+ }
104
+
105
+ .form-field input {
106
+ background-color: #0d1117;
107
+ border: 1px solid #30363d;
108
+ border-radius: 6px;
109
+ padding: 0.75rem;
110
+ color: #f0f6fc;
111
+ font-size: 1rem;
112
+ transition: border-color 0.2s ease;
113
+ }
114
+
115
+ .form-field input:focus {
116
+ outline: none;
117
+ border-color: #58a6ff;
118
+ box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
119
+ }
120
+
121
+ .login-btn {
122
+ background-color: #238636;
123
+ color: white;
124
+ border: none;
125
+ border-radius: 6px;
126
+ padding: 0.75rem 1.5rem;
127
+ font-size: 1rem;
128
+ font-weight: 500;
129
+ cursor: pointer;
130
+ transition: background-color 0.2s ease;
131
+ }
132
+
133
+ .login-btn:hover {
134
+ background-color: #2ea043;
135
+ }
136
+
137
+ .login-btn:disabled {
138
+ background-color: #30363d;
139
+ cursor: not-allowed;
140
+ }
141
+
142
+ .login-error {
143
+ background-color: rgba(248, 81, 73, 0.1);
144
+ border: 1px solid rgba(248, 81, 73, 0.3);
145
+ border-radius: 6px;
146
+ padding: 0.75rem;
147
+ color: #f85149;
148
+ font-size: 0.9rem;
149
+ text-align: center;
150
+ }
151
+
152
+ .login-error.hidden {
153
+ display: none;
154
+ }
155
+
156
+ .login-footer {
157
+ text-align: center;
158
+ margin-top: 1.5rem;
159
+ }
160
+
161
+ .login-footer p {
162
+ color: #8b949e;
163
+ font-size: 0.8rem;
164
+ }
165
+ </style>
src/pages/index.astro CHANGED
@@ -1,6 +1,7 @@
1
  ---
2
  import BaseLayout from '../layouts/BaseLayout.astro';
3
  import { loadSpeakingData, loadWritingData } from '../utils/loadData';
 
4
 
5
  const ITEMS_PER_PAGE = 5;
6
 
@@ -42,38 +43,38 @@ const totalItems = defaultRecords.length;
42
  const totalPages = totalItems > 0 ? Math.ceil(totalItems / ITEMS_PER_PAGE) : 0;
43
  const firstPageRecords = totalItems > 0 ? defaultRecords.slice(0, ITEMS_PER_PAGE) : [];
44
 
45
- const escapeHtml = (value: string | undefined | null) =>
46
- String(value ?? '')
47
- .replace(/&/g, '&amp;')
48
- .replace(/</g, '&lt;')
49
- .replace(/>/g, '&gt;');
50
-
51
- const formatMultiline = (value: string | undefined | null) =>
52
- escapeHtml(value).replace(/\n/g, '<br />');
53
-
54
- const renderIdeas = (ideas: any[] | undefined | null) => {
55
- if (!ideas || ideas.length === 0) return '';
56
- const items = ideas
57
- .map((idea) => {
58
- const title = escapeHtml(idea?.title ?? 'Idea');
59
- const description = escapeHtml(idea?.description ?? '');
60
- return `<li><strong>${title}:</strong> ${description}</li>`;
61
- })
62
- .join('');
63
- return `<ul>${items}</ul>`;
64
- };
65
-
66
- const renderVocabulary = (words: string[] | undefined | null) => {
67
- if (!words || words.length === 0) return '';
68
- const items = words.map((word) => `<li>${escapeHtml(word)}</li>`).join('');
69
- return `<ul>${items}</ul>`;
70
- };
71
-
72
- const buildCard = (record: Record<string, any>) => {
73
  const recordId = record?.id ?? 'N/A';
74
- const taskType = escapeHtml((record?.task_type ?? '').replace(/_/g, ' ')).replace(/\b\w/g, (char) => char.toUpperCase());
75
-
76
- const sections: string[] = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  if (record?.rephrased_task) {
79
  sections.push(`
@@ -130,14 +131,16 @@ const buildCard = (record: Record<string, any>) => {
130
  }
131
 
132
  return `
133
- <article class="card">
134
- <h3>Task ${recordId} - ${taskType}</h3>
 
 
135
  ${sections.join('\n')}
136
  </article>
137
  `;
138
  };
139
 
140
- const initialCardsHtml = firstPageRecords.map(buildCard).join('');
141
 
142
  const initialSummary = totalItems === 0
143
  ? 'No records available yet for this category.'
@@ -147,7 +150,11 @@ const initialPageInfo = totalItems === 0 ? 'Page 0 of 0' : `Page 1 of ${totalPag
147
  ---
148
 
149
  <BaseLayout title="Speaking Practice Browser">
150
- <main>
 
 
 
 
151
  <header class="app-header">
152
  <h1>Speaking Practice Browser</h1>
153
  <p>
@@ -185,6 +192,22 @@ const initialPageInfo = totalItems === 0 ? 'Page 0 of 0' : `Page 1 of ${totalPag
185
  ))}
186
  </select>
187
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  </section>
189
 
190
  <section class="content-area" aria-live="polite">
@@ -198,8 +221,138 @@ const initialPageInfo = totalItems === 0 ? 'Page 0 of 0' : `Page 1 of ${totalPag
198
  <button id="next-btn" type="button">Next</button>
199
  </nav>
200
  </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
 
202
- <script define:vars={{ speakingRecords, writingRecords, defaultCategory }} type="module">
203
  // Data embedded directly from server
204
  const appData = {
205
  records: {
@@ -226,27 +379,38 @@ const initialPageInfo = totalItems === 0 ? 'Page 0 of 0' : `Page 1 of ${totalPag
226
  {id: 'Writing', label: 'Writing'}
227
  ],
228
  itemsPerPage: 5,
229
- defaultCategory: defaultCategory
230
  };
231
 
232
  const state = {
233
  category: appData.defaultCategory,
234
  taskType: 'all',
 
235
  page: 0,
236
- totalPages: 0
 
 
237
  };
238
 
239
  const recordsByCategory = appData.records;
240
  const taskMappings = appData.taskMappings;
241
  const itemsPerPage = appData.itemsPerPage;
242
 
 
243
  const cardsContainer = document.getElementById('cards-container');
244
  const summaryText = document.getElementById('summary-text');
245
  const pageInfo = document.getElementById('page-info');
246
  const prevBtn = document.getElementById('prev-btn');
247
  const nextBtn = document.getElementById('next-btn');
248
  const taskFilter = document.getElementById('task-filter');
 
249
  const categoryInputs = Array.from(document.querySelectorAll('input[name="category"]'));
 
 
 
 
 
 
250
 
251
  const escapeHtml = (value) =>
252
  String(value ?? '')
@@ -261,12 +425,15 @@ const initialPageInfo = totalItems === 0 ? 'Page 0 of 0' : `Page 1 of ${totalPag
261
  return `<ul>${items.map(formatter).join('')}</ul>`;
262
  };
263
 
264
- const buildCard = (record) => {
265
  const recordId = record?.id ?? 'N/A';
266
  const taskType = escapeHtml(String(record?.task_type ?? ''))
267
  .replace(/_/g, ' ')
268
  .replace(/\b\w/g, (char) => char.toUpperCase());
269
 
 
 
 
270
  const sections = [];
271
 
272
  if (record?.rephrased_task) {
@@ -332,19 +499,159 @@ const initialPageInfo = totalItems === 0 ? 'Page 0 of 0' : `Page 1 of ${totalPag
332
  }
333
 
334
  return `
335
- <article class="card">
336
- <h3>Task ${recordId} - ${taskType}</h3>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  ${sections.join('\n')}
338
  </article>
339
  `;
340
  };
341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  const applyState = () => {
343
  const allRecords = recordsByCategory[state.category] ?? [];
344
- const filtered = state.taskType === 'all'
 
 
345
  ? allRecords
346
  : allRecords.filter((item) => item?.task_type === state.taskType);
347
 
 
 
 
 
 
 
 
 
 
348
  const totalItems = filtered.length;
349
  state.totalPages = totalItems === 0 ? 0 : Math.ceil(totalItems / itemsPerPage);
350
  state.page = Math.min(state.page, Math.max(state.totalPages - 1, 0));
@@ -354,14 +661,28 @@ const initialPageInfo = totalItems === 0 ? 'Page 0 of 0' : `Page 1 of ${totalPag
354
  const pageItems = filtered.slice(start, end);
355
 
356
  if (cardsContainer) {
357
- cardsContainer.innerHTML = pageItems.map(buildCard).join('');
 
 
 
 
 
 
 
 
 
 
358
  }
359
 
360
  if (totalItems === 0) {
361
  const hasRecordsInCategory = allRecords.length > 0;
362
  if (summaryText) {
363
  if (hasRecordsInCategory) {
364
- summaryText.textContent = 'No records found for the selected task type.';
 
 
 
 
365
  } else {
366
  if (state.category === 'Writing') {
367
  summaryText.textContent = 'No writing tasks available yet. Writing data will be displayed here when available.';
@@ -404,10 +725,6 @@ const initialPageInfo = totalItems === 0 ? 'Page 0 of 0' : `Page 1 of ${totalPag
404
  taskFilter.disabled = !hasRecords;
405
  };
406
 
407
- // Initialize
408
- updateTaskOptions();
409
- applyState();
410
-
411
  // Event listeners
412
  categoryInputs.forEach((input) => {
413
  input.addEventListener('change', (event) => {
@@ -431,6 +748,16 @@ const initialPageInfo = totalItems === 0 ? 'Page 0 of 0' : `Page 1 of ${totalPag
431
  });
432
  }
433
 
 
 
 
 
 
 
 
 
 
 
434
  if (prevBtn) {
435
  prevBtn.addEventListener('click', () => {
436
  state.page = Math.max(0, state.page - 1);
@@ -444,5 +771,22 @@ const initialPageInfo = totalItems === 0 ? 'Page 0 of 0' : `Page 1 of ${totalPag
444
  applyState();
445
  });
446
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  </script>
448
  </BaseLayout>
 
1
  ---
2
  import BaseLayout from '../layouts/BaseLayout.astro';
3
  import { loadSpeakingData, loadWritingData } from '../utils/loadData';
4
+ import LoginForm from '../components/LoginForm.astro';
5
 
6
  const ITEMS_PER_PAGE = 5;
7
 
 
43
  const totalPages = totalItems > 0 ? Math.ceil(totalItems / ITEMS_PER_PAGE) : 0;
44
  const firstPageRecords = totalItems > 0 ? defaultRecords.slice(0, ITEMS_PER_PAGE) : [];
45
 
46
+ const buildInitialCard = (record: any) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  const recordId = record?.id ?? 'N/A';
48
+ const taskType = String(record?.task_type ?? '').replace(/_/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
49
+
50
+ const escapeHtml = (value: string | undefined | null) =>
51
+ String(value ?? '')
52
+ .replace(/&/g, '&amp;')
53
+ .replace(/</g, '&lt;')
54
+ .replace(/>/g, '&gt;');
55
+
56
+ const formatMultiline = (value: string | undefined | null) =>
57
+ escapeHtml(value).replace(/\n/g, '<br />');
58
+
59
+ const renderIdeas = (ideas: any[] | undefined | null) => {
60
+ if (!ideas || ideas.length === 0) return '';
61
+ const items = ideas
62
+ .map((idea) => {
63
+ const title = escapeHtml(idea?.title ?? 'Idea');
64
+ const description = escapeHtml(idea?.description ?? '');
65
+ return `<li><strong>${title}:</strong> ${description}</li>`;
66
+ })
67
+ .join('');
68
+ return `<ul>${items}</ul>`;
69
+ };
70
+
71
+ const renderVocabulary = (words: string[] | undefined | null) => {
72
+ if (!words || words.length === 0) return '';
73
+ const items = words.map((word) => `<li>${escapeHtml(word)}</li>`).join('');
74
+ return `<ul>${items}</ul>`;
75
+ };
76
+
77
+ let sections = [];
78
 
79
  if (record?.rephrased_task) {
80
  sections.push(`
 
131
  }
132
 
133
  return `
134
+ <article class="card" data-task-id="${recordId}-${record.task_type}">
135
+ <div class="card-header">
136
+ <h3>Task ${recordId} - ${taskType}</h3>
137
+ </div>
138
  ${sections.join('\n')}
139
  </article>
140
  `;
141
  };
142
 
143
+ const initialCardsHtml = firstPageRecords.map(buildInitialCard).join('');
144
 
145
  const initialSummary = totalItems === 0
146
  ? 'No records available yet for this category.'
 
150
  ---
151
 
152
  <BaseLayout title="Speaking Practice Browser">
153
+ <LoginForm />
154
+
155
+ <!-- Main content - only visible when authenticated -->
156
+ <div id="main-content" class="main-content">
157
+ <main>
158
  <header class="app-header">
159
  <h1>Speaking Practice Browser</h1>
160
  <p>
 
192
  ))}
193
  </select>
194
  </div>
195
+
196
+ <div class="field">
197
+ <label class="field-label" for="completion-filter">Completion Status</label>
198
+ <select id="completion-filter">
199
+ <option value="all">All Tasks</option>
200
+ <option value="not-completed">Not Completed</option>
201
+ <option value="completed">Completed</option>
202
+ </select>
203
+ </div>
204
+
205
+ <div class="user-section">
206
+ <div id="user-info" class="user-info hidden">
207
+ <span class="user-name">Not logged in</span>
208
+ <button id="logout-btn" class="logout-btn">Logout</button>
209
+ </div>
210
+ </div>
211
  </section>
212
 
213
  <section class="content-area" aria-live="polite">
 
221
  <button id="next-btn" type="button">Next</button>
222
  </nav>
223
  </main>
224
+ </div>
225
+
226
+ <script define:vars={{ supabaseUrl: import.meta.env.SUPABASE_URL, supabaseAnonKey: import.meta.env.SUPABASE_ANON_KEY, defaultCategory, speakingRecords, writingRecords }} type="module">
227
+ // Import Supabase
228
+ import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm';
229
+
230
+ // Initialize Supabase client
231
+ const supabaseUrlFromEnv = supabaseUrl;
232
+ const supabaseKeyFromEnv = supabaseAnonKey;
233
+
234
+ console.log('Supabase URL:', supabaseUrlFromEnv ? 'Found' : 'Missing');
235
+ console.log('Supabase Key:', supabaseKeyFromEnv ? 'Found' : 'Missing');
236
+
237
+ let supabase;
238
+
239
+ if (supabaseUrlFromEnv && supabaseKeyFromEnv) {
240
+ try {
241
+ supabase = createClient(supabaseUrlFromEnv, supabaseKeyFromEnv);
242
+ console.log('Supabase client initialized successfully');
243
+ } catch (error) {
244
+ console.error('Error initializing Supabase:', error);
245
+ }
246
+ } else {
247
+ console.error('Missing Supabase environment variables');
248
+ }
249
+
250
+ // Real authentication and task tracking functions
251
+ async function authenticateUser(username, password) {
252
+ if (!supabase) {
253
+ console.error('Supabase not initialized');
254
+ return null;
255
+ }
256
+
257
+ try {
258
+ const { data: user, error } = await supabase
259
+ .from('users')
260
+ .select('*')
261
+ .eq('username', username)
262
+ .eq('password_hash', password)
263
+ .single();
264
+
265
+ if (error || !user) {
266
+ console.error('Authentication error:', error);
267
+ return null;
268
+ }
269
+
270
+ return user;
271
+ } catch (error) {
272
+ console.error('Error authenticating user:', error);
273
+ return null;
274
+ }
275
+ }
276
+
277
+ async function getCompletedTasks(userId) {
278
+ if (!supabase) {
279
+ console.error('Supabase not initialized');
280
+ return [];
281
+ }
282
+
283
+ try {
284
+ const { data, error } = await supabase
285
+ .from('completed_tasks')
286
+ .select('*')
287
+ .eq('user_id', userId);
288
+
289
+ if (error) {
290
+ console.error('Error fetching completed tasks:', error);
291
+ return [];
292
+ }
293
+
294
+ return data || [];
295
+ } catch (error) {
296
+ console.error('Error fetching completed tasks:', error);
297
+ return [];
298
+ }
299
+ }
300
+
301
+ async function markTaskComplete(userId, taskId, taskType, category) {
302
+ if (!supabase) {
303
+ console.error('Supabase not initialized');
304
+ return null;
305
+ }
306
+
307
+ try {
308
+ const { data, error } = await supabase
309
+ .from('completed_tasks')
310
+ .upsert({
311
+ user_id: userId,
312
+ task_id: taskId,
313
+ task_type: taskType,
314
+ category: category
315
+ })
316
+ .select()
317
+ .single();
318
+
319
+ if (error) {
320
+ console.error('Error marking task complete:', error);
321
+ return null;
322
+ }
323
+
324
+ return data;
325
+ } catch (error) {
326
+ console.error('Error marking task complete:', error);
327
+ return null;
328
+ }
329
+ }
330
+
331
+ async function unmarkTaskComplete(userId, taskId) {
332
+ if (!supabase) {
333
+ console.error('Supabase not initialized');
334
+ return false;
335
+ }
336
+
337
+ try {
338
+ const { error } = await supabase
339
+ .from('completed_tasks')
340
+ .delete()
341
+ .eq('user_id', userId)
342
+ .eq('task_id', taskId);
343
+
344
+ if (error) {
345
+ console.error('Error unmarking task complete:', error);
346
+ return false;
347
+ }
348
+
349
+ return true;
350
+ } catch (error) {
351
+ console.error('Error unmarking task complete:', error);
352
+ return false;
353
+ }
354
+ }
355
 
 
356
  // Data embedded directly from server
357
  const appData = {
358
  records: {
 
379
  {id: 'Writing', label: 'Writing'}
380
  ],
381
  itemsPerPage: 5,
382
+ defaultCategory: {defaultCategory}
383
  };
384
 
385
  const state = {
386
  category: appData.defaultCategory,
387
  taskType: 'all',
388
+ completionFilter: 'all',
389
  page: 0,
390
+ totalPages: 0,
391
+ user: null,
392
+ completedTasks: []
393
  };
394
 
395
  const recordsByCategory = appData.records;
396
  const taskMappings = appData.taskMappings;
397
  const itemsPerPage = appData.itemsPerPage;
398
 
399
+ // DOM elements
400
  const cardsContainer = document.getElementById('cards-container');
401
  const summaryText = document.getElementById('summary-text');
402
  const pageInfo = document.getElementById('page-info');
403
  const prevBtn = document.getElementById('prev-btn');
404
  const nextBtn = document.getElementById('next-btn');
405
  const taskFilter = document.getElementById('task-filter');
406
+ const completionFilter = document.getElementById('completion-filter');
407
  const categoryInputs = Array.from(document.querySelectorAll('input[name="category"]'));
408
+ const logoutBtn = document.getElementById('logout-btn');
409
+ const loginOverlay = document.getElementById('login-overlay');
410
+ const loginForm = document.getElementById('login-form');
411
+ const loginError = document.getElementById('login-error');
412
+ const userInfo = document.getElementById('user-info');
413
+ const userName = document.querySelector('.user-name');
414
 
415
  const escapeHtml = (value) =>
416
  String(value ?? '')
 
425
  return `<ul>${items.map(formatter).join('')}</ul>`;
426
  };
427
 
428
+ const buildCard = (record, isCompleted = false, showToggle = false) => {
429
  const recordId = record?.id ?? 'N/A';
430
  const taskType = escapeHtml(String(record?.task_type ?? ''))
431
  .replace(/_/g, ' ')
432
  .replace(/\b\w/g, (char) => char.toUpperCase());
433
 
434
+ const taskId = `${recordId}-${record?.task_type}`;
435
+ const category = record?.category || state.category;
436
+
437
  const sections = [];
438
 
439
  if (record?.rephrased_task) {
 
499
  }
500
 
501
  return `
502
+ <article class="card ${isCompleted ? 'completed' : ''}" data-task-id="${taskId}">
503
+ <div class="card-header">
504
+ <h3>Task ${recordId} - ${taskType}</h3>
505
+ ${showToggle ? `
506
+ <div class="task-toggle">
507
+ <input
508
+ type="checkbox"
509
+ id="toggle-${taskId}"
510
+ class="task-checkbox"
511
+ ${isCompleted ? 'checked' : ''}
512
+ data-task-id="${recordId}"
513
+ data-task-type="${record?.task_type}"
514
+ data-category="${category}"
515
+ >
516
+ <label for="toggle-${taskId}" class="toggle-label">
517
+ <span class="toggle-slider"></span>
518
+ <span class="toggle-text">${isCompleted ? 'Completed' : 'Mark as Complete'}</span>
519
+ </label>
520
+ </div>
521
+ ` : ''}
522
+ </div>
523
  ${sections.join('\n')}
524
  </article>
525
  `;
526
  };
527
 
528
+ // Authentication functions
529
+ const checkAuthStatus = () => {
530
+ const sessionData = localStorage.getItem('userSession');
531
+ if (sessionData) {
532
+ const { user, expiresAt } = JSON.parse(sessionData);
533
+ if (expiresAt > Date.now()) {
534
+ state.user = user;
535
+ updateUserUI();
536
+ loadCompletedTasks();
537
+ return true;
538
+ } else {
539
+ localStorage.removeItem('userSession');
540
+ }
541
+ }
542
+ return false;
543
+ };
544
+
545
+ const updateUserUI = () => {
546
+ const mainContent = document.getElementById('main-content');
547
+
548
+ if (state.user) {
549
+ // User is authenticated - show main content and user info
550
+ userInfo.classList.remove('hidden');
551
+ userName.textContent = `Logged in as ${state.user.username}`;
552
+ mainContent.classList.add('authenticated');
553
+ loginOverlay.classList.add('hidden');
554
+ loginOverlay.classList.remove('visible');
555
+ document.body.style.overflow = ''; // Restore scrolling
556
+ } else {
557
+ // User is not authenticated - hide main content and user info, show login
558
+ userInfo.classList.add('hidden');
559
+ mainContent.classList.remove('authenticated');
560
+ loginOverlay.classList.remove('hidden');
561
+ loginOverlay.classList.add('visible');
562
+ document.body.style.overflow = 'hidden'; // Prevent background scrolling
563
+ }
564
+ };
565
+
566
+ const loadCompletedTasks = async () => {
567
+ if (!state.user) return;
568
+
569
+ try {
570
+ const completedTasks = await getCompletedTasks(state.user.id);
571
+ state.completedTasks = completedTasks.map(task => `${task.task_id}-${task.task_type}`);
572
+ applyState();
573
+ } catch (error) {
574
+ console.error('Error loading completed tasks:', error);
575
+ }
576
+ };
577
+
578
+ const login = async (username, password) => {
579
+ try {
580
+ const user = await authenticateUser(username, password);
581
+ if (user) {
582
+ const expiresAt = Date.now() + (7 * 24 * 60 * 60 * 1000); // 1 week
583
+ localStorage.setItem('userSession', JSON.stringify({ user, expiresAt }));
584
+ state.user = user;
585
+ loginError.classList.add('hidden');
586
+ updateUserUI();
587
+ loadCompletedTasks();
588
+ return true;
589
+ } else {
590
+ loginError.classList.remove('hidden');
591
+ return false;
592
+ }
593
+ } catch (error) {
594
+ console.error('Login error:', error);
595
+ loginError.classList.remove('hidden');
596
+ return false;
597
+ }
598
+ };
599
+
600
+ const logout = () => {
601
+ localStorage.removeItem('userSession');
602
+ state.user = null;
603
+ state.completedTasks = [];
604
+
605
+ // Refresh the page to reset all UI state
606
+ window.location.reload();
607
+ };
608
+
609
+ const handleTaskToggle = async (event) => {
610
+ if (!state.user) return;
611
+
612
+ const checkbox = event.target;
613
+ const taskId = checkbox.dataset.taskId;
614
+ const taskType = checkbox.dataset.taskType;
615
+ const category = checkbox.dataset.category;
616
+ const isCompleted = checkbox.checked;
617
+
618
+ try {
619
+ if (isCompleted) {
620
+ await markTaskComplete(state.user.id, taskId, taskType, category);
621
+ state.completedTasks.push(`${taskId}-${taskType}`);
622
+ } else {
623
+ await unmarkTaskComplete(state.user.id, taskId);
624
+ state.completedTasks = state.completedTasks.filter(id => id !== `${taskId}-${taskType}`);
625
+ }
626
+
627
+ // Update toggle text
628
+ const toggleText = checkbox.nextElementSibling.querySelector('.toggle-text');
629
+ toggleText.textContent = isCompleted ? 'Completed' : 'Mark as Complete';
630
+
631
+ applyState();
632
+ } catch (error) {
633
+ console.error('Error updating task status:', error);
634
+ checkbox.checked = !isCompleted; // Revert on error
635
+ }
636
+ };
637
+
638
  const applyState = () => {
639
  const allRecords = recordsByCategory[state.category] ?? [];
640
+
641
+ // Apply task type filter
642
+ let filtered = state.taskType === 'all'
643
  ? allRecords
644
  : allRecords.filter((item) => item?.task_type === state.taskType);
645
 
646
+ // Apply completion filter
647
+ if (state.user && state.completionFilter !== 'all') {
648
+ filtered = filtered.filter((item) => {
649
+ const taskId = `${item.id}-${item.task_type}`;
650
+ const isCompleted = state.completedTasks.includes(taskId);
651
+ return state.completionFilter === 'completed' ? isCompleted : !isCompleted;
652
+ });
653
+ }
654
+
655
  const totalItems = filtered.length;
656
  state.totalPages = totalItems === 0 ? 0 : Math.ceil(totalItems / itemsPerPage);
657
  state.page = Math.min(state.page, Math.max(state.totalPages - 1, 0));
 
661
  const pageItems = filtered.slice(start, end);
662
 
663
  if (cardsContainer) {
664
+ cardsContainer.innerHTML = pageItems.map(record => {
665
+ const taskId = `${record.id}-${record.task_type}`;
666
+ const isCompleted = state.completedTasks.includes(taskId);
667
+ return buildCard(record, isCompleted, state.user !== null);
668
+ }).join('');
669
+
670
+ // Add event listeners to new checkboxes
671
+ const checkboxes = cardsContainer.querySelectorAll('.task-checkbox');
672
+ checkboxes.forEach(checkbox => {
673
+ checkbox.addEventListener('change', handleTaskToggle);
674
+ });
675
  }
676
 
677
  if (totalItems === 0) {
678
  const hasRecordsInCategory = allRecords.length > 0;
679
  if (summaryText) {
680
  if (hasRecordsInCategory) {
681
+ if (state.user && state.completionFilter !== 'all') {
682
+ summaryText.textContent = `No ${state.completionFilter === 'completed' ? 'completed' : 'not completed'} tasks found for the selected filters.`;
683
+ } else {
684
+ summaryText.textContent = 'No records found for the selected task type.';
685
+ }
686
  } else {
687
  if (state.category === 'Writing') {
688
  summaryText.textContent = 'No writing tasks available yet. Writing data will be displayed here when available.';
 
725
  taskFilter.disabled = !hasRecords;
726
  };
727
 
 
 
 
 
728
  // Event listeners
729
  categoryInputs.forEach((input) => {
730
  input.addEventListener('change', (event) => {
 
748
  });
749
  }
750
 
751
+ if (completionFilter) {
752
+ completionFilter.addEventListener('change', (event) => {
753
+ const target = event.target;
754
+ if (!(target instanceof HTMLSelectElement)) return;
755
+ state.completionFilter = target.value;
756
+ state.page = 0;
757
+ applyState();
758
+ });
759
+ }
760
+
761
  if (prevBtn) {
762
  prevBtn.addEventListener('click', () => {
763
  state.page = Math.max(0, state.page - 1);
 
771
  applyState();
772
  });
773
  }
774
+
775
+ loginForm.addEventListener('submit', async (event) => {
776
+ event.preventDefault();
777
+ const formData = new FormData(loginForm);
778
+ const username = formData.get('username');
779
+ const password = formData.get('password');
780
+ await login(username, password);
781
+ });
782
+
783
+ logoutBtn.addEventListener('click', logout);
784
+
785
+ // Don't close login overlay when clicking outside - user must authenticate
786
+
787
+ // Initialize
788
+ checkAuthStatus();
789
+ updateTaskOptions();
790
+ applyState();
791
  </script>
792
  </BaseLayout>
src/styles/global.css CHANGED
@@ -247,6 +247,142 @@ select {
247
  }
248
  }
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  @media (max-width: 540px) {
251
  main {
252
  padding: clamp(14px, 6vw, 28px);
@@ -267,4 +403,33 @@ select {
267
  width: 100%;
268
  justify-content: center;
269
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  }
 
 
 
 
 
 
 
 
 
 
 
247
  }
248
  }
249
 
250
+ /* User section styles */
251
+ .user-section {
252
+ margin-top: 1.5rem;
253
+ padding-top: 1.5rem;
254
+ border-top: 1px solid #30363d;
255
+ display: flex;
256
+ justify-content: space-between;
257
+ align-items: center;
258
+ }
259
+
260
+ .user-info {
261
+ display: flex;
262
+ align-items: center;
263
+ gap: 1rem;
264
+ }
265
+
266
+ .user-name {
267
+ color: #c9d1d9;
268
+ font-size: 0.9rem;
269
+ }
270
+
271
+ .logout-btn {
272
+ background-color: #da3633;
273
+ color: white;
274
+ border: none;
275
+ border-radius: 6px;
276
+ padding: 0.5rem 1rem;
277
+ font-size: 0.875rem;
278
+ cursor: pointer;
279
+ transition: background-color 0.2s ease;
280
+ }
281
+
282
+ .logout-btn:hover {
283
+ background-color: #b91c1c;
284
+ }
285
+
286
+ .login-btn-header {
287
+ background-color: #238636;
288
+ color: white;
289
+ border: none;
290
+ border-radius: 6px;
291
+ padding: 0.75rem 1.5rem;
292
+ font-size: 0.9rem;
293
+ cursor: pointer;
294
+ transition: background-color 0.2s ease;
295
+ }
296
+
297
+ .login-btn-header:hover {
298
+ background-color: #2ea043;
299
+ }
300
+
301
+ /* Task card styles with toggle */
302
+ .card {
303
+ background-color: #161b22;
304
+ border: 1px solid #30363d;
305
+ border-radius: 12px;
306
+ padding: 1.5rem;
307
+ margin-bottom: 1rem;
308
+ transition: all 0.2s ease;
309
+ }
310
+
311
+ .card.completed {
312
+ background-color: rgba(35, 134, 54, 0.1);
313
+ border-color: rgba(35, 134, 54, 0.3);
314
+ }
315
+
316
+ .card.completed h3 {
317
+ color: #3fb950;
318
+ }
319
+
320
+ .card-header {
321
+ display: flex;
322
+ justify-content: space-between;
323
+ align-items: flex-start;
324
+ margin-bottom: 1rem;
325
+ gap: 1rem;
326
+ }
327
+
328
+ .task-toggle {
329
+ display: flex;
330
+ align-items: center;
331
+ flex-shrink: 0;
332
+ }
333
+
334
+ .task-checkbox {
335
+ display: none;
336
+ }
337
+
338
+ .toggle-label {
339
+ display: flex;
340
+ align-items: center;
341
+ gap: 0.5rem;
342
+ cursor: pointer;
343
+ user-select: none;
344
+ }
345
+
346
+ .toggle-slider {
347
+ width: 44px;
348
+ height: 24px;
349
+ background-color: #30363d;
350
+ border-radius: 12px;
351
+ position: relative;
352
+ transition: background-color 0.2s ease;
353
+ }
354
+
355
+ .toggle-slider::before {
356
+ content: '';
357
+ position: absolute;
358
+ width: 18px;
359
+ height: 18px;
360
+ background-color: #f0f6fc;
361
+ border-radius: 50%;
362
+ top: 3px;
363
+ left: 3px;
364
+ transition: transform 0.2s ease;
365
+ }
366
+
367
+ .task-checkbox:checked + .toggle-label .toggle-slider {
368
+ background-color: #238636;
369
+ }
370
+
371
+ .task-checkbox:checked + .toggle-label .toggle-slider::before {
372
+ transform: translateX(20px);
373
+ }
374
+
375
+ .toggle-text {
376
+ font-size: 0.875rem;
377
+ color: #8b949e;
378
+ transition: color 0.2s ease;
379
+ }
380
+
381
+ .task-checkbox:checked + .toggle-label .toggle-text {
382
+ color: #3fb950;
383
+ font-weight: 500;
384
+ }
385
+
386
  @media (max-width: 540px) {
387
  main {
388
  padding: clamp(14px, 6vw, 28px);
 
403
  width: 100%;
404
  justify-content: center;
405
  }
406
+
407
+ .card-header {
408
+ flex-direction: column;
409
+ align-items: stretch;
410
+ }
411
+
412
+ .task-toggle {
413
+ align-self: flex-end;
414
+ }
415
+
416
+ .user-section {
417
+ flex-direction: column;
418
+ gap: 1rem;
419
+ align-items: stretch;
420
+ }
421
+
422
+ .user-info {
423
+ justify-content: center;
424
+ }
425
  }
426
+
427
+ /* Main content visibility for authentication */
428
+ .main-content {
429
+ display: none;
430
+ }
431
+
432
+ .main-content.authenticated {
433
+ display: block;
434
+ }
435
+