mirror of
https://github.com/nagaoo0/HabbitGrid.git
synced 2026-01-11 15:34:54 +00:00
Add supabase setup
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_SUPABASE_URL=
|
||||||
|
VITE_SUPABASE_ANON_KEY=
|
||||||
135
package-lock.json
generated
135
package-lock.json
generated
@@ -20,6 +20,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"@supabase/supabase-js": "^2.75.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"framer-motion": "^10.16.4",
|
"framer-motion": "^10.16.4",
|
||||||
@@ -4060,6 +4061,80 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.75.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.75.1.tgz",
|
||||||
|
"integrity": "sha512-zktlxtXstQuVys/egDpVsargD9hQtG20CMdtn+mMn7d2Ulkzy2tgUT5FUtpppvCJtd9CkhPHO/73rvi5W6Am5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "2.6.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.75.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.75.1.tgz",
|
||||||
|
"integrity": "sha512-xO+01SUcwVmmo67J7Htxq8FmhkYLFdWkxfR/taxBOI36wACEUNQZmroXGPl4PkpYxBO7TaDsRHYGxUpv9zTKkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "2.6.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/node-fetch": {
|
||||||
|
"version": "2.6.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
|
||||||
|
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.75.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.75.1.tgz",
|
||||||
|
"integrity": "sha512-FiYBD0MaKqGW8eo4Xqu7/100Xm3ddgh+3qHtqS18yQRoglJTFRQCJzY1xkrGS0JFHE2YnbjL6XCiOBXiG8DK4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "2.6.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.75.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.75.1.tgz",
|
||||||
|
"integrity": "sha512-lBIJ855bUsBFScHA/AY+lxIFkubduUvmwbagbP1hq0wDBNAsYdg3ql80w8YmtXCDjkCwlE96SZqcFn7BGKKJKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "2.6.15",
|
||||||
|
"@types/phoenix": "^1.6.6",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"ws": "^8.18.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.75.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.75.1.tgz",
|
||||||
|
"integrity": "sha512-WdGEhroflt5O398Yg3dpf1uKZZ6N3CGloY9iGsdT873uWbkQKoP0wG8mtx98dh0fhj6dAlzBqOAvnlV12cJfzA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "2.6.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.75.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.75.1.tgz",
|
||||||
|
"integrity": "sha512-GEPVBvjQimcMd9z5K1eTKTixTRb6oVbudoLQ9JKqTUJnR6GQdBU4OifFZean1AnHfsQwtri1fop2OWwsMv019w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.75.1",
|
||||||
|
"@supabase/functions-js": "2.75.1",
|
||||||
|
"@supabase/node-fetch": "2.6.15",
|
||||||
|
"@supabase/postgrest-js": "2.75.1",
|
||||||
|
"@supabase/realtime-js": "2.75.1",
|
||||||
|
"@supabase/storage-js": "2.75.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -4130,7 +4205,6 @@
|
|||||||
"version": "20.19.21",
|
"version": "20.19.21",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz",
|
||||||
"integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
|
"integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -4143,6 +4217,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/phoenix": {
|
||||||
|
"version": "1.6.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||||
|
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -4184,6 +4264,15 @@
|
|||||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "5.62.0",
|
"version": "5.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
|
||||||
@@ -9551,6 +9640,12 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-interface-checker": {
|
"node_modules/ts-interface-checker": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||||
@@ -9754,7 +9849,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
@@ -10015,6 +10109,22 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -10230,6 +10340,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"@supabase/supabase-js": "^2.75.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"framer-motion": "^10.16.4",
|
"framer-motion": "^10.16.4",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import HomePage from './pages/HomePage';
|
|||||||
import HabitDetailPage from './pages/HabitDetailPage';
|
import HabitDetailPage from './pages/HabitDetailPage';
|
||||||
import AddEditHabitPage from './pages/AddEditHabitPage';
|
import AddEditHabitPage from './pages/AddEditHabitPage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
import LoginProvidersPage from './pages/LoginProvidersPage';
|
||||||
import { Toaster } from './components/ui/toaster';
|
import { Toaster } from './components/ui/toaster';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -21,6 +22,7 @@ function App() {
|
|||||||
<Route path="/add" element={<AddEditHabitPage />} />
|
<Route path="/add" element={<AddEditHabitPage />} />
|
||||||
<Route path="/edit/:id" element={<AddEditHabitPage />} />
|
<Route path="/edit/:id" element={<AddEditHabitPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/login-providers" element={<LoginProvidersPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useMemo, useEffect } from 'react';
|
import React, { useMemo, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { getColorIntensity, isToday, formatDate, getWeekdayLabel, getFrozenDays } from '../lib/utils-habit';
|
import { getColorIntensity, isToday, formatDate, getWeekdayLabel, getFrozenDays } from '../lib/utils-habit';
|
||||||
import { toggleCompletion } from '../lib/storage';
|
import { toggleCompletion } from '../lib/datastore';
|
||||||
|
|
||||||
const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||||
const frozenDays = getFrozenDays(habit.completions);
|
const frozenDays = getFrozenDays(habit.completions);
|
||||||
@@ -39,7 +39,18 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCellClick = (date) => {
|
const handleCellClick = (date) => {
|
||||||
toggleCompletion(habit.id, formatDate(date));
|
const dateStr = formatDate(date);
|
||||||
|
// Optimistic local update
|
||||||
|
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
|
const idx = habits.findIndex(h => h.id === habit.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
const completions = Array.isArray(habits[idx].completions) ? [...habits[idx].completions] : [];
|
||||||
|
const cidx = completions.indexOf(dateStr);
|
||||||
|
if (cidx > -1) completions.splice(cidx, 1); else completions.push(dateStr);
|
||||||
|
habits[idx].completions = completions;
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
||||||
|
}
|
||||||
|
toggleCompletion(habit.id, dateStr); // background sync
|
||||||
onUpdate();
|
onUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function getFreezeIcon() {
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { getColorIntensity, isToday, formatDate } from '../lib/utils-habit';
|
import { getColorIntensity, isToday, formatDate } from '../lib/utils-habit';
|
||||||
import { getFrozenDays } from '../lib/utils-habit';
|
import { getFrozenDays } from '../lib/utils-habit';
|
||||||
import { toggleCompletion } from '../lib/storage';
|
import { toggleCompletion } from '../lib/datastore';
|
||||||
import { toast } from './ui/use-toast';
|
import { toast } from './ui/use-toast';
|
||||||
|
|
||||||
const MiniGrid = ({ habit, onUpdate }) => {
|
const MiniGrid = ({ habit, onUpdate }) => {
|
||||||
@@ -69,7 +69,17 @@ const MiniGrid = ({ habit, onUpdate }) => {
|
|||||||
const dateStr = formatDate(date);
|
const dateStr = formatDate(date);
|
||||||
const isTodayCell = isToday(date);
|
const isTodayCell = isToday(date);
|
||||||
const wasCompleted = habit.completions.includes(dateStr);
|
const wasCompleted = habit.completions.includes(dateStr);
|
||||||
toggleCompletion(habit.id, dateStr);
|
// Optimistic local update
|
||||||
|
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
|
const idx = habits.findIndex(h => h.id === habit.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
const completions = Array.isArray(habits[idx].completions) ? [...habits[idx].completions] : [];
|
||||||
|
const cidx = completions.indexOf(dateStr);
|
||||||
|
if (cidx > -1) completions.splice(cidx, 1); else completions.push(dateStr);
|
||||||
|
habits[idx].completions = completions;
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
||||||
|
}
|
||||||
|
toggleCompletion(habit.id, dateStr); // background sync
|
||||||
onUpdate();
|
onUpdate();
|
||||||
// Only show encouragement toast if validating (adding) today's dot
|
// Only show encouragement toast if validating (adding) today's dot
|
||||||
if (isTodayCell && !wasCompleted) {
|
if (isTodayCell && !wasCompleted) {
|
||||||
|
|||||||
184
src/lib/datastore.js
Normal file
184
src/lib/datastore.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { supabase, isSupabaseConfigured } from './supabase';
|
||||||
|
import * as local from './storage';
|
||||||
|
|
||||||
|
const SYNC_FLAG = 'habitgrid_remote_synced_at';
|
||||||
|
|
||||||
|
export const getAuthUser = async () => {
|
||||||
|
if (!isSupabaseConfigured()) return null;
|
||||||
|
const { data } = await supabase.auth.getUser();
|
||||||
|
return data?.user ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isLoggedIn = async () => Boolean(await getAuthUser());
|
||||||
|
|
||||||
|
// Remote schema suggestion:
|
||||||
|
// table habits {
|
||||||
|
// id uuid primary key default gen_random_uuid(),
|
||||||
|
// user_id uuid not null,
|
||||||
|
// name text not null,
|
||||||
|
// color text,
|
||||||
|
// category text,
|
||||||
|
// completions jsonb default '[]'::jsonb,
|
||||||
|
// current_streak int default 0,
|
||||||
|
// longest_streak int default 0,
|
||||||
|
// sort_order int default 0,
|
||||||
|
// created_at timestamptz default now(),
|
||||||
|
// updated_at timestamptz default now()
|
||||||
|
// };
|
||||||
|
|
||||||
|
export async function getHabits() {
|
||||||
|
if (!(await isLoggedIn())) return local.getHabits();
|
||||||
|
|
||||||
|
const { data: user } = await supabase.auth.getUser();
|
||||||
|
const userId = user?.user?.id;
|
||||||
|
if (!userId) return local.getHabits();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.select('id,user_id,name,color,category,completions,current_streak,longest_streak,sort_order,updated_at,created_at')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('sort_order');
|
||||||
|
if (error) {
|
||||||
|
console.warn('Supabase getHabits error, falling back to local:', error.message);
|
||||||
|
return local.getHabits();
|
||||||
|
}
|
||||||
|
// Map to local shape
|
||||||
|
return (data || []).map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
color: row.color,
|
||||||
|
category: row.category || '',
|
||||||
|
completions: row.completions || [],
|
||||||
|
currentStreak: row.current_streak ?? 0,
|
||||||
|
longestStreak: row.longest_streak ?? 0,
|
||||||
|
sortOrder: row.sort_order ?? 0,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveHabit(habit) {
|
||||||
|
if (!(await isLoggedIn())) return local.saveHabit(habit);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const { data: auth } = await supabase.auth.getUser();
|
||||||
|
const insert = {
|
||||||
|
user_id: auth?.user?.id,
|
||||||
|
name: habit.name,
|
||||||
|
color: habit.color,
|
||||||
|
category: habit.category || '',
|
||||||
|
completions: habit.completions || [],
|
||||||
|
current_streak: habit.currentStreak ?? 0,
|
||||||
|
longest_streak: habit.longestStreak ?? 0,
|
||||||
|
sort_order: habit.sortOrder ?? 0,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
const { data, error } = await supabase.from('habits').insert(insert).select('*').single();
|
||||||
|
if (error) {
|
||||||
|
console.warn('Supabase saveHabit error, writing local:', error.message);
|
||||||
|
return local.saveHabit(habit);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
sortOrder: data.sort_order ?? 0,
|
||||||
|
...habit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateHabit(id, updates) {
|
||||||
|
if (!(await isLoggedIn())) return local.updateHabit(id, updates);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const patch = {
|
||||||
|
...(updates.name !== undefined ? { name: updates.name } : {}),
|
||||||
|
...(updates.color !== undefined ? { color: updates.color } : {}),
|
||||||
|
...(updates.category !== undefined ? { category: updates.category } : {}),
|
||||||
|
...(updates.completions !== undefined ? { completions: updates.completions } : {}),
|
||||||
|
...(updates.currentStreak !== undefined ? { current_streak: updates.currentStreak } : {}),
|
||||||
|
...(updates.longestStreak !== undefined ? { longest_streak: updates.longestStreak } : {}),
|
||||||
|
...(updates.sortOrder !== undefined ? { sort_order: updates.sortOrder } : {}),
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
const { error } = await supabase.from('habits').update(patch).eq('id', id);
|
||||||
|
if (error) {
|
||||||
|
console.warn('Supabase updateHabit error, writing local:', error.message);
|
||||||
|
return local.updateHabit(id, updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHabit(id) {
|
||||||
|
if (!(await isLoggedIn())) return local.deleteHabit(id);
|
||||||
|
const { error } = await supabase.from('habits').delete().eq('id', id);
|
||||||
|
if (error) {
|
||||||
|
console.warn('Supabase deleteHabit error, writing local:', error.message);
|
||||||
|
return local.deleteHabit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleCompletion(habitId, dateStr) {
|
||||||
|
if (!(await isLoggedIn())) return local.toggleCompletion(habitId, dateStr);
|
||||||
|
// Fetch current then delegate to local logic for streak calc
|
||||||
|
const habits = await getHabits();
|
||||||
|
const target = habits.find(h => h.id === habitId);
|
||||||
|
if (!target) return;
|
||||||
|
const completions = Array.isArray(target.completions) ? [...target.completions] : [];
|
||||||
|
const idx = completions.indexOf(dateStr);
|
||||||
|
if (idx > -1) completions.splice(idx, 1); else completions.push(dateStr);
|
||||||
|
return updateHabit(habitId, { completions });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportData() {
|
||||||
|
// Always export from local snapshot for portability
|
||||||
|
const habits = await getHabits();
|
||||||
|
return JSON.stringify(habits, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importData(jsonString) {
|
||||||
|
// Always import to local; remote sync will push on login
|
||||||
|
return local.importData(jsonString);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAllData() {
|
||||||
|
// Clear local only; remote data persists per account
|
||||||
|
return local.clearAllData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync: push local data to remote when user first logs in or when no remote data exists
|
||||||
|
export async function syncLocalToRemoteIfNeeded() {
|
||||||
|
if (!isSupabaseConfigured()) return;
|
||||||
|
const user = await getAuthUser();
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const already = localStorage.getItem(SYNC_FLAG);
|
||||||
|
const { data: remote, error } = await supabase.from('habits').select('id').limit(1);
|
||||||
|
if (error) return;
|
||||||
|
|
||||||
|
if (!already || (remote || []).length === 0) {
|
||||||
|
const habits = local.getHabits();
|
||||||
|
if (habits.length === 0) return localStorage.setItem(SYNC_FLAG, new Date().toISOString());
|
||||||
|
const rows = habits.map(h => ({
|
||||||
|
id: h.id && h.id.length > 0 ? h.id : undefined,
|
||||||
|
user_id: user.id,
|
||||||
|
name: h.name,
|
||||||
|
color: h.color,
|
||||||
|
category: h.category || '',
|
||||||
|
completions: h.completions || [],
|
||||||
|
current_streak: h.currentStreak ?? 0,
|
||||||
|
longest_streak: h.longestStreak ?? 0,
|
||||||
|
sort_order: h.sortOrder ?? 0,
|
||||||
|
created_at: h.createdAt || new Date().toISOString(),
|
||||||
|
updated_at: h.updatedAt || new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
await supabase.from('habits').upsert(rows, { onConflict: 'id' });
|
||||||
|
localStorage.setItem(SYNC_FLAG, new Date().toISOString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncRemoteToLocal() {
|
||||||
|
const user = await getAuthUser();
|
||||||
|
if (!user) return;
|
||||||
|
const remote = await getHabits();
|
||||||
|
// write to local in the app's expected format
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(remote));
|
||||||
|
// Notify UI to reload if listening
|
||||||
|
window.dispatchEvent(new CustomEvent('habitgrid-sync-updated'));
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Local storage remains the primary source. If Supabase auth is active, we mirror writes to the remote DB.
|
||||||
|
import { supabase } from './supabase';
|
||||||
const STORAGE_KEY = 'habitgrid_data';
|
const STORAGE_KEY = 'habitgrid_data';
|
||||||
|
|
||||||
export const getHabits = () => {
|
export const getHabits = () => {
|
||||||
@@ -15,15 +17,55 @@ export const getHabit = (id) => {
|
|||||||
return habits.find(h => h.id === id);
|
return habits.find(h => h.id === id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const nowIso = () => new Date().toISOString();
|
||||||
|
|
||||||
|
const remoteMirrorUpsert = async (habit) => {
|
||||||
|
try {
|
||||||
|
if (!supabase) return;
|
||||||
|
const { data: auth } = await supabase.auth.getUser();
|
||||||
|
if (!auth?.user) return;
|
||||||
|
const row = {
|
||||||
|
id: habit.id,
|
||||||
|
name: habit.name ?? habit.title ?? habit?.name,
|
||||||
|
color: habit.color,
|
||||||
|
category: habit.category || '',
|
||||||
|
completions: habit.completions || [],
|
||||||
|
current_streak: habit.currentStreak ?? 0,
|
||||||
|
longest_streak: habit.longestStreak ?? 0,
|
||||||
|
sort_order: habit.sortOrder ?? 0,
|
||||||
|
created_at: habit.createdAt || nowIso(),
|
||||||
|
updated_at: habit.updatedAt || nowIso(),
|
||||||
|
user_id: auth.user.id,
|
||||||
|
};
|
||||||
|
await supabase.from('habits').upsert(row, { onConflict: 'id' });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Remote mirror upsert failed:', e?.message || e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remoteMirrorDelete = async (id) => {
|
||||||
|
try {
|
||||||
|
if (!supabase) return;
|
||||||
|
const { data: auth } = await supabase.auth.getUser();
|
||||||
|
if (!auth?.user) return;
|
||||||
|
await supabase.from('habits').delete().eq('id', id).eq('user_id', auth.user.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Remote mirror delete failed:', e?.message || e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const saveHabit = (habit) => {
|
export const saveHabit = (habit) => {
|
||||||
const habits = getHabits();
|
const habits = getHabits();
|
||||||
const newHabit = {
|
const newHabit = {
|
||||||
...habit,
|
...habit,
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
sortOrder: habits.length,
|
sortOrder: habits.length,
|
||||||
|
createdAt: nowIso(),
|
||||||
|
updatedAt: nowIso(),
|
||||||
};
|
};
|
||||||
habits.push(newHabit);
|
habits.push(newHabit);
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
|
||||||
|
remoteMirrorUpsert(newHabit);
|
||||||
return newHabit;
|
return newHabit;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,8 +73,9 @@ export const updateHabit = (id, updates) => {
|
|||||||
const habits = getHabits();
|
const habits = getHabits();
|
||||||
const index = habits.findIndex(h => h.id === id);
|
const index = habits.findIndex(h => h.id === id);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
habits[index] = { ...habits[index], ...updates };
|
habits[index] = { ...habits[index], ...updates, updatedAt: nowIso() };
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
|
||||||
|
remoteMirrorUpsert(habits[index]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,6 +83,7 @@ export const deleteHabit = (id) => {
|
|||||||
const habits = getHabits();
|
const habits = getHabits();
|
||||||
const filtered = habits.filter(h => h.id !== id);
|
const filtered = habits.filter(h => h.id !== id);
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||||
|
remoteMirrorDelete(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleCompletion = (habitId, dateStr) => {
|
export const toggleCompletion = (habitId, dateStr) => {
|
||||||
@@ -138,4 +182,8 @@ export const importData = (jsonString) => {
|
|||||||
|
|
||||||
export const clearAllData = () => {
|
export const clearAllData = () => {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Re-export a thin Supabase-aware facade so the rest of the app can import from 'lib/storage'
|
||||||
|
// without refactors. We keep original names but allow higher-level modules to import the remote-aware versions.
|
||||||
|
export * as remote from './datastore';
|
||||||
11
src/lib/supabase.js
Normal file
11
src/lib/supabase.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
// Expect env vars provided by Vite
|
||||||
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
export const supabase = (supabaseUrl && supabaseAnonKey)
|
||||||
|
? createClient(supabaseUrl, supabaseAnonKey)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
export const isSupabaseConfigured = () => Boolean(supabase);
|
||||||
@@ -7,7 +7,7 @@ import { Input } from '../components/ui/input';
|
|||||||
import { Label } from '../components/ui/label';
|
import { Label } from '../components/ui/label';
|
||||||
import { useToast } from '../components/ui/use-toast';
|
import { useToast } from '../components/ui/use-toast';
|
||||||
import ColorPicker from '../components/ColorPicker';
|
import ColorPicker from '../components/ColorPicker';
|
||||||
import { getHabit, saveHabit, updateHabit } from '../lib/storage';
|
import { getHabits, saveHabit, updateHabit } from '../lib/datastore';
|
||||||
|
|
||||||
const AddEditHabitPage = () => {
|
const AddEditHabitPage = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -49,14 +49,25 @@ const AddEditHabitPage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optimistic local update
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
updateHabit(id, { name: name.trim(), color, category: category.trim() });
|
// Update localStorage directly for instant UI
|
||||||
|
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
|
const idx = habits.findIndex(h => h.id === id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
habits[idx] = { ...habits[idx], name: name.trim(), color, category: category.trim() };
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
||||||
|
}
|
||||||
|
updateHabit(id, { name: name.trim(), color, category: category.trim() }); // background sync
|
||||||
toast({
|
toast({
|
||||||
title: "✅ Habit updated",
|
title: "✅ Habit updated",
|
||||||
description: "Your habit has been updated successfully.",
|
description: "Your habit has been updated successfully.",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
saveHabit({
|
// Add to localStorage for instant UI
|
||||||
|
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
|
const newHabit = {
|
||||||
|
id: Date.now().toString(),
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
color,
|
color,
|
||||||
category: category.trim(),
|
category: category.trim(),
|
||||||
@@ -64,7 +75,12 @@ const AddEditHabitPage = () => {
|
|||||||
currentStreak: 0,
|
currentStreak: 0,
|
||||||
longestStreak: 0,
|
longestStreak: 0,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
updatedAt: new Date().toISOString(),
|
||||||
|
sortOrder: habits.length,
|
||||||
|
};
|
||||||
|
habits.push(newHabit);
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
||||||
|
saveHabit(newHabit); // background sync
|
||||||
toast({
|
toast({
|
||||||
title: "✅ Habit created",
|
title: "✅ Habit created",
|
||||||
description: "Your new habit is ready to track!",
|
description: "Your new habit is ready to track!",
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import { Button } from '../components/ui/button';
|
|||||||
import { useToast } from '../components/ui/use-toast';
|
import { useToast } from '../components/ui/use-toast';
|
||||||
import HabitGrid from '../components/HabitGrid';
|
import HabitGrid from '../components/HabitGrid';
|
||||||
import DeleteHabitDialog from '../components/DeleteHabitDialog';
|
import DeleteHabitDialog from '../components/DeleteHabitDialog';
|
||||||
import { getHabit, deleteHabit } from '../lib/storage';
|
import { getHabits, deleteHabit } from '../lib/datastore';
|
||||||
|
|
||||||
|
// Local helper to get habit by id from localStorage
|
||||||
|
function getHabit(id) {
|
||||||
|
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
|
return habits.find(h => h.id === id);
|
||||||
|
}
|
||||||
import AnimatedCounter from '../components/AnimatedCounter';
|
import AnimatedCounter from '../components/AnimatedCounter';
|
||||||
|
|
||||||
const HabitDetailPage = () => {
|
const HabitDetailPage = () => {
|
||||||
@@ -44,7 +50,11 @@ const HabitDetailPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
deleteHabit(id);
|
// Optimistic local delete
|
||||||
|
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
|
const filtered = habits.filter(h => h.id !== id);
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(filtered));
|
||||||
|
deleteHabit(id); // background sync
|
||||||
toast({
|
toast({
|
||||||
title: "✅ Habit deleted",
|
title: "✅ Habit deleted",
|
||||||
description: "Your habit has been removed successfully.",
|
description: "Your habit has been removed successfully.",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import HabitCard from '../components/HabitCard';
|
|||||||
import AnimatedCounter from '../components/AnimatedCounter';
|
import AnimatedCounter from '../components/AnimatedCounter';
|
||||||
import GitActivityGrid from '../components/GitActivityGrid';
|
import GitActivityGrid from '../components/GitActivityGrid';
|
||||||
import { getGitEnabled } from '../lib/git';
|
import { getGitEnabled } from '../lib/git';
|
||||||
import { getHabits, updateHabit } from '../lib/storage';
|
import { getHabits, updateHabit, syncLocalToRemoteIfNeeded, syncRemoteToLocal, getAuthUser } from '../lib/datastore';
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -22,8 +22,26 @@ const HomePage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadHabits();
|
(async () => {
|
||||||
setGitEnabled(getGitEnabled());
|
// On login, pull remote habits into localStorage
|
||||||
|
const user = await getAuthUser();
|
||||||
|
if (user) {
|
||||||
|
await syncRemoteToLocal();
|
||||||
|
}
|
||||||
|
await loadHabits();
|
||||||
|
setGitEnabled(getGitEnabled());
|
||||||
|
})();
|
||||||
|
// Background sync every 10s if logged in
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
syncLocalToRemoteIfNeeded();
|
||||||
|
}, 10000);
|
||||||
|
// Listen for remote sync event to reload habits
|
||||||
|
const syncListener = () => loadHabits();
|
||||||
|
window.addEventListener('habitgrid-sync-updated', syncListener);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
window.removeEventListener('habitgrid-sync-updated', syncListener);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,9 +54,9 @@ const HomePage = () => {
|
|||||||
}
|
}
|
||||||
}, [darkMode]);
|
}, [darkMode]);
|
||||||
|
|
||||||
const loadHabits = () => {
|
const loadHabits = async () => {
|
||||||
const loadedHabits = getHabits();
|
// Always read from local for instant UI
|
||||||
// Sort by sortOrder if present, then fallback to createdAt
|
const loadedHabits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
loadedHabits.sort((a, b) => {
|
loadedHabits.sort((a, b) => {
|
||||||
if (a.sortOrder !== undefined && b.sortOrder !== undefined) return a.sortOrder - b.sortOrder;
|
if (a.sortOrder !== undefined && b.sortOrder !== undefined) return a.sortOrder - b.sortOrder;
|
||||||
if (a.sortOrder !== undefined) return -1;
|
if (a.sortOrder !== undefined) return -1;
|
||||||
@@ -211,7 +229,7 @@ const HomePage = () => {
|
|||||||
...Object.values(grouped).flat()
|
...Object.values(grouped).flat()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
setTimeout(loadHabits, 100); // reload after update
|
setTimeout(loadHabits, 0); // reload instantly after update
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
40
src/pages/LoginProvidersPage.jsx
Normal file
40
src/pages/LoginProvidersPage.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { supabase, isSupabaseConfigured } from '../lib/supabase';
|
||||||
|
import { Button } from '../components/ui/button';
|
||||||
|
|
||||||
|
const PROVIDERS = [
|
||||||
|
{ id: 'github', label: 'GitHub' },
|
||||||
|
{ id: 'discord', label: 'Discord' },
|
||||||
|
// Add more providers here if needed
|
||||||
|
];
|
||||||
|
|
||||||
|
const LoginProvidersPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogin = async (provider) => {
|
||||||
|
if (!isSupabaseConfigured()) return alert('Supabase not configured. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY');
|
||||||
|
const { error } = await supabase.auth.signInWithOAuth({ provider });
|
||||||
|
if (error) alert(error.message);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||||
|
<div className="max-w-md w-full p-8 bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<h1 className="text-2xl font-bold mb-6 text-center">Choose Login Provider</h1>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{PROVIDERS.map(p => (
|
||||||
|
<Button key={p.id} onClick={() => handleLogin(p.id)} className="w-full">
|
||||||
|
{`Login with ${p.label}`}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" className="mt-8 w-full" onClick={() => navigate(-1)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginProvidersPage;
|
||||||
@@ -7,7 +7,9 @@ import { Separator } from '../components/ui/separator';
|
|||||||
import { Switch } from '../components/ui/switch';
|
import { Switch } from '../components/ui/switch';
|
||||||
import { Label } from '../components/ui/label';
|
import { Label } from '../components/ui/label';
|
||||||
import { useToast } from '../components/ui/use-toast';
|
import { useToast } from '../components/ui/use-toast';
|
||||||
import { exportData, importData, clearAllData } from '../lib/storage';
|
import { exportData, importData, clearAllData } from '../lib/datastore';
|
||||||
|
import { supabase, isSupabaseConfigured } from '../lib/supabase';
|
||||||
|
import { syncLocalToRemoteIfNeeded } from '../lib/datastore';
|
||||||
import { addIntegration, getIntegrations, removeIntegration, getGitEnabled, setGitEnabled, fetchAllGitActivity, getCachedGitActivity } from '../lib/git';
|
import { addIntegration, getIntegrations, removeIntegration, getGitEnabled, setGitEnabled, fetchAllGitActivity, getCachedGitActivity } from '../lib/git';
|
||||||
|
|
||||||
const DEFAULT_STREAK_ICON = 'flame';
|
const DEFAULT_STREAK_ICON = 'flame';
|
||||||
@@ -57,6 +59,27 @@ const SettingsPage = () => {
|
|||||||
const [form, setForm] = useState({ provider: 'github', baseUrl: '', username: '', token: '' });
|
const [form, setForm] = useState({ provider: 'github', baseUrl: '', username: '', token: '' });
|
||||||
const [{ lastSync }, setCacheInfo] = useState(getCachedGitActivity());
|
const [{ lastSync }, setCacheInfo] = useState(getCachedGitActivity());
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [userEmail, setUserEmail] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSupabaseConfigured()) return;
|
||||||
|
supabase.auth.getUser().then(({ data }) => setUserEmail(data?.user?.email || ''));
|
||||||
|
const { data: sub } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
|
setUserEmail(session?.user?.email || '');
|
||||||
|
if (session?.user) syncLocalToRemoteIfNeeded();
|
||||||
|
});
|
||||||
|
return () => sub?.subscription?.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = async (provider) => {
|
||||||
|
if (!isSupabaseConfigured()) return alert('Supabase not configured. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY');
|
||||||
|
const { error } = await supabase.auth.signInWithOAuth({ provider });
|
||||||
|
if (error) alert(error.message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await supabase?.auth?.signOut();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (darkMode) {
|
if (darkMode) {
|
||||||
@@ -178,10 +201,20 @@ const SettingsPage = () => {
|
|||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
<p className="text-sm text-muted-foreground">Customize your experience</p>
|
<p className="text-sm text-muted-foreground">Customize your experience</p>
|
||||||
</div>
|
</div>
|
||||||
|
{isSupabaseConfigured() && (
|
||||||
|
userEmail ? (
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
<span className="text-sm text-muted-foreground">{userEmail}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleLogout} className="rounded-full">Logout</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => navigate('/login-providers')} variant="outline" size="sm" className="rounded-full ml-auto">Login to Sync</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
2
supabase/habits_table.csv
Normal file
2
supabase/habits_table.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
id,user_id,name,color,category,completions,current_streak,longest_streak,sort_order,created_at,updated_at
|
||||||
|
3fa85f64-5717-4562-b3fc-2c963f66afa6,11111111-1111-1111-1111-111111111111,Sample Habit,#22c55e,Health,"[""2025-01-01"",""2025-01-02""]",2,5,0,2025-01-01T00:00:00Z,2025-01-02T00:00:00Z
|
||||||
|
57
supabase/habits_table.sql
Normal file
57
supabase/habits_table.sql
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
-- Create habits table with proper types, defaults, constraints, and RLS
|
||||||
|
-- Run this in Supabase SQL editor
|
||||||
|
|
||||||
|
-- Enable gen_random_uuid()
|
||||||
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
create table if not exists public.habits (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
name text not null,
|
||||||
|
color text,
|
||||||
|
category text,
|
||||||
|
completions jsonb not null default '[]'::jsonb,
|
||||||
|
current_streak integer not null default 0,
|
||||||
|
longest_streak integer not null default 0,
|
||||||
|
sort_order integer not null default 0,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Useful indexes
|
||||||
|
create index if not exists idx_habits_user_id on public.habits(user_id);
|
||||||
|
create index if not exists idx_habits_user_sort on public.habits(user_id, sort_order);
|
||||||
|
|
||||||
|
-- Automatically update updated_at
|
||||||
|
create or replace function public.set_updated_at()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
new.updated_at = now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_habits_set_updated_at on public.habits;
|
||||||
|
create trigger trg_habits_set_updated_at
|
||||||
|
before update on public.habits
|
||||||
|
for each row execute function public.set_updated_at();
|
||||||
|
|
||||||
|
-- Row Level Security
|
||||||
|
alter table public.habits enable row level security;
|
||||||
|
|
||||||
|
-- Policies: each user can only access their own rows
|
||||||
|
drop policy if exists habits_select on public.habits;
|
||||||
|
create policy habits_select on public.habits
|
||||||
|
for select using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
drop policy if exists habits_insert on public.habits;
|
||||||
|
create policy habits_insert on public.habits
|
||||||
|
for insert with check (auth.uid() = user_id);
|
||||||
|
|
||||||
|
drop policy if exists habits_update on public.habits;
|
||||||
|
create policy habits_update on public.habits
|
||||||
|
for update using (auth.uid() = user_id) with check (auth.uid() = user_id);
|
||||||
|
|
||||||
|
drop policy if exists habits_delete on public.habits;
|
||||||
|
create policy habits_delete on public.habits
|
||||||
|
for delete using (auth.uid() = user_id);
|
||||||
Reference in New Issue
Block a user