Files
cs249r_book/tinytorch/quarto/community/profile_setup.html

631 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Complete Profile</title>
<link rel="icon" href="assets/flame.svg" type="image/svg+xml">
<!-- Load Three.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" integrity="sha384-CI3ELBVUz9XQO+97x6nwMDPosPR5XvsxW2ua7N1Xeygeh1IxtgqtCkGfQY9WWdHu" crossorigin="anonymous"></script>
<style>
/* Basic clean styling */
body {
margin: 0;
width: 100%;
min-height: 100vh;
overflow: hidden; /* Prevent body scroll, handle inside container */
display: flex;
align-items: center;
justify-content: center;
background-color: #f0f4f8;
font-family: 'Verdana', sans-serif;
color: #333;
}
#canvas-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
z-index: -1;
}
.container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
width: 100%;
max-width: 800px;
max-height: 85vh;
display: flex;
align-items: stretch;
overflow: hidden;
z-index: 1;
margin: 20px;
}
.form-content {
flex: 1;
padding: 40px;
overflow-y: auto;
}
.candle-side {
flex: 0 0 250px;
background: rgba(252, 252, 252, 0.5);
display: flex;
align-items: center;
justify-content: center;
border-left: 1px solid #f0f0f0;
position: relative;
overflow: hidden;
}
#candleCanvas {
width: 160px;
height: auto;
aspect-ratio: 16/24;
image-rendering: pixelated;
filter: drop-shadow(4px 4px 0px rgba(0,0,0,0.05));
z-index: 2;
pointer-events: none;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.container {
flex-direction: column;
max-height: 90vh;
}
.candle-side {
display: none; /* Hide candle side on mobile */
}
.form-content {
width: 100%;
padding: 25px;
}
}
h2 { margin-top: 0; color: #222; font-size: 1.5rem; margin-bottom: 8px; }
p.subtitle { color: #666; font-size: 0.9rem; margin-bottom: 25px; line-height: 1.4; }
.form-group { margin-bottom: 20px; }
label { display: block; font-weight: bold; font-size: 0.9rem; color: #444; margin-bottom: 8px; }
.optional { font-weight: normal; color: #888; font-size: 0.8rem; margin-left: 5px; }
input, textarea {
width: 100%; padding: 12px;
border: 1px solid #ddd; border-radius: 8px;
box-sizing: border-box; font-size: 1rem; font-family: inherit;
transition: border-color 0.2s;
}
input:focus, textarea:focus { border-color: #ff6600; outline: none; }
textarea { resize: vertical; min-height: 80px; }
button {
width: 100%; padding: 14px;
background: #ff6600; color: white;
border: none; border-radius: 8px;
font-weight: bold; font-size: 1.1rem;
cursor: pointer; transition: background 0.2s, transform 0.1s;
margin-top: 10px;
}
button:hover { background: #e65c00; transform: translateY(-1px); }
button:disabled { background: #ffcc99; cursor: not-allowed; transform: none; }
/* Autocomplete Dropdown */
.input-wrapper { position: relative; }
.suggestions {
position: absolute; top: 100%; left: 0; right: 0;
background: white; border: 1px solid #ccc; border-radius: 8px;
max-height: 150px; overflow-y: auto; display: none; z-index: 10;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
margin-top: 4px;
}
.suggestion-item { padding: 12px; cursor: pointer; font-size: 0.9em; border-bottom: 1px solid #eee; }
.suggestion-item:last-child { border-bottom: none; }
.suggestion-item:hover { background: #fff5e6; color: #ff6600; }
</style>
</head>
<body>
<!-- 3D Background Container -->
<div id="canvas-container"></div>
<div class="container">
<div class="form-content">
<h2>🔥 Tiny 🔥 Torch: Complete Your Profile</h2>
<p class="subtitle">Tell us a bit about yourself to get started</p>
<form id="setupForm">
<div class="form-group">
<label for="displayName">Display Name *</label>
<input type="text" id="displayName" required placeholder="e.g. Alex">
</div>
<div class="form-group">
<label for="fullName">Full Name <span class="optional">(Optional)</span></label>
<input type="text" id="fullName" placeholder="e.g. Alex Smith">
</div>
<div class="form-group">
<label for="institution">Institution / Company *</label>
<input type="text" id="institution" required placeholder="e.g. University of Science" value="Independent">
<div style="font-size: 0.75rem; color: #888; margin-top: 4px;">Defaults to "Independent" if you are not affiliated.</div>
</div>
<div class="form-group">
<label for="roleInput">Role *</label>
<select id="roleInput" style="width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 1rem; background: white;">
<option value="student" selected>Student</option>
<option value="educator">Educator</option>
<option value="industry">Industry</option>
</select>
</div>
<div class="form-group">
<label for="locationInput">Location (City) *</label>
<div class="input-wrapper">
<input type="text" id="locationInput" required placeholder="Start typing..." autocomplete="off">
<div id="suggestions" class="suggestions"></div>
</div>
</div>
<div class="form-group">
<label for="summary">Summary / Bio <span class="optional">(Optional)</span></label>
<textarea id="summary" placeholder="A brief summary about yourself and your interests in AI..."></textarea>
</div>
<div class="form-group">
<label for="websites">Websites <span class="optional">(Optional, comma-separated)</span></label>
<input type="text" id="websites" placeholder="https://github.com/..., https://linkedin.com/...">
</div>
<div class="form-group" style="display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="mailingList" checked style="width: auto;">
<label for="mailingList" style="margin: 0; font-weight: normal;">Subscribe to our mailing list</label>
</div>
<input type="hidden" id="latParams">
<input type="hidden" id="lonParams">
<button type="submit" id="saveBtn">Complete Setup</button>
</form>
</div>
<div class="candle-side">
<canvas id="candleCanvas" width="16" height="24"></canvas>
</div>
</div>
<!-- Sky Shader Vertex -->
<script id="sky-vs" type="x-shader/x-vertex">
varying vec2 vUv;
varying vec3 vWorldPosition;
void main() {
vUv = uv;
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
</script>
<!-- Sky Shader Fragment -->
<script id="sky-fs" type="x-shader/x-fragment">
uniform float uTime;
uniform vec3 uColor;
uniform float uCloudDramatic;
varying vec2 vUv;
varying vec3 vWorldPosition;
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x*34.0)+1.0)*x); }
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v){
const vec2 C = vec2(1.0/6.0, 1.0/3.0) ;
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy) );
vec3 x0 = v - i + dot(i, C.xxx) ;
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min( g.xyz, l.zxy );
vec3 i2 = max( g.xyz, l.zxy );
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod289(i);
vec4 p = permute( permute( permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0 ))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0 ))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0 ));
float n_ = 0.142857142857;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_ );
vec4 x = x_ *ns.x + ns.yyyy;
vec4 y = y_ *ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4( x.xy, y.xy );
vec4 b1 = vec4( x.zw, y.zw );
vec4 s0 = floor(b0)*2.0 + 1.0;
vec4 s1 = floor(b1)*2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy ;
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww ;
vec3 p0 = vec3(a0.xy,h.x);
vec3 p1 = vec3(a0.zw,h.y);
vec3 p2 = vec3(a1.xy,h.z);
vec3 p3 = vec3(a1.zw,h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
p0 *= norm.x;
p1 *= norm.y;
p2 *= norm.z;
p3 *= norm.w;
vec4 m = max(0.5 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
m = m * m;
return 105.0 * dot( m*m, vec4( dot(p0,x0), dot(p1,x1),
dot(p2,x2), dot(p3,x3) ) );
}
float fbm(vec3 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 5; i++) {
value += amplitude * snoise(p);
p *= 2.0;
amplitude *= 0.5;
}
return value;
}
void main() {
vec3 pos = normalize(vWorldPosition);
vec3 motion = vec3(uTime * 0.02, 0.0, 0.0);
vec3 warp = vec3(
fbm(pos * 2.0 + vec3(uTime * 0.05)),
fbm(pos * 2.0 + vec3(uTime * 0.05 + 10.0)),
fbm(pos * 2.0 + vec3(uTime * 0.05 + 20.0))
);
float n = fbm(pos * 3.0 + motion + warp * 0.5);
float cloudVal = (n + 1.0) * 0.5;
float dramFactor = (1.0 - uCloudDramatic) * 0.2;
cloudVal = smoothstep(0.45 - dramFactor, 0.65 + dramFactor, cloudVal);
vec3 baseColor = uColor;
float intensity = uCloudDramatic * uCloudDramatic;
vec3 cloudHighlight = baseColor + vec3(0.2, 0.2, 0.25) * intensity;
cloudHighlight = min(cloudHighlight, vec3(1.0));
vec3 finalColor = mix(baseColor, cloudHighlight, cloudVal);
gl_FragColor = vec4(finalColor, 1.0);
}
</script>
<script type="module">
import { SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY, SUPABASE_URL, NETLIFY_URL, getBasePath } from './modules/config.js';
import { initCandle } from './modules/candle.js';
import { supabase } from './modules/state.js';
import './modules/guard.js'; // Guard handles access control and completeness redirects
// Initialize Candle
initCandle('candleCanvas');
// --- THREE.JS BACKGROUND ---
const Noise = (function() {
var p = [151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,
190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,88,237,149,56,87,174,20,125,136,171,168,
68,175,74,165,71,134,139,48,27,166,77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,
102,143,54,65,25,63,161,1,216,80,73,209,76,132,187,208,89,18,169,200,196,135,130,116,188,159,86,164,100,109,198,173,
186,3,64,52,217,226,250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,
223,183,170,213,119,248,152,2,44,154,163,70,221,153,101,155,167,43,172,9,129,22,39,253,19,98,108,110,79,113,224,232,
178,185,112,104,218,246,97,228,251,34,242,193,238,210,144,12,191,179,162,241,81,51,145,235,249,14,239,107,49,192,
214,31,181,199,106,157,184,84,204,176,115,121,50,45,127,4,150,254,138,236,205,93,222,114,67,29,24,72,243,141,128,
195,78,66,215,61,156,180];
for (var i=0; i < 256 ; i++) p[256+i] = p[i];
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(t, a, b) { return a + t * (b - a); }
function grad(hash, x, y, z) {
var h = hash & 15;
var u = h<8 ? x : y, v = h<4 ? y : h==12||h==14 ? x : z;
return ((h&1) == 0 ? u : -u) + ((h&2) == 0 ? v : -v);
}
return {
perlin2: function(x, y) {
var X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
x -= Math.floor(x); y -= Math.floor(y);
var u = fade(x), v = fade(y);
var A = p[X]+Y, AA = p[A], AB = p[A+1], B = p[X+1]+Y, BA = p[B], BB = p[B+1];
return lerp(v, lerp(u, grad(p[AA], x, y, 0), grad(p[BA], x-1, y, 0)),
lerp(u, grad(p[AB], x, y-1, 0), grad(p[BB], x-1, y-1, 0)));
}
};
})();
// Decorative only: keep failures here from blocking the setup form below.
try {
const container = document.getElementById('canvas-container');
const scene = new THREE.Scene();
const initialBgColor = new THREE.Color(0xf0f4f8);
scene.fog = new THREE.Fog(initialBgColor, 20, 90);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 150);
camera.position.set(0, 6, 12);
camera.rotation.x = -Math.PI / 6;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
const skyGeo = new THREE.SphereGeometry(100, 64, 64);
const skyMat = new THREE.ShaderMaterial({
vertexShader: document.getElementById('sky-vs').textContent,
fragmentShader: document.getElementById('sky-fs').textContent,
uniforms: {
uTime: { value: 0 },
uColor: { value: new THREE.Color(0xf0f4f8) },
uCloudDramatic: { value: 1.0 }
},
side: THREE.BackSide,
fog: false
});
const skyMesh = new THREE.Mesh(skyGeo, skyMat);
scene.add(skyMesh);
function animate() {
requestAnimationFrame(animate);
const time = performance.now() * 0.001;
skyMesh.material.uniforms.uTime.value = time;
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
} catch (bgErr) {
console.warn('Profile setup: 3D background failed to initialize (non-critical):', bgErr);
}
// --- AUTH & PROFILE LOGIC ---
// supabase is imported from state.js above
// --- 1. AUTOCOMPLETE LOGIC ---
const locInput = document.getElementById('locationInput');
const box = document.getElementById('suggestions');
let timer;
locInput.addEventListener('input', (e) => {
clearTimeout(timer);
document.getElementById('latParams').value = "";
document.getElementById('lonParams').value = "";
if(e.target.value.length < 3) { box.style.display = 'none'; return; }
timer = setTimeout(async () => {
try {
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(e.target.value)}`);
const data = await res.json();
box.innerHTML = '';
if(data.length) box.style.display = 'block';
data.slice(0,5).forEach(place => {
const div = document.createElement('div');
div.className = 'suggestion-item';
div.textContent = place.display_name;
div.onclick = () => {
locInput.value = place.display_name;
document.getElementById('latParams').value = place.lat;
document.getElementById('lonParams').value = place.lon;
box.style.display = 'none';
};
box.appendChild(div);
});
} catch(e) { console.error(e); }
}, 300);
});
// --- 2. PRE-FILL LOGIC ---
async function loadUser(user) {
if (!user) return;
// 1. Try to grab name from Google/GitHub metadata
if(user.user_metadata) {
if(user.user_metadata.full_name) {
document.getElementById('fullName').value = user.user_metadata.full_name;
const parts = user.user_metadata.full_name.split(' ');
if(parts.length > 0 && !document.getElementById('displayName').value) {
document.getElementById('displayName').value = parts[0];
}
}
if(user.user_metadata.name && !document.getElementById('fullName').value) {
document.getElementById('fullName').value = user.user_metadata.name;
}
}
// 2. Load Profile Data from DB
const { data: profile } = await supabase.from('profiles').select('*').eq('id', user.id).single();
if(profile) populateForm(profile);
}
function populateForm(profile) {
if(profile.display_name) document.getElementById('displayName').value = profile.display_name;
if(profile.full_name) document.getElementById('fullName').value = profile.full_name;
if(profile.location) document.getElementById('locationInput').value = profile.location;
if(profile.role) document.getElementById('roleInput').value = profile.role;
let inst = "Independent";
if(profile.institution) {
const dbInst = Array.isArray(profile.institution) ? profile.institution[0] : profile.institution;
if(dbInst && dbInst.trim() !== "") inst = dbInst;
}
document.getElementById('institution').value = inst;
if(profile.bio || profile.summary) document.getElementById('summary').value = profile.bio || profile.summary;
if(profile.website || profile.websites) {
const sites = profile.website || profile.websites;
document.getElementById('websites').value = Array.isArray(sites) ? sites.join(', ') : sites;
}
if(profile.mailing_list !== undefined && profile.mailing_list !== null) {
document.getElementById('mailingList').checked = profile.mailing_list;
}
}
// Listen for auth state to trigger load
supabase.auth.onAuthStateChange((event, session) => {
if (session && session.user) {
loadUser(session.user);
}
});
// Initial load check
supabase.auth.getUser().then(({ data: { user } }) => {
if (user) loadUser(user);
});
// --- 3. SUBMIT LOGIC ---
// Geocode helper (used if user typed location without picking from dropdown)
async function geocodeLocation(location) {
if (!location) return null;
try {
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(location)}&limit=1`);
if (!res.ok) return null;
const data = await res.json();
if (data && data.length > 0) {
return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) };
}
} catch(e) { console.warn('Geocoding failed:', e); }
return null;
}
document.getElementById('setupForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('saveBtn');
btn.textContent = "Saving Profile...";
btn.disabled = true;
let token = localStorage.getItem("tinytorch_token");
const refreshToken = localStorage.getItem("tinytorch_refresh_token");
if (!token) {
alert("Session lost. Please log in again.");
window.location.href = getBasePath() + '/index.html?action=login';
return;
}
const instVal = document.getElementById('institution').value.trim();
const institutions = instVal ? [instVal] : ["Independent"];
const webVal = document.getElementById('websites').value;
const websites = webVal.split(',').map(s => s.trim()).filter(s => s);
// Get location coordinates (from autocomplete or geocode now)
let lat = document.getElementById('latParams').value;
let lon = document.getElementById('lonParams').value;
if (!lat || !lon) {
// User typed location without selecting from dropdown - geocode now
const coords = await geocodeLocation(locInput.value);
if (coords) {
lat = coords.lat;
lon = coords.lon;
}
}
let retryCount = 0;
const MAX_RETRIES = 1;
let lastError = null;
do {
try {
const res = await fetch(`${SUPABASE_URL}/update-profile`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
display_name: document.getElementById('displayName').value,
full_name: document.getElementById('fullName').value,
institution: institutions,
role: document.getElementById('roleInput').value,
location: locInput.value,
bio: document.getElementById('summary').value,
website: websites,
mailing_list: document.getElementById('mailingList').checked,
latitude: lat ? parseFloat(lat) : null,
longitude: lon ? parseFloat(lon) : null
})
});
// Check if token needs refresh (401 or "Invalid Token" on 400)
let needsRefresh = res.status === 401;
if (res.status === 400) {
try {
const errData = await res.clone().json();
if (errData?.error?.includes("Invalid Token")) needsRefresh = true;
} catch(e) {}
}
if (needsRefresh && retryCount === 0 && refreshToken) {
console.log("Token expired, attempting refresh...");
const refreshRes = await fetch(`${NETLIFY_URL}/api/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken })
});
if (!refreshRes.ok) throw new Error("Session expired. Please log in again.");
const refreshData = await refreshRes.json();
const session = refreshData.session || refreshData;
if (session?.access_token) {
token = session.access_token;
localStorage.setItem("tinytorch_token", token);
if (session.refresh_token) localStorage.setItem("tinytorch_refresh_token", session.refresh_token);
retryCount++;
continue; // Retry with new token
} else {
throw new Error("Session expired. Please log in again.");
}
}
if (!res.ok) {
let errMsg = "Save failed";
try {
const errData = await res.json();
errMsg = errData.error || errData.message || `Error ${res.status}`;
} catch(e) {}
throw new Error(errMsg);
}
// Success! Redirect to dashboard
window.location.href = getBasePath() + '/dashboard.html';
return;
} catch(err) {
console.error("Profile update error:", err);
lastError = err;
if (retryCount >= MAX_RETRIES) break;
}
} while (retryCount < MAX_RETRIES);
// If we get here, all retries failed
alert("Error saving profile: " + (lastError?.message || "Please try again."));
btn.textContent = "Complete Setup";
btn.disabled = false;
});
document.addEventListener('click', (e) => {
if(e.target !== locInput) box.style.display = 'none';
});
</script>
</body>
</html>