mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-02-07 04:21:01 -05:00
Add voting / proposal page
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
"verify": "yarn --cwd=../.. verify",
|
||||
"verify:dir": "npx eslint . --max-warnings 0",
|
||||
"regen-types": "cd ../supabase && make ENV=prod regen-types",
|
||||
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types"
|
||||
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -56,6 +56,8 @@ import * as fs from "fs"
|
||||
import {sendSearchNotifications} from "api/send-search-notifications";
|
||||
import {sendDiscordMessage} from "common/discord/core";
|
||||
import {getMessagesCount} from "api/get-messages-count";
|
||||
import {createVote} from "api/create-vote";
|
||||
import {vote} from "api/vote";
|
||||
|
||||
const allowCorsUnrestricted: RequestHandler = cors({})
|
||||
|
||||
@@ -156,6 +158,8 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||
'create-comment': createComment,
|
||||
'hide-comment': hideComment,
|
||||
'create-compatibility-question': createCompatibilityQuestion,
|
||||
'create-vote': createVote,
|
||||
'vote': vote,
|
||||
'compatible-profiles': getCompatibleProfilesHandler,
|
||||
'search-location': searchLocation,
|
||||
'search-near-city': searchNearCity,
|
||||
|
||||
26
backend/api/src/create-vote.ts
Normal file
26
backend/api/src/create-vote.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { getUser } from 'shared/utils'
|
||||
import { APIHandler, APIError } from './helpers/endpoint'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
|
||||
export const createVote: APIHandler<
|
||||
'create-vote'
|
||||
> = async ({ title, description }, auth) => {
|
||||
const creator = await getUser(auth.uid)
|
||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'votes', {
|
||||
creator_id: creator.id,
|
||||
title,
|
||||
description,
|
||||
})
|
||||
)
|
||||
|
||||
if (error) throw new APIError(401, 'Error creating question')
|
||||
|
||||
return { data }
|
||||
}
|
||||
39
backend/api/src/vote.ts
Normal file
39
backend/api/src/vote.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { getUser } from 'shared/utils'
|
||||
import { APIHandler, APIError } from './helpers/endpoint'
|
||||
|
||||
export const vote: APIHandler<'vote'> = async ({ voteId, choice, priority }, auth) => {
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) throw new APIError(401, 'Your account was not found')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Map string choice to smallint (-1, 0, 1)
|
||||
const choiceMap: Record<string, number> = {
|
||||
'for': 1,
|
||||
'abstain': 0,
|
||||
'against': -1,
|
||||
}
|
||||
const choiceVal = choiceMap[choice]
|
||||
if (choiceVal === undefined) {
|
||||
throw new APIError(400, 'Invalid choice')
|
||||
}
|
||||
|
||||
// Upsert the vote result to ensure one vote per user per vote
|
||||
// Assuming table vote_results with unique (user_id, vote_id)
|
||||
const query = `
|
||||
insert into vote_results (user_id, vote_id, choice, priority)
|
||||
values ($1, $2, $3, $4)
|
||||
on conflict (user_id, vote_id)
|
||||
do update set choice = excluded.choice,
|
||||
priority = excluded.priority
|
||||
returning *;
|
||||
`
|
||||
|
||||
try {
|
||||
const result = await pg.one(query, [user.id, voteId, choiceVal, priority])
|
||||
return { data: result }
|
||||
} catch (e) {
|
||||
throw new APIError(500, 'Error recording vote', e as any)
|
||||
}
|
||||
}
|
||||
66
backend/supabase/vote_results.sql
Normal file
66
backend/supabase/vote_results.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
CREATE TABLE IF NOT EXISTS vote_results (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
vote_id BIGINT NOT NULL,
|
||||
choice smallint NOT NULL CHECK (choice IN (-1, 0, 1)),
|
||||
priority smallint NOT NULL CHECK (priority IN (0, 1, 2, 3)),
|
||||
UNIQUE (user_id, vote_id) -- ensures one vote per user
|
||||
);
|
||||
|
||||
-- Foreign Keys
|
||||
alter table vote_results
|
||||
add constraint vote_results_user_id_fkey foreign key (user_id) references users (id);
|
||||
|
||||
alter table vote_results
|
||||
add constraint vote_results_vote_id_fkey foreign key (vote_id) references votes (id);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE vote_results ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policies
|
||||
DROP POLICY IF EXISTS "public read" ON vote_results;
|
||||
CREATE POLICY "public read" ON vote_results
|
||||
FOR ALL USING (true);
|
||||
|
||||
-- Indexes
|
||||
DROP INDEX IF EXISTS user_id_idx;
|
||||
CREATE INDEX user_id_idx ON vote_results (user_id);
|
||||
|
||||
DROP INDEX IF EXISTS vote_id_idx;
|
||||
CREATE INDEX vote_id_idx ON vote_results (vote_id);
|
||||
|
||||
DROP INDEX IF EXISTS idx_vote_results_vote_choice;
|
||||
CREATE INDEX idx_vote_results_vote_choice ON vote_results (vote_id, choice);
|
||||
|
||||
|
||||
create or replace function get_votes_with_results()
|
||||
returns table (
|
||||
id BIGINT,
|
||||
title text,
|
||||
description jsonb,
|
||||
created_time timestamptz,
|
||||
creator_id TEXT,
|
||||
votes_for int,
|
||||
votes_against int,
|
||||
votes_abstain int,
|
||||
priority int
|
||||
)
|
||||
as $$
|
||||
SELECT
|
||||
v.id,
|
||||
v.title,
|
||||
v.description,
|
||||
v.created_time,
|
||||
v.creator_id,
|
||||
COALESCE(SUM(CASE WHEN r.choice = 1 THEN 1 ELSE 0 END), 0) AS votes_for,
|
||||
COALESCE(SUM(CASE WHEN r.choice = -1 THEN 1 ELSE 0 END), 0) AS votes_against,
|
||||
COALESCE(SUM(CASE WHEN r.choice = 0 THEN 1 ELSE 0 END), 0) AS votes_abstain,
|
||||
coalesce(SUM(r.priority), 0) AS priority
|
||||
FROM votes v
|
||||
LEFT JOIN vote_results r ON v.id = r.vote_id
|
||||
GROUP BY v.id
|
||||
ORDER BY v.created_time DESC;
|
||||
$$ language sql stable;
|
||||
|
||||
|
||||
26
backend/supabase/votes.sql
Normal file
26
backend/supabase/votes.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
creator_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description JSONB
|
||||
);
|
||||
|
||||
-- Foreign Keys
|
||||
alter table votes
|
||||
add constraint votes_creator_id_fkey foreign key (creator_id) references users (id);
|
||||
|
||||
-- Row Level Security
|
||||
ALTER TABLE votes ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policies
|
||||
DROP POLICY IF EXISTS "public read" ON votes;
|
||||
CREATE POLICY "public read" ON votes
|
||||
FOR ALL USING (true);
|
||||
|
||||
-- Indexes
|
||||
DROP INDEX IF EXISTS creator_id_idx;
|
||||
CREATE INDEX creator_id_idx ON votes (creator_id);
|
||||
|
||||
DROP INDEX IF EXISTS idx_votes_created_time;
|
||||
CREATE INDEX idx_votes_created_time ON votes (created_time DESC);
|
||||
@@ -513,6 +513,27 @@ export const API = (_apiTypeCheck = {
|
||||
options: z.record(z.string(), z.number()),
|
||||
}),
|
||||
},
|
||||
'create-vote': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
title: z.string().min(1),
|
||||
description: contentSchema,
|
||||
}),
|
||||
},
|
||||
'vote': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
voteId: z.number(),
|
||||
priority: z.number(),
|
||||
choice: z.enum(['for', 'abstain', 'against']),
|
||||
}),
|
||||
},
|
||||
'search-location': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import {DEV_CONFIG} from './dev'
|
||||
import {PROD_CONFIG} from './prod'
|
||||
import {isProd} from "common/envs/is-prod";
|
||||
|
||||
export const MAX_DESCRIPTION_LENGTH = 16000
|
||||
export const MAX_DESCRIPTION_LENGTH = 100000
|
||||
export const MAX_ANSWER_LENGTH = 240
|
||||
|
||||
export const ENV_CONFIG = isProd() ? PROD_CONFIG : DEV_CONFIG
|
||||
|
||||
@@ -10,7 +10,7 @@ export type Database = {
|
||||
// Allows to automatically instantiate createClient with right options
|
||||
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
||||
__InternalSupabase: {
|
||||
PostgrestVersion: '13.0.4'
|
||||
PostgrestVersion: '13.0.5'
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
@@ -471,7 +471,7 @@ export type Database = {
|
||||
geodb_city_id?: string | null
|
||||
has_kids?: number | null
|
||||
height_in_inches?: number | null
|
||||
id?: number
|
||||
id?: never
|
||||
is_smoker?: boolean | null
|
||||
is_vegetarian_or_vegan?: boolean | null
|
||||
last_modification_time?: string
|
||||
@@ -519,7 +519,7 @@ export type Database = {
|
||||
geodb_city_id?: string | null
|
||||
has_kids?: number | null
|
||||
height_in_inches?: number | null
|
||||
id?: number
|
||||
id?: never
|
||||
is_smoker?: boolean | null
|
||||
is_vegetarian_or_vegan?: boolean | null
|
||||
last_modification_time?: string
|
||||
@@ -700,6 +700,80 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
vote_results: {
|
||||
Row: {
|
||||
choice: number
|
||||
created_time: string
|
||||
id: number
|
||||
priority: number
|
||||
user_id: string
|
||||
vote_id: number
|
||||
}
|
||||
Insert: {
|
||||
choice: number
|
||||
created_time?: string
|
||||
id?: never
|
||||
priority: number
|
||||
user_id: string
|
||||
vote_id: number
|
||||
}
|
||||
Update: {
|
||||
choice?: number
|
||||
created_time?: string
|
||||
id?: never
|
||||
priority?: number
|
||||
user_id?: string
|
||||
vote_id?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'vote_results_user_id_fkey'
|
||||
columns: ['user_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'vote_results_vote_id_fkey'
|
||||
columns: ['vote_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'votes'
|
||||
referencedColumns: ['id']
|
||||
}
|
||||
]
|
||||
}
|
||||
votes: {
|
||||
Row: {
|
||||
created_time: string
|
||||
creator_id: string
|
||||
description: Json | null
|
||||
id: number
|
||||
title: string
|
||||
}
|
||||
Insert: {
|
||||
created_time?: string
|
||||
creator_id: string
|
||||
description?: Json | null
|
||||
id?: never
|
||||
title: string
|
||||
}
|
||||
Update: {
|
||||
created_time?: string
|
||||
creator_id?: string
|
||||
description?: Json | null
|
||||
id?: never
|
||||
title?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'votes_creator_id_fkey'
|
||||
columns: ['creator_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
@@ -733,6 +807,20 @@ export type Database = {
|
||||
Args: { p_question_id: number }
|
||||
Returns: Record<string, unknown>[]
|
||||
}
|
||||
get_votes_with_results: {
|
||||
Args: Record<PropertyKey, never>
|
||||
Returns: {
|
||||
created_time: string
|
||||
creator_id: string
|
||||
description: Json
|
||||
id: number
|
||||
priority: number
|
||||
title: string
|
||||
votes_abstain: number
|
||||
votes_against: number
|
||||
votes_for: number
|
||||
}[]
|
||||
}
|
||||
gtrgm_compress: {
|
||||
Args: { '': unknown }
|
||||
Returns: unknown
|
||||
|
||||
@@ -24,6 +24,7 @@ import {useProfile} from 'web/hooks/use-profile'
|
||||
import {Profile} from 'common/love/profile'
|
||||
import {NotificationsIcon, SolidNotificationsIcon} from './notifications-icon'
|
||||
import {IS_MAINTENANCE} from "common/constants";
|
||||
import {MdThumbUp} from "react-icons/md";
|
||||
|
||||
export function LovePage(props: {
|
||||
trackPageView: string | false
|
||||
@@ -113,10 +114,12 @@ const NotifsSolid = {name: 'Notifs', href: `/notifications`, icon: SolidNotifica
|
||||
const Messages = {name: 'Messages', href: '/messages', icon: PrivateMessagesIcon};
|
||||
const Social = {name: 'Social', href: '/social', icon: LinkIcon};
|
||||
const Organization = {name: 'Organization', href: '/organization', icon: GlobeAltIcon};
|
||||
const Vote = {name: 'Vote', href: '/vote', icon: MdThumbUp};
|
||||
|
||||
const base = [
|
||||
About,
|
||||
faq,
|
||||
Vote,
|
||||
Social,
|
||||
Organization,
|
||||
]
|
||||
|
||||
@@ -86,7 +86,6 @@ const bottomNav = (
|
||||
toggleTheme: () => void
|
||||
) =>
|
||||
buildArray<Item>(
|
||||
!loggedIn && { name: 'Sign in', icon: LoginIcon, href: '/signin' },
|
||||
{
|
||||
name: theme ?? 'auto',
|
||||
children:
|
||||
@@ -114,6 +113,7 @@ const bottomNav = (
|
||||
),
|
||||
onClick: toggleTheme,
|
||||
},
|
||||
!loggedIn && { name: 'Sign in', icon: LoginIcon, href: '/signin' },
|
||||
loggedIn && { name: 'Sign out', icon: LogoutIcon, onClick: logout }
|
||||
)
|
||||
|
||||
|
||||
129
web/components/votes/vote-buttons.tsx
Normal file
129
web/components/votes/vote-buttons.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Button } from 'web/components/buttons/button'
|
||||
import clsx from 'clsx'
|
||||
import toast from 'react-hot-toast'
|
||||
import { api } from 'web/lib/api'
|
||||
import { useState } from 'react'
|
||||
import {useUser} from "web/hooks/use-user";
|
||||
|
||||
export type VoteChoice = 'for' | 'abstain' | 'against'
|
||||
|
||||
export function VoteButtons(props: {
|
||||
voteId: number
|
||||
counts: { for: number; abstain: number; against: number }
|
||||
onVoted?: () => void | Promise<void>
|
||||
className?: string
|
||||
}) {
|
||||
const user = useUser()
|
||||
const { voteId, counts, onVoted, className } = props
|
||||
const [loading, setLoading] = useState<VoteChoice | null>(null)
|
||||
const [showPriority, setShowPriority] = useState(false)
|
||||
const disabled = loading !== null
|
||||
|
||||
const sendVote = async (choice: VoteChoice, priority: number) => {
|
||||
try {
|
||||
setLoading(choice)
|
||||
if (!user) {
|
||||
toast.error('Please sign in to vote')
|
||||
return
|
||||
}
|
||||
await api('vote', { voteId, choice, priority })
|
||||
toast.success(`Voted ${choice}${choice === 'for' ? ` with priority ${priority}` : ''}`)
|
||||
await onVoted?.()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error('Failed to vote — please try again')
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVote = async (choice: VoteChoice) => {
|
||||
if (choice === 'for') {
|
||||
// Toggle the priority dropdown
|
||||
setShowPriority((v) => !v)
|
||||
return
|
||||
}
|
||||
// Default priority 0 for non-for choices
|
||||
await sendVote(choice, 0)
|
||||
}
|
||||
|
||||
function VoteButton(props: {
|
||||
color: string
|
||||
count: number
|
||||
title: string
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
const { color, count, title, disabled, onClick } = props
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
disabled={disabled}
|
||||
className={clsx('px-4 py-2 rounded-lg', color)}
|
||||
onClick={onClick}
|
||||
color={'gray-white'}
|
||||
>
|
||||
<div className="font-semibold mx-2">{count}</div>
|
||||
<div className="text-sm">{title}</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const priorities = [
|
||||
{ label: 'Urgent', value: 3 },
|
||||
{ label: 'High', value: 2 },
|
||||
{ label: 'Medium', value: 1 },
|
||||
{ label: 'Low', value: 0 },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<Row className={clsx('gap-4 mt-2', className)}>
|
||||
<div className="relative">
|
||||
<VoteButton
|
||||
color={clsx('bg-green-700 text-white hover:bg-green-500')}
|
||||
count={counts.for}
|
||||
title={'For'}
|
||||
disabled={disabled}
|
||||
onClick={() => handleVote('for')}
|
||||
/>
|
||||
{showPriority && (
|
||||
<div className={clsx(
|
||||
'absolute z-10 mt-2 w-40 rounded-md border border-ink-200 bg-white shadow-lg',
|
||||
'dark:bg-ink-900'
|
||||
)}>
|
||||
{priorities.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2 text-sm hover:bg-ink-100',
|
||||
'dark:hover:bg-ink-800'
|
||||
)}
|
||||
onClick={async () => {
|
||||
setShowPriority(false)
|
||||
await sendVote('for', p.value)
|
||||
}}
|
||||
>
|
||||
{p.label} priority
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<VoteButton
|
||||
color={clsx('bg-yellow-700 text-white hover:bg-yellow-500')}
|
||||
count={counts.abstain}
|
||||
title={'Abstain'}
|
||||
disabled={disabled}
|
||||
onClick={() => handleVote('abstain')}
|
||||
/>
|
||||
<VoteButton
|
||||
color={clsx('bg-red-700 text-white hover:bg-red-500')}
|
||||
count={counts.against}
|
||||
title={'Against'}
|
||||
disabled={disabled}
|
||||
onClick={() => handleVote('against')}
|
||||
/>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
111
web/components/votes/vote-info.tsx
Normal file
111
web/components/votes/vote-info.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {useGetter} from 'web/hooks/use-getter'
|
||||
import {TextEditor, useTextEditor} from "web/components/widgets/editor";
|
||||
import {JSONContent} from "@tiptap/core";
|
||||
import {getVotes} from "web/lib/supabase/votes";
|
||||
import {MAX_DESCRIPTION_LENGTH} from "common/envs/constants";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Button} from "web/components/buttons/button";
|
||||
import {Input} from "web/components/widgets/input";
|
||||
import {api} from "web/lib/api";
|
||||
import {Title} from "web/components/widgets/title";
|
||||
import toast from "react-hot-toast";
|
||||
import {Vote, VoteItem} from 'web/components/votes/vote-item'
|
||||
|
||||
export function VoteComponent(props: {}) {
|
||||
const user = useUser()
|
||||
|
||||
const {data: votes, refresh: refreshVotes} = useGetter(
|
||||
'votes',
|
||||
{},
|
||||
getVotes
|
||||
)
|
||||
|
||||
const [title, setTitle] = useState<string>('')
|
||||
const [editor, setEditor] = useState<any>(null)
|
||||
|
||||
const hideButton = title.length == 0
|
||||
|
||||
console.debug('votes', votes)
|
||||
|
||||
return (
|
||||
<Col className="mx-4">
|
||||
<Title className="!mb-2 text-3xl">Proposals</Title>
|
||||
{votes && votes.length > 0 && <Col className={'mt-4'}>
|
||||
{votes.map((vote: Vote) => {
|
||||
return (
|
||||
<VoteItem key={vote.id} vote={vote} onVoted={refreshVotes}/>
|
||||
)
|
||||
})}
|
||||
</Col>}
|
||||
{user && <Col>
|
||||
<Title className="!mb-2 text-3xl">Add a new proposal</Title>
|
||||
<Input
|
||||
value={title}
|
||||
placeholder={'Title'}
|
||||
className={'w-full mb-2'}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<VoteCreator
|
||||
onEditor={(e) => setEditor(e)}
|
||||
/>
|
||||
{!hideButton && (
|
||||
<Row className="right-1 justify-between gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
const data = {
|
||||
title: title,
|
||||
description: editor.getJSON() as JSONContent,
|
||||
};
|
||||
const newVote = await api('create-vote', data).catch(() => {
|
||||
toast.error('Failed to create vote — try again or contact us...')
|
||||
})
|
||||
if (!newVote) return
|
||||
toast.success('Vote created')
|
||||
console.debug('Vote created', newVote)
|
||||
refreshVotes()
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</Col>
|
||||
}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
interface BaseBioProps {
|
||||
defaultValue?: any
|
||||
onBlur?: (editor: any) => void
|
||||
onEditor?: (editor: any) => void
|
||||
}
|
||||
|
||||
export function VoteCreator({defaultValue, onBlur, onEditor}: BaseBioProps) {
|
||||
const editor = useTextEditor({
|
||||
// extensions: [StarterKit],
|
||||
max: MAX_DESCRIPTION_LENGTH,
|
||||
defaultValue: defaultValue,
|
||||
placeholder: 'Please describe your proposal here',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onEditor?.(editor)
|
||||
}, [editor, onEditor])
|
||||
|
||||
return (
|
||||
<div className={'mb-2'}>
|
||||
{/*<p>Description</p>*/}
|
||||
<TextEditor
|
||||
editor={editor}
|
||||
onBlur={() => onBlur?.(editor)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
web/components/votes/vote-item.tsx
Normal file
51
web/components/votes/vote-item.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {Row as rowFor} from 'common/supabase/utils'
|
||||
import {Content} from 'web/components/widgets/editor'
|
||||
import {JSONContent} from '@tiptap/core'
|
||||
import {VoteButtons} from 'web/components/votes/vote-buttons'
|
||||
import {getVoteCreator} from "web/lib/supabase/votes";
|
||||
import {useEffect, useState} from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export type Vote = rowFor<'votes'> & {
|
||||
votes_for: number
|
||||
votes_against: number
|
||||
votes_abstain: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
export function VoteItem(props: {
|
||||
vote: Vote
|
||||
onVoted?: () => void | Promise<void>
|
||||
}) {
|
||||
const {vote, onVoted} = props
|
||||
const [creator, setCreator] = useState<any>(null)
|
||||
useEffect(() => {
|
||||
getVoteCreator(vote.creator_id).then(setCreator)
|
||||
}, [vote.creator_id])
|
||||
console.debug('creator', creator)
|
||||
return (
|
||||
<Col className={'mb-4 rounded-lg border border-ink-200 p-4'}>
|
||||
<Row className={'mb-2'}>
|
||||
<Col className={'flex-grow'}>
|
||||
<p className={'text-2xl'}>{vote.title}</p>
|
||||
<Col className='text-sm text-gray-500 italic'>
|
||||
<Content className="w-full" content={vote.description as JSONContent}/>
|
||||
</Col>
|
||||
{vote.priority && <Col>Priority: {vote.priority / vote.votes_for}</Col>}
|
||||
{creator?.username && <Col className={'mt-2 customlink text-right'}><Link href={`/${creator.username}`}>{creator.username}</Link></Col>}
|
||||
<VoteButtons
|
||||
voteId={vote.id}
|
||||
counts={{
|
||||
for: vote.votes_for,
|
||||
abstain: vote.votes_abstain,
|
||||
against: vote.votes_against,
|
||||
}}
|
||||
onVoted={onVoted}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
25
web/lib/supabase/votes.ts
Normal file
25
web/lib/supabase/votes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {run} from 'common/supabase/utils'
|
||||
import {db} from 'web/lib/supabase/db'
|
||||
|
||||
export const getVotes = async () => {
|
||||
const {data, error} = await db.rpc('get_votes_with_results' as any);
|
||||
if (error) throw error;
|
||||
|
||||
// data.forEach((vote: any) => {
|
||||
// console.log(vote.title, vote.votes_for, vote.votes_against, vote.votes_abstain);
|
||||
// });
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export const getVoteCreator = async (creatorId: string) => {
|
||||
const {data} = await run(
|
||||
db
|
||||
.from('users')
|
||||
.select(`id, name, username`)
|
||||
.eq('id', creatorId)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
return data[0]
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export default function About() {
|
||||
<AboutBlock
|
||||
title="Democratic"
|
||||
text={<span
|
||||
className="customlink">Governed by the community, while ensuring no drift through our <Link
|
||||
className="customlink">Governed and <Link href="/vote">voted</Link> by the community, while ensuring no drift through our <Link
|
||||
href="/constitution">constitution</Link>.</span>}
|
||||
/>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function Organization() {
|
||||
>
|
||||
<GeneralButton url={'/support'} content={'Support'}/>
|
||||
<GeneralButton url={'/constitution'} content={'Constitution'}/>
|
||||
<GeneralButton url={'/vote'} content={'Proposals'}/>
|
||||
<GeneralButton url={'/financials'} content={'Financials'}/>
|
||||
<GeneralButton url={'/stats'} content={'Growth & Stats'}/>
|
||||
<GeneralButton url={'/terms'} content={'Terms and Conditions'}/>
|
||||
|
||||
36
web/pages/vote.tsx
Normal file
36
web/pages/vote.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import {LovePage} from 'web/components/love-page'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {SEO} from 'web/components/SEO'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {BackButton} from 'web/components/back-button'
|
||||
import {CompassLoadingIndicator} from "web/components/widgets/loading-indicator";
|
||||
import {VoteComponent} from "web/components/votes/vote-info";
|
||||
|
||||
|
||||
export default function VotePage() {
|
||||
const user = useUser()
|
||||
|
||||
// console.log('user:', user)
|
||||
|
||||
return (
|
||||
<LovePage
|
||||
trackPageView={'vote page'}
|
||||
className={'relative p-2 sm:pt-0'}
|
||||
>
|
||||
<SEO
|
||||
title={`Votes`}
|
||||
description={'A place to vote on decisions'}
|
||||
url={`/vote`}
|
||||
/>
|
||||
<BackButton className="-ml-2 mb-2 self-start"/>
|
||||
|
||||
{user === undefined ? (
|
||||
<CompassLoadingIndicator/>
|
||||
) : (
|
||||
<Col className={'gap-4'}>
|
||||
<VoteComponent/>
|
||||
</Col>
|
||||
)}
|
||||
</LovePage>
|
||||
)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ Martin continues to serve as an initiator and steward of Compass, but its direct
|
||||
|
||||
Compass is run democratically under a [constitution](/constitution) that prevents central control and ensures long-term alignment with its mission.
|
||||
|
||||
* Major decisions (scope, funding, rules) are voted on by **active contributors**.
|
||||
* Major decisions (scope, funding, rules) are [voted](/vote) on by **active contributors**.
|
||||
* The full constitution is **public and transparent**.
|
||||
* No corporate capture — Compass will always remain a community-owned project.
|
||||
|
||||
@@ -103,6 +103,7 @@ We chose the name Compass because our goal is to help people orient themselves t
|
||||
|
||||
* **Give Feedback**: [Fill out the suggestion form](https://forms.gle/tKnXUMAbEreMK6FC6)
|
||||
* **Join the Discussion**: [Discord Community](https://discord.gg/8Vd7jzqjun)
|
||||
* **Vote on proposals**: [vote here](/vote)
|
||||
* **Contribute to Development**: [View the code on GitHub](https://github.com/CompassConnections/Compass)
|
||||
* **Donate**: [Support the infrastructure](/support)
|
||||
* **Spread the Word**: Tell friends and family who value depth and real connection.
|
||||
|
||||
@@ -105,11 +105,82 @@
|
||||
--color-no-950: 17 17 17;
|
||||
--color-no-1000: 0 0 0;
|
||||
|
||||
--color-green-950: 5 46 22; /* darkest green */
|
||||
--color-green-900: 20 83 45;
|
||||
--color-green-800: 22 101 52;
|
||||
--color-green-700: 21 128 61;
|
||||
--color-green-600: 22 163 74;
|
||||
--color-green-500: 34 197 94; /* standard green */
|
||||
--color-green-400: 74 222 128;
|
||||
--color-green-300: 110 231 183; /* vibrant but not neon */
|
||||
--color-green-200: 167 243 208; /* gentle mid-light tone */
|
||||
--color-green-100: 209 260 229; /* soft and airy */
|
||||
--color-green-50: 240 263 245; /* subtle, barely tinted background */
|
||||
|
||||
--color-red-950: 69 10 10; /* darkest red */
|
||||
--color-red-900: 127 29 29;
|
||||
--color-red-800: 153 27 27;
|
||||
--color-red-700: 185 28 28;
|
||||
--color-red-600: 220 38 38;
|
||||
--color-red-500: 239 68 68; /* standard red */
|
||||
--color-red-400: 248 113 113;
|
||||
--color-red-300: 252 165 165;
|
||||
--color-red-200: 254 202 202;
|
||||
--color-red-100: 254 226 226;
|
||||
--color-red-50: 254 242 242; /* lightest red */
|
||||
|
||||
--color-yellow-950: 66 50 3; /* darkest yellow */
|
||||
--color-yellow-900: 113 63 18;
|
||||
--color-yellow-800: 146 64 14;
|
||||
--color-yellow-700: 180 83 9;
|
||||
--color-yellow-600: 217 119 6;
|
||||
--color-yellow-500: 245 158 11; /* standard yellow */
|
||||
--color-yellow-400: 251 191 36;
|
||||
--color-yellow-300: 252 211 77;
|
||||
--color-yellow-200: 253 230 138;
|
||||
--color-yellow-100: 254 243 199;
|
||||
--color-yellow-50: 255 251 235; /* lightest yellow */
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
|
||||
--color-green-50: 240 253 244; /* lightest green */
|
||||
--color-green-100: 220 252 231;
|
||||
--color-green-200: 187 247 208;
|
||||
--color-green-300: 134 239 172;
|
||||
--color-green-400: 74 222 128;
|
||||
--color-green-500: 34 197 94; /* standard green */
|
||||
--color-green-600: 22 163 74;
|
||||
--color-green-700: 21 128 61;
|
||||
--color-green-800: 22 101 52;
|
||||
--color-green-900: 20 83 45;
|
||||
--color-green-950: 5 46 22; /* darkest green */
|
||||
|
||||
--color-red-50: 254 242 242; /* lightest red */
|
||||
--color-red-100: 254 226 226;
|
||||
--color-red-200: 254 202 202;
|
||||
--color-red-300: 252 165 165;
|
||||
--color-red-400: 248 113 113;
|
||||
--color-red-500: 239 68 68; /* standard red */
|
||||
--color-red-600: 220 38 38;
|
||||
--color-red-700: 185 28 28;
|
||||
--color-red-800: 153 27 27;
|
||||
--color-red-900: 127 29 29;
|
||||
--color-red-950: 69 10 10; /* darkest red */
|
||||
|
||||
--color-yellow-50: 255 251 235; /* lightest yellow */
|
||||
--color-yellow-100: 254 243 199;
|
||||
--color-yellow-200: 253 230 138;
|
||||
--color-yellow-300: 252 211 77;
|
||||
--color-yellow-400: 251 191 36;
|
||||
--color-yellow-500: 245 158 11; /* standard yellow */
|
||||
--color-yellow-600: 217 119 6;
|
||||
--color-yellow-700: 180 83 9;
|
||||
--color-yellow-800: 146 64 14;
|
||||
--color-yellow-900: 113 63 18;
|
||||
--color-yellow-950: 66 50 3; /* darkest yellow */
|
||||
|
||||
--color-ink-1000: 255 255 255; /* white */
|
||||
--color-ink-950: 250 250 250; /* #FAFAFA */
|
||||
--color-ink-900: 242 242 242; /* #F2F2F2 */
|
||||
|
||||
@@ -350,6 +350,45 @@ module.exports = {
|
||||
900: 'hsl(0,0%,10%)',
|
||||
950: 'hsl(0,0%,5%)',
|
||||
},
|
||||
green: {
|
||||
50: 'rgb(var(--color-green-50) / <alpha-value>)',
|
||||
100: 'rgb(var(--color-green-100) / <alpha-value>)',
|
||||
200: 'rgb(var(--color-green-200) / <alpha-value>)',
|
||||
300: 'rgb(var(--color-green-300) / <alpha-value>)',
|
||||
400: 'rgb(var(--color-green-400) / <alpha-value>)',
|
||||
500: 'rgb(var(--color-green-500) / <alpha-value>)',
|
||||
600: 'rgb(var(--color-green-600) / <alpha-value>)',
|
||||
700: 'rgb(var(--color-green-700) / <alpha-value>)',
|
||||
800: 'rgb(var(--color-green-800) / <alpha-value>)',
|
||||
900: 'rgb(var(--color-green-900) / <alpha-value>)',
|
||||
950: 'rgb(var(--color-green-950) / <alpha-value>)',
|
||||
},
|
||||
yellow: {
|
||||
50: 'rgb(var(--color-yellow-50) / <alpha-value>)',
|
||||
100: 'rgb(var(--color-yellow-100) / <alpha-value>)',
|
||||
200: 'rgb(var(--color-yellow-200) / <alpha-value>)',
|
||||
300: 'rgb(var(--color-yellow-300) / <alpha-value>)',
|
||||
400: 'rgb(var(--color-yellow-400) / <alpha-value>)',
|
||||
500: 'rgb(var(--color-yellow-500) / <alpha-value>)',
|
||||
600: 'rgb(var(--color-yellow-600) / <alpha-value>)',
|
||||
700: 'rgb(var(--color-yellow-700) / <alpha-value>)',
|
||||
800: 'rgb(var(--color-yellow-800) / <alpha-value>)',
|
||||
900: 'rgb(var(--color-yellow-900) / <alpha-value>)',
|
||||
950: 'rgb(var(--color-yellow-950) / <alpha-value>)',
|
||||
},
|
||||
red: {
|
||||
50: 'rgb(var(--color-red-50) / <alpha-value>)',
|
||||
100: 'rgb(var(--color-red-100) / <alpha-value>)',
|
||||
200: 'rgb(var(--color-red-200) / <alpha-value>)',
|
||||
300: 'rgb(var(--color-red-300) / <alpha-value>)',
|
||||
400: 'rgb(var(--color-red-400) / <alpha-value>)',
|
||||
500: 'rgb(var(--color-red-500) / <alpha-value>)',
|
||||
600: 'rgb(var(--color-red-600) / <alpha-value>)',
|
||||
700: 'rgb(var(--color-red-700) / <alpha-value>)',
|
||||
800: 'rgb(var(--color-red-800) / <alpha-value>)',
|
||||
900: 'rgb(var(--color-red-900) / <alpha-value>)',
|
||||
950: 'rgb(var(--color-red-950) / <alpha-value>)',
|
||||
},
|
||||
warning: '#F0D630',
|
||||
error: '#E70D3D',
|
||||
scarlet: {
|
||||
|
||||
Reference in New Issue
Block a user