upgrade update
This commit is contained in:
parent
92dc0448ac
commit
b30e4301bb
@ -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"
|
||||
|
||||
@ -2818,7 +2818,7 @@
|
||||
</ul>
|
||||
<p class="free-modal-footer">
|
||||
None of these are unique to Changemaker Lite — 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>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user