upgrade update
This commit is contained in:
parent
92dc0448ac
commit
b30e4301bb
@ -31,6 +31,7 @@ import {
|
|||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
EditOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -67,6 +68,7 @@ const TIMEZONE_OPTIONS = [
|
|||||||
|
|
||||||
export default function MeetingPlannerPage() {
|
export default function MeetingPlannerPage() {
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const [polls, setPolls] = useState<SchedulingPoll[]>([]);
|
const [polls, setPolls] = useState<SchedulingPoll[]>([]);
|
||||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
|
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -93,6 +95,15 @@ export default function MeetingPlannerPage() {
|
|||||||
const [convertForm] = Form.useForm();
|
const [convertForm] = Form.useForm();
|
||||||
const [converting, setConverting] = useState(false);
|
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 () => {
|
const fetchPolls = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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 copyShareLink = (slug: string) => {
|
||||||
const url = `${window.location.origin}/poll/${slug}`;
|
const url = `${window.location.origin}/poll/${slug}`;
|
||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
@ -274,6 +358,9 @@ export default function MeetingPlannerPage() {
|
|||||||
width: 140,
|
width: 140,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
|
<Tooltip title="Edit poll">
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => openEditDrawer(record.id)} />
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title="Copy share link">
|
<Tooltip title="Copy share link">
|
||||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyShareLink(record.slug)} />
|
<Button size="small" icon={<CopyOutlined />} onClick={() => copyShareLink(record.slug)} />
|
||||||
</Tooltip>
|
</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 (
|
return (
|
||||||
<div style={{ padding: screens.md ? 24 : 16 }}>
|
<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 }}>
|
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||||
<Col>
|
<Col>
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
<Title level={4} style={{ margin: 0 }}>
|
||||||
@ -431,13 +534,27 @@ export default function MeetingPlannerPage() {
|
|||||||
}}
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create Poll Drawer */}
|
{/* Create Poll Drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
title="Create Scheduling Poll"
|
title="Create Scheduling Poll"
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => { setCreateOpen(false); createForm.resetFields(); }}
|
||||||
width={screens.md ? 560 : '100%'}
|
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
|
||||||
form={createForm}
|
form={createForm}
|
||||||
@ -522,10 +639,6 @@ export default function MeetingPlannerPage() {
|
|||||||
<Form.Item name="votingDeadline" label="Voting Deadline (optional)">
|
<Form.Item name="votingDeadline" label="Voting Deadline (optional)">
|
||||||
<DatePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
|
<DatePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Button type="primary" htmlType="submit" loading={creating} block>
|
|
||||||
Create Poll
|
|
||||||
</Button>
|
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
@ -534,8 +647,11 @@ export default function MeetingPlannerPage() {
|
|||||||
title={selectedPoll?.title || 'Poll Details'}
|
title={selectedPoll?.title || 'Poll Details'}
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
onClose={() => { setDetailOpen(false); setSelectedPoll(null); }}
|
onClose={() => { setDetailOpen(false); setSelectedPoll(null); }}
|
||||||
width={screens.md ? 720 : '100%'}
|
width={isMobile ? '100%' : 720}
|
||||||
loading={detailLoading}
|
loading={detailLoading}
|
||||||
|
mask={false}
|
||||||
|
destroyOnHidden
|
||||||
|
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
|
||||||
extra={
|
extra={
|
||||||
selectedPoll && (
|
selectedPoll && (
|
||||||
<Space>
|
<Space>
|
||||||
@ -660,6 +776,131 @@ export default function MeetingPlannerPage() {
|
|||||||
)}
|
)}
|
||||||
</Drawer>
|
</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 */}
|
{/* Finalize Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
title="Finalize Poll"
|
title="Finalize Poll"
|
||||||
|
|||||||
@ -2818,7 +2818,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p class="free-modal-footer">
|
<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.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -75,6 +75,37 @@ elapsed() {
|
|||||||
printf '%dm %ds' $((secs / 60)) $((secs % 60))
|
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 ---
|
# --- Lockfile ---
|
||||||
acquire_lock() {
|
acquire_lock() {
|
||||||
if [[ -f "$LOCK_FILE" ]]; then
|
if [[ -f "$LOCK_FILE" ]]; then
|
||||||
@ -142,6 +173,10 @@ print_rollback_help() {
|
|||||||
# --- Failure trap ---
|
# --- Failure trap ---
|
||||||
on_failure() {
|
on_failure() {
|
||||||
local exit_code=$?
|
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
|
release_lock
|
||||||
if [[ $exit_code -ne 0 ]] && [[ "$DRY_RUN" != "true" ]]; then
|
if [[ $exit_code -ne 0 ]] && [[ "$DRY_RUN" != "true" ]]; then
|
||||||
error "Upgrade failed at line ${BASH_LINENO[0]} (exit code $exit_code)"
|
error "Upgrade failed at line ${BASH_LINENO[0]} (exit code $exit_code)"
|
||||||
@ -454,11 +489,15 @@ if [[ "$DRY_RUN" == "true" ]]; then
|
|||||||
else
|
else
|
||||||
info "No new commits to pull."
|
info "No new commits to pull."
|
||||||
fi
|
fi
|
||||||
|
info "[DRY RUN] Would preserve user-modifiable paths: ${USER_PATHS[*]}"
|
||||||
info "[DRY RUN] Would stash local changes, pull, and pop stash"
|
info "[DRY RUN] Would stash local changes, pull, and pop stash"
|
||||||
release_lock
|
release_lock
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Step 0: Save user-modifiable paths before any git operations
|
||||||
|
save_user_paths
|
||||||
|
|
||||||
# Step 1: Stash user changes if any exist
|
# Step 1: Stash user changes if any exist
|
||||||
HAS_CHANGES=false
|
HAS_CHANGES=false
|
||||||
if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then
|
if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then
|
||||||
@ -523,6 +562,9 @@ if [[ "$HAS_CHANGES" == "true" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Step 4b: Restore user-modifiable paths (unconditionally overwrites with saved copies)
|
||||||
|
restore_user_paths
|
||||||
|
|
||||||
# Step 5: Detect new env vars
|
# Step 5: Detect new env vars
|
||||||
info "Checking for new environment variables..."
|
info "Checking for new environment variables..."
|
||||||
if [[ -f "$PROJECT_DIR/.env.example" ]] && [[ -f "$PROJECT_DIR/.env" ]]; then
|
if [[ -f "$PROJECT_DIR/.env.example" ]] && [[ -f "$PROJECT_DIR/.env" ]]; then
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user