mirror of
https://github.com/fergalmoran/opengifame.git
synced 2025-12-22 09:38:44 +00:00
feat: Add comprehensive legal compliance and UI enhancements
• Add comprehensive privacy documentation - Create docs/PRIVACY.md with complete privacy policy - Create docs/GDPR.md with detailed GDPR compliance guide - Include account deletion procedures and user rights • Implement legal page routes - Add /privacy page with markdown rendering - Add /gdpr page with markdown rendering - Install react-markdown for content display • Enhance site layout and navigation - Create footer component with legal links - Make footer sticky to bottom of page - Add responsive layout with flexbox structure • Improve header UI/UX - Add user avatar with initials and colors - Implement dropdown menu for user actions - Create prominent centered upload button - Remove redundant navigation items • Fix code quality issues - Resolve all ESLint warnings and errors - Comment out unused imports for future features - Fix TypeScript interface redundancy - Update Next.js 15 async params handling • Add UI components - Create Avatar component with fallback initials - Add DropdownMenu component for user actions - Enhance voting buttons with hover animations The changes establish proper legal compliance (GDPR/privacy), improve user experience with better navigation and visual design, and maintain clean code standards throughout the application.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,4 +39,5 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env.production
|
||||
INSTRUCTIONS.md
|
||||
299
docs/GDPR.md
Normal file
299
docs/GDPR.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# GDPR Compliance Guide
|
||||
|
||||
## Effective Date: July 5, 2025
|
||||
|
||||
## Introduction
|
||||
|
||||
This document outlines OpenGIFame's compliance with the General Data Protection Regulation (GDPR) and your rights as a data subject. The GDPR gives you specific rights regarding your personal data, and we are committed to respecting and facilitating these rights.
|
||||
|
||||
## Your Rights Under GDPR
|
||||
|
||||
### 1. Right to Information (Article 13-14)
|
||||
|
||||
You have the right to know:
|
||||
|
||||
- What personal data we collect
|
||||
- Why we collect it
|
||||
- How long we keep it
|
||||
- Who we share it with
|
||||
- Your rights regarding this data
|
||||
|
||||
This information is detailed in our [Privacy Policy](./PRIVACY.md).
|
||||
|
||||
### 2. Right of Access (Article 15)
|
||||
|
||||
You have the right to:
|
||||
|
||||
- Confirm whether we process your personal data
|
||||
- Access your personal data
|
||||
- Receive information about how we process it
|
||||
|
||||
**How to exercise this right:**
|
||||
|
||||
1. Sign in to your OpenGIFame account
|
||||
2. Go to Account Settings → Privacy & Data
|
||||
3. Click "Download My Data" to receive a complete copy of your data
|
||||
4. Alternatively, contact us at [privacy@opengifame.com] with your request
|
||||
|
||||
### 3. Right to Rectification (Article 16)
|
||||
|
||||
You have the right to correct inaccurate or incomplete personal data.
|
||||
|
||||
**How to exercise this right:**
|
||||
|
||||
1. **Profile Information**: Update directly in Account Settings
|
||||
2. **Content**: Edit your uploaded images, titles, and descriptions
|
||||
3. **Other Data**: Contact us at [privacy@opengifame.com] for assistance
|
||||
|
||||
### 4. Right to Erasure ("Right to be Forgotten") (Article 17)
|
||||
|
||||
You have the right to request deletion of your personal data when:
|
||||
|
||||
- The data is no longer necessary for the original purpose
|
||||
- You withdraw consent
|
||||
- You object to processing and there are no overriding legitimate grounds
|
||||
- The data has been unlawfully processed
|
||||
- Deletion is required for legal compliance
|
||||
|
||||
**See the "Complete Account Deletion" section below for detailed instructions.**
|
||||
|
||||
### 5. Right to Restrict Processing (Article 18)
|
||||
|
||||
You can request we limit how we use your data while we:
|
||||
|
||||
- Verify the accuracy of your data
|
||||
- Determine legitimate grounds for processing
|
||||
- Handle your objection to processing
|
||||
|
||||
**How to exercise this right:**
|
||||
Contact us at [privacy@opengifame.com] with your specific request.
|
||||
|
||||
### 6. Right to Data Portability (Article 20)
|
||||
|
||||
You have the right to:
|
||||
|
||||
- Receive your personal data in a structured, commonly used format
|
||||
- Transfer your data to another service
|
||||
|
||||
**How to exercise this right:**
|
||||
|
||||
1. Go to Account Settings → Privacy & Data
|
||||
2. Click "Export Data" to download your data in JSON format
|
||||
3. This includes your profile, uploaded images, comments, and voting history
|
||||
|
||||
### 7. Right to Object (Article 21)
|
||||
|
||||
You can object to processing based on:
|
||||
|
||||
- Legitimate interests
|
||||
- Direct marketing
|
||||
- Profiling
|
||||
|
||||
**How to exercise this right:**
|
||||
Contact us at [privacy@opengifame.com] to discuss your objection.
|
||||
|
||||
### 8. Rights Related to Automated Decision Making (Article 22)
|
||||
|
||||
We do not use automated decision-making or profiling that significantly affects you. Our recommendation algorithms are designed to enhance user experience and do not make decisions that have legal or similarly significant effects.
|
||||
|
||||
## Complete Account Deletion Guide
|
||||
|
||||
### What Gets Deleted
|
||||
|
||||
When you delete your account, we will permanently remove:
|
||||
|
||||
- **Account Information**: Name, email, profile picture, and all account settings
|
||||
- **Authentication Data**: All login credentials and session tokens
|
||||
- **Uploaded Content**: All images, titles, and descriptions you've uploaded
|
||||
- **Social Activity**: All comments, votes (upvotes/downvotes), and reactions
|
||||
- **Metadata**: Upload timestamps, IP addresses, and activity logs
|
||||
- **Tags**: Any tags you created (if not used by other users)
|
||||
|
||||
### What Happens to Your Content
|
||||
|
||||
- **Your Images**: Permanently deleted from our servers and CDN
|
||||
- **Your Comments**: Removed from all discussions
|
||||
- **Your Votes**: All voting records are deleted
|
||||
- **Content References**: Any references to your deleted content are cleaned up
|
||||
|
||||
### Before You Delete Your Account
|
||||
|
||||
**Important considerations:**
|
||||
|
||||
1. **Irreversible Action**: Account deletion cannot be undone
|
||||
2. **Download Your Data**: Export your data first if you want to keep copies
|
||||
3. **Active Discussions**: Your comments in discussions will be removed
|
||||
4. **Shared Content**: Any images you've shared will no longer be accessible
|
||||
|
||||
### Step-by-Step Deletion Process
|
||||
|
||||
#### Method 1: Self-Service Deletion (Recommended)
|
||||
|
||||
1. **Sign in** to your OpenGIFame account
|
||||
2. **Navigate** to Account Settings → Privacy & Data
|
||||
3. **Review** the "Delete Account" section warnings
|
||||
4. **Optional**: Download your data using "Export Data" button
|
||||
5. **Click** "Delete My Account"
|
||||
6. **Confirm** by typing "DELETE" in the confirmation field
|
||||
7. **Enter** your password to verify identity
|
||||
8. **Final Confirmation**: Click "Permanently Delete Account"
|
||||
|
||||
#### Method 2: Contact-Based Deletion
|
||||
|
||||
If you cannot access your account:
|
||||
|
||||
1. **Email** us at [privacy@opengifame.com]
|
||||
2. **Include** the following information:
|
||||
- Full name associated with the account
|
||||
- Email address used for registration
|
||||
- Approximate account creation date
|
||||
- Reason you cannot access the account
|
||||
3. **Verification**: We may ask for additional verification
|
||||
4. **Processing**: We'll process your request within 30 days
|
||||
|
||||
### Deletion Timeline
|
||||
|
||||
- **Immediate**: Account becomes inaccessible
|
||||
- **24 hours**: Content removed from public view
|
||||
- **7 days**: Data purged from active systems
|
||||
- **30 days**: Complete removal from all backups and archives
|
||||
- **90 days**: Final verification that all data has been removed
|
||||
|
||||
### Data We May Retain
|
||||
|
||||
In limited circumstances, we may retain some information for:
|
||||
|
||||
**Legal Compliance**:
|
||||
|
||||
- Transaction records (if applicable)
|
||||
- Compliance with data retention laws
|
||||
- Evidence for legal proceedings
|
||||
|
||||
**Security and Fraud Prevention**:
|
||||
|
||||
- Anonymized security logs (without personal identifiers)
|
||||
- Records of policy violations or fraudulent activity
|
||||
|
||||
**Technical Requirements**:
|
||||
|
||||
- Anonymized analytics data (aggregated, non-personal)
|
||||
- System performance metrics
|
||||
|
||||
### Exceptions to Deletion
|
||||
|
||||
We may be unable to delete data if:
|
||||
|
||||
- **Legal Hold**: Data is subject to legal proceedings
|
||||
- **Regulatory Requirements**: Required by law to retain specific data
|
||||
- **Public Interest**: Data is required for public health or safety
|
||||
- **Technical Impossibility**: Data is technically impossible to isolate and delete
|
||||
|
||||
## Exercising Your Rights
|
||||
|
||||
### Response Time
|
||||
|
||||
We will respond to your requests:
|
||||
|
||||
- **Acknowledgment**: Within 72 hours
|
||||
- **Complete Response**: Within 30 days (extendable to 60 days for complex requests)
|
||||
|
||||
### Verification Process
|
||||
|
||||
To protect your privacy, we may need to verify your identity before processing requests:
|
||||
|
||||
1. **Account Access**: Sign in to your account when possible
|
||||
2. **Email Verification**: Confirm ownership of the registered email
|
||||
3. **Additional Verification**: Answer security questions if needed
|
||||
|
||||
### No Cost
|
||||
|
||||
Exercising your GDPR rights is free of charge. However, we may charge a reasonable fee for:
|
||||
|
||||
- Manifestly unfounded or excessive requests
|
||||
- Additional copies of data beyond the first free copy
|
||||
|
||||
### Appeal Process
|
||||
|
||||
If you're not satisfied with our response:
|
||||
|
||||
1. **Contact** our Data Protection Officer at [dpo@opengifame.com]
|
||||
2. **Escalate** to your local supervisory authority
|
||||
3. **EU Residents**: Contact your national data protection authority
|
||||
4. **UK Residents**: Contact the Information Commissioner's Office (ICO)
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Data Mapping
|
||||
|
||||
We maintain a comprehensive data map showing:
|
||||
|
||||
- What personal data we collect
|
||||
- Where it's stored
|
||||
- How it's processed
|
||||
- Retention periods
|
||||
- Sharing arrangements
|
||||
|
||||
### Security Measures
|
||||
|
||||
- **Encryption**: All personal data is encrypted at rest and in transit
|
||||
- **Access Controls**: Strict access controls and audit logs
|
||||
- **Regular Audits**: Quarterly security and privacy audits
|
||||
- **Staff Training**: Regular GDPR training for all staff
|
||||
|
||||
### Data Processing Records
|
||||
|
||||
We maintain detailed records of:
|
||||
|
||||
- Processing activities
|
||||
- Legal basis for processing
|
||||
- Data sharing agreements
|
||||
- Retention schedules
|
||||
- Security measures
|
||||
|
||||
## Contact Information
|
||||
|
||||
### Data Protection Officer (DPO)
|
||||
|
||||
- **Email**: [dpo@opengifame.com]
|
||||
- **Response Time**: Within 72 hours
|
||||
|
||||
### Privacy Team
|
||||
|
||||
- **Email**: [privacy@opengifame.com]
|
||||
- **Response Time**: Within 24 hours for urgent matters
|
||||
|
||||
### Supervisory Authorities
|
||||
|
||||
If you believe we have not complied with GDPR:
|
||||
|
||||
**EU Residents**: Contact your national data protection authority
|
||||
**UK Residents**: Information Commissioner's Office (ICO)
|
||||
|
||||
- Website: ico.org.uk
|
||||
- Helpline: 0303 123 1113
|
||||
|
||||
## Updates to This Document
|
||||
|
||||
We may update this GDPR compliance guide to reflect:
|
||||
|
||||
- Changes in data protection law
|
||||
- Updates to our data processing practices
|
||||
- Feedback from supervisory authorities
|
||||
- User feedback and requests
|
||||
|
||||
**Notification**: We will notify you of significant changes through:
|
||||
|
||||
- Email notification to registered users
|
||||
- Notice on our platform
|
||||
- Updated "Effective Date" at the top of this document
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Privacy Policy](./PRIVACY.md) - Comprehensive privacy information
|
||||
- Terms of Service - Platform usage terms (coming soon)
|
||||
- Data Processing Agreement - For business users (coming soon)
|
||||
- Cookie Policy - Information about cookies and tracking (coming soon)
|
||||
|
||||
---
|
||||
|
||||
*This document was last updated on July 5, 2025. For questions about GDPR compliance, contact our privacy team at [privacy@opengifame.com].*
|
||||
159
docs/PRIVACY.md
Normal file
159
docs/PRIVACY.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Privacy Policy
|
||||
|
||||
## Effective Date: July 5, 2025
|
||||
|
||||
## Introduction
|
||||
|
||||
Welcome to OpenGIFame ("we," "our," or "us"). We are committed to protecting your privacy and being transparent about how we collect, use, and share your information. This Privacy Policy explains our practices regarding your personal information when you use our image sharing platform.
|
||||
|
||||
## Information We Collect
|
||||
|
||||
### Information You Provide Directly
|
||||
|
||||
- **Account Information**: When you create an account, we collect your name, email address, and any profile information you choose to provide
|
||||
- **Content**: Images, titles, descriptions, comments, and other content you upload or share on our platform
|
||||
- **Communications**: Messages you send to us or other users through our platform
|
||||
|
||||
### Information We Collect Automatically
|
||||
|
||||
- **Usage Data**: How you interact with our platform, including pages visited, features used, and time spent
|
||||
- **Device Information**: Information about your device, browser, and operating system
|
||||
- **Log Data**: IP addresses, access times, and technical details about your requests
|
||||
- **Cookies and Similar Technologies**: We use cookies and similar technologies to improve your experience and analyze platform usage
|
||||
|
||||
### Information from Third Parties
|
||||
|
||||
- **Authentication Providers**: If you sign in using third-party services (Google, GitHub, etc.), we receive basic profile information as permitted by those services
|
||||
- **Analytics Services**: We may use third-party analytics services that collect information about your use of our platform
|
||||
|
||||
## How We Use Your Information
|
||||
|
||||
We use the information we collect to:
|
||||
|
||||
- **Provide Our Services**: Create and manage your account, enable image uploads, display content, and facilitate voting and commenting
|
||||
- **Improve Our Platform**: Analyze usage patterns, fix bugs, and develop new features
|
||||
- **Communicate**: Send you important updates, respond to your inquiries, and provide customer support
|
||||
- **Security**: Protect against fraud, abuse, and security threats
|
||||
- **Legal Compliance**: Comply with applicable laws and regulations
|
||||
|
||||
## How We Share Your Information
|
||||
|
||||
We may share your information in the following circumstances:
|
||||
|
||||
### Public Information
|
||||
|
||||
- **Profile Information**: Your username and profile picture are public by default
|
||||
- **Uploaded Content**: Images, titles, descriptions, comments, and votes you post are public and visible to all users
|
||||
- **Activity**: Your voting patterns and comments are visible to other users
|
||||
|
||||
### Service Providers
|
||||
|
||||
We may share information with trusted third-party service providers who help us operate our platform, including:
|
||||
|
||||
- Cloud hosting providers
|
||||
- Analytics services
|
||||
- Authentication services
|
||||
- Content delivery networks
|
||||
|
||||
### Legal Requirements
|
||||
|
||||
We may disclose your information if required by law or if we believe it's necessary to:
|
||||
|
||||
- Comply with legal processes or government requests
|
||||
- Protect the rights, property, or safety of OpenGIFame, our users, or the public
|
||||
- Investigate potential violations of our terms of service
|
||||
|
||||
### Business Transfers
|
||||
|
||||
If we're involved in a merger, acquisition, or sale of assets, your information may be transferred as part of that transaction.
|
||||
|
||||
## Data Retention
|
||||
|
||||
We retain your information for as long as necessary to provide our services and comply with legal obligations:
|
||||
|
||||
- **Account Information**: Until you delete your account or request deletion
|
||||
- **Uploaded Content**: Until you delete the content or your account
|
||||
- **Usage Data**: Typically retained for 2 years unless longer retention is required by law
|
||||
- **Security Logs**: Retained for up to 1 year for security and fraud prevention
|
||||
|
||||
## Your Rights and Choices
|
||||
|
||||
### Account Management
|
||||
|
||||
- **Access**: You can access and review your account information at any time
|
||||
- **Update**: You can update your profile information through your account settings
|
||||
- **Delete**: You can delete your uploaded content and close your account
|
||||
|
||||
### Communication Preferences
|
||||
|
||||
- **Notifications**: You can control notification preferences in your account settings
|
||||
- **Marketing**: You can opt out of promotional communications
|
||||
|
||||
### Data Portability
|
||||
|
||||
Upon request, we can provide you with a copy of your personal data in a commonly used format.
|
||||
|
||||
### Deletion Rights
|
||||
|
||||
You can request deletion of your personal information, subject to certain legal limitations.
|
||||
|
||||
## Data Security
|
||||
|
||||
We implement appropriate technical and organizational measures to protect your information:
|
||||
|
||||
- **Encryption**: Data is encrypted in transit and at rest
|
||||
- **Access Controls**: Limited access to personal information on a need-to-know basis
|
||||
- **Regular Security Reviews**: We regularly assess and update our security practices
|
||||
- **Incident Response**: We have procedures in place to respond to potential security breaches
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
Our platform is not intended for children under 13. We do not knowingly collect personal information from children under 13. If we become aware that we have collected such information, we will take steps to delete it promptly.
|
||||
|
||||
## International Data Transfers
|
||||
|
||||
Your information may be transferred to and processed in countries other than your country of residence. We ensure appropriate safeguards are in place for such transfers.
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
We may update this Privacy Policy from time to time. When we make changes:
|
||||
|
||||
- We will post the updated policy on this page
|
||||
- We will update the "Effective Date" at the top
|
||||
- For material changes, we may provide additional notice through our platform or email
|
||||
|
||||
## Contact Us
|
||||
|
||||
If you have questions about this Privacy Policy or our privacy practices, please contact us at:
|
||||
|
||||
- **Email**: [Your contact email]
|
||||
- **Address**: [Your business address]
|
||||
|
||||
## Additional Rights for EU/UK Residents
|
||||
|
||||
If you are located in the European Union or United Kingdom, you have additional rights under GDPR/UK GDPR:
|
||||
|
||||
- **Right to Access**: Request access to your personal data
|
||||
- **Right to Rectification**: Request correction of inaccurate data
|
||||
- **Right to Erasure**: Request deletion of your data
|
||||
- **Right to Restrict Processing**: Request limitation of processing
|
||||
- **Right to Data Portability**: Request transfer of your data
|
||||
- **Right to Object**: Object to processing based on legitimate interests
|
||||
- **Right to Withdraw Consent**: Withdraw consent for processing
|
||||
|
||||
To exercise these rights, please contact us using the information above.
|
||||
|
||||
## Additional Rights for California Residents
|
||||
|
||||
If you are a California resident, you have additional rights under the California Consumer Privacy Act (CCPA):
|
||||
|
||||
- **Right to Know**: Request information about the categories and specific pieces of personal information we collect
|
||||
- **Right to Delete**: Request deletion of your personal information
|
||||
- **Right to Opt-Out**: Opt-out of the sale of personal information (we do not sell personal information)
|
||||
- **Right to Non-Discrimination**: You cannot be discriminated against for exercising your privacy rights
|
||||
|
||||
To exercise these rights, please contact us using the information above.
|
||||
|
||||
---
|
||||
|
||||
*This privacy policy was last updated on July 5, 2025.*
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/drizzle-adapter": "^1.10.0",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
@@ -34,6 +35,7 @@
|
||||
"postgres": "^3.4.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import bcrypt from 'bcryptjs';
|
||||
// TODO: Uncomment when database is connected
|
||||
// import bcrypt from 'bcryptjs';
|
||||
// import { db } from '@/lib/db';
|
||||
// import { users } from '@/lib/db/schema';
|
||||
|
||||
@@ -63,9 +64,9 @@ export async function POST(request: NextRequest) {
|
||||
// );
|
||||
// }
|
||||
|
||||
// Hash password
|
||||
const saltRounds = 12;
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
// TODO: Hash password when database is connected
|
||||
// const saltRounds = 12;
|
||||
// const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// TODO: Create user in database when DB is connected
|
||||
// const newUser = await db.insert(users).values({
|
||||
|
||||
78
src/app/gdpr/page.tsx
Normal file
78
src/app/gdpr/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Metadata } from 'next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'GDPR Compliance - OpenGIFame',
|
||||
description: 'GDPR compliance guide and data protection rights for OpenGIFame users',
|
||||
};
|
||||
|
||||
export default function GDPRPage() {
|
||||
// Read the markdown file
|
||||
const markdownPath = join(process.cwd(), 'docs', 'GDPR.md');
|
||||
const markdownContent = readFileSync(markdownPath, 'utf8');
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="prose prose-slate dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-4xl font-bold mb-6 text-foreground">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-2xl font-semibold mt-8 mb-4 text-foreground">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-xl font-medium mt-6 mb-3 text-foreground">{children}</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-lg font-medium mt-4 mb-2 text-foreground">{children}</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 text-muted-foreground leading-relaxed">{children}</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-6 mb-4 text-muted-foreground">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-6 mb-4 text-muted-foreground">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="mb-2">{children}</li>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-foreground">{children}</strong>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
hr: () => (
|
||||
<hr className="my-8 border-border" />
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-border pl-4 italic text-muted-foreground">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<code className="bg-muted px-2 py-1 rounded text-sm font-mono">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{markdownContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,14 +9,15 @@ import { VotingButtons } from '@/components/voting-buttons';
|
||||
import { getServerAuthSession } from '@/lib/server-auth';
|
||||
|
||||
interface ImagePageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function ImagePage({ params }: ImagePageProps) {
|
||||
try {
|
||||
const session = await getServerAuthSession();
|
||||
const { id } = await params;
|
||||
|
||||
const imageResult = await db
|
||||
.select({
|
||||
@@ -34,7 +35,7 @@ export default async function ImagePage({ params }: ImagePageProps) {
|
||||
})
|
||||
.from(images)
|
||||
.leftJoin(users, eq(images.uploadedBy, users.id))
|
||||
.where(eq(images.id, params.id))
|
||||
.where(eq(images.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (imageResult.length === 0) {
|
||||
@@ -51,7 +52,7 @@ export default async function ImagePage({ params }: ImagePageProps) {
|
||||
isUpvote: votes.isUpvote,
|
||||
})
|
||||
.from(votes)
|
||||
.where(and(eq(votes.imageId, params.id), eq(votes.userId, session.user.id)))
|
||||
.where(and(eq(votes.imageId, id), eq(votes.userId, session.user.id)))
|
||||
.limit(1);
|
||||
|
||||
if (userVoteResult.length > 0) {
|
||||
@@ -70,7 +71,7 @@ export default async function ImagePage({ params }: ImagePageProps) {
|
||||
})
|
||||
.from(comments)
|
||||
.leftJoin(users, eq(comments.authorId, users.id))
|
||||
.where(eq(comments.imageId, params.id))
|
||||
.where(eq(comments.imageId, id))
|
||||
.orderBy(desc(comments.createdAt));
|
||||
|
||||
return (
|
||||
@@ -78,7 +79,7 @@ export default async function ImagePage({ params }: ImagePageProps) {
|
||||
<Card className="max-w-4xl mx-auto">
|
||||
<CardHeader>
|
||||
<EditableTitle
|
||||
imageId={params.id}
|
||||
imageId={id}
|
||||
initialTitle={image.title || 'Untitled'}
|
||||
imageOwnerId={image.uploadedBy}
|
||||
/>
|
||||
@@ -89,7 +90,7 @@ export default async function ImagePage({ params }: ImagePageProps) {
|
||||
{/* Voting Buttons - Left Side */}
|
||||
<div className="flex flex-col items-center space-y-2 pt-4">
|
||||
<VotingButtons
|
||||
imageId={params.id}
|
||||
imageId={id}
|
||||
initialUpvotes={image.upvotes}
|
||||
initialDownvotes={image.downvotes}
|
||||
initialUserVote={userVote}
|
||||
@@ -119,7 +120,7 @@ export default async function ImagePage({ params }: ImagePageProps) {
|
||||
{/* Comments Section */}
|
||||
<div className="border-t pt-6">
|
||||
<Comments
|
||||
imageId={params.id}
|
||||
imageId={id}
|
||||
initialComments={imageComments.map(comment => ({
|
||||
id: comment.id,
|
||||
content: comment.content,
|
||||
|
||||
@@ -4,6 +4,7 @@ import "./globals.css";
|
||||
import { AuthProvider } from '@/components/auth-provider';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { Header } from '@/components/header';
|
||||
import { Footer } from '@/components/footer';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -37,12 +38,13 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<main className="flex-grow">{children}</main>
|
||||
<Footer />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { ImageCard } from '@/components/image-card';
|
||||
import { getServerAuthSession } from '@/lib/server-auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { images, users, imageTags, tags, votes, comments } from '@/lib/db/schema';
|
||||
import { eq, desc, inArray } from 'drizzle-orm';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { ImageCard } from "@/components/image-card";
|
||||
import { getServerAuthSession } from "@/lib/server-auth";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
images,
|
||||
users,
|
||||
imageTags,
|
||||
tags,
|
||||
votes,
|
||||
comments,
|
||||
} from "@/lib/db/schema";
|
||||
import { eq, desc, inArray } from "drizzle-orm";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export default async function Home() {
|
||||
try {
|
||||
@@ -28,7 +35,7 @@ export default async function Home() {
|
||||
.limit(20);
|
||||
|
||||
// Get tags for each image
|
||||
const imageIds = imagesData.map(img => img.id);
|
||||
const imageIds = imagesData.map((img) => img.id);
|
||||
|
||||
let imageTags_data: Array<{
|
||||
imageId: string;
|
||||
@@ -50,7 +57,7 @@ export default async function Home() {
|
||||
}
|
||||
|
||||
// Get user votes if logged in
|
||||
let userVotes: Record<string, 'up' | 'down'> = {};
|
||||
let userVotes: Record<string, "up" | "down"> = {};
|
||||
if (session?.user?.id && imageIds.length > 0) {
|
||||
const userVotesData = await db
|
||||
.select({
|
||||
@@ -58,16 +65,22 @@ export default async function Home() {
|
||||
isUpvote: votes.isUpvote,
|
||||
})
|
||||
.from(votes)
|
||||
.where(
|
||||
eq(votes.userId, session.user.id)
|
||||
);
|
||||
.where(eq(votes.userId, session.user.id));
|
||||
|
||||
const filteredVotes = userVotesData.filter(vote => imageIds.includes(vote.imageId));
|
||||
const filteredVotes = userVotesData.filter((vote) =>
|
||||
imageIds.includes(vote.imageId)
|
||||
);
|
||||
|
||||
userVotes = filteredVotes.reduce((acc: Record<string, 'up' | 'down'>, vote: { imageId: string; isUpvote: boolean }) => {
|
||||
acc[vote.imageId] = vote.isUpvote ? 'up' : 'down';
|
||||
return acc;
|
||||
}, {});
|
||||
userVotes = filteredVotes.reduce(
|
||||
(
|
||||
acc: Record<string, "up" | "down">,
|
||||
vote: { imageId: string; isUpvote: boolean }
|
||||
) => {
|
||||
acc[vote.imageId] = vote.isUpvote ? "up" : "down";
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
// Get comment counts
|
||||
@@ -83,29 +96,31 @@ export default async function Home() {
|
||||
.groupBy(comments.imageId);
|
||||
}
|
||||
|
||||
const commentCountMap = commentCounts.reduce((acc: Record<string, number>, { imageId, count }: { imageId: string; count: number }) => {
|
||||
acc[imageId] = count;
|
||||
return acc;
|
||||
}, {});
|
||||
const commentCountMap = commentCounts.reduce(
|
||||
(
|
||||
acc: Record<string, number>,
|
||||
{ imageId, count }: { imageId: string; count: number }
|
||||
) => {
|
||||
acc[imageId] = count;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
// Group tags by image
|
||||
const tagsByImage = imageTags_data.reduce((acc: Record<string, Array<{ id: string; name: string }>>, item) => {
|
||||
if (!acc[item.imageId]) acc[item.imageId] = [];
|
||||
if (item.tag) {
|
||||
acc[item.imageId].push(item.tag);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
const tagsByImage = imageTags_data.reduce(
|
||||
(acc: Record<string, Array<{ id: string; name: string }>>, item) => {
|
||||
if (!acc[item.imageId]) acc[item.imageId] = [];
|
||||
if (item.tag) {
|
||||
acc[item.imageId].push(item.tag);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Latest Images</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Discover and share amazing images with the community
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{imagesData.map((image) => (
|
||||
<ImageCard
|
||||
@@ -138,7 +153,7 @@ export default async function Home() {
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error loading homepage:', error);
|
||||
console.error("Error loading homepage:", error);
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center py-12">
|
||||
|
||||
70
src/app/privacy/page.tsx
Normal file
70
src/app/privacy/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Metadata } from 'next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Privacy Policy - OpenGIFame',
|
||||
description: 'Privacy policy for OpenGIFame image sharing platform',
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
// Read the markdown file
|
||||
const markdownPath = join(process.cwd(), 'docs', 'PRIVACY.md');
|
||||
const markdownContent = readFileSync(markdownPath, 'utf8');
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="prose prose-slate dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-4xl font-bold mb-6 text-foreground">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-2xl font-semibold mt-8 mb-4 text-foreground">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-xl font-medium mt-6 mb-3 text-foreground">{children}</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 text-muted-foreground leading-relaxed">{children}</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-6 mb-4 text-muted-foreground">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-6 mb-4 text-muted-foreground">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="mb-2">{children}</li>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-foreground">{children}</strong>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
hr: () => (
|
||||
<hr className="my-8 border-border" />
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-border pl-4 italic text-muted-foreground">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{markdownContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/footer.tsx
Normal file
37
src/components/footer.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
© 2025 OpenGIFame. All rights reserved.
|
||||
</div>
|
||||
<nav className="flex gap-6">
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Privacy
|
||||
</Link>
|
||||
<Link
|
||||
href="/gdpr"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
GDPR
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/fergalmoran/opengifame"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Upload, User, LogOut, Github, Mail, AlertCircle } from 'lucide-react';
|
||||
import { Upload, User, LogOut, Github, Mail, AlertCircle, ChevronDown } from 'lucide-react';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { OpenGifameLogo } from '@/components/opengifame-logo';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Provider {
|
||||
@@ -104,6 +106,42 @@ export function Header() {
|
||||
}
|
||||
};
|
||||
|
||||
const getUserInitials = (name?: string | null) => {
|
||||
if (!name) return 'U';
|
||||
|
||||
const names = name.trim().split(' ');
|
||||
if (names.length === 1) {
|
||||
return names[0].charAt(0).toUpperCase();
|
||||
}
|
||||
return (names[0].charAt(0) + names[names.length - 1].charAt(0)).toUpperCase();
|
||||
};
|
||||
|
||||
const getUserAvatarColor = (name?: string | null) => {
|
||||
if (!name) return 'bg-gray-500';
|
||||
|
||||
// Generate a consistent color based on the username
|
||||
const colors = [
|
||||
'bg-red-500',
|
||||
'bg-blue-500',
|
||||
'bg-green-500',
|
||||
'bg-purple-500',
|
||||
'bg-yellow-500',
|
||||
'bg-pink-500',
|
||||
'bg-indigo-500',
|
||||
'bg-teal-500',
|
||||
'bg-orange-500',
|
||||
'bg-cyan-500'
|
||||
];
|
||||
|
||||
// Simple hash function to get consistent color for same name
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background">
|
||||
<div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
|
||||
@@ -112,47 +150,52 @@ export function Header() {
|
||||
<OpenGifameLogo className="h-8 w-8 flex-shrink-0" />
|
||||
<span className="text-xl font-bold">OpenGifame</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden sm:flex items-center space-x-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm font-medium transition-colors hover:text-primary"
|
||||
>
|
||||
Gallery
|
||||
</Link>
|
||||
<Link
|
||||
href="/trending"
|
||||
className="text-sm font-medium transition-colors hover:text-primary"
|
||||
>
|
||||
Trending
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Prominent Upload Button - Center */}
|
||||
{session && (
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2">
|
||||
<Button asChild className="bg-gradient-to-r from-purple-500 via-pink-500 to-red-500 hover:from-purple-600 hover:via-pink-600 hover:to-red-600 text-white font-semibold shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200 border-0">
|
||||
<Link href="/upload">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-3 flex-shrink-0">
|
||||
<ThemeToggle />
|
||||
{session ? (
|
||||
<>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/upload">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/profile">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
{session.user?.name}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => signOut()}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="flex items-center space-x-1 p-1">
|
||||
<Avatar className="h-8 w-8 border-2 border-border bg-background">
|
||||
<AvatarImage src={session.user?.image || undefined} alt="Profile" />
|
||||
<AvatarFallback className={`${getUserAvatarColor(session.user?.name)} text-white`}>
|
||||
{getUserInitials(session.user?.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/profile" className="flex items-center">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => signOut()}
|
||||
className="flex items-center text-red-600 focus:text-red-600"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Dialog open={isSignInOpen} onOpenChange={setIsSignInOpen}>
|
||||
<DialogTrigger asChild>
|
||||
|
||||
50
src/components/ui/avatar.tsx
Normal file
50
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
200
src/components/ui/dropdown-menu.tsx
Normal file
200
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
|
||||
@@ -75,7 +75,7 @@ export function VotingButtons({
|
||||
size="sm"
|
||||
onClick={() => handleVote(true)}
|
||||
disabled={isVoting}
|
||||
className="flex flex-col items-center space-y-1 h-auto py-2 px-3"
|
||||
className="flex flex-col items-center space-y-1 h-auto py-2 px-3 cursor-pointer hover:scale-105 transition-transform"
|
||||
>
|
||||
<span className="text-lg">👍</span>
|
||||
<span className="text-xs">{upvotes}</span>
|
||||
@@ -86,7 +86,7 @@ export function VotingButtons({
|
||||
size="sm"
|
||||
onClick={() => handleVote(false)}
|
||||
disabled={isVoting}
|
||||
className="flex flex-col items-center space-y-1 h-auto py-2 px-3"
|
||||
className="flex flex-col items-center space-y-1 h-auto py-2 px-3 cursor-pointer hover:scale-105 transition-transform"
|
||||
>
|
||||
<span className="text-lg">👎</span>
|
||||
<span className="text-xs">{downvotes}</span>
|
||||
|
||||
@@ -4,7 +4,8 @@ import GitHubProvider from 'next-auth/providers/github';
|
||||
import GoogleProvider from 'next-auth/providers/google';
|
||||
import FacebookProvider from 'next-auth/providers/facebook';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import bcrypt from 'bcryptjs';
|
||||
// TODO: Uncomment when database authentication is implemented
|
||||
// import bcrypt from 'bcryptjs';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
|
||||
Reference in New Issue
Block a user