Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions frontend/src/components/DraftProposalList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useState } from 'react';
import type { DraftProposal } from '../types';
import { getDrafts, deleteDraft } from '../drafts';

interface Props {
onEdit: (draft: DraftProposal) => void;
onPublish: (draft: DraftProposal) => void;
}

export function DraftProposalList({ onEdit, onPublish }: Props) {
const [drafts, setDrafts] = useState<DraftProposal[]>(() => getDrafts());

const handleDelete = (id: string) => {
deleteDraft(id);
setDrafts(getDrafts());
};

if (drafts.length === 0) return <p style={{ color: '#6b7280' }}>No saved drafts.</p>;

return (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{drafts.map(draft => (
<li key={draft.id} style={{ border: '1px solid #e5e7eb', borderRadius: 8, padding: '1rem', marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
<div>
<strong>{draft.title || <em>Untitled</em>}</strong>
<div style={{ fontSize: '0.75rem', color: '#6b7280', marginTop: '0.25rem' }}>
Saved {new Date(draft.savedAt).toLocaleString()}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button
onClick={() => onEdit(draft)}
style={{ padding: '0.25rem 0.75rem', border: '1px solid #d1d5db', borderRadius: 6, background: '#fff', cursor: 'pointer', fontSize: '0.875rem' }}
>
Edit
</button>
<button
onClick={() => onPublish(draft)}
style={{ padding: '0.25rem 0.75rem', border: 'none', borderRadius: 6, background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: '0.875rem' }}
>
Publish
</button>
<button
onClick={() => handleDelete(draft.id)}
style={{ padding: '0.25rem 0.75rem', border: 'none', borderRadius: 6, background: '#ef4444', color: '#fff', cursor: 'pointer', fontSize: '0.875rem' }}
aria-label={`Delete draft "${draft.title}"`}
>
Delete
</button>
</div>
</div>
</li>
))}
</ul>
);
}
26 changes: 24 additions & 2 deletions frontend/src/components/NewProposalForm.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { useState } from 'react';
import type { DraftProposal } from '../types';
import { saveDraft } from '../drafts';

interface Props {
onClose: () => void;
onSuccess: (proposalId: number) => void;
onAnnounce?: (msg: string) => void;
onError?: (msg: string) => void;
initialDraft?: DraftProposal | null;
onDraftSaved?: (draft: DraftProposal) => void;
}

interface FormState {
Expand Down Expand Up @@ -50,14 +54,24 @@ function validate(f: FormState): Partial<Record<keyof FormState, string>> {
return errs;
}

export function NewProposalForm({ onClose, onSuccess, onAnnounce, onError }: Props) {
const [form, setForm] = useState<FormState>(INITIAL);
export function NewProposalForm({ onClose, onSuccess, onAnnounce, onError, initialDraft, onDraftSaved }: Props) {
const [form, setForm] = useState<FormState>(
initialDraft
? { title: initialDraft.title, description: initialDraft.description, quorum: initialDraft.quorum, duration: initialDraft.duration }
: INITIAL
);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [submitting, setSubmitting] = useState(false);

const set = (k: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));

const handleSaveDraft = () => {
const draft = saveDraft({ title: form.title, description: form.description, quorum: form.quorum, duration: form.duration });
onAnnounce?.('Draft saved.');
onDraftSaved?.(draft);
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errs = validate(form);
Expand Down Expand Up @@ -164,6 +178,14 @@ export function NewProposalForm({ onClose, onSuccess, onAnnounce, onError }: Pro
>
Cancel
</button>
<button
type="button"
onClick={handleSaveDraft}
disabled={submitting}
style={{ padding: '0.5rem 1rem', border: '1px solid #6b7280', borderRadius: 6, background: '#fff', cursor: 'pointer' }}
>
Save as Draft
</button>
<button
type="submit"
disabled={submitting}
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/drafts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { DraftProposal } from './types';

const KEY = 'cosmosvote_drafts';

function load(): DraftProposal[] {
try { return JSON.parse(localStorage.getItem(KEY) ?? '[]'); } catch { return []; }
}

function save(drafts: DraftProposal[]): void {
localStorage.setItem(KEY, JSON.stringify(drafts));
}

export function saveDraft(draft: Omit<DraftProposal, 'id' | 'savedAt'>): DraftProposal {
const drafts = load();
const entry: DraftProposal = { ...draft, id: crypto.randomUUID(), savedAt: Date.now() };
save([...drafts, entry]);
return entry;
}

export function getDrafts(): DraftProposal[] {
return load();
}

export function getDraft(id: string): DraftProposal | null {
return load().find(d => d.id === id) ?? null;
}

export function deleteDraft(id: string): void {
save(load().filter(d => d.id !== id));
}
11 changes: 10 additions & 1 deletion frontend/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
// Governance contract types mirroring the Rust contract

export type ProposalState = 'Active' | 'Passed' | 'Rejected' | 'Executed' | 'Cancelled';
export type ProposalState = 'Active' | 'Passed' | 'Rejected' | 'Executed' | 'Cancelled' | 'Draft';

export interface DraftProposal {
id: string;
title: string;
description: string;
quorum: string;
duration: string;
savedAt: number;
}
export type VoteType = 'Yes' | 'No' | 'Abstain';

export interface Proposal {
Expand Down