upgrade update

This commit is contained in:
bunker-admin 2026-03-02 10:00:15 -07:00
parent 92dc0448ac
commit b30e4301bb
3 changed files with 291 additions and 8 deletions

View File

@ -31,6 +31,7 @@ import {
CheckCircleOutlined,
ClockCircleOutlined,
TeamOutlined,
EditOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
@ -67,6 +68,7 @@ const TIMEZONE_OPTIONS = [
export default function MeetingPlannerPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [polls, setPolls] = useState<SchedulingPoll[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
const [loading, setLoading] = useState(false);
@ -93,6 +95,15 @@ export default function MeetingPlannerPage() {
const [convertForm] = Form.useForm();
const [converting, setConverting] = useState(false);
// Edit drawer
const [editOpen, setEditOpen] = useState(false);
const [editForm] = Form.useForm();
const [editing, setEditing] = useState(false);
const [editPoll, setEditPoll] = useState<PollDetailResponse | null>(null);
const [newOptionDate, setNewOptionDate] = useState<dayjs.Dayjs | null>(null);
const [newOptionStart, setNewOptionStart] = useState<dayjs.Dayjs | null>(null);
const [newOptionEnd, setNewOptionEnd] = useState<dayjs.Dayjs | null>(null);
const fetchPolls = useCallback(async () => {
setLoading(true);
try {
@ -222,6 +233,79 @@ export default function MeetingPlannerPage() {
}
};
const openEditDrawer = async (id: string) => {
try {
const { data } = await api.get<PollDetailResponse>(`/meeting-planner/${id}`);
setEditPoll(data);
editForm.setFieldsValue({
title: data.title,
description: data.description || '',
location: data.location || '',
timezone: data.timezone,
allowAnonymous: data.allowAnonymous,
notifyOnVote: data.notifyOnVote,
votingDeadline: data.votingDeadline ? dayjs(data.votingDeadline) : null,
});
setEditOpen(true);
} catch {
message.error('Failed to load poll');
}
};
const handleUpdate = async (values: any) => {
if (!editPoll) return;
setEditing(true);
try {
await api.put(`/meeting-planner/${editPoll.id}`, {
title: values.title,
description: values.description || null,
location: values.location || null,
timezone: values.timezone,
allowAnonymous: values.allowAnonymous,
notifyOnVote: values.notifyOnVote,
votingDeadline: values.votingDeadline?.toISOString() || null,
});
message.success('Poll updated');
setEditOpen(false);
setEditPoll(null);
editForm.resetFields();
fetchPolls();
// Refresh detail drawer if same poll is open
if (selectedPoll?.id === editPoll.id) {
fetchPollDetail(editPoll.id);
}
} catch {
message.error('Failed to update poll');
} finally {
setEditing(false);
}
};
const handleAddOption = async (option: { date: string; startTime: string; endTime: string }) => {
if (!editPoll) return;
try {
await api.post(`/meeting-planner/${editPoll.id}/options`, { options: [option] });
message.success('Option added');
// Refresh edit poll data
const { data } = await api.get<PollDetailResponse>(`/meeting-planner/${editPoll.id}`);
setEditPoll(data);
} catch {
message.error('Failed to add option');
}
};
const handleRemoveOption = async (optionId: string) => {
if (!editPoll) return;
try {
await api.delete(`/meeting-planner/${editPoll.id}/options/${optionId}`);
message.success('Option removed');
const { data } = await api.get<PollDetailResponse>(`/meeting-planner/${editPoll.id}`);
setEditPoll(data);
} catch {
message.error('Failed to remove option');
}
};
const copyShareLink = (slug: string) => {
const url = `${window.location.origin}/poll/${slug}`;
navigator.clipboard.writeText(url);
@ -274,6 +358,9 @@ export default function MeetingPlannerPage() {
width: 140,
render: (_, record) => (
<Space size="small">
<Tooltip title="Edit poll">
<Button size="small" icon={<EditOutlined />} onClick={() => openEditDrawer(record.id)} />
</Tooltip>
<Tooltip title="Copy share link">
<Button size="small" icon={<CopyOutlined />} onClick={() => copyShareLink(record.slug)} />
</Tooltip>
@ -378,8 +465,24 @@ export default function MeetingPlannerPage() {
);
};
// Calculate active drawer width for content adjustment
const getActiveDrawerWidth = () => {
if (createOpen) return 560;
if (editOpen) return 560;
if (detailOpen) return 720;
return 0;
};
const activeDrawerWidth = getActiveDrawerWidth();
return (
<div style={{ padding: screens.md ? 24 : 16 }}>
{/* Main content shifts left when drawer opens */}
<div
style={{
marginRight: isMobile ? 0 : activeDrawerWidth,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Title level={4} style={{ margin: 0 }}>
@ -431,13 +534,27 @@ export default function MeetingPlannerPage() {
}}
size="small"
/>
</div>
{/* Create Poll Drawer */}
<Drawer
title="Create Scheduling Poll"
open={createOpen}
onClose={() => setCreateOpen(false)}
width={screens.md ? 560 : '100%'}
onClose={() => { setCreateOpen(false); createForm.resetFields(); }}
width={isMobile ? '100%' : 560}
mask={false}
destroyOnHidden
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Space>
<Button onClick={() => { setCreateOpen(false); createForm.resetFields(); }} disabled={creating}>
Cancel
</Button>
<Button type="primary" loading={creating} onClick={() => createForm.submit()}>
Create
</Button>
</Space>
}
>
<Form
form={createForm}
@ -522,10 +639,6 @@ export default function MeetingPlannerPage() {
<Form.Item name="votingDeadline" label="Voting Deadline (optional)">
<DatePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={creating} block>
Create Poll
</Button>
</Form>
</Drawer>
@ -534,8 +647,11 @@ export default function MeetingPlannerPage() {
title={selectedPoll?.title || 'Poll Details'}
open={detailOpen}
onClose={() => { setDetailOpen(false); setSelectedPoll(null); }}
width={screens.md ? 720 : '100%'}
width={isMobile ? '100%' : 720}
loading={detailLoading}
mask={false}
destroyOnHidden
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
selectedPoll && (
<Space>
@ -660,6 +776,131 @@ export default function MeetingPlannerPage() {
)}
</Drawer>
{/* Edit Poll Drawer */}
<Drawer
title="Edit Poll"
open={editOpen}
onClose={() => { setEditOpen(false); setEditPoll(null); editForm.resetFields(); setNewOptionDate(null); setNewOptionStart(null); setNewOptionEnd(null); }}
width={isMobile ? '100%' : 560}
mask={false}
destroyOnHidden
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Space>
<Button onClick={() => { setEditOpen(false); setEditPoll(null); editForm.resetFields(); setNewOptionDate(null); setNewOptionStart(null); setNewOptionEnd(null); }}>
Cancel
</Button>
<Button type="primary" loading={editing} onClick={() => editForm.submit()}>
Save
</Button>
</Space>
}
>
<Form form={editForm} layout="vertical" onFinish={handleUpdate}>
<Form.Item name="title" label="Title" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="location" label="Location">
<Input />
</Form.Item>
<Form.Item name="timezone" label="Timezone">
<Select options={TIMEZONE_OPTIONS.map((tz) => ({ value: tz, label: tz }))} />
</Form.Item>
<Divider>Settings</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="allowAnonymous" label="Allow Anonymous" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="notifyOnVote" label="Notify on Vote" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item name="votingDeadline" label="Voting Deadline (optional)">
<DatePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
</Form.Item>
</Form>
{/* Options management for OPEN polls */}
{editPoll?.status === 'OPEN' && (
<>
<Divider>Date/Time Options</Divider>
{editPoll.options?.map((opt) => (
<Row key={opt.id} gutter={8} align="middle" style={{ marginBottom: 8 }}>
<Col flex="auto">
<Text>
{dayjs(opt.date).format('MMM D, YYYY')} {opt.startTime}{opt.endTime}
</Text>
</Col>
<Col>
{(editPoll.options?.length ?? 0) > 1 && (
<Popconfirm title="Remove this option?" onConfirm={() => handleRemoveOption(opt.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Col>
</Row>
))}
<Row gutter={8} align="middle" style={{ marginTop: 12 }}>
<Col flex="auto">
<Space wrap>
<DatePicker
format="YYYY-MM-DD"
placeholder="Date"
value={newOptionDate}
onChange={setNewOptionDate}
/>
<TimePicker
format="HH:mm"
minuteStep={15}
placeholder="Start"
value={newOptionStart}
onChange={setNewOptionStart}
/>
<TimePicker
format="HH:mm"
minuteStep={15}
placeholder="End"
value={newOptionEnd}
onChange={setNewOptionEnd}
/>
</Space>
</Col>
<Col>
<Button
icon={<PlusOutlined />}
onClick={() => {
if (!newOptionDate || !newOptionStart || !newOptionEnd) {
message.warning('Fill in date, start, and end time');
return;
}
handleAddOption({
date: newOptionDate.format('YYYY-MM-DD'),
startTime: newOptionStart.format('HH:mm'),
endTime: newOptionEnd.format('HH:mm'),
}).then(() => {
setNewOptionDate(null);
setNewOptionStart(null);
setNewOptionEnd(null);
});
}}
>
Add
</Button>
</Col>
</Row>
</>
)}
</Drawer>
{/* Finalize Modal */}
<Modal
title="Finalize Poll"

View File

@ -2818,7 +2818,7 @@
</ul>
<p class="free-modal-footer">
None of these are unique to Changemaker Lite &mdash; any self-hosted platform needs them. The software itself will always be free.
Self-host at no cost, or pay for a pre-configured hardware device or a managed deployment.
Self-host at no cost, or pay for a pre-configured hardware device or a managed Cloudflare deployment.
</p>
</div>
</div>

View File

@ -75,6 +75,37 @@ elapsed() {
printf '%dm %ds' $((secs / 60)) $((secs % 60))
}
# --- Save/restore user-modifiable paths across git pull ---
save_user_paths() {
USER_SAVE_DIR="$(mktemp -d)"
for p in "${USER_PATHS[@]}"; do
if [[ -e "$PROJECT_DIR/$p" ]]; then
mkdir -p "$USER_SAVE_DIR/$(dirname "$p")"
cp -a "$PROJECT_DIR/$p" "$USER_SAVE_DIR/$p"
fi
done
}
restore_user_paths() {
if [[ -z "${USER_SAVE_DIR:-}" ]] || [[ ! -d "${USER_SAVE_DIR:-}" ]]; then
return
fi
local restored=0
for p in "${USER_PATHS[@]}"; do
if [[ -e "$USER_SAVE_DIR/$p" ]]; then
# Ensure parent directory exists (in case pull deleted it)
mkdir -p "$PROJECT_DIR/$(dirname "$p")"
rm -rf "$PROJECT_DIR/$p"
cp -a "$USER_SAVE_DIR/$p" "$PROJECT_DIR/$p"
restored=$((restored + 1))
fi
done
rm -rf "$USER_SAVE_DIR"
if [[ $restored -gt 0 ]]; then
success "Restored $restored user-modifiable path(s)"
fi
}
# --- Lockfile ---
acquire_lock() {
if [[ -f "$LOCK_FILE" ]]; then
@ -142,6 +173,10 @@ print_rollback_help() {
# --- Failure trap ---
on_failure() {
local exit_code=$?
# Clean up user path save directory if it exists
if [[ -n "${USER_SAVE_DIR:-}" ]] && [[ -d "${USER_SAVE_DIR:-}" ]]; then
rm -rf "$USER_SAVE_DIR"
fi
release_lock
if [[ $exit_code -ne 0 ]] && [[ "$DRY_RUN" != "true" ]]; then
error "Upgrade failed at line ${BASH_LINENO[0]} (exit code $exit_code)"
@ -454,11 +489,15 @@ if [[ "$DRY_RUN" == "true" ]]; then
else
info "No new commits to pull."
fi
info "[DRY RUN] Would preserve user-modifiable paths: ${USER_PATHS[*]}"
info "[DRY RUN] Would stash local changes, pull, and pop stash"
release_lock
exit 0
fi
# Step 0: Save user-modifiable paths before any git operations
save_user_paths
# Step 1: Stash user changes if any exist
HAS_CHANGES=false
if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then
@ -523,6 +562,9 @@ if [[ "$HAS_CHANGES" == "true" ]]; then
fi
fi
# Step 4b: Restore user-modifiable paths (unconditionally overwrites with saved copies)
restore_user_paths
# Step 5: Detect new env vars
info "Checking for new environment variables..."
if [[ -f "$PROJECT_DIR/.env.example" ]] && [[ -f "$PROJECT_DIR/.env" ]]; then