Merge branch 'master' of github.com:Protospace/spaceport into webgl-footer

This commit is contained in:
Elijah Lucian
2021-03-17 10:35:12 -06:00
58 changed files with 1650 additions and 214 deletions

View File

@@ -21,7 +21,8 @@
"react-to-print": "~2.5.1",
"recharts": "~1.8.5",
"semantic-ui-react": "~0.88.2",
"three": "^0.119.1"
"three": "^0.119.1",
"serialize-javascript": "^3.1.0"
},
"scripts": {
"start": "react-scripts start",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -123,7 +123,7 @@ export function AdminMemberCards(props) {
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [viewCard, setViewCard] = useState(false);
const [cardPhoto, setCardPhoto] = useState(false);
const { id } = useParams();
useEffect(() => {
@@ -155,6 +155,18 @@ export function AdminMemberCards(props) {
});
};
const getCardPhoto = (e) => {
e.preventDefault();
requester('/members/' + id + '/card_photo/', 'GET', token)
.then(res => res.blob())
.then(res => {
setCardPhoto(URL.createObjectURL(res));
})
.catch(err => {
console.log(err);
});
};
const makeProps = (name) => ({
name: name,
onChange: handleChange,
@@ -176,17 +188,17 @@ export function AdminMemberCards(props) {
<Form onSubmit={handleSubmit}>
<Header size='small'>Add a Card</Header>
{result.member.card_photo ?
{result.member.photo_large ?
<p>
<Button onClick={() => setViewCard(true)}>View card image</Button>
<Button onClick={(e) => getCardPhoto(e)}>View card image</Button>
</p>
:
<p>No card image, member photo missing!</p>
}
{viewCard && <>
{cardPhoto && <>
<p>
<Image rounded size='medium' src={staticUrl + '/' + result.member.card_photo} />
<Image rounded size='medium' src={cardPhoto} />
</p>
<Header size='small'>How to Print a Card</Header>
@@ -221,7 +233,15 @@ export function AdminMemberCards(props) {
/>
</Form.Group>
<Form.Button loading={loading} error={error.non_field_errors}>
<Form.Checkbox
label='Confirmed that the member has been given a tour and knows the alarm code'
required
{...makeProps('given_tour')}
onChange={handleCheck}
checked={input.given_tour}
/>
<Form.Button disabled={!input.given_tour} loading={loading} error={error.non_field_errors}>
Submit
</Form.Button>
{success && <div>Success!</div>}
@@ -498,6 +518,12 @@ export function AdminMemberInfo(props) {
<Table.Cell>Emergency Contact Phone:</Table.Cell>
<Table.Cell>{member.emergency_contact_phone || 'None'}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>On Spaceport:</Table.Cell>
<Table.Cell>{member.user ? 'Yes' : 'No'}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Public Bio:</Table.Cell>
</Table.Row>
@@ -541,6 +567,7 @@ export function AdminCert(props) {
const handleCert = (e) => {
e.preventDefault();
if (loading) return;
setLoading(true);
let data = Object();
data[field] = moment.utc().tz('America/Edmonton').format('YYYY-MM-DD');
@@ -555,6 +582,7 @@ export function AdminCert(props) {
const handleUncert = (e) => {
e.preventDefault();
if (loading) return;
setLoading(true);
let data = Object();
data[field] = null;
@@ -646,6 +674,18 @@ export function AdminMemberCertifications(props) {
<Table.Cell><Link to='/courses/259'>Tormach: CAM and Tormach Intro</Link></Table.Cell>
<Table.Cell><AdminCert name='CNC' field='cnc_cert_date' {...props} /></Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Rabbit Laser</Table.Cell>
<Table.Cell>{member.rabbit_cert_date ? 'Yes, ' + member.rabbit_cert_date : 'No'}</Table.Cell>
<Table.Cell><Link to='/courses/247'>Laser: Cutting and Engraving</Link></Table.Cell>
<Table.Cell><AdminCert name='Rabbit' field='rabbit_cert_date' {...props} /></Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Trotec Laser</Table.Cell>
<Table.Cell>{member.trotec_cert_date ? 'Yes, ' + member.trotec_cert_date : 'No'}</Table.Cell>
<Table.Cell><Link to='/courses/321'>Laser: Trotec Course</Link></Table.Cell>
<Table.Cell><AdminCert name='Trotec' field='trotec_cert_date' {...props} /></Table.Cell>
</Table.Row>
</Table.Body>
</Table>

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useReducer, useContext } from 'react';
import { BrowserRouter as Router, Switch, Route, Link, useParams, useHistory } from 'react-router-dom';
import './semantic-ui/semantic.min.css';
import './light.css';
import './dark.css';
import { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Segment, Table } from 'semantic-ui-react';
import Darkmode from 'darkmode-js';
import { isAdmin, requester } from './utils.js';
@@ -19,6 +20,7 @@ import { Courses, CourseDetail } from './Courses.js';
import { Classes, ClassDetail } from './Classes.js';
import { Members, MemberDetail } from './Members.js';
import { Charts } from './Charts.js';
import { Auth } from './Auth.js';
import { PasswordReset, ConfirmReset } from './PasswordReset.js';
import { NotFound, PleaseLogin } from './Misc.js';
import { Footer } from './Footer.js';
@@ -107,6 +109,10 @@ function App() {
<img src='/logo-long.svg' className='logo-long' />
</Link>
</div>
{window.location.hostname !== 'my.protospace.ca' &&
<p style={{ background: 'yellow' }}>~~~~~ Development site ~~~~~</p>
}
</Container>
<Menu>
@@ -216,6 +222,10 @@ function App() {
<Charts />
</Route>
<Route path='/auth'>
<Auth user={user} />
</Route>
{user && user.member.set_details ?
<Switch>
<Route path='/account'>

132
webclient/src/Auth.js Normal file
View File

@@ -0,0 +1,132 @@
import React, { useState, useEffect, useReducer } from 'react';
import { BrowserRouter as Router, Switch, Route, Link, useParams, useLocation } from 'react-router-dom';
import moment from 'moment-timezone';
import './light.css';
import { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Popup, Segment, Table } from 'semantic-ui-react';
import { statusColor, BasicTable, staticUrl, requester, isAdmin } from './utils.js';
export function AuthForm(props) {
const { user } = props;
const username = user ? user.username : '';
const [input, setInput] = useState({ username: username });
const [error, setError] = useState({});
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const handleValues = (e, v) => setInput({ ...input, [v.name]: v.value });
const handleChange = (e) => handleValues(e, e.currentTarget);
const handleSubmit = (e) => {
if (input.username.includes('@')) {
setError({ username: 'Username, not email.' });
} else {
if (loading) return;
setLoading(true);
const data = { ...input, username: input.username.toLowerCase() };
requester('/spaceport-auth/login/', 'POST', '', data)
.then(res => {
setSuccess(true);
setError({});
})
.catch(err => {
setLoading(false);
console.log(err);
setError(err.data);
});
}
};
return (
success ?
props.children
:
<Form
onSubmit={handleSubmit}
warning={error.non_field_errors && error.non_field_errors[0] === 'Unable to log in with provided credentials.'}
>
<Header size='medium'>Log In to Spaceport</Header>
{user ?
<><Form.Input
label='Spaceport Username'
name='username'
value={user.username}
onChange={handleChange}
error={error.username}
/>
<Form.Input
label='Spaceport Password'
name='password'
type='password'
onChange={handleChange}
error={error.password}
autoFocus
/></>
:
<><Form.Input
label='Spaceport Username'
name='username'
placeholder='first.last'
onChange={handleChange}
error={error.username}
autoFocus
/>
<Form.Input
label='Spaceport Password'
name='password'
type='password'
onChange={handleChange}
error={error.password}
/></>
}
<Form.Button loading={loading} error={error.non_field_errors}>
Authorize
</Form.Button>
<Message warning>
<Message.Header>Forgot your password?</Message.Header>
<p><Link to='/password/reset/'>Click here</Link> to reset it.</p>
</Message>
</Form>
);
};
export function AuthWiki(props) {
const { user } = props;
return (
<Segment compact padded>
<Header size='medium'>
<Image src={'/wikilogo.png'} />
Protospace Wiki
</Header>
<p>would like to request Spaceport authentication.</p>
<p>URL: <a href='http://wiki.protospace.ca/Welcome_to_Protospace' target='_blank' rel='noopener noreferrer'>wiki.protospace.ca</a></p>
<AuthForm user={user}>
<Header size='small'>Success!</Header>
<p>You can now log into the wiki:</p>
<p><a href='http://wiki.protospace.ca/index.php?title=Special:UserLogin&returnto=Welcome+to+Protospace' rel='noopener noreferrer'>Protospace Wiki</a></p>
</AuthForm>
</Segment>
);
}
export function Auth(props) {
const { user } = props;
return (
<Container>
<Header size='large'>Spaceport Auth</Header>
<p>Use this page to link different applications to your Spaceport account.</p>
<Route path='/auth/wiki'>
<AuthWiki user={user} />
</Route>
</Container>
);
}

View File

@@ -13,6 +13,7 @@ export function Charts(props) {
const [memberCount, setMemberCount] = useState(memberCountCache);
const [signupCount, setSignupCount] = useState(signupCountCache);
const [spaceActivity, setSpaceActivity] = useState(spaceActivityCache);
const [fullActivity, setFullActivity] = useState(false);
useEffect(() => {
requester('/charts/membercount/', 'GET')
@@ -47,6 +48,33 @@ export function Charts(props) {
<Container>
<Header size='large'>Charts</Header>
<Header size='medium'>Summary</Header>
{memberCount && signupCount &&
<>
<p>
The total member count is {memberCount.slice().reverse()[0].member_count} members,
compared to {memberCount.slice().reverse()[30].member_count} members 30 days ago.
</p>
<p>
The green member count is {memberCount.slice().reverse()[0].green_count} members,
compared to {memberCount.slice().reverse()[30].green_count} members 30 days ago.
</p>
<p>
The older than six months member count is {memberCount.slice().reverse()[0].six_month_plus_count} members,
compared to {memberCount.slice().reverse()[30].six_month_plus_count} members 30 days ago.
</p>
<p>
The vetted member count is {memberCount.slice().reverse()[0].vetted_count} members,
compared to {memberCount.slice().reverse()[30].vetted_count} members 30 days ago.
</p>
<p>
There were {signupCount.slice().reverse()[0].signup_count} signups so far this month,
and {signupCount.slice().reverse()[1].signup_count} signups last month.
</p>
</>
}
<Header size='medium'>Member Counts</Header>
<p>Daily since March 2nd, 2020.</p>
@@ -87,18 +115,99 @@ export function Charts(props) {
}
</p>
<p>The Member Count is the amount of Prepaid, Current, Due, and Overdue members on Spaceport.</p>
<p>Member Count: number of active paying members on Spaceport.</p>
<p>The Green Count is the amount of Prepaid and Current members.</p>
<p>Green Count: number of Prepaid and Current members.</p>
<p>
{memberCount &&
<ResponsiveContainer width='100%' height={300}>
<LineChart data={memberCount}>
<XAxis dataKey='date' minTickGap={10} />
<YAxis />
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip />
<Legend />
<Line
type='monotone'
dataKey='member_count'
name='Member Count'
stroke='#8884d8'
strokeWidth={2}
dot={false}
animationDuration={1000}
/>
<Line
type='monotone'
dataKey='six_month_plus_count'
name='Six Months+'
stroke='red'
strokeWidth={2}
dot={false}
animationDuration={1500}
/>
</LineChart>
</ResponsiveContainer>
}
</p>
<p>Member Count: same as above.</p>
<p>Six Months+: number of active memberships older than six months.</p>
<p>
{memberCount &&
<ResponsiveContainer width='100%' height={300}>
<LineChart data={memberCount}>
<XAxis dataKey='date' minTickGap={10} />
<YAxis />
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip />
<Legend />
<Line
type='monotone'
dataKey='member_count'
name='Member Count'
stroke='#8884d8'
strokeWidth={2}
dot={false}
animationDuration={1000}
/>
<Line
type='monotone'
dataKey='vetted_count'
name='Vetted Count'
stroke='purple'
strokeWidth={2}
dot={false}
animationDuration={1500}
/>
</LineChart>
</ResponsiveContainer>
}
</p>
<p>Member Count: same as above.</p>
<p>Vetted Count: number of active vetted members.</p>
<Header size='medium'>Space Activity</Header>
<p>Daily since March 7th, 2020, updates hourly.</p>
{fullActivity ?
<p>Daily since March 7th, 2020, updates hourly.</p>
:
<p>
Last four weeks, updates hourly.
{' '}<Button size='tiny' onClick={() => setFullActivity(true)} >View All</Button>
</p>
}
<p>
{spaceActivity &&
<ResponsiveContainer width='100%' height={300}>
<BarChart data={spaceActivity}>
<BarChart data={fullActivity ? spaceActivity : spaceActivity.slice(-28)}>
<XAxis dataKey='date' minTickGap={10} />
<YAxis />
<CartesianGrid strokeDasharray='3 3'/>
@@ -111,14 +220,14 @@ export function Charts(props) {
name='Card Scans'
fill='#8884d8'
maxBarSize={20}
animationDuration={1000}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
}
</p>
<p>Cards Scans is the number of individual members who have scanned to enter the space.</p>
<p>Cards Scans: number of individual members who have scanned to enter the space.</p>
<Header size='medium'>Signup Count</Header>
@@ -146,14 +255,14 @@ export function Charts(props) {
type='monotone'
dataKey='vetted_count'
fill='#80b3d3'
name='Vetted Count'
name='Later Vetted Count'
maxBarSize={20}
animationDuration={1200}
/>
<Bar
type='monotone'
dataKey='retain_count'
name='Retain Count'
name='Retained Count'
fill='#82ca9d'
maxBarSize={20}
animationDuration={1400}
@@ -163,11 +272,11 @@ export function Charts(props) {
}
</p>
<p>The Signup Count is the number of brand new account registrations that month.</p>
<p>Signup Count: number of brand new account registrations that month.</p>
<p>The Vetted Count is the number of those signups who eventually got vetted (at a later date).</p>
<p>Later Vetted Count: number of those signups who eventually got vetted (at a later date).</p>
<p>The Retain Count is the number of those signups who are still a member currently.</p>
<p>Retained Count: number of those signups who are still a member currently.</p>
</Container>
);

View File

@@ -72,13 +72,19 @@ export function Classes(props) {
<Header size='large'>Class List</Header>
<Header size='medium'>Upcoming</Header>
<p>Ordered by nearest date.</p>
{classes ?
<ClassTable classes={classes.filter(x => x.datetime > now)} />
<ClassTable classes={classes.filter(x => x.datetime > now).sort((a, b) => a.datetime > b.datetime ? 1 : -1)} />
:
<p>Loading...</p>
}
<Header size='medium'>Recent</Header>
<p>Ordered by nearest date.</p>
{classes ?
<ClassTable classes={classes.filter(x => x.datetime < now)} />
:
@@ -92,6 +98,7 @@ export function ClassDetail(props) {
const [clazz, setClass] = useState(false);
const [refreshCount, refreshClass] = useReducer(x => x + 1, 0);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const { token, user, refreshUser } = props;
const { id } = useParams();
const userTraining = clazz && clazz.students.find(x => x.user == user.id);
@@ -108,6 +115,8 @@ export function ClassDetail(props) {
}, [refreshCount]);
const handleSignup = () => {
if (loading) return;
setLoading(true);
const data = { attendance_status: 'Waiting for payment', session: id };
requester('/training/', 'POST', token, data)
.then(res => {
@@ -120,6 +129,8 @@ export function ClassDetail(props) {
};
const handleToggle = (newStatus) => {
if (loading) return;
setLoading(true);
const data = { attendance_status: newStatus, session: id };
requester('/training/'+userTraining.id+'/', 'PUT', token, data)
.then(res => {
@@ -132,6 +143,10 @@ export function ClassDetail(props) {
});
};
useEffect(() => {
setLoading(false);
}, [userTraining]);
// TODO: calculate yesterday and lock signups
return (
@@ -198,11 +213,11 @@ export function ClassDetail(props) {
<p>Status: {userTraining.attendance_status}</p>
<p>
{userTraining.attendance_status === 'Withdrawn' ?
<Button onClick={() => handleToggle('Waiting for payment')}>
<Button loading={loading} onClick={() => handleToggle('Waiting for payment')}>
Sign back up
</Button>
:
<Button onClick={() => handleToggle('Withdrawn')}>
<Button loading={loading} onClick={() => handleToggle('Withdrawn')}>
Withdraw from Class
</Button>
}
@@ -226,7 +241,7 @@ export function ClassDetail(props) {
((clazz.max_students && clazz.student_count >= clazz.max_students) ?
<p>The class is full.</p>
:
<Button onClick={handleSignup}>
<Button loading={loading} onClick={handleSignup}>
Sign me up!
</Button>
)

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useReducer } from 'react';
import { BrowserRouter as Router, Switch, Route, Link, useParams } from 'react-router-dom';
import { BrowserRouter as Router, Switch, Route, Link, useParams, useLocation } from 'react-router-dom';
import moment from 'moment-timezone';
import './light.css';
import { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Popup, Segment, Table } from 'semantic-ui-react';
@@ -128,12 +128,15 @@ function MemberInfo(props) {
};
export function Home(props) {
const { user } = props;
const { user, token } = props;
const [stats, setStats] = useState(JSON.parse(localStorage.getItem('stats', 'false')));
const [refreshCount, refreshStats] = useReducer(x => x + 1, 0);
const location = useLocation();
const bypass_code = location.hash.replace('#', '');
useEffect(() => {
requester('/stats/', 'GET')
requester('/stats/', 'GET', token)
.then(res => {
setStats(res);
localStorage.setItem('stats', JSON.stringify(res));
@@ -142,17 +145,21 @@ export function Home(props) {
console.log(err);
setStats(false);
});
}, [refreshCount]);
}, [refreshCount, token]);
const getStat = (x) => stats && stats[x] ? stats[x] : '?';
const getZeroStat = (x) => stats && stats[x] ? stats[x] : '0';
const getDateStat = (x) => stats && stats[x] ? moment.utc(stats[x]).tz('America/Edmonton').format('ll') : '?';
const mcPlayers = stats && stats['minecraft_players'] ? stats['minecraft_players'] : [];
const mumbleUsers = stats && stats['mumble_users'] ? stats['mumble_users'] : [];
const getTrackStat = (x) => stats && stats.track && stats.track[x] ? moment().unix() - stats.track[x] > 60 ? 'Free' : 'In Use' : '?';
const getTrackLast = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]).tz('America/Edmonton').format('llll') : 'Unknown';
const getTrackAgo = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]).tz('America/Edmonton').fromNow() : '';
const getTrackStat = (x) => stats && stats.track && stats.track[x] ? moment().unix() - stats.track[x]['time'] > 60 ? 'Free' : 'In Use' : '?';
const getTrackLast = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]['time']).tz('America/Edmonton').format('llll') : 'Unknown';
const getTrackAgo = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]['time']).tz('America/Edmonton').fromNow() : '';
const getTrackName = (x) => stats && stats.track && stats.track[x] && stats.track[x]['username'] ? stats.track[x]['username'] : 'Unknown';
const alarmStat = () => stats && stats.alarm && moment().unix() - stats.alarm['time'] < 300 ? stats.alarm['data'] > 200 ? 'Armed' : 'Disarmed' : 'Unknown';
return (
<Container>
@@ -172,9 +179,18 @@ export function Home(props) {
</div>
:
<div>
<LoginForm {...props} />
{bypass_code ?
<Message warning>
<Message.Header>Outside Registration</Message.Header>
<p>This page allows you to sign up from outside of Protospace.</p>
</Message>
:
<>
<LoginForm {...props} />
<Divider section horizontal>Or</Divider>
<Divider section horizontal>Or</Divider>
</>
}
<SignupForm {...props} />
</div>
@@ -201,11 +217,10 @@ export function Home(props) {
<p>Next monthly clean: {getDateStat('next_clean')}</p>
<p>Member count: {getStat('member_count')} <Link to='/charts'>[more]</Link></p>
<p>Green members: {getStat('green_count')}</p>
<p>Old members: {getStat('paused_count')}</p>
<p>Card scans today: {getZeroStat('card_scans')}</p>
<p>
Minecraft players: {mcPlayers.length} <Popup content={
Minecraft players: {mcPlayers.length} {mcPlayers.length > 5 && '🔥'} <Popup content={
<React.Fragment>
<p>
Server IP:<br />
@@ -217,6 +232,22 @@ export function Home(props) {
</p>
</React.Fragment>
} trigger={<a>[more]</a>} />
{' '}<a href='http://games.protospace.ca:8123/?worldname=world&mapname=flat&zoom=3&x=74&y=64&z=354' target='_blank'>[map]</a>
</p>
<p>
Mumble users: {mumbleUsers.length} <Popup content={
<React.Fragment>
<p>
Server IP:<br />
mumble.protospace.ca
</p>
<p>
Users:<br />
{mumbleUsers.length ? mumbleUsers.map(x => <React.Fragment>{x}<br /></React.Fragment>) : 'None'}
</p>
</React.Fragment>
} trigger={<a>[more]</a>} />
</p>
<p>
@@ -225,7 +256,8 @@ export function Home(props) {
<p>
Last use:<br />
{getTrackLast('TROTECS300')}<br />
{getTrackAgo('TROTECS300')}
{getTrackAgo('TROTECS300')}<br />
by {getTrackName('TROTECS300')}
</p>
</React.Fragment>
} trigger={<a>[more]</a>} />
@@ -237,11 +269,14 @@ export function Home(props) {
<p>
Last use:<br />
{getTrackLast('FRICKIN-LASER')}<br />
{getTrackAgo('FRICKIN-LASER')}
{getTrackAgo('FRICKIN-LASER')}<br />
by {getTrackName('FRICKIN-LASER')}
</p>
</React.Fragment>
} trigger={<a>[more]</a>} />
</p>
{user && user.member.vetted_date && <p>Alarm status: {alarmStat()}</p>}
</div>
</Segment>

View File

@@ -68,8 +68,12 @@ class AttendanceSheet extends React.Component {
function AttendanceRow(props) {
const { student, token, refreshClass } = props;
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const handleMark = (newStatus) => {
if (loading) return;
if (student.attendance_status == newStatus) return;
setLoading(newStatus);
const data = { ...student, attendance_status: newStatus };
requester('/training/'+student.id+'/', 'PATCH', token, data)
.then(res => {
@@ -86,11 +90,19 @@ function AttendanceRow(props) {
onClick: () => handleMark(name),
toggle: true,
active: student.attendance_status === name,
loading: loading === name,
});
useEffect(() => {
setLoading(false);
}, [student.attendance_status]);
return (
<div className='attendance-row'>
<p>{student.student_name}:</p>
<p>
<Link to={'/members/'+student.student_id}>{student.student_name}</Link>
{student.attendance_status === 'Waiting for payment' && ' (Waiting for payment)'}:
</p>
<Button {...makeProps('Withdrawn')}>
Withdrawn
@@ -118,9 +130,11 @@ function AttendanceRow(props) {
);
}
let attendanceOpenCache = false;
export function InstructorClassAttendance(props) {
const { clazz, token, refreshClass, user } = props;
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(attendanceOpenCache);
const [input, setInput] = useState({});
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
@@ -201,7 +215,7 @@ export function InstructorClassAttendance(props) {
</Form>
</div>
:
<Button onClick={() => setOpen(true)}>
<Button onClick={() => {setOpen(true); attendanceOpenCache = true;}}>
Edit Attendance
</Button>
}
@@ -321,7 +335,7 @@ export function InstructorClassDetail(props) {
export function InstructorClassList(props) {
const { course, setCourse, token } = props;
const [open, setOpen] = useState(false);
const [input, setInput] = useState({});
const [input, setInput] = useState({ max_students: null });
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);

View File

@@ -110,7 +110,7 @@ export function SignupForm(props) {
return (
<Form onSubmit={handleSubmit}>
<Header size='medium'>Sign Up from Protospace</Header>
<Header size='medium'>Sign Up to Spaceport</Header>
<Form.Group widths='equal'>
<Form.Input

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useReducer } from 'react';
import { BrowserRouter as Router, Switch, Route, Link, useParams } from 'react-router-dom';
import './light.css';
import { Button, Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Input, Item, Menu, Message, Segment, Table } from 'semantic-ui-react';
import { statusColor, isAdmin, BasicTable, staticUrl, requester } from './utils.js';
import { statusColor, isAdmin, isInstructor, BasicTable, staticUrl, requester } from './utils.js';
import { NotFound, PleaseLogin } from './Misc.js';
import { AdminMemberInfo, AdminMemberPause, AdminMemberForm, AdminMemberCards, AdminMemberTraining, AdminMemberCertifications } from './AdminMembers.js';
import { AdminMemberTransactions } from './AdminTransactions.js';
@@ -107,7 +107,7 @@ export function Members(props) {
{x.member.preferred_name} {x.member.last_name}
</Item.Header>
<Item.Description>Status: {x.member.status || 'Unknown'}</Item.Description>
<Item.Description>Joined: {x.member.current_start_date || 'Unknown'}</Item.Description>
<Item.Description>Joined: {x.member.application_date || 'Unknown'}</Item.Description>
</Item.Content>
</Item>
)
@@ -154,7 +154,7 @@ export function MemberDetail(props) {
<Header size='large'>{member.preferred_name} {member.last_name}</Header>
<Grid stackable columns={2}>
<Grid.Column>
<Grid.Column width={isAdmin(user) ? 8 : 5}>
<p>
<Image rounded size='medium' src={member.photo_large ? staticUrl + '/' + member.photo_large : '/nophoto.png'} />
</p>
@@ -174,7 +174,7 @@ export function MemberDetail(props) {
</Table.Row>
<Table.Row>
<Table.Cell>Joined:</Table.Cell>
<Table.Cell>{member.current_start_date || 'Unknown'}</Table.Cell>
<Table.Cell>{member.application_date || 'Unknown'}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Public Bio:</Table.Cell>
@@ -189,7 +189,11 @@ export function MemberDetail(props) {
}
</Grid.Column>
<Grid.Column>
<Grid.Column width={isAdmin(user) ? 8 : 11}>
{isInstructor(user) && !isAdmin(user) && <Segment padded>
<AdminMemberTraining result={result} refreshResult={refreshResult} {...props} />
</Segment>}
{isAdmin(user) && <Segment padded>
<AdminMemberForm result={result} refreshResult={refreshResult} {...props} />
</Segment>}

View File

@@ -37,7 +37,7 @@ function ResetForm() {
});
return (
<Form onSubmit={handleSubmit}>
<Form onSubmit={handleSubmit} error={error.email == 'Not found.'}>
<Form.Input
label='Email'
name='email'
@@ -45,10 +45,16 @@ function ResetForm() {
error={error.email}
/>
<Message
error
header='Email not found in Spaceport'
content='You can only use this form if you have an account with this new member portal.'
/>
<Form.Button loading={loading} error={error.non_field_errors}>
Submit
</Form.Button>
{success && <div>Success!</div>}
{success && <div>Success! Be sure to check your spam folder.</div>}
</Form>
);
};

View File

@@ -13,6 +13,8 @@ export function Paymaster(props) {
const [locker, setLocker] = useState('5.00');
const [donate, setDonate] = useState('20.00');
const monthly_fees = user.member.monthly_fees || 55;
return (
<Container>
<Header size='large'>Paymaster</Header>
@@ -62,27 +64,27 @@ export function Paymaster(props) {
<Header size='medium'>Member Dues</Header>
<Grid stackable padded columns={3}>
<Grid.Column>
<p>Pay ${user.member.monthly_fees}.00 once:</p>
<p>Pay ${monthly_fees}.00 once:</p>
<PayPalPayNow
amount={user.member.monthly_fees}
amount={monthly_fees}
name='Protospace Membership'
custom={JSON.stringify({ member: user.member.id })}
/>
</Grid.Column>
<Grid.Column>
<p>Subscribe ${user.member.monthly_fees}.00 / month:</p>
<p>Subscribe ${monthly_fees}.00 / month:</p>
<PayPalSubscribe
amount={user.member.monthly_fees}
amount={monthly_fees}
name='Protospace Membership'
custom={JSON.stringify({ member: user.member.id })}
/>
</Grid.Column>
<Grid.Column>
<p>Pay ${user.member.monthly_fees * 11}.00 for a year:</p>
<p>Pay ${monthly_fees * 11}.00 for a year:</p>
<PayPalPayNow
amount={user.member.monthly_fees * 11}
amount={monthly_fees * 11}
name='Protospace Membership'
custom={JSON.stringify({ deal: 12, member: user.member.id })}
/>

View File

@@ -57,6 +57,16 @@ export function CertList(props) {
<Table.Cell>{member.cnc_cert_date ? 'Yes, ' + member.cnc_cert_date : 'No'}</Table.Cell>
<Table.Cell><Link to='/courses/259'>Tormach: CAM and Tormach Intro</Link></Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Rabbit Laser</Table.Cell>
<Table.Cell>{member.rabbit_cert_date ? 'Yes, ' + member.rabbit_cert_date : 'No'}</Table.Cell>
<Table.Cell><Link to='/courses/247'>Laser: Cutting and Engraving</Link></Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Trotec Laser</Table.Cell>
<Table.Cell>{member.trotec_cert_date ? 'Yes, ' + member.trotec_cert_date : 'No'}</Table.Cell>
<Table.Cell><Link to='/courses/321'>Laser: Trotec Course</Link></Table.Cell>
</Table.Row>
</Table.Body>
</Table>
);

View File

@@ -23,14 +23,14 @@ export function TransactionEditor(props) {
});
const accountOptions = [
{ key: '0', text: 'Cash (CAD Lock Box)', value: 'Cash' },
{ key: '0', text: 'Cash (Lock Box)', value: 'Cash' },
{ key: '1', text: 'Interac (Email) Transfer (TD)', value: 'Interac' },
{ key: '2', text: 'Square (Credit)', value: 'Square Pmt' },
{ key: '3', text: 'Dream Payments (Debit/Credit)', value: 'Dream Pmt' },
{ key: '4', text: 'Deposit to TD (Not Interac)', value: 'TD Chequing' },
{ key: '5', text: 'PayPal', value: 'PayPal' },
{ key: '6', text: 'Member Balance / Protocash', value: 'Member' },
{ key: '7', text: 'Supense (Clearing) Acct / Membership Adjustment', value: 'Clearing' },
{ key: '2', text: 'Square (Credit Card)', value: 'Square Pmt' },
//{ key: '3', text: 'Dream Payments (Debit/Credit)', value: 'Dream Pmt' },
{ key: '4', text: 'Cheque / Deposit to TD', value: 'TD Chequing' },
//{ key: '5', text: 'Member Balance / Protocash', value: 'Member' },
{ key: '6', text: 'Membership Adjustment / Clearing', value: 'Clearing' },
{ key: '7', text: 'PayPal', value: 'PayPal' },
];
const sourceOptions = [
@@ -53,9 +53,9 @@ export function TransactionEditor(props) {
{ key: '1', text: 'Payment On Account (ie. Course Fee)', value: 'OnAcct' },
{ key: '2', text: 'Snack / Pop / Coffee', value: 'Snacks' },
{ key: '3', text: 'Donations', value: 'Donation' },
{ key: '4', text: 'Consumables (Specify which in memo)', value: 'Consumables' },
{ key: '4', text: 'Consumables (Explain in memo)', value: 'Consumables' },
{ key: '5', text: 'Purchase of Locker / Goods / Merch / Stock', value: 'Purchases' },
{ key: '6', text: 'Auction, Garage Sale, Nearly Free Shelf', value: 'Garage Sale' },
//{ key: '6', text: 'Auction, Garage Sale, Nearly Free Shelf', value: 'Garage Sale' },
{ key: '7', text: 'Reimbursement (Enter a negative value)', value: 'Reimburse' },
{ key: '8', text: 'Other (Explain in memo)', value: 'Other' },
];
@@ -94,14 +94,14 @@ export function TransactionEditor(props) {
/>
<Form.Select
label='Account'
label='Payment Method / Account'
fluid
options={accountOptions}
{...makeProps('account_type')}
onChange={handleValues}
/>
<Form.Group widths='equal'>
{/* <Form.Group widths='equal'>
<Form.Input
label='Payment Method'
fluid
@@ -114,7 +114,7 @@ export function TransactionEditor(props) {
{...makeProps('info_source')}
onChange={handleValues}
/>
</Form.Group>
</Form.Group> */}
<Form.Group widths='equal'>
<Form.Input
@@ -124,7 +124,7 @@ export function TransactionEditor(props) {
/>
<Form.Input
label='# Membership Months'
label='Number of Membership Months'
fluid
{...makeProps('number_of_membership_months')}
/>
@@ -349,10 +349,10 @@ class TransactionTable extends React.Component {
<Table.Cell>Account:</Table.Cell>
<Table.Cell>{transaction.account_type}</Table.Cell>
</Table.Row>
<Table.Row>
{/* <Table.Row>
<Table.Cell>Payment Method:</Table.Cell>
<Table.Cell>{transaction.payment_method}</Table.Cell>
</Table.Row>
</Table.Row> */}
<Table.Row>
<Table.Cell>Info Source:</Table.Cell>
<Table.Cell>{transaction.info_source}</Table.Cell>

57
webclient/src/dark.css Normal file
View File

@@ -0,0 +1,57 @@
.darkmode-layer, .darkmode-toggle {
z-index: 500;
}
.darkmode--activated .ui.image {
mix-blend-mode: difference;
filter: brightness(75%);
}
.darkmode--activated i.green.circle.icon {
mix-blend-mode: difference;
color: #21ba4582 !important;
}
.darkmode--activated i.yellow.circle.icon {
mix-blend-mode: difference;
color: #fbbd0882 !important;
}
.darkmode--activated i.red.circle.icon {
mix-blend-mode: difference;
color: #db282882 !important;
}
.darkmode--activated .footer {
mix-blend-mode: difference;
}
.darkmode--activated .ql-toolbar.ql-snow,
.darkmode--activated .ql-container.ql-snow,
.darkmode--activated .ui.segment,
.darkmode--activated .ui.form .field input,
.darkmode--activated .ui.form .field .selection.dropdown,
.darkmode--activated .ui.form .field .ui.checkbox label::before,
.darkmode--activated .ui.form .field textarea {
border: 1px solid rgba(34,36,38,.50) !important;
}
.darkmode--activated .ui.basic.table tbody tr {
border-bottom: 1px solid rgba(34,36,38,.50) !important;
}
.darkmode--activated .ui.button {
background: #c9c9c9 !important;
}
.darkmode--activated .ui.red.button {
mix-blend-mode: difference;
background: #db282882 !important;
}
.darkmode--activated .ui.green.button,
.darkmode--activated .ui.button.toggle.active {
mix-blend-mode: difference;
background: #21ba4582 !important;
}

View File

@@ -70,7 +70,17 @@ export const requester = (route, method, token, data) => {
if (!response.ok) {
throw customError(response);
}
return method === 'DELETE' ? {} : response.json();
if (method === 'DELETE') {
return {};
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.indexOf('application/json') !== -1) {
return response.json();
} else {
return response;
}
})
.catch(error => {
const code = error.data ? error.data.status : null;

View File

@@ -3664,7 +3664,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9:
dependencies:
ms "2.0.0"
debug@^3.0.0, debug@^3.1.1, debug@^3.2.5:
debug@^3.1.1, debug@^3.2.5:
version "3.2.6"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
@@ -4376,9 +4376,9 @@ eventemitter3@^2.0.3:
integrity sha1-teEHm1n7XhuidxwKmTvgYKWMmbo=
eventemitter3@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb"
integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==
version "4.0.7"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
events@^3.0.0:
version "3.1.0"
@@ -4767,11 +4767,9 @@ flush-write-stream@^1.0.0:
readable-stream "^2.3.6"
follow-redirects@^1.0.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.10.0.tgz#01f5263aee921c6a54fb91667f08f4155ce169eb"
integrity sha512-4eyLK6s6lH32nOvLLwlIOnr9zrL8Sm+OvW4pVTJNoXeGzYIkHVf+pADQi+OJ0E67hiuSLezPVPyBcIZO50TmmQ==
dependencies:
debug "^3.0.0"
version "1.13.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"
integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==
for-in@^0.1.3:
version "0.1.8"
@@ -5335,9 +5333,9 @@ http-proxy-middleware@0.19.1:
micromatch "^3.1.10"
http-proxy@^1.17.0:
version "1.18.0"
resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a"
integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==
version "1.18.1"
resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==
dependencies:
eventemitter3 "^4.0.0"
follow-redirects "^1.0.0"
@@ -8842,7 +8840,7 @@ raf@^3.4.0, raf@^3.4.1:
dependencies:
performance-now "^2.1.0"
randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5:
randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
@@ -9754,6 +9752,13 @@ serialize-javascript@^2.1.2:
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61"
integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==
serialize-javascript@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-3.1.0.tgz#8bf3a9170712664ef2561b44b691eafe399214ea"
integrity sha512-JIJT1DGiWmIKhzRsG91aS6Ze4sFUrYbltlkg2onR5OrnNM02Kl/hnY/T4FN2omvyeBbQmMJv+K4cPOpGzOTFBg==
dependencies:
randombytes "^2.1.0"
serve-index@^1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239"