stuff changed:

- ui has been made 'kinda better' (after making it worse for a while lol
- ESP rfid readers are now supported [ill upload the code for them in another repo later]
- admin system has been secured a bit better and seems to be working well
This commit is contained in:
2026-05-08 20:46:58 +01:00
parent 1a0b4dc25d
commit d024bf7fa3
32 changed files with 7480 additions and 2740 deletions
@@ -14,22 +14,16 @@ interface ProfileQuestionsFormProps {
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
saveLabel?: string;
allowAdminManagedEdit?: boolean;
surface?: 'member' | 'admin';
}
const formatAnswerForDisplay = (question: ProfileQuestionForUser, value: ProfileQuestionAnswerValue): string => {
if (value === null || value === undefined || value === '') {
return 'Not set';
}
if (question.input_type === 'boolean') {
return value === true || value === 'true' ? 'Yes' : 'No';
}
if (value === null || value === undefined || value === '') return 'Not set';
if (question.input_type === 'boolean') return value === true || value === 'true' ? 'Yes' : 'No';
if (question.input_type === 'select') {
const matchingOption = question.options.find((option) => option.value === String(value));
return matchingOption?.label || String(value);
}
return String(value);
};
@@ -39,7 +33,8 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
questions,
onSave,
saveLabel = 'Save Answers',
allowAdminManagedEdit = false
allowAdminManagedEdit = false,
surface = 'admin'
}) => {
const initialAnswers = useMemo(() => {
const values: Record<number, ProfileQuestionAnswerValue> = {};
@@ -55,7 +50,6 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const pageSize = 10;
useEffect(() => {
@@ -67,39 +61,31 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
const visibleQuestions = useMemo(() => {
const byId = new Map<number, ProfileQuestionForUser>();
questions.forEach((question) => byId.set(question.id, question));
return questions.filter((question) => isProfileQuestionVisible(question, byId, answers));
}, [questions, answers]);
const filteredQuestions = useMemo(() => {
const searchTerm = search.trim().toLowerCase();
return visibleQuestions
.filter((question) => {
if (!searchTerm) {
return true;
}
return (
question.label.toLowerCase().includes(searchTerm) ||
question.key.toLowerCase().includes(searchTerm) ||
(question.help_text || '').toLowerCase().includes(searchTerm)
);
});
return visibleQuestions.filter((question) => {
if (!searchTerm) return true;
return (
question.label.toLowerCase().includes(searchTerm) ||
question.key.toLowerCase().includes(searchTerm) ||
(question.help_text || '').toLowerCase().includes(searchTerm)
);
});
}, [visibleQuestions, search]);
const paginatedQuestions = useMemo(() => {
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
return filteredQuestions.slice(start, start + pageSize);
}, [filteredQuestions, page]);
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
const paginatedQuestions = useMemo(() => {
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
return filteredQuestions.slice(start, start + pageSize);
}, [filteredQuestions, page, totalPages]);
const setAnswerValue = (questionId: number, value: ProfileQuestionAnswerValue) => {
setAnswers((prev) => ({
...prev,
[questionId]: value
}));
setAnswers((prev) => ({ ...prev, [questionId]: value }));
setSuccessMessage(null);
setError(null);
};
@@ -117,11 +103,7 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
const visibleQuestionIds = new Set(visibleQuestions.map((question) => question.id));
const changedAnswers: ProfileAnswerInput[] = questions
.filter((question) => canEditProfileQuestion(question, allowAdminManagedEdit) && visibleQuestionIds.has(question.id))
.filter((question) => {
const current = answerToComparable(answers[question.id] ?? null);
const initial = answerToComparable(initialAnswers[question.id] ?? null);
return current !== initial;
})
.filter((question) => answerToComparable(answers[question.id] ?? null) !== answerToComparable(initialAnswers[question.id] ?? null))
.map((question) => ({
question_id: question.id,
value: answers[question.id] ?? null
@@ -141,27 +123,16 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
const disabled = !canEditProfileQuestion(question, allowAdminManagedEdit) || saving;
if (disabled && !saving) {
return (
<div className="profile-question-readonly">
{formatAnswerForDisplay(question, value)}
</div>
);
return <div className="profile-question-readonly">{formatAnswerForDisplay(question, value)}</div>;
}
if (question.input_type === 'boolean') {
return (
<select
value={value === null ? '' : String(value)}
onChange={(event) => {
const nextValue = event.target.value;
if (nextValue === '') {
setAnswerValue(question.id, null);
} else {
setAnswerValue(question.id, nextValue === 'true');
}
}}
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : event.target.value === 'true')}
disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
className="profile-question-input"
>
<option value="">Prefer not to say</option>
<option value="true">Yes</option>
@@ -176,13 +147,11 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
className="profile-question-input"
>
<option value="">Select an option</option>
{question.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
);
@@ -195,7 +164,7 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
className="profile-question-input"
/>
);
}
@@ -208,7 +177,7 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : Number(event.target.value))}
disabled={disabled}
placeholder={question.placeholder || ''}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
className="profile-question-input"
/>
);
}
@@ -220,60 +189,51 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
placeholder={question.placeholder || ''}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
className="profile-question-input"
/>
);
};
return (
<div className="card" style={{ marginTop: '20px' }}>
<h3 style={{ marginBottom: '8px' }}>{title}</h3>
{description && <p style={{ color: '#555', marginBottom: '16px' }}>{description}</p>}
<div className={`card profile-questions-form ${surface === 'member' ? 'member-surface' : 'admin-surface'}`}>
<h3 className="profile-questions-title">{title}</h3>
{description && <p className="profile-questions-description">{description}</p>}
<div style={{ display: 'grid', gap: '10px', marginBottom: '14px' }}>
<div className="profile-questions-search">
<input
type="text"
placeholder="Search questions..."
value={search}
onChange={(event) => setSearch(event.target.value)}
style={{ width: '100%', padding: '9px 10px', borderRadius: '6px', border: '1px solid #d5d9e0' }}
className="profile-question-input"
/>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
{successMessage && (
<div className="alert alert-success">
{successMessage}
</div>
)}
{error && <div className="alert alert-error">{error}</div>}
{successMessage && <div className="alert alert-success">{successMessage}</div>}
{filteredQuestions.length === 0 ? (
<p style={{ color: '#666' }}>No questions available.</p>
<p className="profile-questions-empty">No questions available.</p>
) : (
<div style={{ display: 'grid', gap: '16px' }}>
<div className="profile-questions-list">
{paginatedQuestions.map((question) => (
<div key={question.id} className="profile-question-row">
<div
key={question.id}
className={`profile-question-row ${surface === 'member' ? 'profile-question-row-member' : 'profile-question-row-admin'}`}
>
<div className="profile-question-meta">
<label style={{ display: 'block', fontWeight: 600, marginBottom: '4px' }}>
<label className="profile-question-label">
{question.label}
{question.is_required && <span style={{ color: '#dc3545' }}> *</span>}
{question.admin_only_edit && (
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
Admin Managed
</span>
)}
{question.is_required && <span className="profile-question-required"> *</span>}
{question.admin_only_edit && <span className="admin-inline-badge">Admin Managed</span>}
</label>
{question.help_text && (
<p style={{ marginBottom: '0', color: '#666', fontSize: '13px' }}>{question.help_text}</p>
<p className="profile-question-help">{question.help_text}</p>
)}
</div>
<div className="profile-question-answer">{renderField(question)}</div>
{!canEditProfileQuestion(question, allowAdminManagedEdit) && (
<p style={{ marginTop: '6px', color: '#5b6472', fontSize: '12px', fontWeight: 600, gridColumn: '1 / -1' }}>
<p className="profile-question-lock-note">
This field can only be changed by an admin.
</p>
)}
@@ -283,22 +243,22 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
)}
{filteredQuestions.length > pageSize && (
<div style={{ marginTop: '14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }}>
<span style={{ fontSize: '13px', color: '#525a66' }}>
<div className="profile-questions-pagination">
<span className="profile-questions-page-copy">
Page {Math.min(page, totalPages)} of {totalPages} ({filteredQuestions.length} questions)
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
<div className="profile-questions-pager-buttons">
<button className="btn btn-secondary profile-questions-pager-button" disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
Previous
</button>
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
<button className="btn btn-secondary profile-questions-pager-button" disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
Next
</button>
</div>
</div>
)}
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end' }}>
<div className="profile-questions-actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : saveLabel}
</button>