Skip to content
Merged
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
32 changes: 31 additions & 1 deletion database_schema_definition.sql
Original file line number Diff line number Diff line change
Expand Up @@ -214,4 +214,34 @@ WHERE id = p_problem_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Ensure the function is accessible to authenticated users
GRANT EXECUTE ON FUNCTION update_problem_feedback TO authenticated;
GRANT EXECUTE ON FUNCTION update_problem_feedback TO authenticated;
-- Create user_solved_problems table to track which problems users have solved
CREATE TABLE IF NOT EXISTS user_solved_problems (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
problem_id UUID NOT NULL REFERENCES problems(id) ON DELETE CASCADE,
solved_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, problem_id)
);

-- Create RLS policies for user_solved_problems table
ALTER TABLE user_solved_problems ENABLE ROW LEVEL SECURITY;

-- Users can read all solved problems (needed for displaying statistics)
CREATE POLICY "Anyone can read solved problems" ON user_solved_problems FOR
SELECT USING (true);

-- Users can only mark their own problems as solved
CREATE POLICY "Users can mark their own solved problems" ON user_solved_problems FOR
INSERT WITH CHECK (auth.uid() = user_id);

-- Users can only update their own solved problems
CREATE POLICY "Users can update their own solved problems" ON user_solved_problems FOR
UPDATE USING (auth.uid() = user_id);

-- Users can only delete their own solved problems
CREATE POLICY "Users can delete their own solved problems" ON user_solved_problems FOR
DELETE USING (auth.uid() = user_id);

-- Grant access to authenticated users
GRANT ALL ON user_solved_problems TO authenticated;
101 changes: 87 additions & 14 deletions src/lib/components/ProblemTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ const kattisLogo = '/images/kattis.png';
// Props
export let problems: Problem[] = [];
export let userFeedback: Record<string, 'like' | 'dislike' | null> = {};
export let userSolvedProblems: Set<string> = new Set();
export let onLike: (problemId: string, isLike: boolean) => Promise<void>;
export let onToggleSolved: (problemId: string, isSolved: boolean) => Promise<void>;

// State
let isAuthenticated = false;
Expand Down Expand Up @@ -62,6 +64,8 @@ function getDifficultyTooltip(problem: Problem): string {
>
<thead>
<tr>
<th class="sticky top-0 z-10 w-[5%] bg-[var(--color-tertiary)] p-3 text-center font-bold"
></th>
<th class="sticky top-0 z-10 w-[5%] bg-[var(--color-tertiary)] p-3 text-center font-bold"
></th>
<th class="sticky top-0 z-10 w-[25%] bg-[var(--color-tertiary)] p-3 text-left font-bold"
Expand All @@ -76,13 +80,53 @@ function getDifficultyTooltip(problem: Problem): string {
<th class="sticky top-0 z-10 w-[20%] bg-[var(--color-tertiary)] p-3 text-left font-bold"
>Added By</th
>
<th class="sticky top-0 z-10 w-[25%] bg-[var(--color-tertiary)] p-3 text-right font-bold"
<th class="sticky top-0 z-10 w-[20%] bg-[var(--color-tertiary)] p-3 text-right font-bold"
></th>
</tr>
</thead>
<tbody>
{#each problems as problem}
<tr class="border-b border-[var(--color-border)] last:border-b-0 hover:bg-black/5">
<tr
class="relative border-b border-[var(--color-border)] transition-colors duration-200 last:border-b-0
${problem.id && userSolvedProblems.has(problem.id)
? 'bg-[color-mix(in_oklab,rgb(34_197_94)_15%,transparent)] hover:bg-[color-mix(in_oklab,rgb(34_197_94)_20%,transparent)]'
: 'hover:bg-black/5'}"
>
<td class="p-3 text-center">
{#if problem.id}
{@const isSolved = userSolvedProblems.has(problem.id)}
<button
class={`flex h-8 w-8 cursor-pointer items-center justify-center rounded-full shadow-sm transition-all duration-300
${isSolved
? 'solved-button bg-[rgb(34_197_94)] text-white shadow-[0_0_8px_rgba(34,197,94,0.4)]'
: 'border border-[var(--color-border)] bg-transparent text-[var(--color-text)] hover:border-[rgb(34_197_94)] hover:bg-[color-mix(in_oklab,rgb(34_197_94)_10%,transparent)] hover:text-[rgb(34_197_94)] hover:shadow-[0_0_5px_rgba(34,197,94,0.2)]'
} ${!isAuthenticated ? 'cursor-not-allowed opacity-50' : ''}`}
on:click={() => isAuthenticated && onToggleSolved(problem.id!, !isSolved)}
title={!isAuthenticated
? 'Sign in to mark problems as solved'
: isSolved
? 'Mark as unsolved'
: 'Mark as solved'}
disabled={!isAuthenticated}
aria-label={isSolved ? 'Mark as unsolved' : 'Mark as solved'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="checkmark-icon stroke-2"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</button>
{/if}
</td>
<td class="p-3 text-center">
<span class="flex items-center justify-center">
<img
Expand Down Expand Up @@ -148,17 +192,18 @@ function getDifficultyTooltip(problem: Problem): string {
{@const hasLiked = userFeedback[problem.id] === 'like'}
{@const hasDisliked = userFeedback[problem.id] === 'dislike'}

<!-- Like button -->
<button
class={`flex cursor-pointer items-center gap-1 rounded border px-2 py-1 transition-all duration-200
${hasLiked
? 'border-[color-mix(in_oklab,rgb(34_197_94)_50%,transparent)] bg-[color-mix(in_oklab,rgb(34_197_94)_10%,transparent)] text-[rgb(34_197_94)]'
${hasLiked
? 'border-[color-mix(in_oklab,rgb(34_197_94)_50%,transparent)] bg-[color-mix(in_oklab,rgb(34_197_94)_10%,transparent)] text-[rgb(34_197_94)]'
: 'border-[var(--color-border)] bg-transparent text-[var(--color-text)] hover:border-[color-mix(in_oklab,rgb(34_197_94)_50%,transparent)] hover:bg-[color-mix(in_oklab,rgb(34_197_94)_10%,transparent)] hover:text-[rgb(34_197_94)]'
} ${!isAuthenticated ? 'cursor-not-allowed opacity-50' : ''}`}
on:click={() => isAuthenticated && onLike(problem.id!, true)}
title={!isAuthenticated
? 'Sign in to like problems'
: hasLiked
? 'Undo like'
title={!isAuthenticated
? 'Sign in to like problems'
: hasLiked
? 'Undo like'
: 'Like this problem'}
disabled={!isAuthenticated}
>
Expand All @@ -181,17 +226,18 @@ function getDifficultyTooltip(problem: Problem): string {
<span>{problem.likes}</span>
</button>

<!-- Dislike button -->
<button
class={`flex cursor-pointer items-center gap-1 rounded border px-2 py-1 transition-all duration-200
${hasDisliked
? 'border-[color-mix(in_oklab,rgb(239_68_68)_50%,transparent)] bg-[color-mix(in_oklab,rgb(239_68_68)_10%,transparent)] text-[rgb(239_68_68)]'
${hasDisliked
? 'border-[color-mix(in_oklab,rgb(239_68_68)_50%,transparent)] bg-[color-mix(in_oklab,rgb(239_68_68)_10%,transparent)] text-[rgb(239_68_68)]'
: 'border-[var(--color-border)] bg-transparent text-[var(--color-text)] hover:border-[color-mix(in_oklab,rgb(239_68_68)_50%,transparent)] hover:bg-[color-mix(in_oklab,rgb(239_68_68)_10%,transparent)] hover:text-[rgb(239_68_68)]'
} ${!isAuthenticated ? 'cursor-not-allowed opacity-50' : ''}`}
on:click={() => isAuthenticated && onLike(problem.id!, false)}
title={!isAuthenticated
? 'Sign in to dislike problems'
: hasDisliked
? 'Undo dislike'
title={!isAuthenticated
? 'Sign in to dislike problems'
: hasDisliked
? 'Undo dislike'
: 'Dislike this problem'}
disabled={!isAuthenticated}
>
Expand Down Expand Up @@ -255,4 +301,31 @@ td:last-child {
-webkit-overflow-scrolling: touch;
width: 100%;
}

/* Add subtle animation for solved problems */
tr {
overflow: hidden;
}

/* Checkmark animation */
.solved-button {
animation: pulse 1.5s infinite alternate;
}

.checkmark-icon {
transition: transform 0.3s ease;
}

.solved-button .checkmark-icon {
transform: scale(1.1);
}

@keyframes pulse {
0% {
box-shadow: 0 0 5px rgba(34, 197, 94, 0.4);
}
100% {
box-shadow: 0 0 10px rgba(34, 197, 94, 0.7);
}
}
</style>
91 changes: 91 additions & 0 deletions src/lib/services/problem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ export type UserFeedback = {
feedback_type: 'like' | 'dislike';
};

/**
* User solved problem type
*/
export type UserSolvedProblem = {
id: string;
user_id: string;
problem_id: string;
solved_at: string;
};

/**
* Determine the problem source based on URL
*/
Expand Down Expand Up @@ -221,6 +231,87 @@ export async function fetchUserFeedback(): Promise<Record<string, 'like' | 'disl
}
}

/**
* Fetches user's solved problems
* @returns Set of solved problem IDs
*/
export async function fetchUserSolvedProblems(): Promise<Set<string>> {
const currentUser = get(user);

if (!currentUser) {
return new Set();
}

try {
const { data, error } = await supabase
.from('user_solved_problems')
.select('problem_id')
.eq('user_id', currentUser.id);

if (error) {
console.error('Error fetching user solved problems:', error);
return new Set();
}

return new Set(data.map((item) => item.problem_id));
} catch (err) {
console.error('Failed to fetch user solved problems:', err);
return new Set();
}
}

/**
* Marks a problem as solved or unsolved by the current user
* @param problemId - Problem ID
* @param isSolved - Whether to mark as solved (true) or unsolved (false)
* @returns Promise with success flag
*/
export async function toggleProblemSolved(problemId: string, isSolved: boolean): Promise<boolean> {
const currentUser = get(user);

if (!currentUser) {
console.error('Cannot update solved status: User not authenticated');
return false;
}

try {
if (isSolved) {
// Mark problem as solved
const { error } = await supabase.from('user_solved_problems').insert({
user_id: currentUser.id,
problem_id: problemId
});

if (error) {
// If the error is a duplicate key error, it means the problem is already marked as solved
if (error.code === '23505') {
// Postgres unique violation code
return true; // Already solved, so consider it a success
}
console.error(`Error marking problem ${problemId} as solved:`, error);
return false;
}
} else {
// Mark problem as unsolved (delete the record)
const { error } = await supabase
.from('user_solved_problems')
.delete()
.eq('user_id', currentUser.id)
.eq('problem_id', problemId);

if (error) {
console.error(`Error marking problem ${problemId} as unsolved:`, error);
return false;
}
}

return true;
} catch (err) {
console.error(`Failed to update solved status for problem ${problemId}:`, err);
return false;
}
}

/**
* Updates a problem's likes or dislikes in the database
* @param problemId - Problem ID
Expand Down
Loading