惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cloudbric
Cloudbric
有赞技术团队
有赞技术团队
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
T
Threat Research - Cisco Blogs
L
LangChain Blog
Simon Willison's Weblog
Simon Willison's Weblog
Project Zero
Project Zero
Latest news
Latest news
S
Schneier on Security
Cisco Talos Blog
Cisco Talos Blog
MyScale Blog
MyScale Blog
C
Check Point Blog
IT之家
IT之家
P
Palo Alto Networks Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
C
CERT Recently Published Vulnerability Notes
Scott Helme
Scott Helme
The Hacker News
The Hacker News
C
CXSECURITY Database RSS Feed - CXSecurity.com
G
Google Developers Blog
T
Tor Project blog
T
Threatpost
D
DataBreaches.Net
博客园 - 【当耐特】
酷 壳 – CoolShell
酷 壳 – CoolShell
T
Troy Hunt's Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Vercel News
Vercel News
云风的 BLOG
云风的 BLOG
NISL@THU
NISL@THU
P
Privacy & Cybersecurity Law Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
C
Cisco Blogs
博客园_首页
S
Securelist
T
The Exploit Database - CXSecurity.com
Last Week in AI
Last Week in AI
量子位
U
Unit 42
Know Your Adversary
Know Your Adversary
Hugging Face - Blog
Hugging Face - Blog
S
Security Affairs
Google Online Security Blog
Google Online Security Blog
Hacker News: Ask HN
Hacker News: Ask HN
Webroot Blog
Webroot Blog
S
SegmentFault 最新的问题
Engineering at Meta
Engineering at Meta
N
News and Events Feed by Topic
P
Proofpoint News Feed
阮一峰的网络日志
阮一峰的网络日志

Clerk Blog

Going to production with Clerk Deploy Clerk Init: The fastest way to start a new project Introducing Clerk CLI Middleware-based route protection bypass Postmortem: Clerk System Outage (March 10, 2026) Clerk for the AI era Add API Key support to your SaaS in minutes Postmortem: Clerk System Outage (February 19, 2026) Using Clerk in a React Native app Postmortem: DNS Provider Outage (February 10, 2026) How do I implement passkeys in Next.js? Clerk ranked #4 fastest-growing software vendor on Ramp’s December 2025 list How do I handle JWT verification in Next.js? Committing to Agent Identity: Clerk raises $50m Series C from Menlo and Anthropic’s Anthology Fund What is the best way to handle authentication in Next.js App Router? Postmortem: Database Incident (September 14–18, 2025) How do I add authentication to a Next.js app? Introducing Free Trials in Clerk Billing Postmortem: August 28, 2025 - elevated API latency and errors Introducing Mosaic: Bring Your Brand to Every Authentication Flow Multi-tenant authentication: What you need to know (and how Clerk helps) What are the risks and challenges of multi-tenancy? Resilience in Practice: Regional Failover at Clerk Highlights from the MiduDev/Clerk Hackathon Add multi-tenancy to an app built with Clerk, Lovable, and Supabase How to build an AI coding rules app with Clerk, Lovable, and Supabase How to Build Multi-Tenant Authentication with Clerk Choosing the right SaaS architecture: Multi-Tenant vs. Single-Tenant Postmortem: June 26, 2025 service outage How to Design a Multi-Tenant SaaS Architecture What is multi-tenancy and why it matters for B2B SaaS How OAuth Works Synchronize user data from Clerk to Supabase Add subscriptions to your SaaS with Clerk Billing Getting started with Clerk Billing Multi-tenant analytics with Tinybird and Clerk How Huntr Migrated 250K Users to Clerk: A Scalable Auth Solution for Startups How to take Clerk to Production How to take your Clerk application to production A practical guide to testing Clerk Next.js applications Implementing multi-tenancy into a Supabase app with Clerk How Clerk integrates with a Next.js application using Supabase How Clerk integrates with Supabase Build a blog with tRPC, Prisma, Next.js and Clerk How to enrich PostHog events with Clerk user data How to build a secure project management platform with Next.js, Clerk, and Neon Validate your SaaS idea while building an audience Postmortem: February 6, 2025 service outage Implement Role-Based Access Control in Next.js 15 Build a Next.js sign-up form with React Hook Form Build a Next.js login page template How to implement Google authentication in Next.js 15 What is middleware in Next.js? How to customize Next.js metadata How to set environment variables in Node.js Building a React Login Page Template How to implement per-user OAuth scopes with Clerk Using Clerk SSO to access Google Calendar and other service data Streamline enterprise customer onboarding with SAML and Clerk Clerk launches EASIE SSO and eliminates SSO fees How to secure Liveblocks Rooms with Clerk in Next.js Securing Node.js Express APIs with Clerk and React Combining the benefits of session tokens and JWTs Build a task manager with Next.js, Supabase, and Clerk Comparing Clerk Webhooks vs Backend API Automate Neon schema changes with Drizzle and GitHub Actions A guide to reading authenticated user data from Clerk Role based access control with Clerk Organizations Mitigating OAuth’s recently discovered Open Response Type vulnerability Per-user B2B monetization with Stripe and Clerk Organizations Build a team-based task manager with Next.js, Neon, and Clerk Building a Hybrid Sign-Up/Subscribe Form with Stripe Elements Welcoming Colin from Zod as our inaugural Open Source Fellow Build a modern authenticated chat application with Next.js, Ably, and Clerk Build a waitlist with Clerk user metadata How to use Clerk with PostHog Identify in Next.js How to secure API Gateway using JWT and Lambda Authorizers with Clerk What are passkeys and how do they work? Comparing Authentication in React.js vs. Next.js How to Add an Onboarding Flow for your Application with Clerk Create Your Own Custom User Menu with Radix - Part 2 Introducing Webhook Workflows with Inngest & Svix Clerk raises $30M Series B from CRV and Stripe Clerk in 2023: A Year in Review Build a Movie Emoji Quiz App with Remix, Fauna, and Clerk Ultimate Guide to Magic Link Authentication Create Your Own Custom User Menu with Radix Introducing has(), protect(), and <Protect> Updated Pricing: 10,000 MAUs Free, and a new “Pro Plan” Next.js Authentication with Clerk: Streamlined SSR Handling Clerk Webhooks: Data Sync with Convex Exploring Clerk Metadata with Stripe Webhooks The Ultimate Guide to Next.js Authentication Empower Your Support Team With User Impersonation Clerk Webhooks: Getting Started A Complete Guide to Session Management in Next.js The Advanced Guide to Passwordless Authentication in Next.js How We Roll – Chapter 10: Roundup How We Roll – Chapter 9: Infrastructure Password-Based Authentication in Next.js
Build a Cross-Platform B2B App with Clerk, Expo, and Supabase
Brian Morrison II · 2025-08-07 · via Clerk Blog
import { useOrganization, useUser } from '@clerk/clerk-expo'
import { Ionicons } from '@expo/vector-icons'
import React, { useEffect, useRef, useState } from 'react'
import {
  Animated,
  Easing,
  Modal,
  StyleSheet,
  TextInput,
  TouchableOpacity,
  View,
} from 'react-native'

// Import ThemedText and ThemedView components
import { ThemedText } from './ThemedText'
import { ThemedView } from './ThemedView'

interface TimeEntry {
  id: string
  description: string
  start_time: string
  end_time: string | null
  created_at: string
  created_by?: string
}

interface TimeEntryItemProps {
  item: TimeEntry
  onUpdate?: (id: string, updates: Partial<TimeEntry>) => Promise<void>
  onDelete?: (id: string) => Promise<void>
}

// Custom slow spinner component
function SlowSpinner() {
  const spinValue = useRef(new Animated.Value(0)).current

  useEffect(() => {
    // Create a continuous rotation animation
    const startRotation = () => {
      // Reset the value to 0 when starting
      spinValue.setValue(0)

      // Create the animation
      Animated.timing(spinValue, {
        toValue: 1,
        duration: 3000, // Slower animation (3 seconds per rotation)
        easing: Easing.linear,
        useNativeDriver: true,
      }).start(() => startRotation()) // When complete, run again
    }

    // Start the animation loop
    startRotation()

    // Cleanup function
    return () => {
      // This will stop any pending animations when component unmounts
      spinValue.stopAnimation()
    }
  }, [spinValue])

  const spin = spinValue.interpolate({
    inputRange: [0, 1],
    outputRange: ['0deg', '360deg'],
  })

  return (
    <Animated.View
      style={{
        transform: [{ rotate: spin }],
        width: 16,
        height: 16,
        borderWidth: 2,
        borderColor: '#2563EB',
        borderTopColor: 'transparent',
        borderRadius: 8,
      }}
    />
  )
}

export function TimeEntryItem({ item, onUpdate, onDelete }: TimeEntryItemProps) {
  const { user } = useUser()
  const { organization } = useOrganization()
  const [isModalVisible, setIsModalVisible] = useState(false)
  const [editedDescription, setEditedDescription] = useState(item.description)
  const [startDate, setStartDate] = useState(new Date(item.start_time))
  const [endDate, setEndDate] = useState(item.end_time ? new Date(item.end_time) : null)
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [creatorName, setCreatorName] = useState<string | null>(null)

  // Track the text inputs separately from the actual date objects
  const [startDateText, setStartDateText] = useState('')
  const [endDateText, setEndDateText] = useState('')

  // Reset form state when modal is opened
  useEffect(() => {
    if (isModalVisible) {
      setEditedDescription(item.description)
      setStartDate(new Date(item.start_time))
      setEndDate(item.end_time ? new Date(item.end_time) : null)

      // Initialize text fields with formatted dates
      const formattedStartDate = formatDate(new Date(item.start_time).toISOString())
      const formattedStartTime = formatTime(new Date(item.start_time))
      setStartDateText(`${formattedStartDate} ${formattedStartTime}`)

      if (item.end_time) {
        const formattedEndDate = formatDate(new Date(item.end_time).toISOString())
        const formattedEndTime = formatTime(new Date(item.end_time))
        setEndDateText(`${formattedEndDate} ${formattedEndTime}`)
      } else {
        setEndDateText('')
      }
    }
  }, [isModalVisible, item.description, item.start_time, item.end_time])

  // Format date to display in a readable format
  const formatDate = (dateString: string) => {
    const date = new Date(dateString)
    return date.toLocaleDateString()
  }

  // Format time to display in a readable format
  const formatTime = (date: Date) => {
    return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
  }

  // Initialize date text fields on component mount
  useEffect(() => {
    if (startDate) {
      setStartDateText(`${formatDate(startDate.toISOString())} ${formatTime(startDate)}`)
    }
    if (endDate) {
      setEndDateText(`${formatDate(endDate.toISOString())} ${formatTime(endDate)}`)
    }
  }, [startDate, endDate])

  // Get creator name if created_by is available
  useEffect(() => {
    if (item.created_by) {
      // If the current user is the creator
      if (user && user.id === item.created_by) {
        setCreatorName('You')
      } else {
        organization?.getMemberships().then((memberships) => {
          const member = memberships.data.find((m) => m.publicUserData?.userId === item.created_by)
          setCreatorName(
            `${member?.publicUserData?.firstName} ${member?.publicUserData?.lastName} (${member?.publicUserData?.identifier})` ||
              'Another team member',
          )
        })
      }
    } else {
      setCreatorName(null)
    }
  }, [item.created_by, user])

  // Calculate duration between start and end time
  const calculateDuration = (start: string, end: string | null) => {
    if (!end) return 'In progress'

    const startDate = new Date(start)
    const endDate = new Date(end)
    const diffMs = endDate.getTime() - startDate.getTime()

    const diffMins = Math.floor(diffMs / 60000)
    const hours = Math.floor(diffMins / 60)
    const mins = diffMins % 60

    return `${hours}h${mins}m`
  }

  // Handle saving changes
  const handleSave = async () => {
    if (!onUpdate) return

    // Try to parse dates from text inputs before saving
    let validStartDate = startDate
    let validEndDate = endDate

    // Parse start date text
    try {
      const [datePart, timePart] = startDateText.split(' ')
      if (datePart && timePart) {
        const [month, day, year] = datePart.split('/')
        const [hours, minutes] = timePart.replace('AM', '').replace('PM', '').trim().split(':')

        if (month && day && year && hours && minutes) {
          const newDate = new Date()
          newDate.setFullYear(parseInt(year), parseInt(month) - 1, parseInt(day))

          let hrs = parseInt(hours)
          if (timePart.includes('PM') && hrs < 12) hrs += 12
          if (timePart.includes('AM') && hrs === 12) hrs = 0

          newDate.setHours(hrs, parseInt(minutes))
          validStartDate = newDate
        }
      }
    } catch {
      // Use the existing startDate if parsing fails
    }

    // Parse end date text if it exists
    if (endDateText && endDate) {
      try {
        const [datePart, timePart] = endDateText.split(' ')
        if (datePart && timePart) {
          const [month, day, year] = datePart.split('/')
          const [hours, minutes] = timePart.replace('AM', '').replace('PM', '').trim().split(':')

          if (month && day && year && hours && minutes) {
            const newDate = new Date()
            newDate.setFullYear(parseInt(year), parseInt(month) - 1, parseInt(day))

            let hrs = parseInt(hours)
            if (timePart.includes('PM') && hrs < 12) hrs += 12
            if (timePart.includes('AM') && hrs === 12) hrs = 0

            newDate.setHours(hrs, parseInt(minutes))
            validEndDate = newDate
          }
        }
      } catch {
        // Use the existing endDate if parsing fails
        console.error('Failed to parse end date')
      }
    }

    setIsSubmitting(true)
    try {
      await onUpdate(item.id, {
        description: editedDescription,
        start_time: validStartDate.toISOString(),
        end_time: validEndDate ? validEndDate.toISOString() : null,
      })
      setIsModalVisible(false)
    } catch (error) {
      console.error('Failed to update time entry:', error)
    } finally {
      setIsSubmitting(false)
    }
  }

  // Handle deleting the entry
  const handleDelete = async () => {
    if (!onDelete) return

    setIsSubmitting(true)
    try {
      await onDelete(item.id)
      setIsModalVisible(false)
    } catch (error) {
      console.error('Error deleting time entry:', error)
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <ThemedView style={styles.container}>
      {/* View mode - always visible */}
      <TouchableOpacity
        style={styles.entryItem}
        onPress={() => setIsModalVisible(true)}
        activeOpacity={0.7}
      >
        <ThemedView style={styles.entryContent}>
          <View style={styles.descriptionContainer}>
            <ThemedText type="defaultSemiBold" numberOfLines={1}>
              {item.description}
            </ThemedText>
            {organization && creatorName && (
              <ThemedText style={styles.creatorText}>{creatorName}</ThemedText>
            )}
          </View>
          {item.end_time ? (
            <ThemedText style={styles.durationText}>
              {calculateDuration(item.start_time, item.end_time)}
            </ThemedText>
          ) : (
            <View style={styles.inProgressContainer}>
              <SlowSpinner />
              <ThemedText style={styles.inProgressText}>In progress</ThemedText>
            </View>
          )}
        </ThemedView>
        <View style={styles.rightSection}>
          <ThemedText style={styles.dateText}>{formatDate(item.start_time)}</ThemedText>
          <Ionicons name="chevron-forward" size={16} color="#888" />
        </View>
      </TouchableOpacity>

      {/* Edit mode - in modal */}
      <Modal
        visible={isModalVisible}
        onRequestClose={() => setIsModalVisible(false)}
        animationType="fade"
        transparent
      >
        <View style={styles.modalBackdrop}>
          <View style={styles.modalContainer}>
            <ThemedView style={styles.modalContent}>
              <ThemedText style={styles.modalTitle}>Edit Time Entry</ThemedText>

              <View style={styles.formGroup}>
                <ThemedText style={styles.label}>Description</ThemedText>
                <TextInput
                  style={styles.input}
                  value={editedDescription}
                  onChangeText={setEditedDescription}
                  placeholder="What were you working on?"
                  placeholderTextColor="#aaa"
                />
              </View>

              <View style={styles.formGroup}>
                <ThemedText style={styles.label}>Start Time</ThemedText>
                <TextInput
                  style={styles.input}
                  value={startDateText}
                  onChangeText={(text) => {
                    // Just update the text field without validation
                    setStartDateText(text)

                    // Try to parse the date but don't throw errors
                    try {
                      const [datePart, timePart] = text.split(' ')
                      if (datePart && timePart) {
                        const [month, day, year] = datePart.split('/')
                        const [hours, minutes] = timePart
                          .replace('AM', '')
                          .replace('PM', '')
                          .trim()
                          .split(':')

                        if (month && day && year && hours && minutes) {
                          const newDate = new Date(startDate)
                          newDate.setFullYear(parseInt(year), parseInt(month) - 1, parseInt(day))

                          let hrs = parseInt(hours)
                          if (timePart.includes('PM') && hrs < 12) hrs += 12
                          if (timePart.includes('AM') && hrs === 12) hrs = 0

                          newDate.setHours(hrs, parseInt(minutes))
                          setStartDate(newDate)
                        }
                      }
                    } catch {
                      // Silently fail - we'll use the previous valid date if parsing fails
                    }
                  }}
                  placeholder="MM/DD/YYYY HH:MM AM/PM"
                  placeholderTextColor="#aaa"
                />
              </View>

              <View style={styles.formGroup}>
                <ThemedText style={styles.label}>End Time</ThemedText>
                {endDate ? (
                  <TextInput
                    style={styles.input}
                    value={endDateText}
                    onChangeText={(text) => {
                      // Just update the text field without validation
                      setEndDateText(text)

                      // Try to parse the date but don't throw errors
                      try {
                        const [datePart, timePart] = text.split(' ')
                        if (datePart && timePart) {
                          const [month, day, year] = datePart.split('/')
                          const [hours, minutes] = timePart
                            .replace('AM', '')
                            .replace('PM', '')
                            .trim()
                            .split(':')

                          if (month && day && year && hours && minutes) {
                            const newDate = new Date(endDate)
                            newDate.setFullYear(parseInt(year), parseInt(month) - 1, parseInt(day))

                            let hrs = parseInt(hours)
                            if (timePart.includes('PM') && hrs < 12) hrs += 12
                            if (timePart.includes('AM') && hrs === 12) hrs = 0

                            newDate.setHours(hrs, parseInt(minutes))
                            setEndDate(newDate)
                          }
                        }
                      } catch {
                        // Silently fail - we'll use the previous valid date if parsing fails
                      }
                    }}
                    placeholder="MM/DD/YYYY HH:MM AM/PM"
                    placeholderTextColor="#aaa"
                  />
                ) : (
                  <View style={styles.inProgressContainer}>
                    <SlowSpinner />
                    <ThemedText style={styles.inProgressText}>In progress</ThemedText>
                  </View>
                )}
              </View>

              <View style={styles.buttonContainer}>
                <TouchableOpacity
                  style={styles.cancelButton}
                  onPress={() => setIsModalVisible(false)}
                  disabled={isSubmitting}
                >
                  <ThemedText style={styles.buttonText}>Cancel</ThemedText>
                </TouchableOpacity>
                <TouchableOpacity
                  style={[styles.button, styles.deleteButton]}
                  onPress={handleDelete}
                  disabled={isSubmitting}
                >
                  <ThemedText style={styles.buttonText}>Delete</ThemedText>
                </TouchableOpacity>
                <TouchableOpacity
                  style={[styles.button, styles.saveButton]}
                  onPress={handleSave}
                  disabled={isSubmitting}
                >
                  <ThemedText style={styles.buttonText}>Save</ThemedText>
                </TouchableOpacity>
              </View>
            </ThemedView>
          </View>
        </View>
      </Modal>
    </ThemedView>
  )
}

const styles = StyleSheet.create({
  container: {
    marginVertical: 4,
  },
  modalContent: {
    padding: 16,
  },
  modalTitle: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 16,
  },
  entryItem: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    borderBottomWidth: 1,
    borderBottomColor: 'transparent',
    backgroundColor: '#f7f7f7',
    padding: 8,
    marginVertical: 4,
    borderRadius: 6,
    gap: 3,
    alignItems: 'center',
  },
  entryContent: {
    gap: 4,
    alignItems: 'flex-start',
    justifyContent: 'center',
    backgroundColor: 'transparent',
    flex: 1,
  },
  dateText: {
    fontSize: 12,
    color: '#888',
  },
  rightSection: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 4,
  },
  durationText: {
    fontWeight: 'bold',
    fontFamily: 'monospace',
  },
  inProgressContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 6,
    backgroundColor: '#EBF4FF', // Light blue background
    paddingVertical: 1,
    paddingHorizontal: 6,
    borderRadius: 100,
  },
  inProgressText: {
    color: '#2563EB',
    fontWeight: '600',
    fontSize: 12,
  },
  formGroup: {
    marginBottom: 16,
  },
  label: {
    fontSize: 14,
    fontWeight: '600',
    marginBottom: 6,
  },
  input: {
    borderWidth: 1,
    borderRadius: 6,
    padding: 10,
    borderColor: '#ccc',
  },
  dateTimeButton: {
    borderWidth: 1,
    borderRadius: 6,
    padding: 10,
    backgroundColor: '#333',
    justifyContent: 'center',
  },
  buttonContainer: {
    flexDirection: 'row',
    justifyContent: 'flex-end',
    gap: 10,
    marginTop: 20,
  },
  cancelButton: {
    backgroundColor: '#555',
    paddingVertical: 8,
    paddingHorizontal: 16,
    borderRadius: 6,
  },
  webDatePickerContainer: {
    backgroundColor: '#333',
    padding: 10,
    borderRadius: 6,
    marginTop: 8,
    gap: 10,
  },
  webDateInput: {
    borderWidth: 1,
    borderColor: '#444',
    borderRadius: 4,
    padding: 8,
    backgroundColor: '#222',
    color: 'white',
    marginBottom: 10,
  },
  webDatePickerButton: {
    backgroundColor: '#2563EB',
    padding: 8,
    borderRadius: 4,
    alignItems: 'center',
    marginTop: 8,
  },
  webDatePickerButtonText: {
    color: 'white',
    fontWeight: '600',
  },
  button: {
    paddingVertical: 10,
    paddingHorizontal: 20,
    borderRadius: 6,
    alignItems: 'center',
    justifyContent: 'center',
  },
  saveButton: {
    backgroundColor: '#2563EB',
  },
  deleteButton: {
    backgroundColor: '#DC2626',
  },
  buttonText: {
    color: 'white',
    fontWeight: '600',
  },
  modalBackdrop: {
    flex: 1,
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  modalContainer: {
    width: '90%',
    maxWidth: 500,
    borderRadius: 8,
    overflow: 'hidden',
  },
  descriptionContainer: {
    gap: 2,
  },
  creatorText: {
    color: '#777',
    fontSize: 12,
  },
})